From ad9360fc6a34b56a7c2d13cac113c861451452d2 Mon Sep 17 00:00:00 2001 From: Vincent Cai <72774400+vcai122@users.noreply.github.com> Date: Tue, 7 May 2024 01:07:54 -0400 Subject: [PATCH] Refactor dining + add nutrition info (#299) --- backend/dining/api_wrapper.py | 43 ++++++++++------- ...006_remove_diningmenu_stations_and_more.py | 47 +++++++++++++++++++ backend/dining/models.py | 11 ++--- backend/dining/serializers.py | 6 +-- backend/tests/dining/test_views.py | 35 +++++++------- 5 files changed, 98 insertions(+), 44 deletions(-) create mode 100644 backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py diff --git a/backend/dining/api_wrapper.py b/backend/dining/api_wrapper.py index 7965d370..e4632ac7 100644 --- a/backend/dining/api_wrapper.py +++ b/backend/dining/api_wrapper.py @@ -1,4 +1,5 @@ import datetime +import json import requests from django.conf import settings @@ -118,11 +119,8 @@ def load_menu(self, date=timezone.now().date()): # TODO: Handle API responses during empty menus (holidays) menu_base = OPEN_DATA_ENDPOINTS["MENUS"] - venues = Venue.objects.all() + venues = [v for v in Venue.objects.all() if v.venue_id not in skipped_venues] for venue in venues: - if venue.venue_id in skipped_venues: - continue - response = self.request("GET", f"{menu_base}?cafe={venue.venue_id}&date={date}").json() # Load new items into database # TODO: There is something called a "goitem" for venues like English House. @@ -147,33 +145,42 @@ def load_menu(self, date=timezone.now().date()): service=daypart["label"], ) # Append stations to dining menu - stations = self.load_stations(daypart["stations"]) - dining_menu.stations.add(*stations) - dining_menu.save() + self.load_stations(daypart["stations"], dining_menu) - def load_stations(self, station_response): - stations = [None] * len(station_response) - for i, station_data in enumerate(station_response): + def load_stations(self, station_response, dining_menu): + for station_data in station_response: # TODO: This is inefficient for venues such as Houston Market - station = DiningStation.objects.create(name=station_data["label"]) + station = DiningStation.objects.create(name=station_data["label"], menu=dining_menu) item_ids = [int(item) for item in station_data["items"]] # Bulk add the items into the station items = DiningItem.objects.filter(item_id__in=item_ids) station.items.add(*items) station.save() - stations[i] = station - return stations def load_items(self, item_response): - item_list = [None] * len(item_response) - for i, key in enumerate(item_response): - value = item_response[key] - item_list[i] = DiningItem( + 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() + ] # Ignore conflicts because possibility of duplicate items - DiningItem.objects.bulk_create(item_list, ignore_conflicts=True) + DiningItem.objects.bulk_create( + item_list, + update_conflicts=True, + update_fields=[ + field.name for field in DiningItem._meta.fields if not field.primary_key + ], + unique_fields=[DiningItem._meta.pk.name], + ) diff --git a/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py new file mode 100644 index 00000000..35e63881 --- /dev/null +++ b/backend/dining/migrations/0006_remove_diningmenu_stations_and_more.py @@ -0,0 +1,47 @@ +# Generated by Django 5.0.2 on 2024-05-06 22:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dining", "0005_diningitem_allergens_diningitem_nutrition_info_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="diningmenu", + name="stations", + ), + migrations.AlterField( + model_name="diningitem", + name="allergens", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AlterField( + model_name="diningitem", + name="description", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AlterField( + model_name="diningitem", + name="ingredients", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AlterField( + model_name="diningitem", + name="nutrition_info", + field=models.CharField(blank=True, max_length=1000), + ), + migrations.AlterField( + model_name="diningstation", + name="menu", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="stations", + to="dining.diningmenu", + ), + ), + ] diff --git a/backend/dining/models.py b/backend/dining/models.py index ebf45eed..dbb5064a 100644 --- a/backend/dining/models.py +++ b/backend/dining/models.py @@ -14,10 +14,10 @@ def __str__(self): class DiningItem(models.Model): item_id = models.IntegerField(primary_key=True) name = models.CharField(max_length=255) - description = models.CharField(max_length=1000) - ingredients = models.CharField(max_length=1000) # comma separated list - allergens = models.CharField(max_length=1000) # comma separated list - nutrition_info = models.CharField(max_length=1000) # json string. + 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 + 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 @@ -28,7 +28,7 @@ def __str__(self): class DiningStation(models.Model): name = models.CharField(max_length=255) items = models.ManyToManyField(DiningItem) - menu = models.ForeignKey("DiningMenu", on_delete=models.CASCADE) + menu = models.ForeignKey("DiningMenu", on_delete=models.CASCADE, related_name="stations") class DiningMenu(models.Model): @@ -36,5 +36,4 @@ class DiningMenu(models.Model): date = models.DateField(default=timezone.now) start_time = models.DateTimeField() end_time = models.DateTimeField() - stations = models.ManyToManyField(DiningStation) service = models.CharField(max_length=255) diff --git a/backend/dining/serializers.py b/backend/dining/serializers.py index 514129db..b812cae7 100644 --- a/backend/dining/serializers.py +++ b/backend/dining/serializers.py @@ -6,13 +6,13 @@ class VenueSerializer(serializers.ModelSerializer): class Meta: model = Venue - fields = ("venue_id", "name", "image_url") + fields = "__all__" class DiningItemSerializer(serializers.ModelSerializer): class Meta: model = DiningItem - fields = ("item_id", "name", "description", "ingredients") + fields = "__all__" class DiningStationSerializer(serializers.ModelSerializer): @@ -29,4 +29,4 @@ class DiningMenuSerializer(serializers.ModelSerializer): class Meta: model = DiningMenu - fields = ("venue", "date", "start_time", "end_time", "stations", "service") + fields = "__all__" diff --git a/backend/tests/dining/test_views.py b/backend/tests/dining/test_views.py index 058a5e6a..2ab0c474 100644 --- a/backend/tests/dining/test_views.py +++ b/backend/tests/dining/test_views.py @@ -9,7 +9,7 @@ from rest_framework.test import APIClient from dining.api_wrapper import APIError, DiningAPIWrapper -from dining.models import Venue +from dining.models import DiningMenu, Venue User = get_user_model() @@ -128,23 +128,24 @@ def try_structure(self, data): self.assertIn("name", item) self.assertIn("description", item) self.assertIn("ingredients", item) + self.assertIn("allergens", item) + self.assertIn("nutrition_info", item) - # COMMEND OUT FOR MIGRATION - # def test_get_default(self): - # response = self.client.get(reverse("menus")) - # self.try_structure(response.json()) - - # def test_get_date(self): - # response = self.client.get("/dining/menus/2022-10-04/") - # self.try_structure(response.json()) - - # @mock.patch("requests.request", mock_dining_requests) - # 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() - # self.assertEqual(DiningMenu.objects.count(), 0) + def test_get_default(self): + response = self.client.get(reverse("menus")) + self.try_structure(response.json()) + + def test_get_date(self): + response = self.client.get("/dining/menus/2022-10-04/") + self.try_structure(response.json()) + + @mock.patch("requests.request", mock_dining_requests) + 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() + self.assertEqual(DiningMenu.objects.count(), 0) class TestPreferences(TestCase):