From 8b305c247d94aa0dc64cf9f9a0b7d32f048cd7f2 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Sun, 1 Mar 2026 13:57:59 -0500 Subject: [PATCH 1/8] load_next_menu loads 7 days in advance --- .../management/commands/load_next_menu.py | 14 +++++++++++--- backend/dining/views.py | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index 42bb667a..a68e0294 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -5,7 +5,9 @@ from dining.api_wrapper import DiningAPIWrapper +from concurrent.futures import ThreadPoolExecutor +yes class Command(BaseCommand): """ Loads Menu for 1 week in advance. @@ -13,7 +15,13 @@ class Command(BaseCommand): the next 7 days, including the original date. """ - def handle(self, *args, **kwargs): + def load_one_menu(self, delta, *args, **kwargs): d = DiningAPIWrapper() - d.load_menu(timezone.now().date() + datetime.timedelta(days=6)) - self.stdout.write("Loaded new Dining Menu!") + d.load_menu(timezone.now().date() + datetime.timedelta(days=delta)) + self.stdout.write("Loaded new Dining Menu for " + str(timezone.now().date() + datetime.timedelta(days=delta))) + + + def handle(self, *args, **kwargs): + with ThreadPoolExecutor() as executor: + for i in range(7): + executor.submit(self.load_one_menu, i, *args, **kwargs) \ No newline at end of file diff --git a/backend/dining/views.py b/backend/dining/views.py index 15a24815..ef46f6ef 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -6,6 +6,9 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import make_aware +# NEW +from django.db.models import F, Window +from django.db.models.functions.window import RowNumber from rest_framework import generics from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -17,7 +20,6 @@ from pennmobile.analytics import LabsAnalytics from utils.cache import Cache - d = DiningAPIWrapper() @@ -54,11 +56,21 @@ def get_queryset(self): # if date_param is out of bounds if date_param := self.kwargs.get("date"): date = make_aware(datetime.datetime.strptime(date_param, "%Y-%m-%d")) - return DiningMenu.objects.filter(date=date) + base = DiningMenu.objects.filter(date=date) else: start_date = timezone.now().date() end_date = start_date + datetime.timedelta(days=6) - return DiningMenu.objects.filter(date__gte=start_date, date__lte=end_date) + base = DiningMenu.objects.filter(date__gte=start_date, date__lte=end_date) + # Only returns most recently loaded menus (because there may be multiple in the database for one day) + latest = base.annotate( + rn=Window( + expression=RowNumber(), + partition_by=[F("venue_id"), F("date"), F("service"), F("start_time"), F("end_time")], + order_by=[F("id").desc()], + ) + ).filter(rn=1) + + return latest class Preferences(APIView): From f8083ac145875a37b32063dbe2d4763a517a750b Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:15:29 -0400 Subject: [PATCH 2/8] updated threading to only be with API loading --- backend/dining/-- SQLite.sql | 26 +++++++++++++ backend/dining/api_wrapper.py | 39 +++++++++++++++---- .../management/commands/load_next_menu.py | 26 +++++++++---- backend/dining/urls.py | 4 +- 4 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 backend/dining/-- SQLite.sql diff --git a/backend/dining/-- SQLite.sql b/backend/dining/-- SQLite.sql new file mode 100644 index 00000000..9e8e980a --- /dev/null +++ b/backend/dining/-- SQLite.sql @@ -0,0 +1,26 @@ +-- SQLite +SELECT DISTINCT + v.venue_id, + v.name AS venue_name, + m.id AS menu_id, + m.date AS menu_date, + m.service, + s.id AS station_id, + s.name AS station_name, +-- i.item_id, + i.name AS item_name +FROM dining_venue v +JOIN dining_diningmenu m + ON m.venue_id = v.venue_id +JOIN dining_diningstation s + ON s.menu_id = m.id +JOIN dining_diningstation_items si + ON si.diningstation_id = s.id +JOIN dining_diningitem i + ON i.item_id = si.diningitem_id +WHERE v.venue_id IN (593, 636, 637, 1442) + AND m.date = '2026-03-25' + AND station_name = 'comfort' + AND venue_name = 'English House' + -- Hill House, English House, Lauder College House, 1920 Commons +ORDER BY v.name, m.service, s.name, i.name; diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index e4632ac7..23d758e3 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -1,3 +1,4 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed import datetime import json @@ -5,12 +6,11 @@ from django.conf import settings from django.utils import timezone from django.utils.timezone import make_aware -from requests.exceptions import ConnectTimeout, ReadTimeout +from requests.exceptions import ConnectTimeout, ReadTimeout, ConnectionError from dining.models import DiningItem, DiningMenu, DiningStation, Venue from utils.errors import APIError - OPEN_DATA_URL = "https://3scale-public-prod-open-data.apps.k8s.upenn.edu/api/v1/dining/" OPEN_DATA_ENDPOINTS = {"VENUES": OPEN_DATA_URL + "venues", "MENUS": OPEN_DATA_URL + "menus"} @@ -107,21 +107,44 @@ def get_venues(self): results.append(value) return results + def fetch_menu(self, venue_id, date): + """ + Calls API to fetch menu for a given venue and date + """ + worker = DiningAPIWrapper() # avoid shared mutable token state across threads + menu_base = OPEN_DATA_ENDPOINTS["MENUS"] + response = worker.request("GET", f"{menu_base}?cafe={venue_id}&date={date}") + if response.status_code != 200: + raise APIError("Dining: Error connecting to API") # MINGHAN WHICH ERRORS SHOULD I BE CATCHING + return venue_id, response.json() + def load_menu(self, date=timezone.now().date()): """ - Loads the weeks menu starting from today + Loads today's menu NOTE: This method should only be used in load_next_menu.py, which is run based on a cron job every day """ - # Venues without a menu should not be parsed skipped_venues = [747, 1163, 1731, 1732, 1733, 1464004, 1464009] # TODO: Handle API responses during empty menus (holidays) - menu_base = OPEN_DATA_ENDPOINTS["MENUS"] venues = [v for v in Venue.objects.all() if v.venue_id not in skipped_venues] - for venue in venues: - response = self.request("GET", f"{menu_base}?cafe={venue.venue_id}&date={date}").json() + venue_map = {venue.venue_id: venue for venue in venues} + + # Fetch all menus in parallel to speed up loading time. + fetched_menus = [] + with ThreadPoolExecutor(max_workers=8) as executor: # 8 can be tuned + futures = [executor.submit(self.fetch_menu, venue.venue_id, date) for venue in venues] + for future in as_completed(futures): + try: + venue_id, response_json = future.result() + fetched_menus.append((venue_id, response_json)) + except Exception as e: + print(f"Error fetching menu: {e}") + + # Process the fetched menus and load them into the database + for venue_id, response in fetched_menus: + venue = venue_map[venue_id] # Load new items into database # TODO: There is something called a "goitem" for venues like English House. # We are currently not loading them in @@ -144,9 +167,11 @@ def load_menu(self, date=timezone.now().date()): end_time=daypart["endtime"], service=daypart["label"], ) + # Append stations to dining menu self.load_stations(daypart["stations"], dining_menu) + def load_stations(self, station_response, dining_menu): for station_data in station_response: # TODO: This is inefficient for venues such as Houston Market diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index a68e0294..f8292a81 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -1,27 +1,39 @@ import datetime +from concurrent.futures import ThreadPoolExecutor, as_completed from django.core.management.base import BaseCommand +from django.db import close_old_connections from django.utils import timezone from dining.api_wrapper import DiningAPIWrapper -from concurrent.futures import ThreadPoolExecutor - -yes class Command(BaseCommand): """ - Loads Menu for 1 week in advance. + Loads menu for the next 7 days, starting from today. Takes about 3 minutes to run. Invariant: For every date, the database should contain the menus for the next 7 days, including the original date. """ def load_one_menu(self, delta, *args, **kwargs): + """ + Loads menu for a single day + """ d = DiningAPIWrapper() d.load_menu(timezone.now().date() + datetime.timedelta(days=delta)) self.stdout.write("Loaded new Dining Menu for " + str(timezone.now().date() + datetime.timedelta(days=delta))) def handle(self, *args, **kwargs): - with ThreadPoolExecutor() as executor: - for i in range(7): - executor.submit(self.load_one_menu, i, *args, **kwargs) \ No newline at end of file + """ + Load menu for the next 7 days + """ + start_time = timezone.now() + + for i in range(7): + self.load_one_menu(i) + + end_time = timezone.now() + elapsed_time = end_time - start_time + self.stdout.write(f"TOTAL menu stuff for today took {elapsed_time} seconds") + + \ No newline at end of file diff --git a/backend/dining/urls.py b/backend/dining/urls.py index d0e5e6a0..36eacc62 100644 --- a/backend/dining/urls.py +++ b/backend/dining/urls.py @@ -7,7 +7,7 @@ urlpatterns = [ path("venues/", cache_page(12 * Cache.HOUR)(Venues.as_view()), name="venues"), - path("menus/", cache_page(3 * Cache.HOUR)(Menus.as_view()), name="menus"), - path("menus//", cache_page(3 * Cache.HOUR)(Menus.as_view()), name="menus-with-date"), + path("menus/", Menus.as_view(), name="menus"), # cache_page(3 * Cache.HOUR)(Menus.as_view()) + path("menus//", Menus.as_view(), name="menus-with-date"), # cache_page(3 * Cache.HOUR)(Menus.as_view()) path("preferences/", Preferences.as_view(), name="dining-preferences"), ] From 13836917bc4fba72ee8f92ff4349bf8f1d36f231 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:05:52 -0400 Subject: [PATCH 3/8] makes api_wrapper thread only API calls --- backend/dining/-- SQLite.sql | 26 ------------------- backend/dining/api_wrapper.py | 6 ++--- .../management/commands/load_next_menu.py | 6 ----- 3 files changed, 3 insertions(+), 35 deletions(-) delete mode 100644 backend/dining/-- SQLite.sql diff --git a/backend/dining/-- SQLite.sql b/backend/dining/-- SQLite.sql deleted file mode 100644 index 9e8e980a..00000000 --- a/backend/dining/-- SQLite.sql +++ /dev/null @@ -1,26 +0,0 @@ --- SQLite -SELECT DISTINCT - v.venue_id, - v.name AS venue_name, - m.id AS menu_id, - m.date AS menu_date, - m.service, - s.id AS station_id, - s.name AS station_name, --- i.item_id, - i.name AS item_name -FROM dining_venue v -JOIN dining_diningmenu m - ON m.venue_id = v.venue_id -JOIN dining_diningstation s - ON s.menu_id = m.id -JOIN dining_diningstation_items si - ON si.diningstation_id = s.id -JOIN dining_diningitem i - ON i.item_id = si.diningitem_id -WHERE v.venue_id IN (593, 636, 637, 1442) - AND m.date = '2026-03-25' - AND station_name = 'comfort' - AND venue_name = 'English House' - -- Hill House, English House, Lauder College House, 1920 Commons -ORDER BY v.name, m.service, s.name, i.name; diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 23d758e3..a3e97745 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -59,7 +59,7 @@ def get_venues(self): venues_route = OPEN_DATA_ENDPOINTS["VENUES"] response = self.request("GET", venues_route) if response.status_code != 200: - raise APIError("Dining: Error connecting to API") + raise APIError(f"Dining: error connecting to API " + response.text) venues = response.json()["result_data"]["campuses"]["203"]["cafes"] for key, value in venues.items(): # Cleaning up json response @@ -115,8 +115,8 @@ def fetch_menu(self, venue_id, date): menu_base = OPEN_DATA_ENDPOINTS["MENUS"] response = worker.request("GET", f"{menu_base}?cafe={venue_id}&date={date}") if response.status_code != 200: - raise APIError("Dining: Error connecting to API") # MINGHAN WHICH ERRORS SHOULD I BE CATCHING - return venue_id, response.json() + raise APIError(f"Dining: error connecting to API " + response.text) + return venue_id, response.json() # also storing venue_id to later access in fetched_menus list def load_menu(self, date=timezone.now().date()): """ diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index f8292a81..16de74ee 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -27,13 +27,7 @@ def handle(self, *args, **kwargs): """ Load menu for the next 7 days """ - start_time = timezone.now() - for i in range(7): self.load_one_menu(i) - - end_time = timezone.now() - elapsed_time = end_time - start_time - self.stdout.write(f"TOTAL menu stuff for today took {elapsed_time} seconds") \ No newline at end of file From 06987e6ef9355466a1eef272d62f4b8f8c8b4e9a Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:25:15 -0400 Subject: [PATCH 4/8] formatting fixes --- backend/dining/api_wrapper.py | 23 +++++++++++-------- .../management/commands/load_next_menu.py | 11 ++++----- backend/dining/urls.py | 4 ++-- backend/dining/views.py | 19 ++++++++++----- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index a3e97745..f4ef4a5a 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -1,16 +1,17 @@ -from concurrent.futures import ThreadPoolExecutor, as_completed import datetime import json +from concurrent.futures import ThreadPoolExecutor, as_completed import requests from django.conf import settings from django.utils import timezone from django.utils.timezone import make_aware -from requests.exceptions import ConnectTimeout, ReadTimeout, ConnectionError +from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from dining.models import DiningItem, DiningMenu, DiningStation, Venue from utils.errors import APIError + OPEN_DATA_URL = "https://3scale-public-prod-open-data.apps.k8s.upenn.edu/api/v1/dining/" OPEN_DATA_ENDPOINTS = {"VENUES": OPEN_DATA_URL + "venues", "MENUS": OPEN_DATA_URL + "menus"} @@ -59,7 +60,7 @@ def get_venues(self): venues_route = OPEN_DATA_ENDPOINTS["VENUES"] response = self.request("GET", venues_route) if response.status_code != 200: - raise APIError(f"Dining: error connecting to API " + response.text) + raise APIError("Dining: error connecting to API " + response.text) venues = response.json()["result_data"]["campuses"]["203"]["cafes"] for key, value in venues.items(): # Cleaning up json response @@ -111,12 +112,15 @@ def fetch_menu(self, venue_id, date): """ Calls API to fetch menu for a given venue and date """ - worker = DiningAPIWrapper() # avoid shared mutable token state across threads + worker = DiningAPIWrapper() # avoid shared mutable token state across threads menu_base = OPEN_DATA_ENDPOINTS["MENUS"] response = worker.request("GET", f"{menu_base}?cafe={venue_id}&date={date}") if response.status_code != 200: - raise APIError(f"Dining: error connecting to API " + response.text) - return venue_id, response.json() # also storing venue_id to later access in fetched_menus list + raise APIError("Dining: error connecting to API " + response.text) + return ( + venue_id, + response.json(), + ) # also storing venue_id to later access in fetched_menus list def load_menu(self, date=timezone.now().date()): """ @@ -131,9 +135,9 @@ def load_menu(self, date=timezone.now().date()): venues = [v for v in Venue.objects.all() if v.venue_id not in skipped_venues] venue_map = {venue.venue_id: venue for venue in venues} - # Fetch all menus in parallel to speed up loading time. + # Fetch all menus in parallel to speed up loading time. fetched_menus = [] - with ThreadPoolExecutor(max_workers=8) as executor: # 8 can be tuned + with ThreadPoolExecutor(max_workers=8) as executor: # 8 can be tuned futures = [executor.submit(self.fetch_menu, venue.venue_id, date) for venue in venues] for future in as_completed(futures): try: @@ -141,7 +145,7 @@ def load_menu(self, date=timezone.now().date()): fetched_menus.append((venue_id, response_json)) except Exception as e: print(f"Error fetching menu: {e}") - + # Process the fetched menus and load them into the database for venue_id, response in fetched_menus: venue = venue_map[venue_id] @@ -171,7 +175,6 @@ def load_menu(self, date=timezone.now().date()): # Append stations to dining menu self.load_stations(daypart["stations"], dining_menu) - def load_stations(self, station_response, dining_menu): for station_data in station_response: # TODO: This is inefficient for venues such as Houston Market diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index 16de74ee..d201738d 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -1,12 +1,11 @@ import datetime -from concurrent.futures import ThreadPoolExecutor, as_completed from django.core.management.base import BaseCommand -from django.db import close_old_connections from django.utils import timezone from dining.api_wrapper import DiningAPIWrapper + class Command(BaseCommand): """ Loads menu for the next 7 days, starting from today. Takes about 3 minutes to run. @@ -20,8 +19,10 @@ def load_one_menu(self, delta, *args, **kwargs): """ d = DiningAPIWrapper() d.load_menu(timezone.now().date() + datetime.timedelta(days=delta)) - self.stdout.write("Loaded new Dining Menu for " + str(timezone.now().date() + datetime.timedelta(days=delta))) - + self.stdout.write( + "Loaded new Dining Menu for " + + str(timezone.now().date() + datetime.timedelta(days=delta)) + ) def handle(self, *args, **kwargs): """ @@ -29,5 +30,3 @@ def handle(self, *args, **kwargs): """ for i in range(7): self.load_one_menu(i) - - \ No newline at end of file diff --git a/backend/dining/urls.py b/backend/dining/urls.py index 36eacc62..d0e5e6a0 100644 --- a/backend/dining/urls.py +++ b/backend/dining/urls.py @@ -7,7 +7,7 @@ urlpatterns = [ path("venues/", cache_page(12 * Cache.HOUR)(Venues.as_view()), name="venues"), - path("menus/", Menus.as_view(), name="menus"), # cache_page(3 * Cache.HOUR)(Menus.as_view()) - path("menus//", Menus.as_view(), name="menus-with-date"), # cache_page(3 * Cache.HOUR)(Menus.as_view()) + path("menus/", cache_page(3 * Cache.HOUR)(Menus.as_view()), name="menus"), + path("menus//", cache_page(3 * Cache.HOUR)(Menus.as_view()), name="menus-with-date"), path("preferences/", Preferences.as_view(), name="dining-preferences"), ] diff --git a/backend/dining/views.py b/backend/dining/views.py index ef46f6ef..3a8c72ea 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -2,13 +2,13 @@ from analytics.entries import FuncEntry, ViewEntry from django.core.cache import cache -from django.db.models import Count + +# NEW +from django.db.models import Count, F, Window +from django.db.models.functions.window import RowNumber from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import make_aware -# NEW -from django.db.models import F, Window -from django.db.models.functions.window import RowNumber from rest_framework import generics from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -20,6 +20,7 @@ from pennmobile.analytics import LabsAnalytics from utils.cache import Cache + d = DiningAPIWrapper() @@ -61,11 +62,17 @@ def get_queryset(self): start_date = timezone.now().date() end_date = start_date + datetime.timedelta(days=6) base = DiningMenu.objects.filter(date__gte=start_date, date__lte=end_date) - # Only returns most recently loaded menus (because there may be multiple in the database for one day) + # Only returns most recently loaded menus latest = base.annotate( rn=Window( expression=RowNumber(), - partition_by=[F("venue_id"), F("date"), F("service"), F("start_time"), F("end_time")], + partition_by=[ + F("venue_id"), + F("date"), + F("service"), + F("start_time"), + F("end_time"), + ], order_by=[F("id").desc()], ) ).filter(rn=1) From cc1fb3532b2877124e51a673556f6e61b39d9728 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:37:55 -0400 Subject: [PATCH 5/8] PLEASE LINTER --- backend/dining/views.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backend/dining/views.py b/backend/dining/views.py index 3a8c72ea..930c954f 100644 --- a/backend/dining/views.py +++ b/backend/dining/views.py @@ -2,8 +2,6 @@ from analytics.entries import FuncEntry, ViewEntry from django.core.cache import cache - -# NEW from django.db.models import Count, F, Window from django.db.models.functions.window import RowNumber from django.shortcuts import get_object_or_404 From 3820d2f50173bd1b558a6d0e72f41878ca8e4b22 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Sun, 22 Mar 2026 01:00:32 -0400 Subject: [PATCH 6/8] now it passes the test case --- backend/dining/api_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index f4ef4a5a..047ca1ef 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -6,7 +6,7 @@ from django.conf import settings from django.utils import timezone from django.utils.timezone import make_aware -from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout +from requests.exceptions import ConnectTimeout, ReadTimeout from dining.models import DiningItem, DiningMenu, DiningStation, Venue from utils.errors import APIError From ce1f632e76f99fd2a0b74a95bbc1805df2981098 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:14:00 -0400 Subject: [PATCH 7/8] updated imports --- backend/dining/api_wrapper.py | 2 +- backend/tests/dining/test_views.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 047ca1ef..f4ef4a5a 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -6,7 +6,7 @@ from django.conf import settings from django.utils import timezone from django.utils.timezone import make_aware -from requests.exceptions import ConnectTimeout, ReadTimeout +from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout from dining.models import DiningItem, DiningMenu, DiningStation, Venue from utils.errors import APIError diff --git a/backend/tests/dining/test_views.py b/backend/tests/dining/test_views.py index 2ab0c474..305643bf 100644 --- a/backend/tests/dining/test_views.py +++ b/backend/tests/dining/test_views.py @@ -6,6 +6,7 @@ from django.core.management import call_command from django.test import TestCase from django.urls import reverse +from requests.exceptions import ConnectionError from rest_framework.test import APIClient from dining.api_wrapper import APIError, DiningAPIWrapper From ba3825a084e7bc88c2f87ca8973e88875245ba34 Mon Sep 17 00:00:00 2001 From: gracech1 <182316211+gracech1@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:57:07 -0400 Subject: [PATCH 8/8] load_next_menu.py deletes all duplicate menus --- backend/dining/api_wrapper.py | 45 +++++++- .../management/commands/load_next_menu.py | 14 +-- backend/dining/models.py | 4 + backend/tests/dining/test_load_menus.py | 102 ++++++++++++++++++ backend/tests/dining/test_views.py | 2 +- 5 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 backend/tests/dining/test_load_menus.py diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index f4ef4a5a..90c71490 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -4,6 +4,7 @@ import requests from django.conf import settings +from django.db.models import Count, Max from django.utils import timezone from django.utils.timezone import make_aware from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout @@ -122,12 +123,18 @@ def fetch_menu(self, venue_id, date): response.json(), ) # also storing venue_id to later access in fetched_menus list - def load_menu(self, date=timezone.now().date()): + def load_menus(self, date=None): """ Loads today's menu + Invariant: there should be no duplicate Menus. `load_menus` should delete + duplicate menus for all venues for the given date. + NOTE: This method should only be used in load_next_menu.py, which is run based on a cron job every day """ + if date is None: + date = timezone.now().date() + # Venues without a menu should not be parsed skipped_venues = [747, 1163, 1731, 1732, 1733, 1464004, 1464009] @@ -175,6 +182,10 @@ def load_menu(self, date=timezone.now().date()): # Append stations to dining menu self.load_stations(daypart["stations"], dining_menu) + # delete duplicate menus + deleted_count = self.delete_duplicate_menus(date) + print(deleted_count, "duplicate objects deleted for date", date) + def load_stations(self, station_response, dining_menu): for station_data in station_response: # TODO: This is inefficient for venues such as Houston Market @@ -212,3 +223,35 @@ def load_items(self, item_response): ], unique_fields=[DiningItem._meta.pk.name], ) + + def delete_duplicate_menus(self, date): + """Delete duplicate menus for an exact `date`. + Will delete all but the most recently created menus for each dining hall + """ + # Find groups of duplicate menus + duplicate_groups = ( + DiningMenu.objects.values("venue", "date", "start_time", "end_time", "service") + .annotate(menu_count=Count("id"), keep_id=Max("id")) + .filter(menu_count__gt=1, date=date) + ) + + # Find all ids to delete + ids_to_delete = [] + + for group in duplicate_groups: + ids = ( + DiningMenu.objects.filter( + venue=group["venue"], + date=group["date"], + start_time=group["start_time"], + end_time=group["end_time"], + service=group["service"], + ) + .exclude(id=group["keep_id"]) + .values_list("id", flat=True) + ) + ids_to_delete.extend(ids) + + # Delete all duplicates + deleted_count, _ = DiningMenu.objects.filter(id__in=ids_to_delete).delete() + return deleted_count diff --git a/backend/dining/management/commands/load_next_menu.py b/backend/dining/management/commands/load_next_menu.py index d201738d..022955c7 100644 --- a/backend/dining/management/commands/load_next_menu.py +++ b/backend/dining/management/commands/load_next_menu.py @@ -13,20 +13,22 @@ class Command(BaseCommand): the next 7 days, including the original date. """ - def load_one_menu(self, delta, *args, **kwargs): + def load_one_day(self, today, delta, *args, **kwargs): """ - Loads menu for a single day + Loads all menus for a single day """ d = DiningAPIWrapper() - d.load_menu(timezone.now().date() + datetime.timedelta(days=delta)) + + d.load_menus(today + datetime.timedelta(days=delta)) self.stdout.write( - "Loaded new Dining Menu for " - + str(timezone.now().date() + datetime.timedelta(days=delta)) + "Loaded new Dining Menu for " + str(today + datetime.timedelta(days=delta)) ) def handle(self, *args, **kwargs): """ Load menu for the next 7 days """ + today = timezone.now().date() + for i in range(7): - self.load_one_menu(i) + self.load_one_day(today, i) diff --git a/backend/dining/models.py b/backend/dining/models.py index dbb5064a..c6610261 100644 --- a/backend/dining/models.py +++ b/backend/dining/models.py @@ -21,6 +21,10 @@ class DiningItem(models.Model): # Technically, postgres supports json fields but that involves local postgres # instead of sqlite AND we don't need to query on this field + # TODO: New fields to add from allergens: + # vegetarian, vegan, kosher, jain, ask us + # peanut, tree nut, sesame, fish, wheat/gluten, milk, egg, soy + def __str__(self): return f"{self.name}" diff --git a/backend/tests/dining/test_load_menus.py b/backend/tests/dining/test_load_menus.py new file mode 100644 index 00000000..ff8ebc66 --- /dev/null +++ b/backend/tests/dining/test_load_menus.py @@ -0,0 +1,102 @@ +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from dining.api_wrapper import DiningAPIWrapper +from dining.models import DiningMenu, Venue + + +def _make_response(venue_id, date_str, dayparts): + return { + "menus": { + "items": {}, + "days": [ + { + "date": date_str, + "cafes": {str(venue_id): {"dayparts": [dayparts]}}, + } + ], + } + } + + +class TestLoadMenus(TestCase): + def test_load_menus_idempotent(self): + """ + Calling `load_menus` twice should not create duplicate menus. + """ + # Make some new venues + venues = [ + Venue.objects.create(venue_id=2001, name="Hill", image_url="http://x"), + Venue.objects.create(venue_id=2002, name="English", image_url="http://x"), + Venue.objects.create(venue_id=2003, name="Lauder", image_url="http://x"), + ] + + date = timezone.now().date() + date_str = date.isoformat() + + # Each venue will have two meals/dayparts + dayparts = [ + {"starttime": "08:00", "endtime": "10:00", "label": "Breakfast", "stations": []}, + {"starttime": "10:00", "endtime": "14:00", "label": "Lunch", "stations": []}, + ] + + # fetch a fake response for these menus + def fake_fetch(self, venue_id, d): + dayparts_copy = [dict(dp) for dp in dayparts] + return (venue_id, _make_response(venue_id, date_str, dayparts_copy)) + + # load menus twice + with patch.object(DiningAPIWrapper, "fetch_menu", new=fake_fetch): + wrapper = DiningAPIWrapper() + wrapper.load_menus(date) + count_after_first = DiningMenu.objects.filter(date=date).count() + + wrapper.load_menus(date) + count_after_second = DiningMenu.objects.filter(date=date).count() + + # there should be the same amount pre-fetch and post-fetch + expected = len(venues) * len(dayparts) + self.assertEqual(count_after_first, expected) + self.assertEqual(count_after_second, expected) + + def test_delete_duplicate_menus(self): + """ + `delete_duplicate_menus` should remove all but the most recently + created menu for duplicate menu groups on the same date. + """ + venue = Venue.objects.create(venue_id=9001, name="Dup", image_url="http://x") + + date = timezone.now().date() + + start_time = timezone.now() + end_time = start_time + timedelta(hours=1) + + # Create three duplicate menus (same venue, date, times, service). + menus = [ + DiningMenu.objects.create( + venue=venue, + date=date, + start_time=start_time, + end_time=end_time, + service="Dinner", + ) + for _ in range(3) + ] + + # Confrim we created 3 menus + self.assertEqual(DiningMenu.objects.filter(venue=venue, date=date).count(), 3) + + wrapper = DiningAPIWrapper() + deleted_count = wrapper.delete_duplicate_menus(date) + + # Two should be deleted, one should remain + self.assertEqual(deleted_count, 2) + remaining = DiningMenu.objects.filter(venue=venue, date=date) + self.assertEqual(remaining.count(), 1) + + # The remaining menu should be the one with the highest id + kept_id = max(m.id for m in menus) + self.assertEqual(remaining.first().id, kept_id) diff --git a/backend/tests/dining/test_views.py b/backend/tests/dining/test_views.py index 305643bf..c1e852b9 100644 --- a/backend/tests/dining/test_views.py +++ b/backend/tests/dining/test_views.py @@ -145,7 +145,7 @@ def test_skip_venue(self): Venue.objects.all().delete() Venue.objects.create(venue_id=747, name="Skip", image_url="URL") wrapper = DiningAPIWrapper() - wrapper.load_menu() + wrapper.load_menus() self.assertEqual(DiningMenu.objects.count(), 0)