Browse Source

[foods] Add Food scrobbling

Colin Powell 5 months ago
parent
commit
fd38046113

+ 3 - 3
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"
+        )

+ 28 - 0
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,
+    ]

+ 143 - 0
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

+ 5 - 0
vrobbler/apps/foods/apps.py

@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class FoodsConfig(AppConfig):
+    name = "foods"

+ 151 - 0
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,
+            },
+        ),
+    ]

+ 0 - 0
vrobbler/apps/foods/migrations/__init__.py


+ 113 - 0
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"
+        )

+ 14 - 0
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/<slug:slug>/",
+        views.FoodDetailView.as_view(),
+        name="food_detail",
+    ),
+]

+ 11 - 0
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

+ 8 - 0
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

+ 51 - 0
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,
+            ),
+        ),
+    ]

+ 3 - 0
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)

+ 1 - 0
vrobbler/settings.py

@@ -131,6 +131,7 @@ INSTALLED_APPS = [
     "tasks",
     "trails",
     "beers",
+    "foods",
     "lifeevents",
     "moods",
     "mathfilters",

+ 2 - 1
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")),