sessions / fdc18d5a5f6384e870655e2aaefa849f4379b9c3

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit fdc18d5a5f6384e870655e2aaefa849f4379b9c3Author: Colin Powell <colin@unbl.ink>Date:   Mon Jul 13 13:26:28 2026 -0400    [drinks] Rename beers to drinks and add new modelsdiff --git a/PROJECT.org b/PROJECT.orgindex c9413a7..687cac5 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/24] :vrobbler:project:personal:+* Backlog [1/25] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES:@@ -619,6 +619,10 @@ The Edit log form should have from top to bottom:   - Expansion ids (which should a multi-select widget of expansions for this game)   - Location (which should be a drop down of BoardGameLocations for this user) +** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:+:PROPERTIES:+:ID:       add1be0d-eb33-3273-37bf-0f2cab7e67c5+:END: * Version 59.5 [1/1] ** DONE [#A] Fix bug where all variants for board games are in form :boardgames: :PROPERTIES:diff --git a/vrobbler/apps/beers/admin.py b/vrobbler/apps/beers/admin.pydeleted file mode 100644index 7a08e17..0000000--- a/vrobbler/apps/beers/admin.py+++ /dev/null@@ -1,35 +0,0 @@-from beers.models import Beer, BeerProducer, BeerStyle-from django.contrib import admin-from scrobbles.admin import ScrobbleInline---class BeerInline(admin.TabularInline):-    model = Beer-    extra = 0---@admin.register(BeerStyle)-class BeerStyle(admin.ModelAdmin):-    date_hierarchy = "created"-    search_fields = ("name",)---@admin.register(BeerProducer)-class BeerProducer(admin.ModelAdmin):-    date_hierarchy = "created"-    search_fields = ("name",)---@admin.register(Beer)-class BeerAdmin(admin.ModelAdmin):-    date_hierarchy = "created"-    list_display = (-        "uuid",-        "title",-    )-    raw_id_fields = ("styles", "producer")-    ordering = ("-created",)-    search_fields = ("title",)-    inlines = [-        ScrobbleInline,-    ]diff --git a/vrobbler/apps/beers/api/serializers.py b/vrobbler/apps/beers/api/serializers.pydeleted file mode 100644index cfdf4a5..0000000--- a/vrobbler/apps/beers/api/serializers.py+++ /dev/null@@ -1,20 +0,0 @@-from rest_framework import serializers-from beers.models import Beer, BeerProducer, BeerStyle---class BeerSerializer(serializers.HyperlinkedModelSerializer):-    class Meta:-        model = Beer-        fields = "__all__"---class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):-    class Meta:-        model = BeerProducer-        fields = "__all__"---class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):-    class Meta:-        model = BeerStyle-        fields = "__all__"diff --git a/vrobbler/apps/beers/api/views.py b/vrobbler/apps/beers/api/views.pydeleted file mode 100644index 1901649..0000000--- a/vrobbler/apps/beers/api/views.py+++ /dev/null@@ -1,21 +0,0 @@-from rest_framework import permissions, viewsets-from beers.api import serializers-from beers import models---class BeerViewSet(viewsets.ModelViewSet):-    queryset = models.Beer.objects.all().order_by("-created")-    serializer_class = serializers.BeerSerializer-    permission_classes = [permissions.IsAuthenticated]---class BeerProducerViewSet(viewsets.ModelViewSet):-    queryset = models.BeerProducer.objects.all().order_by("-created")-    serializer_class = serializers.BeerProducerSerializer-    permission_classes = [permissions.IsAuthenticated]---class BeerStyleViewSet(viewsets.ModelViewSet):-    queryset = models.BeerStyle.objects.all().order_by("-created")-    serializer_class = serializers.BeerStyleSerializer-    permission_classes = [permissions.IsAuthenticated]diff --git a/vrobbler/apps/beers/models.py b/vrobbler/apps/beers/models.pydeleted file mode 100644index 8e24994..0000000--- a/vrobbler/apps/beers/models.py+++ /dev/null@@ -1,143 +0,0 @@-from dataclasses import dataclass-from typing import Optional-from uuid import uuid4--from beers.untappd import get_beer_from_untappd_id-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 BaseLogData-from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin--BNULL = {"blank": True, "null": True}---@dataclass-class BeerLogData(BaseLogData):-    rating: Optional[str] = None---class BeerStyle(TimeStampedModel):-    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)-    name = models.CharField(max_length=255)-    description = models.TextField(**BNULL)--    def __str__(self):-        return self.name---class BeerProducer(TimeStampedModel):-    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)-    name = models.CharField(max_length=255)-    description = models.TextField(**BNULL)-    location = models.CharField(max_length=255, **BNULL)-    beeradvocate_id = models.CharField(max_length=255, **BNULL)-    untappd_id = models.CharField(max_length=255, **BNULL)--    def find_or_create(cls, title: str) -> "BeerProducer":-        return cls.objects.filter(title=title).first()--    def __str__(self):-        return self.name---class Beer(ScrobblableMixin):-    description = models.TextField(**BNULL)-    ibu = models.SmallIntegerField(**BNULL)-    abv = models.FloatField(**BNULL)-    styles = models.ManyToManyField(BeerStyle, related_name="styles")-    non_alcoholic = models.BooleanField(default=False)-    beeradvocate_id = models.CharField(max_length=255, **BNULL)-    beeradvocate_score = models.SmallIntegerField(**BNULL)-    untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)-    untappd_image_small = ImageSpecField(-        source="untappd_image",-        processors=[ResizeToFit(100, 100)],-        format="JPEG",-        options={"quality": 60},-    )-    untappd_image_medium = ImageSpecField(-        source="untappd_image",-        processors=[ResizeToFit(300, 300)],-        format="JPEG",-        options={"quality": 75},-    )-    untappd_id = models.CharField(max_length=255, **BNULL)-    untappd_rating = models.FloatField(**BNULL)-    producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)--    def get_absolute_url(self) -> str:-        return reverse("beers:beer_detail", kwargs={"slug": self.uuid})--    def __str__(self):-        return f"{self.title} by {self.producer}"--    @property-    def subtitle(self):-        return self.producer.name--    @property-    def strings(self) -> ScrobblableConstants:-        return ScrobblableConstants(verb="Drinking", tags="beer")--    @property-    def beeradvocate_link(self) -> str:-        link = ""-        if self.producer and self.beeradvocate_id:-            if self.beeradvocate_id:-                link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"-        return link--    @property-    def untappd_link(self) -> str:-        link = ""-        if self.untappd_id:-            link = f"https://www.untappd.com/beer/{self.untappd_id}/"-        return link--    @property-    def primary_image_url(self) -> str:-        url = ""-        if self.untappd_image:-            url = self.untappd_image.url-        return url--    @property-    def logdata_cls(self):-        return BeerLogData--    @classmethod-    def find_or_create(cls, untappd_id: str) -> "Beer":-        beer = cls.objects.filter(untappd_id=untappd_id).first()--        if not beer:-            beer_dict = get_beer_from_untappd_id(untappd_id)-            producer_dict = {}-            style_ids = []-            for key in list(beer_dict.keys()):-                if "producer__" in key:-                    pkey = key.replace("producer__", "")-                    producer_dict[pkey] = beer_dict.pop(key)-                if "styles" in key:-                    for style in beer_dict.pop("styles"):-                        style_inst, created = BeerStyle.objects.get_or_create(-                            name=style-                        )-                        style_ids.append(style_inst.id)--            producer, _created = BeerProducer.objects.get_or_create(**producer_dict)-            beer_dict["producer_id"] = producer.id-            beer = Beer.objects.create(**beer_dict)-            for style_id in style_ids:-                beer.styles.add(style_id)--        return beer--    def scrobbles(self, user_id):-        Scrobble = apps.get_model("scrobbles", "Scrobble")-        return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(-            "-timestamp"-        )diff --git a/vrobbler/apps/beers/urls.py b/vrobbler/apps/beers/urls.pydeleted file mode 100644index 4d19aa2..0000000--- a/vrobbler/apps/beers/urls.py+++ /dev/null@@ -1,14 +0,0 @@-from django.urls import path-from beers import views--app_name = "beers"---urlpatterns = [-    path("beers/", views.BeerListView.as_view(), name="beer_list"),-    path(-        "beers/<slug:slug>/",-        views.BeerDetailView.as_view(),-        name="beer_detail",-    ),-]diff --git a/vrobbler/apps/beers/views.py b/vrobbler/apps/beers/views.pydeleted file mode 100644index 22a443a..0000000--- a/vrobbler/apps/beers/views.py+++ /dev/null@@ -1,11 +0,0 @@-from beers.models import Beer--from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView---class BeerListView(ScrobbleableListView):-    model = Beer---class BeerDetailView(ScrobbleableDetailView):-    model = Beerdiff --git a/vrobbler/apps/beers/api/__init__.py b/vrobbler/apps/drinks/__init__.pysimilarity index 100%rename from vrobbler/apps/beers/api/__init__.pyrename to vrobbler/apps/drinks/__init__.pydiff --git a/vrobbler/apps/drinks/admin.py b/vrobbler/apps/drinks/admin.pynew file mode 100644index 0000000..fc90c82--- /dev/null+++ b/vrobbler/apps/drinks/admin.py@@ -0,0 +1,99 @@+from django.contrib import admin+from drinks.models import (+    Beer,+    BeerProducer,+    BeerStyle,+    Coffee,+    CoffeeRoaster,+    Wine,+    WineGrape,+    WineProducer,+    WineRegion,+)+from scrobbles.admin import ScrobbleInline+++class BeerInline(admin.TabularInline):+    model = Beer+    extra = 0+++@admin.register(BeerStyle)+class BeerStyleAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    search_fields = ("name",)+++@admin.register(BeerProducer)+class BeerProducerAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    search_fields = ("name",)+++@admin.register(Beer)+class BeerAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    list_display = (+        "uuid",+        "title",+    )+    raw_id_fields = ("styles", "producer")+    ordering = ("-created",)+    search_fields = ("title",)+    inlines = [+        ScrobbleInline,+    ]+++@admin.register(WineGrape)+class WineGrapeAdmin(admin.ModelAdmin):+    search_fields = ("name",)+++@admin.register(WineRegion)+class WineRegionAdmin(admin.ModelAdmin):+    search_fields = ("name", "country")+++@admin.register(WineProducer)+class WineProducerAdmin(admin.ModelAdmin):+    search_fields = ("name",)+++@admin.register(Wine)+class WineAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    list_display = (+        "uuid",+        "title",+        "wine_type",+        "vintage",+    )+    raw_id_fields = ("grapes", "region", "producer")+    ordering = ("-created",)+    search_fields = ("title",)+    list_filter = ("wine_type",)+    inlines = [+        ScrobbleInline,+    ]+++@admin.register(CoffeeRoaster)+class CoffeeRoasterAdmin(admin.ModelAdmin):+    search_fields = ("name",)+++@admin.register(Coffee)+class CoffeeAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    list_display = (+        "uuid",+        "title",+        "origin",+    )+    raw_id_fields = ("roaster",)+    ordering = ("-created",)+    search_fields = ("title",)+    inlines = [+        ScrobbleInline,+    ]diff --git a/vrobbler/apps/beers/migrations/__init__.py b/vrobbler/apps/drinks/api/__init__.pysimilarity index 100%rename from vrobbler/apps/beers/migrations/__init__.pyrename to vrobbler/apps/drinks/api/__init__.pydiff --git a/vrobbler/apps/drinks/api/serializers.py b/vrobbler/apps/drinks/api/serializers.pynew file mode 100644index 0000000..971f62f--- /dev/null+++ b/vrobbler/apps/drinks/api/serializers.py@@ -0,0 +1,66 @@+from drinks.models import (+    Beer,+    BeerProducer,+    BeerStyle,+    Coffee,+    CoffeeRoaster,+    Wine,+    WineGrape,+    WineProducer,+    WineRegion,+)+from rest_framework import serializers+++class BeerSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = Beer+        fields = "__all__"+++class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = BeerProducer+        fields = "__all__"+++class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = BeerStyle+        fields = "__all__"+++class WineSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = Wine+        fields = "__all__"+++class WineProducerSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = WineProducer+        fields = "__all__"+++class WineRegionSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = WineRegion+        fields = "__all__"+++class WineGrapeSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = WineGrape+        fields = "__all__"+++class CoffeeSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = Coffee+        fields = "__all__"+++class CoffeeRoasterSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = CoffeeRoaster+        fields = "__all__"diff --git a/vrobbler/apps/drinks/api/views.py b/vrobbler/apps/drinks/api/views.pynew file mode 100644index 0000000..d7cd386--- /dev/null+++ b/vrobbler/apps/drinks/api/views.py@@ -0,0 +1,57 @@+from drinks import models+from drinks.api import serializers+from rest_framework import permissions, viewsets+++class BeerViewSet(viewsets.ModelViewSet):+    queryset = models.Beer.objects.all().order_by("-created")+    serializer_class = serializers.BeerSerializer+    permission_classes = [permissions.IsAuthenticated]+++class BeerProducerViewSet(viewsets.ModelViewSet):+    queryset = models.BeerProducer.objects.all().order_by("-created")+    serializer_class = serializers.BeerProducerSerializer+    permission_classes = [permissions.IsAuthenticated]+++class BeerStyleViewSet(viewsets.ModelViewSet):+    queryset = models.BeerStyle.objects.all().order_by("-created")+    serializer_class = serializers.BeerStyleSerializer+    permission_classes = [permissions.IsAuthenticated]+++class WineViewSet(viewsets.ModelViewSet):+    queryset = models.Wine.objects.all().order_by("-created")+    serializer_class = serializers.WineSerializer+    permission_classes = [permissions.IsAuthenticated]+++class WineProducerViewSet(viewsets.ModelViewSet):+    queryset = models.WineProducer.objects.all().order_by("-created")+    serializer_class = serializers.WineProducerSerializer+    permission_classes = [permissions.IsAuthenticated]+++class WineRegionViewSet(viewsets.ModelViewSet):+    queryset = models.WineRegion.objects.all().order_by("-created")+    serializer_class = serializers.WineRegionSerializer+    permission_classes = [permissions.IsAuthenticated]+++class WineGrapeViewSet(viewsets.ModelViewSet):+    queryset = models.WineGrape.objects.all().order_by("-created")+    serializer_class = serializers.WineGrapeSerializer+    permission_classes = [permissions.IsAuthenticated]+++class CoffeeViewSet(viewsets.ModelViewSet):+    queryset = models.Coffee.objects.all().order_by("-created")+    serializer_class = serializers.CoffeeSerializer+    permission_classes = [permissions.IsAuthenticated]+++class CoffeeRoasterViewSet(viewsets.ModelViewSet):+    queryset = models.CoffeeRoaster.objects.all().order_by("-created")+    serializer_class = serializers.CoffeeRoasterSerializer+    permission_classes = [permissions.IsAuthenticated]diff --git a/vrobbler/apps/beers/apps.py b/vrobbler/apps/drinks/apps.pysimilarity index 76%rename from vrobbler/apps/beers/apps.pyrename to vrobbler/apps/drinks/apps.pyindex ea0615e..5b7609a 100644--- a/vrobbler/apps/beers/apps.py+++ b/vrobbler/apps/drinks/apps.py@@ -2,4 +2,4 @@ from django.apps import AppConfig   class BeersConfig(AppConfig):-    name = "beers"+    name = "drinks"diff --git a/vrobbler/apps/drinks/cellartracker.py b/vrobbler/apps/drinks/cellartracker.pynew file mode 100644index 0000000..5218b7e--- /dev/null+++ b/vrobbler/apps/drinks/cellartracker.py@@ -0,0 +1,130 @@+import logging++import requests+from bs4 import BeautifulSoup++logger = logging.getLogger(__name__)++CELLARTRACKER_URL = "https://www.cellartracker.com/wine.asp?iWine={cellartracker_id}"+++def get_title_from_soup(soup) -> str:+    title = ""+    try:+        title = soup.find("h1").get_text(strip=True)+    except AttributeError:+        pass+    return title+++def get_description_from_soup(soup) -> str:+    desc = ""+    try:+        desc = soup.find("div", class_="wine-notes").get_text(strip=True)+    except AttributeError:+        pass+    return desc+++def get_wine_type_from_soup(soup) -> str:+    wine_type = ""+    try:+        wine_type_text = soup.find("td", string=lambda t: t and "Type" in t)+        if wine_type_text:+            wine_type = (+                wine_type_text.find_next_sibling("td").get_text(strip=True).lower()+            )+    except AttributeError:+        pass+    return wine_type+++def get_rating_from_soup(soup) -> float:+    rating = None+    try:+        rating_text = soup.find("td", string=lambda t: t and "Score" in t)+        if rating_text:+            rating = float(rating_text.find_next_sibling("td").get_text(strip=True))+    except (AttributeError, ValueError):+        pass+    return rating+++def get_grapes_from_soup(soup) -> list[str]:+    grapes = []+    try:+        grape_text = soup.find("td", string=lambda t: t and "Varietal" in t)+        if grape_text:+            grape_str = grape_text.find_next_sibling("td").get_text(strip=True)+            grapes = [g.strip() for g in grape_str.split("/") if g.strip()]+    except AttributeError:+        pass+    return grapes+++def get_region_from_soup(soup) -> str:+    region = ""+    try:+        region_text = soup.find("td", string=lambda t: t and "Region" in t)+        if region_text:+            region = region_text.find_next_sibling("td").get_text(strip=True)+    except AttributeError:+        pass+    return region+++def get_producer_from_soup(soup) -> str:+    producer = ""+    try:+        producer_text = soup.find("td", string=lambda t: t and "Producer" in t)+        if producer_text:+            producer = producer_text.find_next_sibling("td").get_text(strip=True)+    except AttributeError:+        pass+    return producer+++def get_vintage_from_soup(soup) -> str:+    vintage = ""+    try:+        vintage_text = soup.find("td", string=lambda t: t and "Vintage" in t)+        if vintage_text:+            vintage = vintage_text.find_next_sibling("td").get_text(strip=True)+    except AttributeError:+        pass+    return vintage+++def get_image_url_from_soup(soup) -> str:+    image_url = ""+    try:+        image_url = soup.find("img", class_="wine-image")["src"]+    except (AttributeError, KeyError, TypeError):+        pass+    return image_url+++def get_wine_from_cellartracker_id(cellartracker_id: str) -> dict:+    wine_url = CELLARTRACKER_URL.format(cellartracker_id=cellartracker_id)+    headers = {"User-Agent": "Vrobbler 0.11.12"}+    response = requests.get(wine_url, headers=headers)+    wine_dict = {"cellartracker_id": cellartracker_id}++    if response.status_code != 200:+        logger.warning(+            "Bad response from cellartracker.com", extra={"response": response}+        )+        return wine_dict++    soup = BeautifulSoup(response.text, "html.parser")+    wine_dict["title"] = get_title_from_soup(soup)+    wine_dict["description"] = get_description_from_soup(soup)+    wine_dict["wine_type"] = get_wine_type_from_soup(soup)+    wine_dict["cellartracker_rating"] = get_rating_from_soup(soup)+    wine_dict["grapes"] = get_grapes_from_soup(soup)+    wine_dict["region"] = get_region_from_soup(soup)+    wine_dict["producer__name"] = get_producer_from_soup(soup)+    wine_dict["vintage"] = get_vintage_from_soup(soup)+    wine_dict["image_url"] = get_image_url_from_soup(soup)++    return wine_dictdiff --git a/vrobbler/apps/drinks/management/__init__.py b/vrobbler/apps/drinks/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/drinks/management/commands/__init__.py b/vrobbler/apps/drinks/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/drinks/management/commands/populate_demo_data.py b/vrobbler/apps/drinks/management/commands/populate_demo_data.pynew file mode 100644index 0000000..2d23286--- /dev/null+++ b/vrobbler/apps/drinks/management/commands/populate_demo_data.py@@ -0,0 +1,507 @@+import random+from datetime import timedelta++from django.contrib.auth import get_user_model+from django.core.management.base import BaseCommand+from django.utils import timezone+from drinks.models import (+    Beer,+    BeerProducer,+    BeerStyle,+    BrewingMethod,+    Coffee,+    CoffeeRoaster,+    DrinkFormat,+    RoastLevel,+    Wine,+    WineGrape,+    WineProducer,+    WineRegion,+    WineType,+)+from scrobbles.models import FavoriteMedia, Scrobble++User = get_user_model()++# Realistic demo data++BEER_PRODUCERS = [+    ("Trillium Brewing", "Canton, MA"),+    ("Tree House Brewing", "Charlton, MA"),+    ("The Alchemist", "Stowe, VT"),+    ("Hill Farmstead", "Greensboro, VT"),+    ("Other Half Brewing", "Brooklyn, NY"),+    ("Equilibrium Brewery", "Middletown, NY"),+    ("Monkish Brewing", "Torrance, CA"),+    ("Russian River Brewing", "Santa Rosa, CA"),+    ("Cellar Maker", "San Francisco, CA"),+    ("Definitive Brewing", "Portland, ME"),+]++BEER_STYLES = [+    "New England IPA",+    "Hazy Double IPA",+    "West Coast IPA",+    "Pale Ale",+    "Stout",+    "Imperial Stout",+    "Porter",+    "Saison",+    "Pilsner",+    "Lager",+    "Gose",+    "Sour Ale",+    "Brown Ale",+    "Barleywine",+    "Wheat Ale",+]++BEERS = [+    ("Congress Street", "Trillium Brewing", ["New England IPA"], 6.8, 45),+    ("Julius", "Tree House Brewing", ["New England IPA"], 6.8, 50),+    ("Heady Topper", "The Alchemist", ["New England IPA"], 8.0, 70),+    ("Sip of Sunshine", "Hill Farmstead", ["New England IPA"], 8.0, 60),+    ("All Green Everything", "Other Half Brewing", ["Hazy Double IPA"], 10.0, 100),+    ("Moment of Clarity", "Equilibrium Brewery", ["New England IPA"], 8.5, 55),+    ("Tired Hands Collab", "Monkish Brewing", ["New England IPA"], 7.2, 40),+    ("Pliny the Elder", "Russian River Brewing", ["West Coast IPA"], 8.0, 100),+    ("Density", "Cellar Maker", ["Hazy Double IPA"], 8.2, 65),+    ("Bright Days Ahead", "Definitive Brewing", ["Pale Ale"], 5.5, 35),+    ("Bright", "Tree House Brewing", ["Pale Ale"], 5.2, 30),+    ("King Julius", "Tree House Brewing", ["Hazy Double IPA"], 9.0, 80),+    ("Very Hazy", "Other Half Brewing", ["New England IPA"], 7.5, 45),+    ("Galaxy Dry Hop", "Equilibrium Brewery", ["New England IPA"], 7.0, 50),+    ("DDH Foggier Window", "Monkish Brewing", ["Hazy Double IPA"], 8.0, 70),+]++WINE_PRODUCERS = [+    ("Domaine de la Romanée-Conti", "Burgundy, France"),+    ("Opus One", "Napa Valley, CA"),+    ("Penfolds", "South Australia"),+    ("Cloudy Bay", "Marlborough, New Zealand"),+    ("Antinori", "Tuscany, Italy"),+    ("Vega Sicilia", "Ribera del Duero, Spain"),+    ("Catena Zapata", "Mendoza, Argentina"),+    ("Concha y Toro", "Casablanca Valley, Chile"),+    ("Kim Crawford", "Hawke's Bay, New Zealand"),+    ("Meiomi", "Monterey County, CA"),+    ("Château d'Esclans", "Provence, France"),+    ("Veuve Clicquot", "Reims, France"),+    ("Giacomo Conterno", "Piemonte, Italy"),+    ("Château de Beaucastel", "Rhône, France"),+    ("Dr. Loosen", "Mosel, Germany"),+]++WINE_REGIONS = [+    ("Burgundy", "France"),+    ("Napa Valley", "USA"),+    ("Barossa Valley", "Australia"),+    ("Marlborough", "New Zealand"),+    ("Tuscany", "Italy"),+    ("Ribera del Duero", "Spain"),+    ("Mendoza", "Argentina"),+    ("Casablanca Valley", "Chile"),+    ("Hawke's Bay", "New Zealand"),+    ("Monterey County", "USA"),+    ("Willamette Valley", "USA"),+    ("Rioja", "Spain"),+    ("Piemonte", "Italy"),+    ("Champagne", "France"),+    ("Mosel", "Germany"),+    ("Provence", "France"),+    ("Rhône", "France"),+    ("Reims", "France"),+]++WINE_GRAPES = [+    "Pinot Noir",+    "Cabernet Sauvignon",+    "Merlot",+    "Syrah",+    "Grenache",+    "Chardonnay",+    "Sauvignon Blanc",+    "Riesling",+    "Tempranillo",+    "Malbec",+    "Nebbiolo",+    "Sangiovese",+    "Zinfandel",+    "Viognier",+    "Mourvèdre",+]++WINES = [+    (+        "2021 Pinot Noir",+        "Domaine de la Romanée-Conti",+        "Burgundy",+        "Pinot Noir",+        "red",+        "2021",+        4.5,+    ),+    (+        "2019 Cabernet Sauvignon",+        "Opus One",+        "Napa Valley",+        "Cabernet Sauvignon",+        "red",+        "2019",+        4.8,+    ),+    ("2020 Shiraz", "Penfolds", "Barossa Valley", "Syrah", "red", "2020", 4.6),+    (+        "2022 Sauvignon Blanc",+        "Cloudy Bay",+        "Marlborough",+        "Sauvignon Blanc",+        "white",+        "2022",+        4.2,+    ),+    ("2020 Tignanello", "Antinori", "Tuscany", "Sangiovese", "red", "2020", 4.7),+    (+        "2018 Unico",+        "Vega Sicilia",+        "Ribera del Duero",+        "Tempranillo",+        "red",+        "2018",+        4.9,+    ),+    ("2021 Malbec", "Catena Zapata", "Mendoza", "Malbec", "red", "2021", 4.4),+    (+        "2022 Chardonnay",+        "Concha y Toro",+        "Casablanca Valley",+        "Chardonnay",+        "white",+        "2022",+        4.1,+    ),+    (+        "2021 Sauvignon Blanc",+        "Kim Crawford",+        "Hawke's Bay",+        "Sauvignon Blanc",+        "white",+        "2021",+        4.0,+    ),+    ("2020 Pinot Noir", "Meiomi", "Monterey County", "Pinot Noir", "red", "2020", 4.3),+    ("2021 Rosé", "Château d'Esclans", "Provence", "Grenache", "rosé", "2021", 4.2),+    (+        "2020 Champagne Brut",+        "Veuve Clicquot",+        "Champagne",+        "Chardonnay",+        "sparkling",+        "NV",+        4.5,+    ),+    ("2019 Barolo", "Giacomo Conterno", "Piemonte", "Nebbiolo", "red", "2019", 4.8),+    (+        "2020 Châteauneuf-du-Pape",+        "Château de Beaucastel",+        "Rhône",+        "Grenache",+        "red",+        "2020",+        4.6,+    ),+    ("2021 Riesling Spätlese", "Dr. Loosen", "Mosel", "Riesling", "white", "2021", 4.3),+]++COFFEE_ROASTERS = [+    ("Counter Culture", "Durham, NC"),+    ("Intelligentsia", "Chicago, IL"),+    ("Stumptown", "Portland, OR"),+    ("Blue Bottle", "Oakland, CA"),+    (+        "Onyx Coffee Lab",+        "Rogers, AR",+    ),+    ("Verve", "Santa Cruz, CA"),+    ("Heart", "Portland, OR"),+    ("George Howell", "Boston, MA"),+    ("SEY", "Brooklyn, NY"),+    ("Black & White", "Raleigh, NC"),+]++COFFEES = [+    ("Hologram", "Counter Culture", "Ethiopia", "medium"),+    ("Big Trouble", "Counter Culture", "Colombia", "medium"),+    ("Black Cat Espresso", "Intelligentsia", "Brazil", "medium"),+    ("Hair Bender", "Intelligentsia", "Latin America", "dark"),+    ("Stumptown Hair Bender", "Stumptown", "Latin America", "medium"),+    ("Holler Mountain", "Stumptown", "Ethiopia", "medium"),+    ("Bella Donovan", "Blue Bottle", "Latin America", "medium"),+    ("Three Africas", "Blue Bottle", "Uganda", "light"),+    ("Monarch", "Onyx Coffee Lab", "Ethiopia", "light"),+    ("Geometry", "Onyx Coffee Lab", "Colombia", "medium"),+    ("Buena Vista", "Verve", "Guatemala", "medium"),+    ("Farmhouse", "Verve", "Costa Rica", "medium"),+    ("Decaf", "Heart", "Colombia", "medium"),+    ("Los Altos", "Heart", "Guatemala", "medium"),+    ("Hayes Valley Espresso", "George Howell", "Brazil", "medium"),+]+++class Command(BaseCommand):+    help = "Populate demo data for the drinks app with realistic scrobbles"++    def add_arguments(self, parser):+        parser.add_argument(+            "--user",+            type=str,+            help="Username to create scrobbles for (default: creates 'demo')",+        )+        parser.add_argument(+            "--days",+            type=int,+            default=30,+            help="Number of days of history to generate (default: 30)",+        )+        parser.add_argument(+            "--clear",+            action="store_true",+            help="Clear existing demo data before populating",+        )++    def handle(self, *args, **options):+        username = options.get("user") or "demo"+        days = options["days"]+        clear = options["clear"]++        user, _created = User.objects.get_or_create(+            username=username,+            defaults={"email": f"{username}@demo.local"},+        )+        if _created:+            user.set_password("demo")+            user.save()+            self.stdout.write(self.style.SUCCESS(f"Created user '{username}'"))+        else:+            self.stdout.write(f"Using existing user '{username}'")++        if clear:+            self.stdout.write("Clearing existing demo drink scrobbles...")+            Scrobble.objects.filter(+                user=user,+                media_type__in=["Beer", "Wine", "Coffee"],+            ).delete()+            FavoriteMedia.objects.filter(user=user).delete()+            self.stdout.write(self.style.SUCCESS("Cleared demo data"))++        self.stdout.write("Creating beer styles...")+        style_objects = {}+        for style_name in BEER_STYLES:+            style, _ = BeerStyle.objects.get_or_create(name=style_name)+            style_objects[style_name] = style++        self.stdout.write("Creating beer producers...")+        producer_objects = {}+        for name, location in BEER_PRODUCERS:+            producer, _ = BeerProducer.objects.get_or_create(+                name=name, defaults={"location": location}+            )+            producer_objects[name] = producer++        self.stdout.write("Creating beers...")+        beer_objects = []+        for title, producer_name, styles, abv, ibu in BEERS:+            beer, _ = Beer.objects.get_or_create(+                untappd_id=title.lower().replace(" ", "-"),+                defaults={+                    "title": title,+                    "abv": abv,+                    "ibu": ibu,+                    "producer_id": producer_objects[producer_name].id,+                    "description": f"A delicious {styles[0]} from {producer_name}.",+                    "base_run_time_seconds": 900,+                    "calories": int(abv * 30),+                },+            )+            for style_name in styles:+                beer.styles.add(style_objects[style_name])+            beer_objects.append(beer)++        self.stdout.write("Creating wine regions...")+        region_objects = {}+        for name, country in WINE_REGIONS:+            region, _ = WineRegion.objects.get_or_create(+                name=name, defaults={"country": country}+            )+            region_objects[name] = region++        self.stdout.write("Creating wine producers...")+        wine_producer_objects = {}+        for name, location in WINE_PRODUCERS:+            producer, _ = WineProducer.objects.get_or_create(+                name=name, defaults={"location": location}+            )+            wine_producer_objects[name] = producer++        self.stdout.write("Creating wine grapes...")+        grape_objects = {}+        for name in WINE_GRAPES:+            grape, _ = WineGrape.objects.get_or_create(name=name)+            grape_objects[name] = grape++        self.stdout.write("Creating wines...")+        wine_objects = []+        for (+            title,+            producer_name,+            region_name,+            grape_name,+            wine_type,+            vintage,+            rating,+        ) in WINES:+            wine, _ = Wine.objects.get_or_create(+                vivino_id=title.lower().replace(" ", "-"),+                defaults={+                    "title": title,+                    "wine_type": wine_type,+                    "vintage": vintage,+                    "vivino_rating": rating,+                    "producer_id": wine_producer_objects[producer_name].id,+                    "region_id": region_objects[region_name].id,+                    "description": f"A fine {wine_type} from {producer_name}.",+                    "base_run_time_seconds": 3600,+                    "calories": 125,+                },+            )+            wine.grapes.add(grape_objects[grape_name])+            wine_objects.append(wine)++        self.stdout.write("Creating coffee roasters...")+        roaster_objects = {}+        for name, location in COFFEE_ROASTERS:+            roaster, _ = CoffeeRoaster.objects.get_or_create(+                name=name, defaults={"location": location}+            )+            roaster_objects[name] = roaster++        self.stdout.write("Creating coffees...")+        coffee_objects = []+        for title, roaster_name, origin, _roast_level in COFFEES:+            coffee, _ = Coffee.objects.get_or_create(+                roastdb_id=title.lower().replace(" ", "-"),+                defaults={+                    "title": title,+                    "origin": origin,+                    "roaster_id": roaster_objects[roaster_name].id,+                    "description": f"Specialty coffee from {roaster_name}.",+                    "base_run_time_seconds": 1200,+                    "calories": 5,+                },+            )+            coffee_objects.append(coffee)++        self.stdout.write("Creating scrobbles...")+        now = timezone.now()+        scrobbles_created = 0++        for day_offset in range(days):+            day = now - timedelta(days=day_offset)++            # Random number of drinks per day (1-5)+            num_drinks = random.randint(1, 5)+            for _ in range(num_drinks):+                # Pick a random drink type weighted toward beers+                drink_type = random.choices(+                    ["beer", "wine", "coffee"], weights=[50, 25, 25]+                )[0]++                if drink_type == "beer":+                    media = random.choice(beer_objects)+                    media_type = Scrobble.MediaType.BEER+                    source = random.choice(["Untappd", "Vrobbler"])+                    log_data = {+                        "rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),+                        "format": random.choice(+                            [+                                DrinkFormat.ALUMINUM_CAN,+                                DrinkFormat.GLASS_BOTTLE,+                                DrinkFormat.DRAFT,+                            ]+                        ),+                        "size_ml": random.choice([330, 355, 473, 500]),+                    }+                elif drink_type == "wine":+                    media = random.choice(wine_objects)+                    media_type = Scrobble.MediaType.WINE+                    source = random.choice(["Vivino", "Vrobbler"])+                    log_data = {+                        "rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),+                        "format": random.choice(+                            [+                                DrinkFormat.GLASS_BOTTLE,+                                DrinkFormat.BY_THE_GLASS,+                                DrinkFormat.BOX,+                            ]+                        ),+                        "size_ml": random.choice([150, 375, 750]),+                    }+                else:+                    media = random.choice(coffee_objects)+                    media_type = Scrobble.MediaType.COFFEE+                    source = random.choice(["RoastDB", "Vrobbler"])+                    log_data = {+                        "rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),+                        "format": random.choice(+                            [+                                DrinkFormat.GLASS_BOTTLE,+                                DrinkFormat.ALUMINUM_CAN,+                                DrinkFormat.BY_THE_GLASS,+                            ]+                        ),+                        "size_ml": random.choice([240, 355, 473]),+                        "roast_level": random.choice(RoastLevel.values),+                        "brewing_method": random.choice(BrewingMethod.values),+                        "single_origin_or_blend": random.choice(+                            ["single origin", "blend"]+                        ),+                    }++                # Random time during the day+                hour = random.randint(7, 22)+                minute = random.randint(0, 59)+                timestamp = day.replace(+                    hour=hour, minute=minute, second=0, microsecond=0+                )++                # Use the FK field name from the media class name+                fk_field = f"{media.__class__.__name__[0].lower()}{media.__class__.__name__[1:]}"+                # Simpler: just lowercase the class name+                fk_field = media.__class__.__name__.lower()++                scrobble = Scrobble.objects.create(+                    user=user,+                    media_type=media_type,+                    timestamp=timestamp,+                    playback_position_seconds=media.base_run_time_seconds or 900,+                    played_to_completion=True,+                    source=source,+                    log={"drink_log": log_data},+                    **{fk_field: media},+                )+                scrobbles_created += 1++                # Some scrobbles get favorited+                if random.random() < 0.15:+                    FavoriteMedia.toggle(media, user)++        self.stdout.write(+            self.style.SUCCESS(+                f"\nDone! Created:\n"+                f"  {len(beer_objects)} beers\n"+                f"  {len(wine_objects)} wines\n"+                f"  {len(coffee_objects)} coffees\n"+                f"  {scrobbles_created} scrobbles over {days} days\n"+                f"  User: {username} / demo"+            )+        )diff --git a/vrobbler/apps/beers/migrations/0001_initial.py b/vrobbler/apps/drinks/migrations/0001_initial.pysimilarity index 97%rename from vrobbler/apps/beers/migrations/0001_initial.pyrename to vrobbler/apps/drinks/migrations/0001_initial.pyindex dd613c3..2e6d892 100644--- a/vrobbler/apps/beers/migrations/0001_initial.py+++ b/vrobbler/apps/drinks/migrations/0001_initial.py@@ -1,9 +1,10 @@ # Generated by Django 4.2.16 on 2024-10-22 21:26 -from django.db import migrations, models+import uuid+ import django_extensions.db.fields import taggit.managers-import uuid+from django.db import migrations, models   class Migration(migrations.Migration):@@ -46,6 +47,7 @@ class Migration(migrations.Migration):                 ),             ],             options={+                "db_table": "beers_beerproducer",                 "get_latest_by": "modified",                 "abstract": False,             },@@ -127,6 +129,7 @@ class Migration(migrations.Migration):                 ),             ],             options={+                "db_table": "beers_beer",                 "abstract": False,             },         ),diff --git a/vrobbler/apps/beers/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.py b/vrobbler/apps/drinks/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.pysimilarity index 92%rename from vrobbler/apps/beers/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.pyrename to vrobbler/apps/drinks/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.pyindex 45c10d5..b0408a6 100644--- a/vrobbler/apps/beers/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.py+++ b/vrobbler/apps/drinks/migrations/0002_beer_beeradvocate_image_beer_producer_and_more.py@@ -1,13 +1,13 @@ # Generated by Django 4.2.16 on 2024-10-22 21:34 -from django.db import migrations, models import django.db.models.deletion+from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [-        ("beers", "0001_initial"),+        ("drinks", "0001_initial"),     ]      operations = [@@ -25,7 +25,7 @@ class Migration(migrations.Migration):                 blank=True,                 null=True,                 on_delete=django.db.models.deletion.DO_NOTHING,-                to="beers.beerproducer",+                to="drinks.beerproducer",             ),         ),         migrations.AddField(diff --git a/vrobbler/apps/beers/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.py b/vrobbler/apps/drinks/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.pysimilarity index 92%rename from vrobbler/apps/beers/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.pyrename to vrobbler/apps/drinks/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.pyindex 274e89a..fd4a8e9 100644--- a/vrobbler/apps/beers/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.py+++ b/vrobbler/apps/drinks/migrations/0003_beerstyle_remove_beer_beeradvocate_image_and_more.py@@ -1,13 +1,13 @@ # Generated by Django 4.2.16 on 2024-10-22 21:47 -from django.db import migrations, models import django_extensions.db.fields+from django.db import migrations, models   class Migration(migrations.Migration):      dependencies = [-        ("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),+        ("drinks", "0002_beer_beeradvocate_image_beer_producer_and_more"),     ]      operations = [@@ -38,6 +38,7 @@ class Migration(migrations.Migration):                 ("description", models.TextField(blank=True, null=True)),             ],             options={+                "db_table": "beers_beerstyle",                 "get_latest_by": "modified",                 "abstract": False,             },@@ -70,6 +71,6 @@ class Migration(migrations.Migration):         migrations.AddField(             model_name="beer",             name="styles",-            field=models.ManyToManyField(to="beers.beerstyle"),+            field=models.ManyToManyField(to="drinks.beerstyle"),         ),     ]diff --git a/vrobbler/apps/beers/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.py b/vrobbler/apps/drinks/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.pysimilarity index 89%rename from vrobbler/apps/beers/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.pyrename to vrobbler/apps/drinks/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.pyindex aa02a18..a850f84 100644--- a/vrobbler/apps/beers/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.py+++ b/vrobbler/apps/drinks/migrations/0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more.py@@ -1,13 +1,14 @@ # Generated by Django 4.2.16 on 2024-10-22 21:52 -from django.db import migrations, models import uuid +from django.db import migrations, models+  class Migration(migrations.Migration):      dependencies = [-        ("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),+        ("drinks", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),     ]      operations = [@@ -41,7 +42,7 @@ class Migration(migrations.Migration):             model_name="beer",             name="styles",             field=models.ManyToManyField(-                related_name="styles", to="beers.beerstyle"+                related_name="styles", to="drinks.beerstyle"             ),         ),     ]diff --git a/vrobbler/apps/beers/migrations/0005_alter_beer_run_time_seconds.py b/vrobbler/apps/drinks/migrations/0005_alter_beer_run_time_seconds.pysimilarity index 95%rename from vrobbler/apps/beers/migrations/0005_alter_beer_run_time_seconds.pyrename to vrobbler/apps/drinks/migrations/0005_alter_beer_run_time_seconds.pyindex b21446a..0207eb7 100644--- a/vrobbler/apps/beers/migrations/0005_alter_beer_run_time_seconds.py+++ b/vrobbler/apps/drinks/migrations/0005_alter_beer_run_time_seconds.py@@ -7,7 +7,7 @@ class Migration(migrations.Migration):      dependencies = [         (-            "beers",+            "drinks",             "0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",         ),     ]diff --git a/vrobbler/apps/beers/migrations/0006_remove_beer_run_time_seconds_and_more.py b/vrobbler/apps/drinks/migrations/0006_remove_beer_run_time_seconds_and_more.pysimilarity index 91%rename from vrobbler/apps/beers/migrations/0006_remove_beer_run_time_seconds_and_more.pyrename to vrobbler/apps/drinks/migrations/0006_remove_beer_run_time_seconds_and_more.pyindex 63b3de4..b9f1558 100644--- a/vrobbler/apps/beers/migrations/0006_remove_beer_run_time_seconds_and_more.py+++ b/vrobbler/apps/drinks/migrations/0006_remove_beer_run_time_seconds_and_more.py@@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration):      dependencies = [-        ('beers', '0005_alter_beer_run_time_seconds'),+        ('drinks', '0005_alter_beer_run_time_seconds'),     ]      operations = [diff --git a/vrobbler/apps/beers/migrations/0007_beer_tags.py b/vrobbler/apps/drinks/migrations/0007_beer_tags.pysimilarity index 90%rename from vrobbler/apps/beers/migrations/0007_beer_tags.pyrename to vrobbler/apps/drinks/migrations/0007_beer_tags.pyindex 26d255e..5001b20 100644--- a/vrobbler/apps/beers/migrations/0007_beer_tags.py+++ b/vrobbler/apps/drinks/migrations/0007_beer_tags.py@@ -1,14 +1,14 @@ # Generated by Django 4.2.29 on 2026-03-26 21:25 -from django.db import migrations import taggit.managers+from django.db import migrations   class Migration(migrations.Migration):      dependencies = [         ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),-        ("beers", "0006_remove_beer_run_time_seconds_and_more"),+        ("drinks", "0006_remove_beer_run_time_seconds_and_more"),     ]      operations = [diff --git a/vrobbler/apps/beers/migrations/0008_alter_beer_genre.py b/vrobbler/apps/drinks/migrations/0008_alter_beer_genre.pysimilarity index 94%rename from vrobbler/apps/beers/migrations/0008_alter_beer_genre.pyrename to vrobbler/apps/drinks/migrations/0008_alter_beer_genre.pyindex 4e5e55a..390b06d 100644--- a/vrobbler/apps/beers/migrations/0008_alter_beer_genre.py+++ b/vrobbler/apps/drinks/migrations/0008_alter_beer_genre.py@@ -1,14 +1,14 @@ # Generated by Django 4.2.29 on 2026-05-01 15:49 -from django.db import migrations import taggit.managers+from django.db import migrations   class Migration(migrations.Migration):      dependencies = [         ("scrobbles", "0075_add_channel_scrobble"),-        ("beers", "0007_beer_tags"),+        ("drinks", "0007_beer_tags"),     ]      operations = [diff --git a/vrobbler/apps/drinks/migrations/0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more.py b/vrobbler/apps/drinks/migrations/0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more.pynew file mode 100644index 0000000..9023bd1--- /dev/null+++ b/vrobbler/apps/drinks/migrations/0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more.py@@ -0,0 +1,400 @@+# Generated by Django 4.2.29 on 2026-07-13 14:33++import uuid++import django.db.models.deletion+import django_extensions.db.fields+import taggit.managers+from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),+        ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),+        ("drinks", "0008_alter_beer_genre"),+    ]++    operations = [+        migrations.CreateModel(+            name="CoffeeRoaster",+            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)),+                ("description", models.TextField(blank=True, null=True)),+                ("location", models.CharField(blank=True, max_length=255, null=True)),+                ("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+        migrations.CreateModel(+            name="WineGrape",+            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)),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+        migrations.CreateModel(+            name="WineProducer",+            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)),+                ("description", models.TextField(blank=True, null=True)),+                ("location", models.CharField(blank=True, max_length=255, null=True)),+                ("vivino_id", models.CharField(blank=True, max_length=255, null=True)),+                (+                    "cellartracker_id",+                    models.CharField(blank=True, max_length=255, null=True),+                ),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+        migrations.CreateModel(+            name="WineRegion",+            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)),+                ("country", models.CharField(blank=True, max_length=255, null=True)),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+        migrations.AddField(+            model_name="beer",+            name="calories",+            field=models.PositiveIntegerField(blank=True, null=True),+        ),+        migrations.AlterField(+            model_name="beer",+            name="producer",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="drinks.beerproducer",+            ),+        ),+        migrations.AlterField(+            model_name="beer",+            name="styles",+            field=models.ManyToManyField(related_name="styles", to="drinks.beerstyle"),+        ),+        migrations.AlterField(+            model_name="beer",+            name="untappd_image",+            field=models.ImageField(blank=True, null=True, upload_to="drinks/untappd/"),+        ),+        migrations.CreateModel(+            name="Wine",+            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)),+                ("base_run_time_seconds", models.IntegerField(blank=True, null=True)),+                ("calories", models.PositiveIntegerField(blank=True, null=True)),+                ("description", models.TextField(blank=True, null=True)),+                (+                    "wine_type",+                    models.CharField(+                        blank=True,+                        choices=[+                            ("red", "Red"),+                            ("white", "White"),+                            ("rosé", "Rosé"),+                            ("sparkling", "Sparkling"),+                            ("dessert", "Dessert"),+                            ("fortified", "Fortified"),+                            ("orange", "Orange"),+                        ],+                        max_length=20,+                        null=True,+                    ),+                ),+                ("vintage", models.CharField(blank=True, max_length=255, null=True)),+                ("vivino_id", models.CharField(blank=True, max_length=255, null=True)),+                ("vivino_rating", models.FloatField(blank=True, null=True)),+                (+                    "vivino_image",+                    models.ImageField(+                        blank=True, null=True, upload_to="drinks/vivino/"+                    ),+                ),+                (+                    "cellartracker_id",+                    models.CharField(blank=True, max_length=255, null=True),+                ),+                ("cellartracker_rating", models.FloatField(blank=True, null=True)),+                (+                    "cellartracker_image",+                    models.ImageField(+                        blank=True, null=True, upload_to="drinks/cellartracker/"+                    ),+                ),+                (+                    "genre",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="scrobbles.ObjectWithGenres",+                        to="scrobbles.Genre",+                        verbose_name="Genre",+                    ),+                ),+                (+                    "grapes",+                    models.ManyToManyField(+                        blank=True, related_name="wines", to="drinks.winegrape"+                    ),+                ),+                (+                    "producer",+                    models.ForeignKey(+                        blank=True,+                        null=True,+                        on_delete=django.db.models.deletion.DO_NOTHING,+                        to="drinks.wineproducer",+                    ),+                ),+                (+                    "region",+                    models.ForeignKey(+                        blank=True,+                        null=True,+                        on_delete=django.db.models.deletion.DO_NOTHING,+                        to="drinks.wineregion",+                    ),+                ),+                (+                    "tags",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="taggit.TaggedItem",+                        to="taggit.Tag",+                        verbose_name="Tags",+                    ),+                ),+            ],+            options={+                "abstract": False,+            },+        ),+        migrations.CreateModel(+            name="Coffee",+            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)),+                ("base_run_time_seconds", models.IntegerField(blank=True, null=True)),+                ("calories", models.PositiveIntegerField(blank=True, null=True)),+                ("description", models.TextField(blank=True, null=True)),+                ("origin", models.CharField(blank=True, max_length=255, null=True)),+                ("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),+                (+                    "roastdb_image",+                    models.ImageField(+                        blank=True, null=True, upload_to="drinks/roastdb/"+                    ),+                ),+                (+                    "genre",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="scrobbles.ObjectWithGenres",+                        to="scrobbles.Genre",+                        verbose_name="Genre",+                    ),+                ),+                (+                    "roaster",+                    models.ForeignKey(+                        blank=True,+                        null=True,+                        on_delete=django.db.models.deletion.DO_NOTHING,+                        to="drinks.coffeeroaster",+                    ),+                ),+                (+                    "tags",+                    taggit.managers.TaggableManager(+                        blank=True,+                        help_text="A comma-separated list of tags.",+                        through="taggit.TaggedItem",+                        to="taggit.Tag",+                        verbose_name="Tags",+                    ),+                ),+            ],+            options={+                "abstract": False,+            },+        ),+    ]diff --git a/vrobbler/apps/drinks/migrations/__init__.py b/vrobbler/apps/drinks/migrations/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.pynew file mode 100644index 0000000..47eb643--- /dev/null+++ b/vrobbler/apps/drinks/models.py@@ -0,0 +1,575 @@+from dataclasses import dataclass+from typing import Optional+from uuid import uuid4++import requests+from django.apps import apps+from django.core.files.base import ContentFile+from django.db import models+from django.urls import reverse+from django_extensions.db.models import TimeStampedModel+from drinks.cellartracker import get_wine_from_cellartracker_id+from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb+from drinks.untappd import get_beer_from_untappd_id+from drinks.vivino import get_wine_from_vivino_id+from imagekit.models import ImageSpecField+from imagekit.processors import ResizeToFit+from scrobbles.dataclasses import BaseLogData+from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin++BNULL = {"blank": True, "null": True}+++class WineType(models.TextChoices):+    RED = "red", "Red"+    WHITE = "white", "White"+    ROSE = "rosé", "Rosé"+    SPARKLING = "sparkling", "Sparkling"+    DESSERT = "dessert", "Dessert"+    FORTIFIED = "fortified", "Fortified"+    ORANGE = "orange", "Orange"+++class DrinkFormat(models.TextChoices):+    ALUMINUM_CAN = "aluminum_can", "Aluminum Can"+    GLASS_BOTTLE = "glass_bottle", "Glass Bottle"+    PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"+    METAL_BOTTLE = "metal_bottle", "Metal Bottle"+    KEG = "keg", "Keg"+    BY_THE_GLASS = "by_the_glass", "By the Glass"+    DRAFT = "draft", "Draft"+    GROWLER = "growler", "Growler"+    BOX = "box", "Box"+++class RoastLevel(models.TextChoices):+    LIGHT = "light", "Light"+    MEDIUM = "medium", "Medium"+    DARK = "dark", "Dark"+++class BrewingMethod(models.TextChoices):+    POUROVER = "pourover", "Pour-over"+    DRIP = "drip", "Drip"+    FRENCH_PRESS = "french_press", "French Press"+    ESPRESSO = "espresso", "Espresso"+    AEROPRESS = "aeropress", "AeroPress"+    KEURIG = "keurig", "Keurig"+    NESCAPRESSO = "nespresso", "Nespresso"+    COLD_BREW = "cold_brew", "Cold Brew"+    MOKA_POT = "moka_pot", "Moka Pot"+    SIPHON = "siphon", "Siphon"+    TURKISH = "turkish", "Turkish"+    CHEMEX = "chemex", "Chemex"+++ML_PER_OZ = 29.5735+++@dataclass+class DrinkLogData(BaseLogData):+    rating: Optional[str] = None+    format: Optional[str] = None+    size_ml: Optional[int] = None++    @property+    def size_oz(self) -> Optional[float]:+        if self.size_ml:+            return round(self.size_ml / ML_PER_OZ, 1)+        return None++    @property+    def format_display(self) -> str:+        if self.format:+            return DrinkFormat(self.format).label+        return ""++    @classmethod+    def override_fields(cls) -> dict:+        from django import forms++        return {+            "format": forms.ChoiceField(+                choices=[("", "---------")] + list(DrinkFormat.choices),+                required=False,+            ),+        }+++@dataclass+class BeerLogData(DrinkLogData):+    pass+++@dataclass+class WineLogData(DrinkLogData):+    pass+++@dataclass+class CoffeeLogData(DrinkLogData):+    roast_level: Optional[str] = None+    brewing_method: Optional[str] = None+    single_origin_or_blend: Optional[str] = None++    @property+    def roast_level_display(self) -> str:+        if self.roast_level:+            return RoastLevel(self.roast_level).label+        return ""++    @property+    def brewing_method_display(self) -> str:+        if self.brewing_method:+            return BrewingMethod(self.brewing_method).label+        return ""++    @classmethod+    def override_fields(cls) -> dict:+        from django import forms++        fields = super().override_fields()+        fields.update(+            {+                "roast_level": forms.ChoiceField(+                    choices=[("", "---------")] + list(RoastLevel.choices),+                    required=False,+                ),+                "brewing_method": forms.ChoiceField(+                    choices=[("", "---------")] + list(BrewingMethod.choices),+                    required=False,+                ),+            }+        )+        return fields+++class Drink(ScrobblableMixin):+    calories = models.PositiveIntegerField(**BNULL)++    class Meta:+        abstract = True++    @property+    def is_alcoholic(self) -> bool:+        return True+++# --- Beer models ---+++class BeerStyle(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)+    description = models.TextField(**BNULL)++    def __str__(self):+        return self.name++    class Meta:+        db_table = "beers_beerstyle"+++class BeerProducer(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)+    description = models.TextField(**BNULL)+    location = models.CharField(max_length=255, **BNULL)+    beeradvocate_id = models.CharField(max_length=255, **BNULL)+    untappd_id = models.CharField(max_length=255, **BNULL)++    def find_or_create(cls, title: str) -> "BeerProducer":+        return cls.objects.filter(title=title).first()++    def __str__(self):+        return self.name++    class Meta:+        db_table = "beers_beerproducer"+++class Beer(Drink):+    description = models.TextField(**BNULL)+    ibu = models.SmallIntegerField(**BNULL)+    abv = models.FloatField(**BNULL)+    styles = models.ManyToManyField(BeerStyle, related_name="styles")+    non_alcoholic = models.BooleanField(default=False)+    beeradvocate_id = models.CharField(max_length=255, **BNULL)+    beeradvocate_score = models.SmallIntegerField(**BNULL)+    untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)+    untappd_image_small = ImageSpecField(+        source="untappd_image",+        processors=[ResizeToFit(100, 100)],+        format="JPEG",+        options={"quality": 60},+    )+    untappd_image_medium = ImageSpecField(+        source="untappd_image",+        processors=[ResizeToFit(300, 300)],+        format="JPEG",+        options={"quality": 75},+    )+    untappd_id = models.CharField(max_length=255, **BNULL)+    untappd_rating = models.FloatField(**BNULL)+    producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)++    def get_absolute_url(self) -> str:+        return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})++    def __str__(self):+        return f"{self.title} by {self.producer}"++    @property+    def subtitle(self):+        return self.producer.name++    @property+    def strings(self) -> ScrobblableConstants:+        return ScrobblableConstants(verb="Drinking", tags="beer")++    @property+    def beeradvocate_link(self) -> str:+        link = ""+        if self.producer and self.beeradvocate_id:+            if self.beeradvocate_id:+                link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"+        return link++    @property+    def untappd_link(self) -> str:+        link = ""+        if self.untappd_id:+            link = f"https://www.untappd.com/beer/{self.untappd_id}/"+        return link++    @property+    def primary_image_url(self) -> str:+        url = ""+        if self.untappd_image:+            url = self.untappd_image.url+        return url++    @property+    def logdata_cls(self):+        return BeerLogData++    @classmethod+    def find_or_create(cls, untappd_id: str) -> "Beer":+        beer = cls.objects.filter(untappd_id=untappd_id).first()++        if not beer:+            beer_dict = get_beer_from_untappd_id(untappd_id)+            producer_dict = {}+            style_ids = []+            for key in list(beer_dict.keys()):+                if "producer__" in key:+                    pkey = key.replace("producer__", "")+                    producer_dict[pkey] = beer_dict.pop(key)+                if "styles" in key:+                    for style in beer_dict.pop("styles"):+                        style_inst, created = BeerStyle.objects.get_or_create(+                            name=style+                        )+                        style_ids.append(style_inst.id)++            producer, _created = BeerProducer.objects.get_or_create(**producer_dict)+            beer_dict["producer_id"] = producer.id+            beer = Beer.objects.create(**beer_dict)+            for style_id in style_ids:+                beer.styles.add(style_id)++        return beer++    def scrobbles(self, user_id):+        Scrobble = apps.get_model("scrobbles", "Scrobble")+        return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(+            "-timestamp"+        )++    class Meta:+        db_table = "beers_beer"+++# --- Wine models ---+++class WineGrape(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)++    def __str__(self):+        return self.name+++class WineRegion(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)+    country = models.CharField(max_length=255, **BNULL)++    def __str__(self):+        return self.name+++class WineProducer(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)+    description = models.TextField(**BNULL)+    location = models.CharField(max_length=255, **BNULL)+    vivino_id = models.CharField(max_length=255, **BNULL)+    cellartracker_id = models.CharField(max_length=255, **BNULL)++    def __str__(self):+        return self.name+++class Wine(Drink):+    description = models.TextField(**BNULL)+    wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)+    grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)+    region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)+    producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)+    vintage = models.CharField(max_length=255, **BNULL)+    vivino_id = models.CharField(max_length=255, **BNULL)+    vivino_rating = models.FloatField(**BNULL)+    vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)+    vivino_image_small = ImageSpecField(+        source="vivino_image",+        processors=[ResizeToFit(100, 100)],+        format="JPEG",+        options={"quality": 60},+    )+    vivino_image_medium = ImageSpecField(+        source="vivino_image",+        processors=[ResizeToFit(300, 300)],+        format="JPEG",+        options={"quality": 75},+    )+    cellartracker_id = models.CharField(max_length=255, **BNULL)+    cellartracker_rating = models.FloatField(**BNULL)+    cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)++    def get_absolute_url(self) -> str:+        return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})++    def __str__(self):+        return f"{self.title} by {self.producer}"++    @property+    def subtitle(self):+        return self.producer.name++    @property+    def strings(self) -> ScrobblableConstants:+        return ScrobblableConstants(verb="Drinking", tags="wine")++    @property+    def vivino_link(self) -> str:+        if self.vivino_id:+            return f"https://www.vivino.com/{self.vivino_id}"+        return ""++    @property+    def cellartracker_link(self) -> str:+        if self.cellartracker_id:+            return (+                f"https://www.cellartracker.com/wine.asp?iWine={self.cellartracker_id}"+            )+        return ""++    @property+    def primary_image_url(self) -> str:+        if self.vivino_image:+            return self.vivino_image.url+        if self.cellartracker_image:+            return self.cellartracker_image.url+        return ""++    @property+    def logdata_cls(self):+        return WineLogData++    def save_image_from_url(self, url):+        headers = {"User-Agent": "Vrobbler 0.11.12"}+        r = requests.get(url, headers=headers)+        if r.status_code == 200:+            fname = f"{self.title}_{self.uuid}.jpg"+            self.vivino_image.save(fname, ContentFile(r.content), save=True)++    @classmethod+    def find_or_create(+        cls, vivino_id: str = None, cellartracker_id: str = None+    ) -> "Wine":+        wine = None+        if vivino_id:+            wine = cls.objects.filter(vivino_id=vivino_id).first()+        if not wine and cellartracker_id:+            wine = cls.objects.filter(cellartracker_id=cellartracker_id).first()++        if wine:+            return wine++        wine_dict = None+        if vivino_id:+            wine_dict = get_wine_from_vivino_id(vivino_id)+        elif cellartracker_id:+            wine_dict = get_wine_from_cellartracker_id(cellartracker_id)++        if not wine_dict or not wine_dict.get("title"):+            return None++        producer_name = wine_dict.pop("producer__name", "")+        region_name = wine_dict.pop("region", "")+        grape_names = wine_dict.pop("grapes", [])+        image_url = wine_dict.pop("image_url", "")++        producer = None+        if producer_name:+            producer, _ = WineProducer.objects.get_or_create(name=producer_name)++        region = None+        if region_name:+            region, _ = WineRegion.objects.get_or_create(name=region_name)++        wine = cls.objects.create(+            title=wine_dict.get("title", ""),+            description=wine_dict.get("description", ""),+            wine_type=wine_dict.get("wine_type", ""),+            vintage=wine_dict.get("vintage", ""),+            vivino_id=wine_dict.get("vivino_id", vivino_id),+            vivino_rating=wine_dict.get("vivino_rating"),+            cellartracker_id=wine_dict.get("cellartracker_id", cellartracker_id),+            cellartracker_rating=wine_dict.get("cellartracker_rating"),+            producer=producer,+            region=region,+        )++        for grape_name in grape_names:+            grape, _ = WineGrape.objects.get_or_create(name=grape_name)+            wine.grapes.add(grape)++        if image_url:+            try:+                wine.save_image_from_url(image_url)+            except Exception:+                pass++        return wine++    def scrobbles(self, user_id):+        Scrobble = apps.get_model("scrobbles", "Scrobble")+        return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(+            "-timestamp"+        )+++# --- Coffee models ---+++class CoffeeRoaster(TimeStampedModel):+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)+    name = models.CharField(max_length=255)+    description = models.TextField(**BNULL)+    location = models.CharField(max_length=255, **BNULL)+    roastdb_id = models.CharField(max_length=255, **BNULL)++    def __str__(self):+        return self.name+++class Coffee(Drink):+    description = models.TextField(**BNULL)+    origin = models.CharField(max_length=255, **BNULL)+    roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL)+    roastdb_id = models.CharField(max_length=255, **BNULL)+    roastdb_image = models.ImageField(upload_to="drinks/roastdb/", **BNULL)+    roastdb_image_small = ImageSpecField(+        source="roastdb_image",+        processors=[ResizeToFit(100, 100)],+        format="JPEG",+        options={"quality": 60},+    )+    roastdb_image_medium = ImageSpecField(+        source="roastdb_image",+        processors=[ResizeToFit(300, 300)],+        format="JPEG",+        options={"quality": 75},+    )++    def get_absolute_url(self) -> str:+        return reverse("drinks:coffee_detail", kwargs={"slug": self.uuid})++    def __str__(self):+        return f"{self.title} by {self.roaster}"++    @property+    def subtitle(self):+        return self.roaster.name++    @property+    def strings(self) -> ScrobblableConstants:+        return ScrobblableConstants(verb="Drinking", tags="coffee")++    @property+    def primary_image_url(self) -> str:+        if self.roastdb_image:+            return self.roastdb_image.url+        return ""++    @property+    def logdata_cls(self):+        return CoffeeLogData++    def save_image_from_url(self, url):+        headers = {"User-Agent": "Vrobbler 0.11.12"}+        r = requests.get(url, headers=headers)+        if r.status_code == 200:+            fname = f"{self.title}_{self.uuid}.jpg"+            self.roastdb_image.save(fname, ContentFile(r.content), save=True)++    @classmethod+    def find_or_create(cls, roastdb_id: str = None, slug: str = None) -> "Coffee":+        coffee = None+        if roastdb_id:+            coffee = cls.objects.filter(roastdb_id=roastdb_id).first()+        if not coffee and slug:+            coffee = cls.objects.filter(roastdb_id=slug).first()+        if coffee:+            return coffee++        coffee_dict = None+        if slug:+            coffee_dict = get_coffee_from_slug(slug)+        elif roastdb_id:+            coffee_dict = get_coffee_from_slug(roastdb_id)++        if not coffee_dict or not coffee_dict.get("title"):+            return None++        roaster_name = coffee_dict.pop("roaster__name", "")+        image_url = coffee_dict.pop("image_url", "")++        roaster = None+        if roaster_name:+            roaster, _ = CoffeeRoaster.objects.get_or_create(name=roaster_name)++        coffee = cls.objects.create(+            title=coffee_dict.get("title", ""),+            description=coffee_dict.get("description", ""),+            origin=coffee_dict.get("origin", ""),+            roastdb_id=slug or roastdb_id,+            roaster=roaster,+        )++        if image_url:+            try:+                coffee.save_image_from_url(image_url)+            except Exception:+                pass++        return coffee++    def scrobbles(self, user_id):+        Scrobble = apps.get_model("scrobbles", "Scrobble")+        return Scrobble.objects.filter(user_id=user_id, coffee=self).order_by(+            "-timestamp"+        )diff --git a/vrobbler/apps/drinks/roastdb.py b/vrobbler/apps/drinks/roastdb.pynew file mode 100644index 0000000..2477bd4--- /dev/null+++ b/vrobbler/apps/drinks/roastdb.py@@ -0,0 +1,89 @@+import json+import logging+import re++import requests+from bs4 import BeautifulSoup++logger = logging.getLogger(__name__)++ROASTDB_URL = "https://www.roastdb.com/beans/{slug}"+ROASTDB_SEARCH_URL = "https://www.roastdb.com/explore?q={query}"+++def get_coffee_from_slug(slug: str) -> dict:+    coffee_url = ROASTDB_URL.format(slug=slug)+    headers = {"User-Agent": "Vrobbler 0.11.12"}+    response = requests.get(coffee_url, headers=headers)+    coffee_dict = {"slug": slug}++    if response.status_code != 200:+        logger.warning("Bad response from roastdb.com", extra={"response": response})+        return coffee_dict++    soup = BeautifulSoup(response.text, "html.parser")++    for script in soup.find_all("script", type="application/ld+json"):+        try:+            data = json.loads(script.string)+            if data.get("@type") == "Product":+                coffee_dict["title"] = data.get("name", "")+                coffee_dict["description"] = data.get("description", "")+                coffee_dict["image_url"] = data.get("image", "")+                brand = data.get("brand", {})+                coffee_dict["roaster__name"] = brand.get("name", "")+                origin = data.get("countryOfOrigin", {})+                coffee_dict["origin"] = origin.get("name", "")+                break+        except (json.JSONDecodeError, AttributeError):+            continue++    if not coffee_dict.get("title"):+        bean_data = _extract_initial_bean(soup)+        if bean_data:+            coffee_dict["title"] = bean_data.get("name", "")+            coffee_dict["description"] = bean_data.get("description", "")+            coffee_dict["origin"] = bean_data.get("origin_country", "")+            coffee_dict["roaster__name"] = bean_data.get("roaster_name", "")+            image_urls = bean_data.get("image_urls", [])+            if image_urls:+                coffee_dict["image_url"] = image_urls[0]++    return coffee_dict+++def _extract_initial_bean(soup) -> dict | None:+    for script in soup.find_all("script"):+        if script.string and "initialBean" in (script.string or ""):+            match = re.search(r'"initialBean"\s*:\s*(\{.*?\})\s*\}', script.string)+            if match:+                try:+                    return json.loads(match.group(1))+                except json.JSONDecodeError:+                    pass+    return None+++def search_coffee_on_roastdb(query: str) -> list[dict]:+    search_url = ROASTDB_SEARCH_URL.format(query=query)+    headers = {"User-Agent": "Vrobbler 0.11.12"}+    response = requests.get(search_url, headers=headers)++    if response.status_code != 200:+        logger.warning(+            "Bad response from roastdb.com search", extra={"response": response}+        )+        return []++    soup = BeautifulSoup(response.text, "html.parser")+    results = []++    for link in soup.find_all("a", href=True):+        href = link["href"]+        if href.startswith("/beans/"):+            slug = href.split("/beans/", 1)[1].rstrip("/")+            title = link.get_text(strip=True)+            if slug and title:+                results.append({"slug": slug, "title": title})++    return resultsdiff --git a/vrobbler/apps/beers/untappd.py b/vrobbler/apps/drinks/untappd.pysimilarity index 100%rename from vrobbler/apps/beers/untappd.pyrename to vrobbler/apps/drinks/untappd.pydiff --git a/vrobbler/apps/drinks/urls.py b/vrobbler/apps/drinks/urls.pynew file mode 100644index 0000000..2f7b3b1--- /dev/null+++ b/vrobbler/apps/drinks/urls.py@@ -0,0 +1,26 @@+from django.urls import path+from drinks import views++app_name = "drinks"+++urlpatterns = [+    path("beers/", views.BeerListView.as_view(), name="beer_list"),+    path(+        "beers/<slug:slug>/",+        views.BeerDetailView.as_view(),+        name="beer_detail",+    ),+    path("wines/", views.WineListView.as_view(), name="wine_list"),+    path(+        "wines/<slug:slug>/",+        views.WineDetailView.as_view(),+        name="wine_detail",+    ),+    path("coffees/", views.CoffeeListView.as_view(), name="coffee_list"),+    path(+        "coffees/<slug:slug>/",+        views.CoffeeDetailView.as_view(),+        name="coffee_detail",+    ),+]diff --git a/vrobbler/apps/drinks/views.py b/vrobbler/apps/drinks/views.pynew file mode 100644index 0000000..e29ad39--- /dev/null+++ b/vrobbler/apps/drinks/views.py@@ -0,0 +1,26 @@+from drinks.models import Beer, Coffee, Wine+from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView+++class BeerListView(ScrobbleableListView):+    model = Beer+++class BeerDetailView(ScrobbleableDetailView):+    model = Beer+++class WineListView(ScrobbleableListView):+    model = Wine+++class WineDetailView(ScrobbleableDetailView):+    model = Wine+++class CoffeeListView(ScrobbleableListView):+    model = Coffee+++class CoffeeDetailView(ScrobbleableDetailView):+    model = Coffeediff --git a/vrobbler/apps/drinks/vivino.py b/vrobbler/apps/drinks/vivino.pynew file mode 100644index 0000000..a9ec6ac--- /dev/null+++ b/vrobbler/apps/drinks/vivino.py@@ -0,0 +1,131 @@+import logging++import requests+from bs4 import BeautifulSoup++logger = logging.getLogger(__name__)++VIVINO_URL = "https://www.vivino.com/{vivino_id}"+++def get_title_from_soup(soup) -> str:+    title = ""+    try:+        title = soup.find("span", class_="winePageHeader__title").get_text(strip=True)+    except AttributeError:+        pass+    return title+++def get_description_from_soup(soup) -> str:+    desc = ""+    try:+        desc = soup.find("div", class_="winePageHeader__description").get_text(+            strip=True+        )+    except AttributeError:+        pass+    return desc+++def get_wine_type_from_soup(soup) -> str:+    wine_type = ""+    try:+        wine_type = (+            soup.find("span", class_="winePageHeader__wine-type")+            .get_text(strip=True)+            .lower()+        )+    except AttributeError:+        pass+    return wine_type+++def get_rating_from_soup(soup) -> float:+    rating = None+    try:+        rating = float(+            soup.find("div", class_="winePageHeader__ratingValue").get_text(strip=True)+        )+    except (AttributeError, ValueError):+        pass+    return rating+++def get_grapes_from_soup(soup) -> list[str]:+    grapes = []+    try:+        grape_elements = soup.find_all("a", class_="wineLink")+        grapes = [+            g.get_text(strip=True) for g in grape_elements if g.get_text(strip=True)+        ]+    except AttributeError:+        pass+    return grapes+++def get_region_from_soup(soup) -> str:+    region = ""+    try:+        region = soup.find("span", class_="winePageHeader__region").get_text(strip=True)+    except AttributeError:+        pass+    return region+++def get_producer_from_soup(soup) -> str:+    producer = ""+    try:+        producer = soup.find("span", class_="winePageHeader__producer").get_text(+            strip=True+        )+    except AttributeError:+        pass+    return producer+++def get_vintage_from_soup(soup) -> str:+    vintage = ""+    try:+        vintage = soup.find("span", class_="winePageHeader__vintage").get_text(+            strip=True+        )+    except AttributeError:+        pass+    return vintage+++def get_image_url_from_soup(soup) -> str:+    image_url = ""+    try:+        image_url = soup.find("img", class_="winePageHeader__image")["src"]+    except (AttributeError, KeyError, TypeError):+        pass+    return image_url+++def get_wine_from_vivino_id(vivino_id: str) -> dict:+    wine_url = VIVINO_URL.format(vivino_id=vivino_id)+    headers = {"User-Agent": "Vrobbler 0.11.12"}+    response = requests.get(wine_url, headers=headers)+    wine_dict = {"vivino_id": vivino_id}++    if response.status_code != 200:+        logger.warning(+            "Bad response from vivino.com",+            extra={"status_code": response.status_code, "vivino_id": vivino_id},+        )+        return wine_dict++    soup = BeautifulSoup(response.text, "html.parser")+    wine_dict["title"] = get_title_from_soup(soup)+    wine_dict["description"] = get_description_from_soup(soup)+    wine_dict["wine_type"] = get_wine_type_from_soup(soup)+    wine_dict["vivino_rating"] = get_rating_from_soup(soup)+    wine_dict["grapes"] = get_grapes_from_soup(soup)+    wine_dict["region"] = get_region_from_soup(soup)+    wine_dict["producer__name"] = get_producer_from_soup(soup)+    wine_dict["vintage"] = get_vintage_from_soup(soup)+    wine_dict["image_url"] = get_image_url_from_soup(soup)++    return wine_dictdiff --git a/vrobbler/apps/profiles/forms.py b/vrobbler/apps/profiles/forms.pyindex 966a747..f3c8bc4 100644--- a/vrobbler/apps/profiles/forms.py+++ b/vrobbler/apps/profiles/forms.py@@ -1,5 +1,4 @@ from django import forms- from profiles.models import UserProfile from scrobbles.constants import Visibility from scrobbles.models import Scrobble@@ -42,6 +41,7 @@ class UserProfileForm(forms.ModelForm):             "home_scrobble_limit",             "live_now_playing",             "weigh_in_units",+            "volume_unit",             "trends_disabled",         ]         widgets = {@@ -51,9 +51,7 @@ class UserProfileForm(forms.ModelForm):         }  -MEDIA_TYPE_LABELS = {-    mt.value: mt.label for mt in Scrobble.MediaType-}+MEDIA_TYPE_LABELS = {mt.value: mt.label for mt in Scrobble.MediaType}  INHERIT = "" diff --git a/vrobbler/apps/profiles/migrations/0041_userprofile_volume_unit.py b/vrobbler/apps/profiles/migrations/0041_userprofile_volume_unit.pynew file mode 100644index 0000000..baa9359--- /dev/null+++ b/vrobbler/apps/profiles/migrations/0041_userprofile_volume_unit.py@@ -0,0 +1,22 @@+# Generated by Django 4.2.29 on 2026-07-13 16:25++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("profiles", "0040_userprofile_trends_disabled"),+    ]++    operations = [+        migrations.AddField(+            model_name="userprofile",+            name="volume_unit",+            field=models.CharField(+                choices=[("metric", "Metric (mL, L)"), ("imperial", "Imperial (oz)")],+                default="metric",+                max_length=16,+            ),+        ),+    ]diff --git a/vrobbler/apps/profiles/models.py b/vrobbler/apps/profiles/models.pyindex 0c982b3..21c1c85 100644--- a/vrobbler/apps/profiles/models.py+++ b/vrobbler/apps/profiles/models.py@@ -1,14 +1,16 @@+import logging from zoneinfo import ZoneInfo+ import pendulum-from django.utils import timezone-import logging from django.conf import settings from django.contrib.auth import get_user_model from django.db import models+from django.utils import timezone from django.utils.functional import cached_property from django_extensions.db.models import TimeStampedModel from encrypted_field import EncryptedField from profiles.constants import PRETTY_TIMEZONE_CHOICES+ VISIBILITY_CHOICES = (     ("public", "Public"),     ("shared", "Shared"),@@ -26,6 +28,11 @@ class WeighUnit(models.TextChoices):     IMPERIAL = "imperial", "Imperial (lbs, in)"  +class VolumeUnit(models.TextChoices):+    METRIC = "metric", "Metric (mL, L)"+    IMPERIAL = "imperial", "Imperial (oz)"++ class UserProfile(TimeStampedModel):     user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")     timezone = models.CharField(@@ -71,11 +78,13 @@ class UserProfile(TimeStampedModel):      mopidy_api_url = models.CharField(max_length=255, **BNULL)     favorites_mopidy_playlist = models.CharField(-        max_length=255, **BNULL,+        max_length=255,+        **BNULL,         help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",     )     monthly_mopidy_playlist_pattern = models.CharField(-        max_length=255, **BNULL,+        max_length=255,+        **BNULL,         help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",     ) @@ -93,7 +102,7 @@ class UserProfile(TimeStampedModel):     media_type_visibility = models.JSONField(         default=dict,         blank=True,-        help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",+        help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',     )      home_scrobble_limit = models.IntegerField(default=20)@@ -106,6 +115,12 @@ class UserProfile(TimeStampedModel):         default=WeighUnit.METRIC,     ) +    volume_unit = models.CharField(+        max_length=16,+        choices=VolumeUnit.choices,+        default=VolumeUnit.METRIC,+    )+     trends_disabled = models.BooleanField(default=False)      disabled_trends = models.JSONField(@@ -153,7 +168,11 @@ class UserProfile(TimeStampedModel):         from django.conf import settings          server_tz = ZoneInfo(settings.TIME_ZONE)-        ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz)+        ref_dt = (+            timestamp+            if timestamp.tzinfo is not None+            else timestamp.replace(tzinfo=server_tz)+        )          timezone = self.tzinfo         if self.timezone_change_log:diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.pyindex 5d9eca6..fb47524 100644--- a/vrobbler/apps/scrobbles/admin.py+++ b/vrobbler/apps/scrobbles/admin.py@@ -1,5 +1,5 @@ from django.contrib import admin-+from scrobbles.mixins import Genre from scrobbles.models import (     AudioScrobblerTSVImport,     BGStatsImport,@@ -14,7 +14,6 @@ from scrobbles.models import (     TrailGPXImport,     UDiscCSVImport, )-from scrobbles.mixins import Genre   class ScrobbleInline(admin.TabularInline):@@ -182,6 +181,8 @@ class FavoriteMediaAdmin(admin.ModelAdmin):         "brick_set",         "trail",         "beer",+        "wine",+        "coffee",         "web_page",         "life_event",         "birding_location",diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex f6f57bc..88d1fad 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -1,12 +1,16 @@-from django.db import models from enum import Enum +from django.db import models+ JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"] + class Visibility(models.TextChoices):     PUBLIC = "public", "Public"     SHARED = "shared", "Shared"     PRIVATE = "private", "Private"++ JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]  LONG_PLAY_MEDIA = {@@ -35,7 +39,7 @@ PLAY_AGAIN_MEDIA = {     "boardgames": "BoardGame",     "locations": "GeoLocation",     "trails": "Trail",-    "beers": "Beer",+    "drinks": "Beer",     "puzzles": "Puzzle",     "foods": "Food",     "tasks": "Task",@@ -48,6 +52,8 @@ PLAY_AGAIN_MEDIA = {     "discgolf": "DiscGolfCourse", } +DRINK_MODELS = ["Beer", "Wine", "Coffee"]+ MEDIA_END_PADDING_SECONDS = {     "Video": 3600,  # 60 min }@@ -59,6 +65,8 @@ SCROBBLE_CONTENT_URLS = {     "-s": ["https://www.thesportsdb.com/event/"],     "-g": ["https://boardgamegeek.com/boardgame/"],     "-u": ["https://untappd.com/"],+    "-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],+    "-co": ["https://roastdb.com/"],     "-b": ["https://www.amazon.com/"],     "-t": ["https://app.todoist.com/app/task/{id}"],     "-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],@@ -77,6 +85,8 @@ MANUAL_SCROBBLE_FNS = {     "-i": "manual_scrobble_video",     "-g": "manual_scrobble_board_game",     "-u": "manual_scrobble_beer",+    "-wi": "manual_scrobble_wine",+    "-co": "manual_scrobble_coffee",     "-w": "manual_scrobble_webpage",     "-t": "manual_scrobble_task",     "-p": "manual_scrobble_puzzle",diff --git a/vrobbler/apps/scrobbles/mcp.py b/vrobbler/apps/scrobbles/mcp.pyindex ca78a47..f5f6371 100644--- a/vrobbler/apps/scrobbles/mcp.py+++ b/vrobbler/apps/scrobbles/mcp.py@@ -18,16 +18,32 @@ class ScrobbleToolset(MCPToolset):         qs = (             Scrobble.objects.filter(user=self.request.user)             .select_related(-                "video", "track", "book", "video_game", "board_game",-                "beer", "puzzle", "food", "trail", "task", "web_page",-                "life_event", "mood", "brick_set", "podcast_episode",-                "sport_event", "geo_location", "birding_location",-                "disc_golf_course", "channel",+                "video",+                "track",+                "book",+                "video_game",+                "board_game",+                "beer",+                "puzzle",+                "food",+                "trail",+                "task",+                "web_page",+                "life_event",+                "mood",+                "brick_set",+                "podcast_episode",+                "sport_event",+                "geo_location",+                "birding_location",+                "disc_golf_course",+                "channel",             )             .order_by("-timestamp")         )         from django.utils import timezone         import datetime+         qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))         if media_type:             qs = qs.filter(media_type=media_type)@@ -47,6 +63,7 @@ class ScrobbleToolset(MCPToolset):     ) -> list[dict]:         """Search scrobbles by text in their log data or related media titles."""         from django.db.models import Q+         qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")         if media_type:             qs = qs.filter(media_type=media_type)@@ -74,6 +91,7 @@ class ScrobbleToolset(MCPToolset):     ) -> list[dict]:         """Get scrobbles for a specific date (YYYY-MM-DD format)."""         import datetime+         try:             dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()         except ValueError:@@ -86,9 +104,7 @@ class ScrobbleToolset(MCPToolset):             qs = qs.filter(media_type=media_type)         return [_scrobble_to_dict(s) for s in qs] -    def get_in_progress_scrobbles(-        self, media_type: str | None = None-    ) -> list[dict]:+    def get_in_progress_scrobbles(self, media_type: str | None = None) -> list[dict]:         """Get scrobbles currently in progress (started but not finished).         These are long-play items like books, video games, brick sets, or tasks."""         qs = Scrobble.objects.filter(@@ -122,70 +138,124 @@ class MediaToolset(MCPToolset):     def get_book(self, uuid: str) -> dict | None:         """Get a book by UUID."""         from books.models import Book+         try:             b = Book.objects.get(uuid=uuid)         except Book.DoesNotExist:             return None-        return _media_to_dict(b, fields=["title", "pages", "language",-                                          "first_publish_year", "isbn_13",-                                          "publisher", "summary"])+        return _media_to_dict(+            b,+            fields=[+                "title",+                "pages",+                "language",+                "first_publish_year",+                "isbn_13",+                "publisher",+                "summary",+            ],+        )      def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:         """List books, optionally filtered by author name."""         from books.models import Book+         qs = Book.objects.all().order_by("title")         if author:             qs = qs.filter(authors__name__icontains=author)-        return [_media_to_dict(b, fields=["title", "pages", "language",-                                           "first_publish_year", "isbn_13",-                                           "publisher"]) for b in qs[:limit]]+        return [+            _media_to_dict(+                b,+                fields=[+                    "title",+                    "pages",+                    "language",+                    "first_publish_year",+                    "isbn_13",+                    "publisher",+                ],+            )+            for b in qs[:limit]+        ]      def get_track(self, uuid: str) -> dict | None:         """Get a music track by UUID."""         from music.models import Track+         try:             t = Track.objects.select_related("artist_fk").get(uuid=uuid)         except Track.DoesNotExist:             return None-        return _media_to_dict(t, fields=["title", "base_run_time_seconds",-                                          "artist_fk__name", "genre"])+        return _media_to_dict(+            t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]+        )      def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:         """List music tracks, optionally filtered by artist name."""         from music.models import Track+         qs = Track.objects.select_related("artist_fk").all().order_by("title")         if artist:             qs = qs.filter(artist_fk__name__icontains=artist)-        return [_media_to_dict(t, fields=["title", "base_run_time_seconds",-                                           "artist_fk__name", "genre"])-                for t in qs[:limit]]+        return [+            _media_to_dict(+                t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]+            )+            for t in qs[:limit]+        ]      def get_video(self, uuid: str) -> dict | None:         """Get a video by UUID."""         from videos.models import Video+         try:             v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)         except Video.DoesNotExist:             return None-        return _media_to_dict(v, fields=["title", "year", "overview",-                                          "imdb_id", "imdb_rating",-                                          "tv_series__name", "channel__title",-                                          "season_number", "episode_number"])+        return _media_to_dict(+            v,+            fields=[+                "title",+                "year",+                "overview",+                "imdb_id",+                "imdb_rating",+                "tv_series__name",+                "channel__title",+                "season_number",+                "episode_number",+            ],+        )      def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:         """List videos, optionally filtered by series name."""         from videos.models import Video-        qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")++        qs = (+            Video.objects.select_related("tv_series", "channel").all().order_by("title")+        )         if series:             qs = qs.filter(tv_series__name__icontains=series)-        return [_media_to_dict(v, fields=["title", "year", "overview",-                                           "tv_series__name", "channel__title",-                                           "season_number", "episode_number"])-                for v in qs[:limit]]+        return [+            _media_to_dict(+                v,+                fields=[+                    "title",+                    "year",+                    "overview",+                    "tv_series__name",+                    "channel__title",+                    "season_number",+                    "episode_number",+                ],+            )+            for v in qs[:limit]+        ]      def get_board_game(self, uuid: str) -> dict | None:         """Get a board game by UUID."""         from boardgames.models import BoardGame+         try:             bg = BoardGame.objects.get(uuid=uuid)         except BoardGame.DoesNotExist:@@ -195,34 +265,69 @@ class MediaToolset(MCPToolset):     def list_board_games(self, limit: int = 20) -> list[dict]:         """List board games."""         from boardgames.models import BoardGame+         qs = BoardGame.objects.all().order_by("title")[:limit]         return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]      def get_podcast_episode(self, uuid: str) -> dict | None:         """Get a podcast episode by UUID."""         from podcasts.models import PodcastEpisode+         try:             pe = PodcastEpisode.objects.select_related("podcast", "producer").get(                 uuid=uuid             )         except PodcastEpisode.DoesNotExist:             return None-        return _media_to_dict(pe, fields=["title", "podcast__title",-                                           "producer__name", "base_run_time_seconds"])+        return _media_to_dict(+            pe,+            fields=[+                "title",+                "podcast__title",+                "producer__name",+                "base_run_time_seconds",+            ],+        )      def get_beer(self, uuid: str) -> dict | None:         """Get a beer by UUID."""-        from beers.models import Beer+        from drinks.models import Beer+         try:             b = Beer.objects.select_related("style", "producer").get(uuid=uuid)         except Beer.DoesNotExist:             return None-        return _media_to_dict(b, fields=["title", "style__name",-                                          "producer__name", "abv"])+        return _media_to_dict(+            b, fields=["title", "style__name", "producer__name", "abv"]+        )++    def get_wine(self, uuid: str) -> dict | None:+        """Get a wine by UUID."""+        from drinks.models import Wine++        try:+            w = Wine.objects.select_related("region", "producer").get(uuid=uuid)+        except Wine.DoesNotExist:+            return None+        return _media_to_dict(+            w,+            fields=["title", "wine_type", "vintage", "producer__name", "region__name"],+        )++    def get_coffee(self, uuid: str) -> dict | None:+        """Get a coffee by UUID."""+        from drinks.models import Coffee++        try:+            c = Coffee.objects.select_related("roaster").get(uuid=uuid)+        except Coffee.DoesNotExist:+            return None+        return _media_to_dict(c, fields=["title", "origin", "roaster__name"])      def get_brick_set(self, uuid: str) -> dict | None:         """Get a brick set (LEGO) by UUID."""         from bricksets.models import BrickSet+         try:             bs = BrickSet.objects.get(uuid=uuid)         except BrickSet.DoesNotExist:@@ -232,26 +337,27 @@ class MediaToolset(MCPToolset):     def get_video_game(self, uuid: str) -> dict | None:         """Get a video game by UUID."""         from videogames.models import VideoGame+         try:             vg = VideoGame.objects.get(uuid=uuid)         except VideoGame.DoesNotExist:             return None-        return _media_to_dict(vg, fields=["title", "genre",-                                           "base_run_time_seconds"])+        return _media_to_dict(vg, fields=["title", "genre", "base_run_time_seconds"])      def get_puzzle(self, uuid: str) -> dict | None:         """Get a puzzle by UUID."""         from puzzles.models import Puzzle+         try:             p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)         except Puzzle.DoesNotExist:             return None-        return _media_to_dict(p, fields=["title", "piece_count",-                                          "manufacturer__name"])+        return _media_to_dict(p, fields=["title", "piece_count", "manufacturer__name"])      def get_web_page(self, uuid: str) -> dict | None:         """Get a web page by UUID."""         from webpages.models import WebPage+         try:             wp = WebPage.objects.select_related("domain").get(uuid=uuid)         except WebPage.DoesNotExist:@@ -261,6 +367,7 @@ class MediaToolset(MCPToolset):     def get_task(self, uuid: str) -> dict | None:         """Get a task by UUID."""         from tasks.models import Task+         try:             t = Task.objects.get(uuid=uuid)         except Task.DoesNotExist:@@ -270,6 +377,7 @@ class MediaToolset(MCPToolset):     def get_trail(self, uuid: str) -> dict | None:         """Get a trail by UUID."""         from trails.models import Trail+         try:             t = Trail.objects.get(uuid=uuid)         except Trail.DoesNotExist:@@ -279,6 +387,7 @@ class MediaToolset(MCPToolset):     def get_geo_location(self, uuid: str) -> dict | None:         """Get a geo location by UUID."""         from locations.models import GeoLocation+         try:             gl = GeoLocation.objects.get(uuid=uuid)         except GeoLocation.DoesNotExist:@@ -288,6 +397,7 @@ class MediaToolset(MCPToolset):     def get_life_event(self, uuid: str) -> dict | None:         """Get a life event by UUID."""         from lifeevents.models import LifeEvent+         try:             le = LifeEvent.objects.get(uuid=uuid)         except LifeEvent.DoesNotExist:@@ -297,6 +407,7 @@ class MediaToolset(MCPToolset):     def get_mood(self, uuid: str) -> dict | None:         """Get a mood entry by UUID."""         from moods.models import Mood+         try:             m = Mood.objects.get(uuid=uuid)         except Mood.DoesNotExist:@@ -306,6 +417,7 @@ class MediaToolset(MCPToolset):     def get_food(self, uuid: str) -> dict | None:         """Get a food entry by UUID."""         from foods.models import Food+         try:             f = Food.objects.select_related("category").get(uuid=uuid)         except Food.DoesNotExist:@@ -315,16 +427,20 @@ class MediaToolset(MCPToolset):     def get_bird_sighting(self, uuid: str) -> dict | None:         """Get a bird sighting by UUID."""         from birds.models import BirdSighting+         try:             bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)         except BirdSighting.DoesNotExist:             return None-        return _media_to_dict(bs, fields=["title", "bird__common_name",-                                           "bird__scientific_name", "location"])+        return _media_to_dict(+            bs,+            fields=["title", "bird__common_name", "bird__scientific_name", "location"],+        )      def get_disc_golf_course(self, uuid: str) -> dict | None:         """Get a disc golf course by UUID."""         from discgolf.models import DiscGolfCourse+         try:             dg = DiscGolfCourse.objects.get(uuid=uuid)         except DiscGolfCourse.DoesNotExist:@@ -434,16 +550,15 @@ def _scrobble_to_dict(s: Scrobble) -> dict:  def _scrobble_related_to_dict(s: Scrobble) -> dict | None:     if s.video:-        return _media_to_dict(s.video, fields=["title", "year", "imdb_id",-                                                "imdb_rating"])+        return _media_to_dict(+            s.video, fields=["title", "year", "imdb_id", "imdb_rating"]+        )     if s.track:-        return _media_to_dict(s.track, fields=["title",-                                                "base_run_time_seconds"])+        return _media_to_dict(s.track, fields=["title", "base_run_time_seconds"])     if s.book:         return _media_to_dict(s.book, fields=["title", "pages"])     if s.video_game:-        return _media_to_dict(s.video_game, fields=["title",-                                                     "base_run_time_seconds"])+        return _media_to_dict(s.video_game, fields=["title", "base_run_time_seconds"])     if s.board_game:         return _media_to_dict(s.board_game, fields=["title"])     if s.beer:@@ -453,8 +568,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:     if s.food:         return _media_to_dict(s.food, fields=["title"])     if s.trail:-        return _media_to_dict(s.trail, fields=["title",-                                                "base_run_time_seconds"])+        return _media_to_dict(s.trail, fields=["title", "base_run_time_seconds"])     if s.task:         return _media_to_dict(s.task, fields=["title"])     if s.web_page:@@ -470,8 +584,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:     if s.sport_event:         return {"title": str(s.sport_event)}     if s.geo_location:-        return _media_to_dict(s.geo_location, fields=["title", "latitude",-                                                       "longitude"])+        return _media_to_dict(s.geo_location, fields=["title", "latitude", "longitude"])     if s.birding_location:         return _media_to_dict(s.birding_location, fields=["title"])     if s.disc_golf_course:diff --git a/vrobbler/apps/scrobbles/migrations/0066_scrobble_beer_alter_scrobble_media_type.py b/vrobbler/apps/scrobbles/migrations/0066_scrobble_beer_alter_scrobble_media_type.pyindex 39b2cfe..b026c0e 100644--- a/vrobbler/apps/scrobbles/migrations/0066_scrobble_beer_alter_scrobble_media_type.py+++ b/vrobbler/apps/scrobbles/migrations/0066_scrobble_beer_alter_scrobble_media_type.py@@ -7,7 +7,7 @@ import django.db.models.deletion class Migration(migrations.Migration):      dependencies = [-        ("beers", "0001_initial"),+        ("drinks", "0001_initial"),         ("scrobbles", "0065_alter_scrobble_log"),     ] @@ -19,7 +19,7 @@ class Migration(migrations.Migration):                 blank=True,                 null=True,                 on_delete=django.db.models.deletion.DO_NOTHING,-                to="beers.beer",+                to="drinks.beer",             ),         ),         migrations.AlterField(diff --git a/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py b/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.pyindex 0ea09ba..fb795df 100644--- a/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py+++ b/vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py@@ -19,7 +19,7 @@ class Migration(migrations.Migration):         ("puzzles", "0006_alter_puzzle_genre"),         ("videogames", "0015_alter_videogame_genre"),         ("lifeevents", "0005_alter_lifeevent_genre"),-        ("beers", "0008_alter_beer_genre"),+        ("drinks", "0008_alter_beer_genre"),         ("foods", "0007_alter_food_genre"),         ("tasks", "0007_alter_task_genre"),         ("books", "0036_alter_book_genre_alter_paper_genre"),@@ -98,7 +98,7 @@ class Migration(migrations.Migration):                         blank=True,                         null=True,                         on_delete=django.db.models.deletion.CASCADE,-                        to="beers.beer",+                        to="drinks.beer",                     ),                 ),                 (diff --git a/vrobbler/apps/scrobbles/migrations/0101_favoritemedia_coffee_favoritemedia_wine_and_more.py b/vrobbler/apps/scrobbles/migrations/0101_favoritemedia_coffee_favoritemedia_wine_and_more.pynew file mode 100644index 0000000..d3145ca--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0101_favoritemedia_coffee_favoritemedia_wine_and_more.py@@ -0,0 +1,140 @@+# Generated by Django 4.2.29 on 2026-07-13 14:33++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++    dependencies = [+        ("drinks", "0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more"),+        ("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="favoritemedia",+            name="coffee",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.CASCADE,+                to="drinks.coffee",+            ),+        ),+        migrations.AddField(+            model_name="favoritemedia",+            name="wine",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.CASCADE,+                to="drinks.wine",+            ),+        ),+        migrations.AddField(+            model_name="scrobble",+            name="coffee",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="drinks.coffee",+            ),+        ),+        migrations.AddField(+            model_name="scrobble",+            name="wine",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="drinks.wine",+            ),+        ),+        migrations.AlterField(+            model_name="favoritemedia",+            name="beer",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.CASCADE,+                to="drinks.beer",+            ),+        ),+        migrations.AlterField(+            model_name="favoritemedia",+            name="media_type",+            field=models.CharField(+                choices=[+                    ("Video", "Video"),+                    ("Track", "Track"),+                    ("PodcastEpisode", "Podcast episode"),+                    ("SportEvent", "Sport event"),+                    ("Book", "Book"),+                    ("Paper", "Paper"),+                    ("VideoGame", "Video game"),+                    ("BoardGame", "Board game"),+                    ("GeoLocation", "GeoLocation"),+                    ("Trail", "Trail"),+                    ("Beer", "Beer"),+                    ("Wine", "Wine"),+                    ("Coffee", "Coffee"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("BirdingLocation", "Birding location"),+                    ("DiscGolfCourse", "Disc golf"),+                ],+                max_length=20,+            ),+        ),+        migrations.AlterField(+            model_name="scrobble",+            name="beer",+            field=models.ForeignKey(+                blank=True,+                null=True,+                on_delete=django.db.models.deletion.DO_NOTHING,+                to="drinks.beer",+            ),+        ),+        migrations.AlterField(+            model_name="scrobble",+            name="media_type",+            field=models.CharField(+                choices=[+                    ("Video", "Video"),+                    ("Track", "Track"),+                    ("PodcastEpisode", "Podcast episode"),+                    ("SportEvent", "Sport event"),+                    ("Book", "Book"),+                    ("Paper", "Paper"),+                    ("VideoGame", "Video game"),+                    ("BoardGame", "Board game"),+                    ("GeoLocation", "GeoLocation"),+                    ("Trail", "Trail"),+                    ("Beer", "Beer"),+                    ("Wine", "Wine"),+                    ("Coffee", "Coffee"),+                    ("Puzzle", "Puzzle"),+                    ("Food", "Food"),+                    ("Task", "Task"),+                    ("WebPage", "Web Page"),+                    ("LifeEvent", "Life event"),+                    ("Mood", "Mood"),+                    ("BrickSet", "Brick set"),+                    ("Channel", "Channel"),+                    ("BirdingLocation", "Birding location"),+                    ("DiscGolfCourse", "Disc golf"),+                ],+                default="Video",+                max_length=20,+            ),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 1155bf3..0f2e359 100644--- a/vrobbler/apps/scrobbles/models.py+++ b/vrobbler/apps/scrobbles/models.py@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo  import pendulum import pytz-from beers.models import Beer+from drinks.models import Beer, Wine, Coffee from birds.models import BirdingLocation from discgolf.models import DiscGolfCourse from boardgames.models import BoardGame@@ -684,6 +684,8 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {     "GeoLocation": ("geo_location",),     "Trail": ("trail",),     "Beer": ("beer",),+    "Wine": ("wine",),+    "Coffee": ("coffee",),     "Puzzle": ("puzzle",),     "Food": ("food",),     "Task": ("task",),@@ -712,6 +714,8 @@ class ScrobbleQuerySet(models.QuerySet):             "board_game",             "geo_location",             "beer",+            "wine",+            "coffee",             "puzzle",             "food",             "trail",@@ -763,6 +767,8 @@ class Scrobble(TimeStampedModel):         GEO_LOCATION = "GeoLocation", "GeoLocation"         TRAIL = "Trail", "Trail"         BEER = "Beer", "Beer"+        WINE = "Wine", "Wine"+        COFFEE = "Coffee", "Coffee"         PUZZLE = "Puzzle", "Puzzle"         FOOD = "Food", "Food"         TASK = "Task", "Task"@@ -792,6 +798,8 @@ class Scrobble(TimeStampedModel):     board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)     geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)     beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)+    wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)+    coffee = models.ForeignKey(Coffee, on_delete=models.DO_NOTHING, **BNULL)     puzzle = models.ForeignKey(Puzzle, 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)@@ -1220,7 +1228,10 @@ class Scrobble(TimeStampedModel):          if self.is_long_play:             long_play_secs = 0-            if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:+            if (+                self.long_play_last_scrobble+                and not self.long_play_last_scrobble.long_play_complete+            ):                 long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0             percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100) @@ -1367,6 +1378,10 @@ class Scrobble(TimeStampedModel):             media_obj = self.trail         if self.beer:             media_obj = self.beer+        if self.wine:+            media_obj = self.wine+        if self.coffee:+            media_obj = self.coffee         if self.puzzle:             media_obj = self.puzzle         if self.task:@@ -1928,6 +1943,8 @@ class FavoriteMedia(TimeStampedModel):     board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)     geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)     beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)+    wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL)+    coffee = models.ForeignKey(Coffee, on_delete=models.CASCADE, **BNULL)     puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)     food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)     trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)@@ -1982,6 +1999,10 @@ class FavoriteMedia(TimeStampedModel):             media_obj = self.trail         if self.beer:             media_obj = self.beer+        if self.wine:+            media_obj = self.wine+        if self.coffee:+            media_obj = self.coffee         if self.puzzle:             media_obj = self.puzzle         if self.task:@@ -2014,6 +2035,8 @@ class FavoriteMedia(TimeStampedModel):             "BoardGame": "board_game",             "GeoLocation": "geo_location",             "Beer": "beer",+            "Wine": "wine",+            "Coffee": "coffee",             "Puzzle": "puzzle",             "Food": "food",             "Trail": "trail",diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex c7c3e1a..86b0888 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -6,7 +6,6 @@ from typing import Any, Optional import pendulum import pytz import requests-from beers.models import Beer from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation from boardgames.utils import board_names_to_variants from books.constants import READCOMICSONLINE_URL@@ -16,6 +15,7 @@ from bricksets.models import BrickSet from dateutil.parser import parse from discgolf.models import DiscGolfCourse from django.utils import timezone+from drinks.models import Beer, Coffee, Wine from foods.models import Food from foods.sources.rscraper import RecipeScraperService from locations.constants import LOCATION_PROVIDERS@@ -648,6 +648,30 @@ def manual_scrobble_from_url(         item_id = url      scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]++    if content_key == "-wi":+        if "cellartracker.com" in url:+            return manual_scrobble_wine(+                cellartracker_id=item_id,+                user_id=user_id,+                source="CellarTracker",+                action=action,+            )+        else:+            return manual_scrobble_wine(+                vivino_id=item_id,+                user_id=user_id,+                source="Vivino",+                action=action,+            )+    elif content_key == "-co":+        return manual_scrobble_coffee(+            roastdb_url=url,+            user_id=user_id,+            source="RoastDB",+            action=action,+        )+     return eval(scrobble_fn)(item_id, user_id, source=source, action=action)  @@ -766,9 +790,7 @@ def todoist_scrobble_task(     todoist_task["title"] = todoist_task.pop("description")     todoist_task["description"] = todoist_task.pop("details")     labels = todoist_task.pop("todoist_label_list", [])-    todoist_task["labels"] = [-        l for l in labels if l.lower() != "inprogress"-    ]+    todoist_task["labels"] = [l for l in labels if l.lower() != "inprogress"]     todoist_task.pop("todoist_type")     todoist_task.pop("todoist_event") @@ -933,7 +955,9 @@ def emacs_scrobble_task(      task_data.pop("notes", None)     task_data["title"] = task_data.pop("description")-    task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description")+    task_data["description"] = _extract_org_section(+        task_data.pop("body"), "*** Description"+    )     task_data["labels"] = task_data.pop("labels")      task_data["orgmode_id"] = task_data.pop("source_id")@@ -1256,6 +1280,76 @@ def manual_scrobble_beer(     return Scrobble.create_or_update(beer, user_id, scrobble_dict)  +def manual_scrobble_wine(+    vivino_id: str = None,+    cellartracker_id: str = None,+    user_id: int = None,+    source: str = "Vivino",+    action: Optional[str] = None,+):+    wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)++    if not wine:+        logger.error(+            f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"+        )+        return++    scrobble_dict = {+        "user_id": user_id,+        "timestamp": timezone.now(),+        "playback_position_seconds": 0,+        "source": source,+    }+    logger.info(+        "[vrobbler-scrobble] wine scrobble request received",+        extra={+            "wine_id": wine.id,+            "user_id": user_id,+            "scrobble_dict": scrobble_dict,+            "media_type": Scrobble.MediaType.WINE,+        },+    )++    return Scrobble.create_or_update(wine, user_id, scrobble_dict)+++def manual_scrobble_coffee(+    roastdb_id: str = None,+    roastdb_url: str = None,+    user_id: int = None,+    source: str = "RoastDB",+    action: Optional[str] = None,+):+    slug = None+    if roastdb_url and not roastdb_id:+        slug = roastdb_url.rstrip("/").split("/")[-1]++    coffee = Coffee.find_or_create(roastdb_id=roastdb_id, slug=slug)++    if not coffee:+        logger.error(f"No coffee found for RoastDB ID {roastdb_id}")+        return++    scrobble_dict = {+        "user_id": user_id,+        "timestamp": timezone.now(),+        "playback_position_seconds": 0,+        "source": source,+    }+    logger.info(+        "[vrobbler-scrobble] coffee scrobble request received",+        extra={+            "coffee_id": coffee.id,+            "user_id": user_id,+            "scrobble_dict": scrobble_dict,+            "media_type": Scrobble.MediaType.COFFEE,+        },+    )++    return Scrobble.create_or_update(coffee, user_id, scrobble_dict)++ def manual_scrobble_puzzle(     ipdb_id: str,     user_id: int,diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex 5e665e2..f171cfa 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -4,10 +4,9 @@ import logging from datetime import datetime, timedelta from uuid import uuid4 -import requests- import pendulum import pytz+import requests from dateutil.relativedelta import relativedelta from django.apps import apps from django.contrib import messages@@ -26,6 +25,7 @@ class BearerTokenAuthentication(TokenAuthentication):     keyword = "Bearer"  +from boardgames.models import BoardGame, BoardGameVariant from django.http import (     FileResponse,     Http404,@@ -51,7 +51,6 @@ from music.aggregators import (     scrobble_counts,     week_of_scrobbles, )-from boardgames.models import BoardGame, BoardGameVariant from pendulum.parsing.exceptions import ParserError from profiles.models import UserProfile from profiles.utils import now_user_timezone@@ -67,14 +66,14 @@ from rest_framework.response import Response from rest_framework.views import APIView from scrobbles.api import serializers from scrobbles.constants import (+    DRINK_MODELS,     LONG_PLAY_MEDIA,     MANUAL_SCROBBLE_FNS,     PLAY_AGAIN_MEDIA,+    Visibility, ) from scrobbles.export import export_scrobbles from scrobbles.forms import ExportScrobbleForm, ScrobbleForm-from scrobbles.constants import Visibility-from scrobbles.sqids import decode_scrobble_share from scrobbles.models import (     AudioScrobblerTSVImport,     BGStatsImport,@@ -91,6 +90,7 @@ from scrobbles.models import (     UDiscCSVImport, ) from scrobbles.scrobblers import *+from scrobbles.sqids import decode_scrobble_share from scrobbles.tasks import (     process_koreader_import,     process_lastfm_import,@@ -211,14 +211,20 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):         media = self.object         if hasattr(media, "is_long_play_media") and media.is_long_play_media():             qs = media.scrobble_set.filter(user=self.request.user)-            completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()+            completed = (+                qs.filter(long_play_complete=True).order_by("-timestamp").first()+            )             if completed and completed.long_play_seconds:                 context_data["long_play_total_seconds"] = completed.long_play_seconds                 context_data["long_play_finished_date"] = completed.timestamp             else:-                latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()+                latest_finished = (+                    qs.filter(played_to_completion=True).order_by("-timestamp").first()+                )                 if latest_finished and latest_finished.long_play_seconds:-                    context_data["long_play_total_seconds"] = latest_finished.long_play_seconds+                    context_data["long_play_total_seconds"] = (+                        latest_finished.long_play_seconds+                    )                 context_data["long_play_finished_date"] = None          return context_data@@ -236,7 +242,8 @@ class RecentScrobbleList(ListView):                 scrobble = manual_scrobble_from_url(                     scrobble_url, self.request.user.id, source, action                 )-                return HttpResponseRedirect(scrobble.redirect_url(user.id))+                if scrobble:+                    return HttpResponseRedirect(scrobble.redirect_url(user.id))         return super().get(*args, **kwargs)      def get_context_data(self, **kwargs):@@ -611,6 +618,18 @@ class ManualScrobbleView(FormView):         item_str = form.cleaned_data.get("item_id")         logger.debug(f"Looking for scrobblable media with input {item_str}") +        if item_str.startswith("http://") or item_str.startswith("https://"):+            scrobble = manual_scrobble_from_url(+                item_str, self.request.user.id, source="Vrobbler"+            )+            if scrobble:+                return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))+            messages.error(+                self.request,+                "Could not scrobble from URL. The source may be unavailable.",+            )+            return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))+         if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:             key = item_str[:3]             item_id = item_str[4:]@@ -889,8 +908,15 @@ def scrobble_start(request, media_uuid):      media_obj = None     for app, model in PLAY_AGAIN_MEDIA.items():-        media_model = apps.get_model(app_label=app, model_name=model)-        media_obj = media_model.objects.filter(uuid=media_uuid).first()+        if app == "drinks":+            for drink_model in DRINK_MODELS:+                media_model = apps.get_model(app_label=app, model_name=drink_model)+                media_obj = media_model.objects.filter(uuid=media_uuid).first()+                if media_obj:+                    break+        else:+            media_model = apps.get_model(app_label=app, model_name=model)+            media_obj = media_model.objects.filter(uuid=media_uuid).first()         if media_obj:             break @@ -1135,7 +1161,9 @@ def toggle_favorite(request, media_type, object_id):         "VideoGame": ("videogames", "VideoGame"),         "BoardGame": ("boardgames", "BoardGame"),         "GeoLocation": ("locations", "GeoLocation"),-        "Beer": ("beers", "Beer"),+        "Beer": ("drinks", "Beer"),+        "Wine": ("drinks", "Wine"),+        "Coffee": ("drinks", "Coffee"),         "Puzzle": ("puzzles", "Puzzle"),         "Food": ("foods", "Food"),         "Trail": ("trails", "Trail"),@@ -1250,9 +1278,9 @@ class ScrobbleDetailView(DetailView):             form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(                 board_game=self.object.media_obj             )-            form.fields["variant_ids"].widget.attrs["data-board-game-id"] = (-                self.object.media_obj.id-            )+            form.fields["variant_ids"].widget.attrs[+                "data-board-game-id"+            ] = self.object.media_obj.id             form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (                 self.request.build_absolute_uri(                     reverse("boardgames:ajax-create-variant")@@ -1271,6 +1299,33 @@ class ScrobbleDetailView(DetailView):          form = FormClass(initial=log)         self._update_board_game_widgets(form)++        drink_types = ("Beer", "Wine", "Coffee")+        if self.object.media_type in drink_types and "size_ml" in form.fields:+            user = self.request.user+            use_oz = hasattr(user, "profile") and user.profile.volume_unit == "imperial"+            if use_oz:+                form.fields["size_ml"].label = "Size (oz)"+                original_clean = form.fields["size_ml"].clean++                def clean_size_ml_oz(value):+                    val = original_clean(value)+                    if val is not None and val != "":+                        from drinks.models import ML_PER_OZ++                        return round(float(val) * ML_PER_OZ)+                    return val++                form.fields["size_ml"].clean = clean_size_ml_oz+                if log.get("size_ml"):+                    from drinks.models import ML_PER_OZ++                    form.fields["size_ml"].initial = round(+                        log["size_ml"] / ML_PER_OZ, 1+                    )+            else:+                form.fields["size_ml"].label = "Size (mL)"+         return form      def post(self, request, *args, **kwargs):@@ -1379,21 +1434,25 @@ class ScrobbleDetailView(DetailView):             context["has_mopidy_uri"] = False          if self.object.is_long_play and fk_field:-            all_scrobbles = Scrobble.objects.filter(-                user=user, **{fk_field: media_obj}+            all_scrobbles = Scrobble.objects.filter(user=user, **{fk_field: media_obj})+            completed = (+                all_scrobbles.filter(long_play_complete=True)+                .order_by("-timestamp")+                .first()             )-            completed = all_scrobbles.filter(-                long_play_complete=True-            ).order_by("-timestamp").first()             if completed and completed.long_play_seconds:                 context["long_play_total_seconds"] = completed.long_play_seconds                 context["long_play_finished_date"] = completed.timestamp             else:-                latest_finished = all_scrobbles.filter(-                    played_to_completion=True-                ).order_by("-timestamp").first()+                latest_finished = (+                    all_scrobbles.filter(played_to_completion=True)+                    .order_by("-timestamp")+                    .first()+                )                 if latest_finished and latest_finished.long_play_seconds:-                    context["long_play_total_seconds"] = latest_finished.long_play_seconds+                    context["long_play_total_seconds"] = (+                        latest_finished.long_play_seconds+                    )                 context["long_play_finished_date"] = None          return contextdiff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex a9d9914..5324314 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -229,7 +229,7 @@ INSTALLED_APPS = [     "webpages",     "tasks",     "trails",-    "beers",+    "drinks",     "puzzles",     "foods",     "lifeevents",diff --git a/vrobbler/templates/beers/beer_detail.html b/vrobbler/templates/drinks/beer_detail.htmlsimilarity index 71%rename from vrobbler/templates/beers/beer_detail.htmlrename to vrobbler/templates/drinks/beer_detail.htmlindex 907fecf..97c5baa 100644--- a/vrobbler/templates/beers/beer_detail.html+++ b/vrobbler/templates/drinks/beer_detail.html@@ -52,12 +52,24 @@                 <thead>                     <tr>                         <th scope="col">Date</th>+                        <th scope="col">Format</th>+                        <th scope="col">Size</th>                     </tr>                 </thead>                 <tbody>                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}                     <tr>                         <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>+                        <td>{{ scrobble.logdata.format_display }}</td>+                        <td>+                            {% if scrobble.logdata.size_ml %}+                                {% if request.user.profile.volume_unit == "imperial" %}+                                    {{ scrobble.logdata.size_oz }} oz+                                {% else %}+                                    {{ scrobble.logdata.size_ml }} mL+                                {% endif %}+                            {% endif %}+                        </td>                     </tr>                     {% endfor %}                 </tbody>diff --git a/vrobbler/templates/beers/beer_list.html b/vrobbler/templates/drinks/beer_list.htmlsimilarity index 100%rename from vrobbler/templates/beers/beer_list.htmlrename to vrobbler/templates/drinks/beer_list.htmldiff --git a/vrobbler/templates/drinks/coffee_detail.html b/vrobbler/templates/drinks/coffee_detail.htmlnew file mode 100644index 0000000..ef95ece--- /dev/null+++ b/vrobbler/templates/drinks/coffee_detail.html@@ -0,0 +1,81 @@+{% extends "base_list.html" %}+{% load mathfilters %}+{% load static %}+{% load naturalduration %}++{% block title %}{{object.title}}{% endblock %}++{% block head_extra %}+<style>+    .cover img {+        width: 250px;+    }++    .cover {+        float: left;+        width: 252px;+        padding: 0;+    }++    .summary {+        float: left;+        width: 600px;+        margin-left: 10px;+    }+</style>+{% endblock %}++{% block lists %}++<div class="row">+    <div class="summary">+        {% if object.description%}+        <p>{{object.description|safe|linebreaks|truncatewords:160}}</p>+        <hr />+        {% endif %}+    </div>+</div>+<div class="row">+    <p>{{scrobbles.count}} scrobbles</p>+    <p>+        <a href="{{object.start_url}}">Drink again</a>+    </p>+</div>+<div class="row">+    <div class="col-md">+        <h3>Last scrobbles</h3>+        <div class="table-responsive">+            <table class="table table-striped table-sm">+                <thead>+                    <tr>+                        <th scope="col">Date</th>+                        <th scope="col">Format</th>+                        <th scope="col">Size</th>+                        <th scope="col">Roast</th>+                        <th scope="col">Method</th>+                    </tr>+                </thead>+                <tbody>+                    {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}+                    <tr>+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>+                        <td>{{ scrobble.logdata.format_display }}</td>+                        <td>+                            {% if scrobble.logdata.size_ml %}+                                {% if request.user.profile.volume_unit == "imperial" %}+                                    {{ scrobble.logdata.size_oz }} oz+                                {% else %}+                                    {{ scrobble.logdata.size_ml }} mL+                                {% endif %}+                            {% endif %}+                        </td>+                        <td>{{ scrobble.logdata.roast_level_display }}</td>+                        <td>{{ scrobble.logdata.brewing_method_display }}</td>+                    </tr>+                    {% endfor %}+                </tbody>+            </table>+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/drinks/coffee_list.html b/vrobbler/templates/drinks/coffee_list.htmlnew file mode 100644index 0000000..693f45a--- /dev/null+++ b/vrobbler/templates/drinks/coffee_list.html@@ -0,0 +1,24 @@+{% extends "base_list.html" %}+{% load static %}++{% block title %}Coffee{% endblock %}++{% block head_extra %}+<style>+ dl { width: 210px; float:left; margin-right: 10px; }+ dt a { color:white; text-decoration: none; font-size:smaller; }+ img { height:200px; width: 200px; object-fit: cover; }+ dd .right { float:right; }+</style>+{% endblock  %}++{% block lists %}+<div class="row">++    <div class="col-md">+        <div class="table-responsive">+            {% include "_scrobblable_list.html" %}+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/drinks/wine_detail.html b/vrobbler/templates/drinks/wine_detail.htmlnew file mode 100644index 0000000..4daf718--- /dev/null+++ b/vrobbler/templates/drinks/wine_detail.html@@ -0,0 +1,85 @@+{% extends "base_list.html" %}+{% load mathfilters %}+{% load static %}+{% load naturalduration %}++{% block title %}{{object.title}}{% endblock %}++{% block head_extra %}+<style>+    .cover img {+        width: 250px;+    }++    .cover {+        float: left;+        width: 252px;+        padding: 0;+    }++    .summary {+        float: left;+        width: 600px;+        margin-left: 10px;+    }+</style>+{% endblock %}++{% block lists %}++<div class="row">+    <div class="summary">+        {% if object.description%}+        <p>{{object.description|safe|linebreaks|truncatewords:160}}</p>+        <hr />+        {% endif %}+        <p style="float:right;">+            {% if object.vivino_link %}+            <a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>+            {% endif %}+            {% if object.cellartracker_link %}+            <a href="{{object.cellartracker_link}}">CellarTracker</a>+            {% endif %}+        </p>+    </div>+</div>+<div class="row">+    <p>{{scrobbles.count}} scrobbles</p>+    <p>+        <a href="{{object.start_url}}">Drink again</a>+    </p>+</div>+<div class="row">+    <div class="col-md">+        <h3>Last scrobbles</h3>+        <div class="table-responsive">+            <table class="table table-striped table-sm">+                <thead>+                    <tr>+                        <th scope="col">Date</th>+                        <th scope="col">Format</th>+                        <th scope="col">Size</th>+                    </tr>+                </thead>+                <tbody>+                    {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}+                    <tr>+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>+                        <td>{{ scrobble.logdata.format_display }}</td>+                        <td>+                            {% if scrobble.logdata.size_ml %}+                                {% if request.user.profile.volume_unit == "imperial" %}+                                    {{ scrobble.logdata.size_oz }} oz+                                {% else %}+                                    {{ scrobble.logdata.size_ml }} mL+                                {% endif %}+                            {% endif %}+                        </td>+                    </tr>+                    {% endfor %}+                </tbody>+            </table>+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/drinks/wine_list.html b/vrobbler/templates/drinks/wine_list.htmlnew file mode 100644index 0000000..0c0a282--- /dev/null+++ b/vrobbler/templates/drinks/wine_list.html@@ -0,0 +1,24 @@+{% extends "base_list.html" %}+{% load static %}++{% block title %}Wines{% endblock %}++{% block head_extra %}+<style>+ dl { width: 210px; float:left; margin-right: 10px; }+ dt a { color:white; text-decoration: none; font-size:smaller; }+ img { height:200px; width: 200px; object-fit: cover; }+ dd .right { float:right; }+</style>+{% endblock  %}++{% block lists %}+<div class="row">++    <div class="col-md">+        <div class="table-responsive">+            {% include "_scrobblable_list.html" %}+        </div>+    </div>+</div>+{% endblock %}diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.htmlindex 55ec8f4..9ac29c1 100644--- a/vrobbler/templates/scrobbles/_last_scrobbles.html+++ b/vrobbler/templates/scrobbles/_last_scrobbles.html@@ -156,7 +156,7 @@         <p>No board games today</p>         {% endif %} -        <h3><a href="{% url 'beers:beer_list' %}">Beers</a></h3>+        <h3><a href="{% url 'drinks:beer_list' %}">Beers</a></h3>         {% if Beer %}         {% with scrobbles=Beer count=Beer_count time=Beer_time %}         {% include "scrobbles/_scrobble_table.html" %}@@ -165,6 +165,24 @@         <p>No beer today</p>         {% endif %} +        <h3><a href="{% url 'drinks:wine_list' %}">Wines</a></h3>+        {% if Wine %}+        {% with scrobbles=Wine count=Wine_count time=Wine_time %}+        {% include "scrobbles/_scrobble_table.html" %}+        {% endwith %}+        {% else %}+        <p>No wine today</p>+        {% endif %}++        <h3><a href="{% url 'drinks:coffee_list' %}">Coffee</a></h3>+        {% if Coffee %}+        {% with scrobbles=Coffee count=Coffee_count time=Coffee_time %}+        {% include "scrobbles/_scrobble_table.html" %}+        {% endwith %}+        {% else %}+        <p>No coffee today</p>+        {% endif %}+         <h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>         {% if BrickSet %}         {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}diff --git a/vrobbler/templates/scrobbles/_scrobble_all_content.html b/vrobbler/templates/scrobbles/_scrobble_all_content.htmlindex 9bf9a36..f0fe55a 100644--- a/vrobbler/templates/scrobbles/_scrobble_all_content.html+++ b/vrobbler/templates/scrobbles/_scrobble_all_content.html@@ -88,7 +88,11 @@                         {% elif scrobble.food %}                         <a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>                         {% elif scrobble.beer %}-                        <a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>+                        <a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>+                        {% elif scrobble.wine %}+                        <a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>+                        {% elif scrobble.coffee %}+                        <a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>                         {% elif scrobble.web_page %}                         <a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>                         {% elif scrobble.podcast_episode %}diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.htmlindex b69b8af..ec6ff52 100644--- a/vrobbler/templates/scrobbles/scrobble_detail.html+++ b/vrobbler/templates/scrobbles/scrobble_detail.html@@ -175,6 +175,36 @@   </dl> </div> {% endif %}+{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}+{% if object.logdata.format_display or object.logdata.size_ml %}+<div class="mb-3">+  <dl class="row" style="max-width: 400px;">+    {% if object.logdata.format_display %}+    <dt class="col-sm-5">Format</dt>+    <dd class="col-sm-7">{{ object.logdata.format_display }}</dd>+    {% endif %}+    {% if object.logdata.size_ml %}+    <dt class="col-sm-5">Size</dt>+    <dd class="col-sm-7">+        {% if request.user.profile.volume_unit == "imperial" %}+            {{ object.logdata.size_oz }} oz+        {% else %}+            {{ object.logdata.size_ml }} mL+        {% endif %}+    </dd>+    {% endif %}+    {% if object.logdata.roast_level_display %}+    <dt class="col-sm-5">Roast</dt>+    <dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>+    {% endif %}+    {% if object.logdata.brewing_method_display %}+    <dt class="col-sm-5">Method</dt>+    <dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>+    {% endif %}+  </dl>+</div>+{% endif %}+{% endif %} {% if object.media_type == "Task" and object.logdata.description %} <p>{{ object.logdata.description }}</p> {% endif %}diff --git a/vrobbler/templates/scrobbles/scrobble_explore.html b/vrobbler/templates/scrobbles/scrobble_explore.htmlindex fe978ae..936efab 100644--- a/vrobbler/templates/scrobbles/scrobble_explore.html+++ b/vrobbler/templates/scrobbles/scrobble_explore.html@@ -82,7 +82,11 @@                         {% elif scrobble.food %}                         <a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>                         {% elif scrobble.beer %}-                        <a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>+                        <a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>+                        {% elif scrobble.wine %}+                        <a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>+                        {% elif scrobble.coffee %}+                        <a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>                         {% elif scrobble.web_page %}                         <a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>                         {% elif scrobble.podcast_episode %}diff --git a/vrobbler/templates/scrobbles/scrobble_share.html b/vrobbler/templates/scrobbles/scrobble_share.htmlindex b8b3726..65ce9ab 100644--- a/vrobbler/templates/scrobbles/scrobble_share.html+++ b/vrobbler/templates/scrobbles/scrobble_share.html@@ -107,6 +107,32 @@   </dl> </div> {% endif %}+{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}+{% if object.logdata.format_display or object.logdata.size_ml %}+<div class="mb-3">+  <dl class="row" style="max-width: 400px;">+    {% if object.logdata.format_display %}+    <dt class="col-sm-5">Format</dt>+    <dd class="col-sm-7">{{ object.logdata.format_display }}</dd>+    {% endif %}+    {% if object.logdata.size_ml %}+    <dt class="col-sm-5">Size</dt>+    <dd class="col-sm-7">+        {{ object.logdata.size_oz }} oz+    </dd>+    {% endif %}+    {% if object.logdata.roast_level_display %}+    <dt class="col-sm-5">Roast</dt>+    <dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>+    {% endif %}+    {% if object.logdata.brewing_method_display %}+    <dt class="col-sm-5">Method</dt>+    <dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>+    {% endif %}+  </dl>+</div>+{% endif %}+{% endif %} {% if object.media_type == "Task" and object.logdata.description %} <p>{{ object.logdata.description }}</p> {% endif %}diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex 5ee8ef9..894fa48 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -6,48 +6,63 @@ from oauth2_provider import urls as oauth2_urls from rest_framework import routers  import vrobbler.apps.scrobbles.views as scrobbles_views--from vrobbler.apps.discgolf import urls as discgolf_urls-from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet-+from vrobbler.apps.birds import urls as birds_urls+from vrobbler.apps.birds.api.views import BirdingLocationViewSet, BirdViewSet from vrobbler.apps.boardgames import urls as boardgame_urls from vrobbler.apps.boardgames.api.views import (-    BoardGameViewSet,     BoardGameDesignerViewSet,-    BoardGamePublisherViewSet,     BoardGameLocationViewSet,+    BoardGamePublisherViewSet,     BoardGameVariantViewSet,+    BoardGameViewSet, )- from vrobbler.apps.books import urls as book_urls from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet-+from vrobbler.apps.bricksets import urls as bricksets_urls+from vrobbler.apps.bricksets.api.views import BrickSetViewSet+from vrobbler.apps.charts import urls as charts_urls+from vrobbler.apps.discgolf import urls as discgolf_urls+from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet+from vrobbler.apps.drinks import urls as drinks_urls+from vrobbler.apps.drinks.api.views import (+    BeerProducerViewSet,+    BeerStyleViewSet,+    BeerViewSet,+    CoffeeRoasterViewSet,+    CoffeeViewSet,+    WineGrapeViewSet,+    WineProducerViewSet,+    WineRegionViewSet,+    WineViewSet,+)+from vrobbler.apps.foods import urls as foods_urls+from vrobbler.apps.foods.api.views import FoodCategoryViewSet, FoodViewSet from vrobbler.apps.lifeevents import urls as lifeevents_urls from vrobbler.apps.lifeevents.api.views import LifeEventViewSet- from vrobbler.apps.locations import urls as locations_urls from vrobbler.apps.locations.api.views import GeoLocationViewSet- from vrobbler.apps.moods import urls as moods_urls from vrobbler.apps.moods.api.views import MoodViewSet--from vrobbler.apps.foods import urls as foods_urls-from vrobbler.apps.foods.api.views import FoodViewSet, FoodCategoryViewSet- from vrobbler.apps.music import urls as music_urls from vrobbler.apps.music.api.views import (     AlbumViewSet,     ArtistViewSet,     TrackViewSet, )-+from vrobbler.apps.people import urls as people_urls from vrobbler.apps.podcasts import urls as podcast_urls from vrobbler.apps.podcasts.api.views import (-    ProducerViewSet,-    PodcastViewSet,     PodcastEpisodeViewSet,+    PodcastViewSet,+    ProducerViewSet,+)+from vrobbler.apps.profiles import urls as profiles_urls+from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet+from vrobbler.apps.puzzles import urls as puzzles_urls+from vrobbler.apps.puzzles.api.views import (+    PuzzleManufacturerViewSet,+    PuzzleViewSet, )- from vrobbler.apps.scrobbles import urls as scrobble_urls from vrobbler.apps.scrobbles.api.views import (     AudioScrobblerTSVImportViewSet,@@ -55,7 +70,6 @@ from vrobbler.apps.scrobbles.api.views import (     LastFmImportViewSet,     ScrobbleViewSet, )- from vrobbler.apps.sports import urls as sports_urls from vrobbler.apps.sports.api.views import (     LeagueViewSet,@@ -67,50 +81,19 @@ from vrobbler.apps.sports.api.views import ( ) from vrobbler.apps.tasks import urls as tasks_urls from vrobbler.apps.tasks.api.views import TaskViewSet--from vrobbler.apps.profiles import urls as profiles_urls-from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet- from vrobbler.apps.trails import urls as trails_urls from vrobbler.apps.trails.api.views import TrailViewSet--from vrobbler.apps.beers import urls as beers_urls-from vrobbler.apps.beers.api.views import (-    BeerViewSet,-    BeerProducerViewSet,-    BeerStyleViewSet,-)--from vrobbler.apps.birds import urls as birds_urls-from vrobbler.apps.birds.api.views import (-    BirdViewSet,-    BirdingLocationViewSet,-)--from vrobbler.apps.puzzles import urls as puzzles_urls-from vrobbler.apps.puzzles.api.views import (-    PuzzleViewSet,-    PuzzleManufacturerViewSet,-)+from vrobbler.apps.trends import urls as trends_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 (     ChannelViewSet,     SeriesViewSet,     VideoViewSet, )--from vrobbler.apps.bricksets import urls as bricksets_urls-from vrobbler.apps.bricksets.api.views import BrickSetViewSet- from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet -from vrobbler.apps.people import urls as people_urls-from vrobbler.apps.charts import urls as charts_urls-from vrobbler.apps.trends import urls as trends_urls- # from vrobbler.apps.modern_ui import urls as modern_ui_urls  router = routers.DefaultRouter()@@ -143,6 +126,12 @@ router.register(r"locations", GeoLocationViewSet) router.register(r"beers", BeerViewSet) router.register(r"beer-producers", BeerProducerViewSet) router.register(r"beer-styles", BeerStyleViewSet)+router.register(r"wines", WineViewSet)+router.register(r"wine-producers", WineProducerViewSet)+router.register(r"wine-regions", WineRegionViewSet)+router.register(r"wine-grapes", WineGrapeViewSet)+router.register(r"coffees", CoffeeViewSet)+router.register(r"coffee-roasters", CoffeeRoasterViewSet) router.register(r"boardgames", BoardGameViewSet) router.register(r"boardgame-designers", BoardGameDesignerViewSet) router.register(r"boardgame-publishers", BoardGamePublisherViewSet)@@ -175,7 +164,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(drinks_urls, namespace="drinks")),     path("", include(discgolf_urls, namespace="discgolf")),     path("", include(foods_urls, namespace="foods")),     path("", include(puzzles_urls, namespace="puzzles")),