Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 110 additions & 23 deletions backend/dining/api_wrapper.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import datetime
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed

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
Expand All @@ -12,9 +14,38 @@
from utils.errors import APIError


logger = logging.getLogger(__name__)

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"}

# Dining icon ids for parsing the API response cor_icon field into allergen boolean fields
DINING_ICON_IDS = {
"vegetarian": "1",
"vegan": "4",
"in_balance": "7",
"halal": "10",
"kosher": "11",
"jain": "141",
"farm_to_fork": "6",
"locally_crafted": "55",
"garden_grown": "251",
"seafood_watch": "3",
"organic": "8",
"humane": "18",
"raw_undercooked": "228",
"peanut": "253",
"tree_nut": "254",
"sesame": "298",
"shellfish": "256",
"fish": "255",
"soy": "260",
"milk": "258",
"egg": "259",
"ask_us": "262",
"wheat_gluten": "257",
}


class DiningAPIWrapper:
def __init__(self):
Expand Down Expand Up @@ -122,29 +153,42 @@ 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]

# TODO: Handle API responses during empty menus (holidays)
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 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):

with ThreadPoolExecutor(max_workers=8) as executor:
future_to_venue = {
executor.submit(self.fetch_menu, venue.venue_id, date): venue.venue_id
for venue in venues
}

for future in as_completed(future_to_venue):
try:
venue_id, response_json = future.result()
fetched_menus.append((venue_id, response_json))
except Exception as e:
print(f"Error fetching menu: {e}")
except Exception:
logger.exception(
f"Dining: error fetching menu for venue {future_to_venue[future]}"
)

# Process the fetched menus and load them into the database
for venue_id, response in fetched_menus:
Expand Down Expand Up @@ -175,6 +219,9 @@ def load_menu(self, date=timezone.now().date()):
# Append stations to dining menu
self.load_stations(daypart["stations"], dining_menu)

# delete duplicate menus
self.delete_duplicate_menus(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
Expand All @@ -186,23 +233,31 @@ def load_stations(self, station_response, dining_menu):
station.items.add(*items)
station.save()

def _build_dining_item(self, key, value):
"""
Helper function for load_items to build a DiningItem object from the dining API
"""
icon_ids = value["cor_icon"] or {}
icon_flags = {
field_name: icon_id in icon_ids for field_name, icon_id in DINING_ICON_IDS.items()
}
return DiningItem(
item_id=key,
name=value["label"],
description=value["description"],
ingredients=value["ingredients"],
allergens=", ".join(value["cor_icon"].values()) if value["cor_icon"] else "",
**icon_flags,
nutrition_info=json.dumps(
{
x["label"]: f"{x['value']}{x['unit']}"
for x in value["nutrition_details"].values()
}
),
)

def load_items(self, item_response):
item_list = [
DiningItem(
item_id=key,
name=value["label"],
description=value["description"],
ingredients=value["ingredients"],
allergens=", ".join(value["cor_icon"].values()) if value["cor_icon"] else "",
nutrition_info=json.dumps(
{
x["label"]: f"{x['value']}{x['unit']}"
for x in value["nutrition_details"].values()
}
),
)
for key, value in item_response.items()
]
item_list = [self._build_dining_item(key, value) for key, value in item_response.items()]
# Ignore conflicts because possibility of duplicate items
DiningItem.objects.bulk_create(
item_list,
Expand All @@ -212,3 +267,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
19 changes: 11 additions & 8 deletions backend/dining/management/commands/load_next_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,24 @@ class Command(BaseCommand):
the next 7 days, including the original date.
"""

def load_one_menu(self, delta, *args, **kwargs):
def load_one_menu(self, today, delta, *args, **kwargs):
"""
Loads menu for a single day
"""
date_to_load = today + datetime.timedelta(days=delta)

d = DiningAPIWrapper()
d.load_menu(timezone.now().date() + datetime.timedelta(days=delta))
delete_menu_view_cache(timezone.now().date() + datetime.timedelta(days=delta))
self.stdout.write(
"Loaded new Dining Menu for "
+ str(timezone.now().date() + datetime.timedelta(days=delta))
)
d.load_menus(date_to_load)
delete_menu_view_cache(date_to_load)

# Error logging
self.stdout.write(f"Loaded new Dining Menu for {date_to_load}")

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_menu(today, i)
128 changes: 128 additions & 0 deletions backend/dining/migrations/0007_diningitem_allergen_booleans.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Generated by Django 5.0.2 on 2026-04-17 00:00

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("dining", "0006_remove_diningmenu_stations_and_more"),
]

operations = [
migrations.AddField(
model_name="diningitem",
name="ask_us",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="egg",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="fish",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="farm_to_fork",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="garden_grown",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="halal",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="humane",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="in_balance",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="jain",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="kosher",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="locally_crafted",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="milk",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="organic",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="peanut",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="raw_undercooked",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="seafood_watch",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="sesame",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="shellfish",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="soy",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="tree_nut",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="vegan",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="vegetarian",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="diningitem",
name="wheat_gluten",
field=models.BooleanField(default=False),
),
]
27 changes: 26 additions & 1 deletion backend/dining/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,36 @@ class DiningItem(models.Model):
name = models.CharField(max_length=255)
description = models.CharField(max_length=1000, blank=True)
ingredients = models.CharField(max_length=1000, blank=True) # comma separated list
allergens = models.CharField(max_length=1000, blank=True) # comma separated list
allergens = models.CharField(max_length=1000, blank=True)
nutrition_info = models.CharField(max_length=1000, blank=True) # json string.
# Technically, postgres supports json fields but that involves local postgres
# instead of sqlite AND we don't need to query on this field

# Dietary information fields (stored as cor_icons)
vegetarian = models.BooleanField(default=False)
vegan = models.BooleanField(default=False)
in_balance = models.BooleanField(default=False)
halal = models.BooleanField(default=False)
kosher = models.BooleanField(default=False)
jain = models.BooleanField(default=False)
farm_to_fork = models.BooleanField(default=False)
locally_crafted = models.BooleanField(default=False)
garden_grown = models.BooleanField(default=False)
seafood_watch = models.BooleanField(default=False)
organic = models.BooleanField(default=False)
humane = models.BooleanField(default=False)
raw_undercooked = models.BooleanField(default=False)
peanut = models.BooleanField(default=False)
tree_nut = models.BooleanField(default=False)
sesame = models.BooleanField(default=False)
shellfish = models.BooleanField(default=False)
fish = models.BooleanField(default=False)
soy = models.BooleanField(default=False)
milk = models.BooleanField(default=False)
egg = models.BooleanField(default=False)
ask_us = models.BooleanField(default=False)
wheat_gluten = models.BooleanField(default=False)

def __str__(self):
return f"{self.name}"

Expand Down
Loading
Loading