-
Notifications
You must be signed in to change notification settings - Fork 104
feat(bus): fetch fcps bus delay information #1796
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)), | ||
| ], | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add docstrings |
||
| """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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you change this to calculate based on schedule too (I know this is old)
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do you mean calculate this with the schedule, this else statement only runs if a day doesn't exist (holiday or smth) so how would I use the schedule app because isn't this supposed to be coded like this?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oops sorry you're right |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you code these
timedelta's in as settings? Also, I feel like checking 1 hour before is unnecessary. Most people are in class and it doesn't really matter if their bus is delayed until 10-15 minutes before dismissal. This is just a suggestion of courseUh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand how you want me to code the
timedelta's in settings, would you want it likeBUS_DELAY_THIRD_WINDOW_START = timedelta(minutes=20)? Because I can't do the windows in as settings because it needs to usestart_datetimeandend_datetime, or I could just leave it as is?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah something like this in settings
and then in the task
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this fine? Please tell me if any of the comments or variable names need changing for any formatting reasons.