From 167767eb43f46ebfb35766aa8d7231681fd70429 Mon Sep 17 00:00:00 2001
From: Mikaeel Taher
Date: Sun, 25 May 2025 16:32:38 -0400
Subject: [PATCH] feat(bus): fetch fcps bus delay information
Closes #1413
---
intranet/apps/bus/migrations/0009_busdelay.py | 22 ++++
.../bus/migrations/0010_auto_20250520_2301.py | 21 +++
.../0011_route_estimated_time_delay.py | 18 +++
intranet/apps/bus/models.py | 6 +-
intranet/apps/bus/tasks.py | 124 +++++++++++++++++-
intranet/apps/bus/views.py | 13 +-
intranet/settings/__init__.py | 22 ++++
intranet/templates/bus/home.html | 15 +++
intranet/templates/bus/morning.html | 15 +++
9 files changed, 250 insertions(+), 6 deletions(-)
create mode 100644 intranet/apps/bus/migrations/0009_busdelay.py
create mode 100644 intranet/apps/bus/migrations/0010_auto_20250520_2301.py
create mode 100644 intranet/apps/bus/migrations/0011_route_estimated_time_delay.py
diff --git a/intranet/apps/bus/migrations/0009_busdelay.py b/intranet/apps/bus/migrations/0009_busdelay.py
new file mode 100644
index 00000000000..a588bc56e9c
--- /dev/null
+++ b/intranet/apps/bus/migrations/0009_busdelay.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.25 on 2025-05-20 23:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bus', '0008_alter_busannouncement_message'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='BusDelay',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('bus_number', models.CharField(max_length=10)),
+ ('reason', models.TextField()),
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
+ ],
+ ),
+ ]
diff --git a/intranet/apps/bus/migrations/0010_auto_20250520_2301.py b/intranet/apps/bus/migrations/0010_auto_20250520_2301.py
new file mode 100644
index 00000000000..eab83fbf6be
--- /dev/null
+++ b/intranet/apps/bus/migrations/0010_auto_20250520_2301.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.2.25 on 2025-05-21 03:01
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bus', '0009_busdelay'),
+ ]
+
+ operations = [
+ migrations.DeleteModel(
+ name='BusDelay',
+ ),
+ migrations.AddField(
+ model_name='route',
+ name='reason',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ ]
diff --git a/intranet/apps/bus/migrations/0011_route_estimated_time_delay.py b/intranet/apps/bus/migrations/0011_route_estimated_time_delay.py
new file mode 100644
index 00000000000..a46c7225f09
--- /dev/null
+++ b/intranet/apps/bus/migrations/0011_route_estimated_time_delay.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.25 on 2025-05-22 14:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('bus', '0010_auto_20250520_2301'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='route',
+ name='estimated_time_delay',
+ field=models.CharField(blank=True, max_length=5),
+ ),
+ ]
diff --git a/intranet/apps/bus/models.py b/intranet/apps/bus/models.py
index 2b50c18bb28..e130af7fa11 100644
--- a/intranet/apps/bus/models.py
+++ b/intranet/apps/bus/models.py
@@ -10,12 +10,16 @@ class Route(models.Model):
space = models.CharField(max_length=4, blank=True)
bus_number = models.CharField(max_length=5, blank=True)
status = models.CharField("arrival status", choices=ARRIVAL_STATUSES, max_length=1, default="o")
+ reason = models.CharField(max_length=50, blank=True)
+ estimated_time_delay = models.CharField(max_length=5, blank=True)
def reset_status(self):
"""Reset status to (on time)"""
self.status = "o"
self.space = ""
- self.save(update_fields=["status", "space"])
+ self.reason = ""
+ self.estimated_time_delay = ""
+ self.save(update_fields=["status", "space", "reason", "estimated_time_delay"])
def __str__(self):
return self.route_name
diff --git a/intranet/apps/bus/tasks.py b/intranet/apps/bus/tasks.py
index 2dc575783b8..1369b9a17b9 100644
--- a/intranet/apps/bus/tasks.py
+++ b/intranet/apps/bus/tasks.py
@@ -1,6 +1,15 @@
+from datetime import timedelta
+
+import requests
+from asgiref.sync import async_to_sync
+from bs4 import BeautifulSoup
from celery import shared_task
from celery.utils.log import get_task_logger
+from channels.layers import get_channel_layer
+from django.conf import settings
+from django.utils import timezone
+from ..schedule.models import Day
from .models import Route
logger = get_task_logger(__name__)
@@ -9,6 +18,119 @@
@shared_task
def reset_routes() -> None:
logger.info("Resetting bus routes")
-
for route in Route.objects.all():
route.reset_status()
+
+
+@shared_task
+def schedule_all_bus_delay_fetches():
+ """Schedules tasks to fetch FCPS bus delay information throughout the day.
+
+ This task calculates multiple intervals based on the start and end times of the day
+ and schedules the fetch_fcps_bus_delays task to run at both
+ one-minute and 15-second intervals during idle and active windows.
+ """
+ day = Day.objects.today()
+ if day is None:
+ logger.error("No Day found for today")
+ return
+
+ tz = timezone.get_current_timezone()
+ start_datetime = timezone.make_aware(day.start_time.date_obj(day.date), tz)
+ end_datetime = timezone.make_aware(day.end_time.date_obj(day.date), tz)
+
+ # 1 minute intervals (idle windows)
+ # Idle window 1: 2.5h before start to 1h after start
+ first_window_start = start_datetime - timedelta(hours=settings.FIRST_WINDOW_START_BUFFER)
+ first_window_end = start_datetime + timedelta(hours=settings.FIRST_WINDOW_END_BUFFER)
+ # Idle window 2: 1h before end to 5m before end
+ second_window_start = end_datetime - timedelta(minutes=settings.SECOND_WINDOW_START_BUFFER)
+ second_window_end = end_datetime - timedelta(minutes=settings.SECOND_WINDOW_END_BUFFER)
+ # Idle window 3: 20m after end to 2h after end
+ third_window_start = end_datetime + timedelta(minutes=settings.THIRD_WINDOW_START_BUFFER)
+ third_window_end = end_datetime + timedelta(hours=settings.THIRD_WINDOW_END_BUFFER)
+
+ # Helper to schedule at 1 minute intervals
+ def schedule_minutely(start, end):
+ t = start
+ while t <= end:
+ logger.info("Scheduling fetch_fcps_bus_delays to run at %s", t)
+ fetch_fcps_bus_delays.apply_async(eta=t)
+ t += timedelta(minutes=1)
+
+ schedule_minutely(first_window_start, first_window_end)
+ schedule_minutely(second_window_start, second_window_end)
+ schedule_minutely(third_window_start, third_window_end)
+
+ # 15 second intervals (active window)
+ active_window_start = end_datetime - timedelta(minutes=settings.ACTIVE_WINDOW_START_BUFFER)
+ active_window_end = end_datetime + timedelta(minutes=settings.ACTIVE_WINDOW_END_BUFFER)
+ t = active_window_start
+ while t <= active_window_end:
+ fetch_fcps_bus_delays.apply_async(eta=t)
+ t += timedelta(seconds=15)
+
+ logger.info("Scheduled all bus delay fetches for today.")
+
+
+@shared_task
+def fetch_fcps_bus_delays():
+ """Fetches bus delay data from the FCPS website and updates the corresponding bus Route record.
+
+ This task retrieves HTML data from the configured BUS_DELAY_URL, parses the HTML to extract
+ delay information for the "JEFFERSON HIGH" bus, and updates the Route in the database if there is a delay.
+ """
+
+ url = settings.BUS_DELAY_URL
+
+ try:
+ response = requests.get(url, timeout=10)
+ response.raise_for_status()
+ except Exception as e:
+ logger.error("Error fetching URL: %s", e)
+ return
+
+ try:
+ soup = BeautifulSoup(response.text, "html.parser")
+ rows = soup.select("table tr")
+ except Exception as e:
+ logger.error("Error parsing HTML: %s", e)
+ return
+
+ if not rows or len(rows) < 2:
+ logger.warning("Not a complete row with all bus information")
+ return
+
+ # Sort out the JEFFERSON HIGH bus delays
+ try:
+ for row in rows[1:]:
+ cells = row.find_all("td")
+ if len(cells) >= 4 and cells[0].text.strip() == "JEFFERSON HIGH":
+ route_name = cells[1].text.strip().split()[0][:100]
+ reason = cells[3].text.strip()[:150]
+ estimated_time_delay = cells[2].text.strip()[:10]
+ try:
+ obj = Route.objects.get(route_name=route_name)
+ # Only update if current status isn't "on time"
+ if obj.status != "a":
+ obj.status = "d"
+ obj.reason = reason
+ obj.estimated_time_delay = estimated_time_delay
+ obj.save(update_fields=["status", "reason", "estimated_time_delay"])
+ logger.info("Updated route %s with delay: %s and ETA: %s", route_name, reason, estimated_time_delay)
+ channel_layer = get_channel_layer()
+ all_routes = list(Route.objects.values())
+ async_to_sync(channel_layer.group_send)(
+ "bus",
+ {
+ "type": "bus.update",
+ "message": {
+ "allRoutes": all_routes,
+ },
+ },
+ )
+ except Route.DoesNotExist:
+ logger.error("Route with route_name %s does not exist", route_name)
+ except Exception as e:
+ logger.error("Error processing bus delays: %s", e)
+ return
diff --git a/intranet/apps/bus/views.py b/intranet/apps/bus/views.py
index 0eb1b766f7d..2676cf2ac51 100644
--- a/intranet/apps/bus/views.py
+++ b/intranet/apps/bus/views.py
@@ -28,11 +28,14 @@ def morning(request, on_home=False):
raise Http404("Bus app not enabled.")
is_bus_admin = request.user.has_admin_permission("bus")
routes = Route.objects.all()
+ bus_delays_queryset = Route.objects.filter(status="d")
+ bus_delays = {delay.route_name: {"reason": delay.reason, "estimated_time_delay": delay.estimated_time_delay} for delay in bus_delays_queryset}
ctx = {
"admin": is_bus_admin,
"enable_bus_driver": False,
"bus_list": routes,
"changeover_time": settings.BUS_PAGE_CHANGEOVER_HOUR,
+ "bus_delays": bus_delays,
"on_home": on_home,
}
return render(request, "bus/morning.html", context=ctx)
@@ -45,19 +48,21 @@ def afternoon(request, on_home=False):
raise Http404("Bus app not enabled.")
is_bus_admin = request.user.has_admin_permission("bus")
- now = timezone.localtime()
+ current_time = timezone.localtime()
day = Day.objects.today()
if day is not None and day.end_time is not None:
- end_of_day = day.end_time.date_obj(now.date())
+ end_of_day = day.end_time.date_obj(current_time.date())
else:
- end_of_day = datetime.datetime(now.year, now.month, now.day, settings.SCHOOL_END_HOUR, settings.SCHOOL_END_MINUTE)
-
+ end_of_day = datetime.datetime(current_time.year, current_time.month, current_time.day, settings.SCHOOL_END_HOUR, settings.SCHOOL_END_MINUTE)
+ bus_delays_queryset = Route.objects.filter(status="d")
+ bus_delays = {delay.route_name: {"reason": delay.reason, "estimated_time_delay": delay.estimated_time_delay} for delay in bus_delays_queryset}
ctx = {
"admin": is_bus_admin,
"enable_bus_driver": True,
"changeover_time": settings.BUS_PAGE_CHANGEOVER_HOUR,
"school_end_hour": end_of_day.hour,
"school_end_time": end_of_day.minute,
+ "bus_delays": bus_delays,
"on_home": on_home,
}
return render(request, "bus/home.html", context=ctx)
diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py
index b480f2ca4e6..3c7d17add86 100644
--- a/intranet/settings/__init__.py
+++ b/intranet/settings/__init__.py
@@ -67,6 +67,23 @@
SENIOR_DESTS_BANNER_LINK = "https://tinyurl.com/tjseniors2025"
""" -------- END UPDATE ANNUALLY -------- """
+
+# Bus Delays
+# Buffer are used in timedelta for when to schedule the bus delay fetches that occur every 15s seconds or 1 minute respectively.
+BUS_DELAY_URL = "https://busdelay.fcps.edu/"
+# First Wwindow Buffer: 2.5 hours before start of school to 1 hour after start of school (for 1 minute task)
+FIRST_WINDOW_START_BUFFER = 2.5
+FIRST_WINDOW_END_BUFFER = 1
+# Second Window Buffer: 20 minutes before end of school to 5 minutes before end of school (for 1 minute task)
+SECOND_WINDOW_START_BUFFER = 20
+SECOND_WINDOW_END_BUFFER = 5
+# Third Window Buffer: 20 minutes after end of school to 2 hours after end of school (for 1 minute task)
+THIRD_WINDOW_START_BUFFER = 20
+THIRD_WINDOW_END_BUFFER = 2
+# Active Window Buffer: 5 minutes before end of school to 20 minutes after end of school (for 15 second task)
+ACTIVE_WINDOW_START_BUFFER = 5
+ACTIVE_WINDOW_END_BUFFER = 20
+
# fmt: on
# Default fallback time for start and end of school if no schedule is available
@@ -955,6 +972,11 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th
"schedule": celery.schedules.crontab(day_of_month=3, hour=1),
"args": (),
},
+ "schedule-all-bus-delay-fetches": {
+ "task": "intranet.apps.bus.tasks.schedule_all_bus_delay_fetches",
+ "schedule": celery.schedules.crontab(hour=0,minute=1),
+ "args": (),
+ },
"remove-old-lostfound-entries": {
"task": "intranet.apps.lostfound.tasks.remove_old_lostfound",
"schedule": celery.schedules.crontab(day_of_month=1, hour=1),
diff --git a/intranet/templates/bus/home.html b/intranet/templates/bus/home.html
index a5b7a59d9fb..012dffc6031 100644
--- a/intranet/templates/bus/home.html
+++ b/intranet/templates/bus/home.html
@@ -113,6 +113,21 @@
{% endif %}
+
+
Current Bus Delays
+ {% if bus_delays %}
+ {% for route, delay in bus_delays.items %}
+
+
+
+ Bus {{ route }} delayed due to: {{ delay.reason|lower }}
ETA: {{ delay.estimated_time_delay }} minutes.
+
+
+ {% endfor %}
+ {% else %}
+
No current delays reported.
+ {% endif %}
+