diff --git a/vrobbler/apps/beers/models.py b/vrobbler/apps/beers/models.py index feb2d89..a35f1e7 100644 --- a/vrobbler/apps/beers/models.py +++ b/vrobbler/apps/beers/models.py @@ -132,6 +132,6 @@ class Beer(ScrobblableMixin): def scrobbles(self, user_id): Scrobble = apps.get_model("scrobbles", "Scrobble") - return Scrobble.objects.filter( - user_id=user_id, life_event=self - ).order_by("-timestamp") + return Scrobble.objects.filter(user_id=user_id, beer=self).order_by( + "-timestamp" + ) diff --git a/vrobbler/apps/foods/admin.py b/vrobbler/apps/foods/admin.py new file mode 100644 index 0000000..6c1b685 --- /dev/null +++ b/vrobbler/apps/foods/admin.py @@ -0,0 +1,28 @@ +from foods.models import Food, FoodCategory +from django.contrib import admin +from scrobbles.admin import ScrobbleInline + + +class FoodInline(admin.TabularInline): + model = Food + extra = 0 + + +@admin.register(FoodCategory) +class FoodCategoryAdmin(admin.ModelAdmin): + date_hierarchy = "created" + search_fields = ("name",) + + +@admin.register(Food) +class FoodAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "uuid", + "title", + ) + ordering = ("-created",) + search_fields = ("title",) + inlines = [ + ScrobbleInline, + ] diff --git a/vrobbler/apps/foods/allrecipe.py b/vrobbler/apps/foods/allrecipe.py new file mode 100644 index 0000000..b812b41 --- /dev/null +++ b/vrobbler/apps/foods/allrecipe.py @@ -0,0 +1,143 @@ +import json +import logging +import urllib +from typing import Optional + +import requests +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + +ALLRECIPE_URL = "https://allrecipe.com/{id}" + + +def get_first(key: str, result: dict) -> str: + obj = "" + if obj_list := result.get(key): + obj = obj_list[0] + return obj + + +def get_title_from_soup(soup) -> str: + title = "" + try: + title = soup.find("h1").get_text() + except AttributeError: + pass + except ValueError: + pass + return title + + +def get_description_from_soup(soup) -> str: + desc = "" + try: + desc = ( + soup.find(class_="beer-descrption-read-less") + .get_text() + .replace("Show Less", "") + .strip() + ) + except AttributeError: + pass + except ValueError: + pass + return desc + + +def get_styles_from_soup(soup) -> list[str]: + styles = [] + try: + styles = soup.find("p", class_="style").get_text().split(" - ") + except AttributeError: + pass + except ValueError: + pass + return styles + + +def get_abv_from_soup(soup) -> Optional[float]: + abv = None + try: + abv = soup.find(class_="abv").get_text() + if abv: + abv = float(abv.strip("\n").strip("% ABV").strip()) + except AttributeError: + pass + except ValueError: + pass + except TypeError: + pass + return abv + + +def get_ibu_from_soup(soup) -> Optional[int]: + ibu = None + try: + ibu = soup.find(class_="ibu").get_text() + if ibu: + ibu = int(ibu.strip("\n").strip(" IBU").strip()) + except AttributeError: + pass + except ValueError: + ibu = None + return ibu + + +def get_rating_from_soup(soup) -> str: + rating = "" + try: + rating = float( + soup.find(class_="num").get_text().strip("(").strip(")") + ) + except AttributeError: + rating = None + except ValueError: + rating = None + return rating + + +def get_producer_id_from_soup(soup) -> str: + id = "" + try: + id = soup.find(class_="brewery").find("a")["href"].strip("/") + except ValueError: + pass + except IndexError: + pass + return id + + +def get_producer_name_from_soup(soup) -> str: + name = "" + try: + name = soup.find(class_="brewery").find("a").get_text() + except AttributeError: + pass + except ValueError: + pass + return name + + +def get_food_from_allrecipe_id(allrecipe_id: str) -> dict: + url = ALLRECIPE_URL.format(id=allrecipe_id) + headers = {"User-Agent": "Vrobbler 0.11.12"} + response = requests.get(url, headers=headers) + + food_dict = {"allrecipe_id": allrecipe_id} + + if response.status_code != 200: + logger.warn( + "Bad response from allrecipe", extra={"response": response} + ) + return food_dict + + import pdb + + pdb.set_trace() + soup = BeautifulSoup(response.text, "html.parser") + food_dict["title"] = get_title_from_soup(soup) + food_dict["description"] = get_description_from_soup(soup) + food_dict["allrecipe_rating"] = get_rating_from_soup(soup) + + return food_dict diff --git a/vrobbler/apps/foods/apps.py b/vrobbler/apps/foods/apps.py new file mode 100644 index 0000000..03c4645 --- /dev/null +++ b/vrobbler/apps/foods/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class FoodsConfig(AppConfig): + name = "foods" diff --git a/vrobbler/apps/foods/migrations/0001_initial.py b/vrobbler/apps/foods/migrations/0001_initial.py new file mode 100644 index 0000000..a9b3d54 --- /dev/null +++ b/vrobbler/apps/foods/migrations/0001_initial.py @@ -0,0 +1,151 @@ +# Generated by Django 4.2.16 on 2024-11-20 19:16 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import taggit.managers +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("scrobbles", "0067_scrobble_food_alter_scrobble_media_type"), + ] + + operations = [ + migrations.CreateModel( + name="FoodCategory", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "uuid", + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ("name", models.CharField(max_length=255)), + ( + "allrecipe_image", + models.ImageField( + blank=True, null=True, upload_to="food/recipe/" + ), + ), + ( + "allrecipe_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Food", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "uuid", + models.UUIDField( + blank=True, + default=uuid.uuid4, + editable=False, + null=True, + ), + ), + ( + "title", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "run_time_seconds", + models.IntegerField(blank=True, null=True), + ), + ( + "run_time_ticks", + models.PositiveBigIntegerField(blank=True, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "allrecipe_image", + models.ImageField( + blank=True, null=True, upload_to="food/recipe/" + ), + ), + ( + "allrecipe_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ("allrecipe_rating", models.FloatField(blank=True, null=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="foods.foodcategory", + ), + ), + ( + "genre", + taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="scrobbles.ObjectWithGenres", + to="scrobbles.Genre", + verbose_name="Tags", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/vrobbler/apps/foods/migrations/__init__.py b/vrobbler/apps/foods/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/foods/models.py b/vrobbler/apps/foods/models.py new file mode 100644 index 0000000..6abde42 --- /dev/null +++ b/vrobbler/apps/foods/models.py @@ -0,0 +1,113 @@ +from uuid import uuid4 + +from django.apps import apps +from django.db import models +from django.urls import reverse +from django_extensions.db.models import TimeStampedModel +from imagekit.models import ImageSpecField +from imagekit.processors import ResizeToFit +from scrobbles.dataclasses import FoodLogData +from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin + +BNULL = {"blank": True, "null": True} + + +class FoodCategory(TimeStampedModel): + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + name = models.CharField(max_length=255) + allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL) + allrecipe_image_small = ImageSpecField( + source="recipe_image", + processors=[ResizeToFit(100, 100)], + format="JPEG", + options={"quality": 60}, + ) + allrecipe_image_medium = ImageSpecField( + source="recipe_image", + processors=[ResizeToFit(300, 300)], + format="JPEG", + options={"quality": 75}, + ) + allrecipe_id = models.CharField(max_length=255, **BNULL) + description = models.TextField(**BNULL) + + def find_or_create(cls, title: str) -> "FoodCategory": + return cls.objects.filter(title=title).first() + + def __str__(self): + return self.name + + +class Food(ScrobblableMixin): + description = models.TextField(**BNULL) + allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL) + allrecipe_image_small = ImageSpecField( + source="recipe_image", + processors=[ResizeToFit(100, 100)], + format="JPEG", + options={"quality": 60}, + ) + allrecipe_image_medium = ImageSpecField( + source="recipe_image", + processors=[ResizeToFit(300, 300)], + format="JPEG", + options={"quality": 75}, + ) + allrecipe_id = models.CharField(max_length=255, **BNULL) + allrecipe_rating = models.FloatField(**BNULL) + category = models.ForeignKey( + FoodCategory, on_delete=models.DO_NOTHING, **BNULL + ) + + def get_absolute_url(self) -> str: + return reverse("foods:food_detail", kwargs={"slug": self.uuid}) + + @property + def subtitle(self): + return self.category.name + + @property + def strings(self) -> ScrobblableConstants: + return ScrobblableConstants(verb="Eating", tags="food") + + @property + def allrecipe_link(self) -> str: + link = "" + if self.producer and self.allrecipe_id: + if self.allrecipe_id: + link = f"https://www.allrecipe.com/{self.allrecipe_id}/" + return link + + @property + def primary_image_url(self) -> str: + url = "" + if self.allrecipe_image: + url = self.allrecipe_image.url + return url + + @property + def logdata_cls(self): + return FoodLogData + + @classmethod + def find_or_create(cls, allrecipe_id: str) -> "Food": + food = cls.objects.filter(allrecipe_id=allrecipe_id).first() + + if not food: + food_dict = get_food_from_allrecipe_id(allrecipe_id) + # category_dict = {} + + # category, _created = FoodCategory.objects.get_or_create( + # **category_dict + # ) + food = Food.objects.create(**food_dict) + # for category_id in category_ids: + # food.category.add(category_id) + + return food + + def scrobbles(self, user_id): + Scrobble = apps.get_model("scrobbles", "Scrobble") + return Scrobble.objects.filter(user_id=user_id, food=self).order_by( + "-timestamp" + ) diff --git a/vrobbler/apps/foods/urls.py b/vrobbler/apps/foods/urls.py new file mode 100644 index 0000000..fedd903 --- /dev/null +++ b/vrobbler/apps/foods/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from foods import views + +app_name = "foods" + + +urlpatterns = [ + path("foods/", views.FoodListView.as_view(), name="food_list"), + path( + "foods//", + views.FoodDetailView.as_view(), + name="food_detail", + ), +] diff --git a/vrobbler/apps/foods/views.py b/vrobbler/apps/foods/views.py new file mode 100644 index 0000000..1b32fa5 --- /dev/null +++ b/vrobbler/apps/foods/views.py @@ -0,0 +1,11 @@ +from foods.models import Food + +from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView + + +class FoodListView(ScrobbleableListView): + model = Food + + +class FoodDetailView(ScrobbleableDetailView): + model = Food diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.py index 716203d..ba3b47a 100644 --- a/vrobbler/apps/scrobbles/dataclasses.py +++ b/vrobbler/apps/scrobbles/dataclasses.py @@ -203,3 +203,11 @@ class BeerLogData(WithOthersLogData): details: Optional[str] = None rating: Optional[str] = None notes: Optional[str] = None + + +@dataclass +class FoodLogData(JSONDataclass): + meal: Optional[str] = None + details: Optional[str] = None + rating: Optional[str] = None + notes: Optional[str] = None diff --git a/vrobbler/apps/scrobbles/migrations/0067_scrobble_food_alter_scrobble_media_type.py b/vrobbler/apps/scrobbles/migrations/0067_scrobble_food_alter_scrobble_media_type.py new file mode 100644 index 0000000..74f1fcd --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0067_scrobble_food_alter_scrobble_media_type.py @@ -0,0 +1,51 @@ +# Generated by Django 4.2.16 on 2024-11-20 19:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("foods", "__first__"), + ("scrobbles", "0066_scrobble_beer_alter_scrobble_media_type"), + ] + + operations = [ + migrations.AddField( + model_name="scrobble", + name="food", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="foods.food", + ), + ), + migrations.AlterField( + model_name="scrobble", + name="media_type", + field=models.CharField( + choices=[ + ("Video", "Video"), + ("Track", "Track"), + ("PodcastEpisode", "Podcast episode"), + ("SportEvent", "Sport event"), + ("Book", "Book"), + ("VideoGame", "Video game"), + ("BoardGame", "Board game"), + ("GeoLocation", "GeoLocation"), + ("Trail", "Trail"), + ("Beer", "Beer"), + ("Food", "Food"), + ("Task", "Task"), + ("WebPage", "Web Page"), + ("LifeEvent", "Life event"), + ("Mood", "Mood"), + ("BrickSet", "Brick set"), + ], + default="Video", + max_length=14, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index ed913d6..fcf8151 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -19,6 +19,7 @@ from django.db import models from django.urls import reverse from django.utils import timezone from django_extensions.db.models import TimeStampedModel +from foods.models import Food from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFit from lifeevents.models import LifeEvent @@ -506,6 +507,7 @@ class Scrobble(TimeStampedModel): GEO_LOCATION = "GeoLocation", "GeoLocation" TRAIL = "Trail", "Trail" BEER = "Beer", "Beer" + FOOD = "Food", "Food" TASK = "Task", "Task" WEBPAGE = "WebPage", "Web Page" LIFE_EVENT = "LifeEvent", "Life event" @@ -532,6 +534,7 @@ class Scrobble(TimeStampedModel): GeoLocation, on_delete=models.DO_NOTHING, **BNULL ) beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL) + food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL) trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL) task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL) web_page = models.ForeignKey(WebPage, on_delete=models.DO_NOTHING, **BNULL) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 15fdb10..b3d05d2 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -131,6 +131,7 @@ INSTALLED_APPS = [ "tasks", "trails", "beers", + "foods", "lifeevents", "moods", "mathfilters", diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 18a33df..0afa92f 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -38,6 +38,7 @@ from vrobbler.apps.sports.api.views import ( from vrobbler.apps.tasks import urls as tasks_urls from vrobbler.apps.trails import urls as trails_urls from vrobbler.apps.beers import urls as beers_urls +from vrobbler.apps.foods import urls as foods_urls from vrobbler.apps.videogames import urls as videogame_urls from vrobbler.apps.videos import urls as video_urls from vrobbler.apps.videos.api.views import SeriesViewSet, VideoViewSet @@ -79,7 +80,7 @@ urlpatterns = [ path("", include(sports_urls, namespace="sports")), path("", include(locations_urls, namespace="locations")), path("", include(trails_urls, namespace="trails")), - path("", include(beers_urls, namespace="beers")), + path("", include(foods_urls, namespace="foods")), path("", include(tasks_urls, namespace="tasks")), path("", include(webpages_urls, namespace="webpages")), path("", include(podcast_urls, namespace="podcasts")),