Skip to content

Commit 6fc5b63

Browse files
committed
feat(bus): fetch fcps afternoon bus delays
Closes tjcsl#1413
1 parent 0feeb1b commit 6fc5b63

File tree

8 files changed

+216
-7
lines changed

8 files changed

+216
-7
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Generated by Django 3.2.25 on 2025-05-20 23:50
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('bus', '0008_alter_busannouncement_message'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='BusDelay',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('bus_number', models.CharField(max_length=10)),
18+
('reason', models.TextField()),
19+
('timestamp', models.DateTimeField(auto_now_add=True)),
20+
],
21+
),
22+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 3.2.25 on 2025-05-21 03:01
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('bus', '0009_busdelay'),
10+
]
11+
12+
operations = [
13+
migrations.DeleteModel(
14+
name='BusDelay',
15+
),
16+
migrations.AddField(
17+
model_name='route',
18+
name='reason',
19+
field=models.CharField(blank=True, max_length=50),
20+
),
21+
]
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 3.2.25 on 2025-05-22 14:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('bus', '0010_auto_20250520_2301'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='route',
15+
name='estimated_time_delay',
16+
field=models.CharField(blank=True, max_length=5),
17+
),
18+
]

intranet/apps/bus/models.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ class Route(models.Model):
1010
space = models.CharField(max_length=4, blank=True)
1111
bus_number = models.CharField(max_length=5, blank=True)
1212
status = models.CharField("arrival status", choices=ARRIVAL_STATUSES, max_length=1, default="o")
13+
reason = models.CharField(max_length=50, blank=True)
14+
estimated_time_delay = models.CharField(max_length=5, blank=True)
1315

1416
def reset_status(self):
1517
"""Reset status to (on time)"""
1618
self.status = "o"
1719
self.space = ""
18-
self.save(update_fields=["status", "space"])
20+
self.reason = ""
21+
self.estimated_time_delay = ""
22+
self.save(update_fields=["status", "space", "reason", "estimated_time_delay"])
1923

2024
def __str__(self):
2125
return self.route_name

intranet/apps/bus/tasks.py

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1+
from datetime import timedelta
2+
3+
import requests
4+
from asgiref.sync import async_to_sync
5+
from bs4 import BeautifulSoup
16
from celery import shared_task
27
from celery.utils.log import get_task_logger
8+
from channels.layers import get_channel_layer
9+
from django.conf import settings
10+
from django.utils import timezone
311

12+
from ..schedule.models import Day
413
from .models import Route
514

615
logger = get_task_logger(__name__)
@@ -9,6 +18,110 @@
918
@shared_task
1019
def reset_routes() -> None:
1120
logger.info("Resetting bus routes")
12-
1321
for route in Route.objects.all():
1422
route.reset_status()
23+
24+
25+
@shared_task
26+
def fetch_bus_delays_idle():
27+
# This one runs every 60 seconds and checks if the current time is within the fetching windows
28+
day = Day.objects.today()
29+
if day is None:
30+
logger.error("No Day found for today")
31+
return
32+
33+
# Make the datetime objects aware bc they are naive
34+
start_datetime = timezone.make_aware(day.start_time.date_obj(day.date), timezone.get_current_timezone())
35+
end_datetime = timezone.make_aware(day.end_time.date_obj(day.date) + timedelta(hours=12), timezone.get_current_timezone())
36+
37+
# Calculate time windows based on the ion schedule using epoch time
38+
first_window_start = (start_datetime - timedelta(hours=2.5)).timestamp()
39+
first_window_end = (start_datetime + timedelta(hours=1)).timestamp()
40+
second_window_start = (end_datetime - timedelta(hours=1)).timestamp()
41+
second_window_end = (end_datetime - timedelta(minutes=5)).timestamp()
42+
third_window_start = (end_datetime + timedelta(minutes=20)).timestamp()
43+
third_window_end = (end_datetime + timedelta(hours=2)).timestamp()
44+
now = timezone.localtime().timestamp()
45+
46+
if not (
47+
(first_window_start <= now <= first_window_end)
48+
or (second_window_start <= now <= second_window_end)
49+
or (third_window_start <= now <= third_window_end)
50+
):
51+
return
52+
else:
53+
fetch_fcps_bus_delays()
54+
55+
56+
@shared_task
57+
def fetch_bus_delays_active():
58+
# this one runs every 15 seconds and checks if the current time is within the fetching windows
59+
day = Day.objects.today()
60+
if day is None:
61+
logger.error("No Day found for today")
62+
return
63+
64+
# Make the datetime objects aware bc they are naive
65+
end_datetime = timezone.make_aware(day.end_time.date_obj(day.date), timezone.get_current_timezone())
66+
67+
# Calculate time windows based on the Ion schedule
68+
active_window_start = end_datetime - timedelta(minutes=5)
69+
active_window_end = end_datetime + timedelta(minutes=20)
70+
now = timezone.localtime()
71+
72+
if not (active_window_start <= now <= active_window_end):
73+
return
74+
else:
75+
fetch_fcps_bus_delays()
76+
77+
78+
def fetch_fcps_bus_delays():
79+
url = settings.BUS_DELAY_URL
80+
81+
try:
82+
response = requests.get(url, timeout=10)
83+
response.raise_for_status()
84+
except Exception as e:
85+
logger.error("Error fetching URL: %s", e)
86+
return
87+
88+
try:
89+
soup = BeautifulSoup(response.text, "html.parser")
90+
rows = soup.select("table tr")
91+
except Exception as e:
92+
logger.error("Error parsing HTML: %s", e)
93+
return
94+
95+
if not rows or len(rows) < 2:
96+
logger.warning("Not a complete row with all bus information")
97+
return
98+
99+
# Sort out the JEFFERSON HIGH bus delays
100+
for row in rows[1:]:
101+
cells = row.find_all("td")
102+
if len(cells) >= 4 and cells[0].text.strip() == "JEFFERSON HIGH":
103+
route_name = cells[1].text.strip().split()[0][:100]
104+
reason = cells[3].text.strip()[:150]
105+
estimated_time_delay = cells[2].text.strip()[:10]
106+
try:
107+
obj = Route.objects.get(route_name=route_name)
108+
# Only update if current status isn't "on time"
109+
if obj.status != "a":
110+
obj.status = "d"
111+
obj.reason = reason
112+
obj.estimated_time_delay = estimated_time_delay
113+
obj.save(update_fields=["status", "reason", "estimated_time_delay"])
114+
logger.info("Updated route %s with delay: %s and ETA: %s", route_name, reason, estimated_time_delay)
115+
channel_layer = get_channel_layer()
116+
all_routes = list(Route.objects.values())
117+
async_to_sync(channel_layer.group_send)(
118+
"bus",
119+
{
120+
"type": "bus.update",
121+
"message": {
122+
"allRoutes": all_routes,
123+
},
124+
},
125+
)
126+
except Route.DoesNotExist:
127+
logger.error("Route with route_name %s does not exist", route_name)

