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 @@
No current delays reported.
+ {% endif %} +