This commit has no recorded session.
diff
commit fdc18d5a5f6384e870655e2aaefa849f4379b9c3Author: Colin Powell <colin@unbl.ink>Date: Mon Jul 13 13:26:28 2026 -0400 [drinks] Rename beers to drinks and add new models@@ -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:@@ -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,- ]@@ -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__"@@ -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]@@ -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"- )@@ -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",- ),-]@@ -1,11 +0,0 @@-from beers.models import Beer--from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView---class BeerListView(ScrobbleableListView):- model = Beer---class BeerDetailView(ScrobbleableDetailView):- model = Beer@@ -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,+ ]@@ -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__"@@ -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]@@ -2,4 +2,4 @@ from django.apps import AppConfig class BeersConfig(AppConfig):- name = "beers"+ name = "drinks"@@ -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_dict@@ -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"+ )+ )@@ -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, }, ),@@ -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(@@ -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"), ), ]@@ -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" ), ), ]@@ -7,7 +7,7 @@ class Migration(migrations.Migration): dependencies = [ (- "beers",+ "drinks", "0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more", ), ]@@ -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 = [@@ -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 = [@@ -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 = [@@ -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,+ },+ ),+ ]@@ -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"+ )@@ -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 results@@ -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",+ ),+]@@ -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 = Coffee@@ -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_dict@@ -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 = "" @@ -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,+ ),+ ),+ ]@@ -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:@@ -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",@@ -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",@@ -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:@@ -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(@@ -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", ), ), (@@ -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,+ ),+ ),+ ]@@ -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",@@ -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,@@ -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 context@@ -229,7 +229,7 @@ INSTALLED_APPS = [ "webpages", "tasks", "trails",- "beers",+ "drinks", "puzzles", "foods", "lifeevents",@@ -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>@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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 %}@@ -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")),