intranet/apps/bus/views.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,21 @@ def afternoon(request, on_home=False):
4545
raise Http404("Bus app not enabled.")
4646
is_bus_admin = request.user.has_admin_permission("bus")
4747

48-
now = timezone.localtime()
48+
current_time = timezone.localtime()
4949
day = Day.objects.today()
5050
if day is not None and day.end_time is not None:
51-
end_of_day = day.end_time.date_obj(now.date())
51+
end_of_day = day.end_time.date_obj(current_time.date())
5252
else:
53-
end_of_day = datetime.datetime(now.year, now.month, now.day, settings.SCHOOL_END_HOUR, settings.SCHOOL_END_MINUTE)
54-
53+
end_of_day = datetime.datetime(current_time.year, current_time.month, current_time.day, settings.SCHOOL_END_HOUR, settings.SCHOOL_END_MINUTE)
54+
bus_delays_queryset = Route.objects.filter(status="d")
55+
bus_delays = {delay.route_name: {"reason": delay.reason, "estimated_time_delay": delay.estimated_time_delay} for delay in bus_delays_queryset}
5556
ctx = {
5657
"admin": is_bus_admin,
5758
"enable_bus_driver": True,
5859
"changeover_time": settings.BUS_PAGE_CHANGEOVER_HOUR,
5960
"school_end_hour": end_of_day.hour,
6061
"school_end_time": end_of_day.minute,
62+
"bus_delays": bus_delays,
6163
"on_home": on_home,
6264
}
6365
return render(request, "bus/home.html", context=ctx)

intranet/settings/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
SENIOR_DESTS_BANNER_LINK = "https://tinyurl.com/tjseniors2025"
6868

6969
""" -------- END UPDATE ANNUALLY -------- """
70+
71+
#Bus Delays URL
72+
BUS_DELAY_URL = "https://busdelay.fcps.edu/"
73+
7074
# fmt: on
7175

7276
# Default fallback time for start and end of school if no schedule is available
@@ -955,6 +959,16 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
955959
"schedule": celery.schedules.crontab(day_of_month=3, hour=1),
956960
"args": (),
957961
},
962+
"fetch-fcps-bus-delays-idle": {
963+
"task": "intranet.apps.bus.tasks.fetch_bus_delays_idle",
964+
"schedule": 60.0,
965+
"args": (),
966+
},
967+
"fetch-fcps-bus-delays-active": {
968+
"task": "intranet.apps.bus.tasks.fetch_bus_delays_active",
969+
"schedule": 15.0,
970+
"args": (),
971+
},
958972
"remove-old-lostfound-entries": {
959973
"task": "intranet.apps.lostfound.tasks.remove_old_lostfound",
960974
"schedule": celery.schedules.crontab(day_of_month=1, hour=1),
@@ -1016,4 +1030,4 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
10161030
sentry_logging = LoggingIntegration(
10171031
level=logging.INFO, event_level=logging.ERROR # Capture info and above as breadcrumbs # Send errors as events
10181032
)
1019-
sentry_sdk.init(SENTRY_PUBLIC_DSN, integrations=[DjangoIntegration(), sentry_logging, CeleryIntegration()], send_default_pii=True)
1033+
sentry_sdk.init(SENTRY_PUBLIC_DSN, integrations=[DjangoIntegration(), sentry_logging, CeleryIntegration()], send_default_pii=True)

intranet/templates/bus/home.html

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,21 @@ <h2 class="bordered-element bus-announcement-header">
113113
JT-100, JT100, jt-100, jt100, or 100.
114114
</p>
115115
{% endif %}
116+
<div class="delay-announcements">
117+
<h3>Current Bus Delays</h3>
118+
{% if bus_delays %}
119+
{% for route, delay in bus_delays.items %}
120+
<div class="delay-card">
121+
<i class="fas fa-exclamation-triangle"></i>
122+
<span>
123+
<strong>Bus {{ route }}</strong> delayed due to: {{ delay.reason|lower }} <br/> ETA: {{ delay.estimated_time_delay }} minutes.
124+
</span>
125+
</div>
126+
{% endfor %}
127+
{% else %}
128+
<p class="no-delays">No current delays reported.</p>
129+
{% endif %}
130+
</div>
116131
</div>
117132
</div>
118133
<script type="text/template" id="map-view">

0 commit comments

Comments
 (0)