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

{{object.description|safe|linebreaks|truncatewords:160}}

+
+ {% endif %} +
+
+
+

{{scrobbles.count}} scrobbles

+

+ Drink again +

+
+
+
+

Last scrobbles

+
+ + + + + + + + + + + + {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} + + + + + + + + {% endfor %} + +
DateFormatSizeRoastMethod
{{scrobble.local_timestamp}}{{ scrobble.logdata.format_display }} + {% 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 %} + {{ scrobble.logdata.roast_level_display }}{{ scrobble.logdata.brewing_method_display }}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/drinks/coffee_list.html b/vrobbler/templates/drinks/coffee_list.html new file mode 100644 index 0000000..693f45a --- /dev/null +++ b/vrobbler/templates/drinks/coffee_list.html @@ -0,0 +1,24 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Coffee{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block lists %} +
+ +
+
+ {% include "_scrobblable_list.html" %} +
+
+
+{% endblock %} diff --git a/vrobbler/templates/drinks/wine_detail.html b/vrobbler/templates/drinks/wine_detail.html new file mode 100644 index 0000000..4daf718 --- /dev/null +++ b/vrobbler/templates/drinks/wine_detail.html @@ -0,0 +1,85 @@ +{% extends "base_list.html" %} +{% load mathfilters %} +{% load static %} +{% load naturalduration %} + +{% block title %}{{object.title}}{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block lists %} + +
+
+ {% if object.description%} +

{{object.description|safe|linebreaks|truncatewords:160}}

+
+ {% endif %} +

+ {% if object.vivino_link %} + + {% endif %} + {% if object.cellartracker_link %} + CellarTracker + {% endif %} +

+
+
+
+

{{scrobbles.count}} scrobbles

+

+ Drink again +

+
+
+
+

Last scrobbles

+
+ + + + + + + + + + {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} + + + + + + {% endfor %} + +
DateFormatSize
{{scrobble.local_timestamp}}{{ scrobble.logdata.format_display }} + {% 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 %} +
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/drinks/wine_list.html b/vrobbler/templates/drinks/wine_list.html new file mode 100644 index 0000000..0c0a282 --- /dev/null +++ b/vrobbler/templates/drinks/wine_list.html @@ -0,0 +1,24 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}Wines{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block lists %} +
+ +
+
+ {% include "_scrobblable_list.html" %} +
+
+
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/_last_scrobbles.html b/vrobbler/templates/scrobbles/_last_scrobbles.html index 55ec8f4..9ac29c1 100644 --- a/vrobbler/templates/scrobbles/_last_scrobbles.html +++ b/vrobbler/templates/scrobbles/_last_scrobbles.html @@ -156,7 +156,7 @@

No board games today

{% endif %} -

Beers

+

Beers

{% if Beer %} {% with scrobbles=Beer count=Beer_count time=Beer_time %} {% include "scrobbles/_scrobble_table.html" %} @@ -165,6 +165,24 @@

No beer today

{% endif %} +

Wines

+ {% if Wine %} + {% with scrobbles=Wine count=Wine_count time=Wine_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No wine today

+ {% endif %} + +

Coffee

+ {% if Coffee %} + {% with scrobbles=Coffee count=Coffee_count time=Coffee_time %} + {% include "scrobbles/_scrobble_table.html" %} + {% endwith %} + {% else %} +

No coffee today

+ {% endif %} +

Brick sets

{% if BrickSet %} {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %} diff --git a/vrobbler/templates/scrobbles/_scrobble_all_content.html b/vrobbler/templates/scrobbles/_scrobble_all_content.html index 9bf9a36..f0fe55a 100644 --- a/vrobbler/templates/scrobbles/_scrobble_all_content.html +++ b/vrobbler/templates/scrobbles/_scrobble_all_content.html @@ -88,7 +88,11 @@ {% elif scrobble.food %} {{ scrobble.food.title }} {% elif scrobble.beer %} - {{ scrobble.beer.title }} + {{ scrobble.beer.title }} + {% elif scrobble.wine %} + {{ scrobble.wine.title }} + {% elif scrobble.coffee %} + {{ scrobble.coffee.title }} {% elif scrobble.web_page %} {{ scrobble.web_page.title }} {% elif scrobble.podcast_episode %} diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index b69b8af..ec6ff52 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -175,6 +175,36 @@ {% 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 %} +
+
+ {% if object.logdata.format_display %} +
Format
+
{{ object.logdata.format_display }}
+ {% endif %} + {% if object.logdata.size_ml %} +
Size
+
+ {% if request.user.profile.volume_unit == "imperial" %} + {{ object.logdata.size_oz }} oz + {% else %} + {{ object.logdata.size_ml }} mL + {% endif %} +
+ {% endif %} + {% if object.logdata.roast_level_display %} +
Roast
+
{{ object.logdata.roast_level_display }}
+ {% endif %} + {% if object.logdata.brewing_method_display %} +
Method
+
{{ object.logdata.brewing_method_display }}
+ {% endif %} +
+
+{% endif %} +{% endif %} {% if object.media_type == "Task" and object.logdata.description %}

{{ object.logdata.description }}

{% endif %} diff --git a/vrobbler/templates/scrobbles/scrobble_explore.html b/vrobbler/templates/scrobbles/scrobble_explore.html index fe978ae..936efab 100644 --- a/vrobbler/templates/scrobbles/scrobble_explore.html +++ b/vrobbler/templates/scrobbles/scrobble_explore.html @@ -82,7 +82,11 @@ {% elif scrobble.food %} {{ scrobble.food.title }} {% elif scrobble.beer %} - {{ scrobble.beer.title }} + {{ scrobble.beer.title }} + {% elif scrobble.wine %} + {{ scrobble.wine.title }} + {% elif scrobble.coffee %} + {{ scrobble.coffee.title }} {% elif scrobble.web_page %} {{ scrobble.web_page.title }} {% elif scrobble.podcast_episode %} diff --git a/vrobbler/templates/scrobbles/scrobble_share.html b/vrobbler/templates/scrobbles/scrobble_share.html index b8b3726..65ce9ab 100644 --- a/vrobbler/templates/scrobbles/scrobble_share.html +++ b/vrobbler/templates/scrobbles/scrobble_share.html @@ -107,6 +107,32 @@ {% 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 %} +
+
+ {% if object.logdata.format_display %} +
Format
+
{{ object.logdata.format_display }}
+ {% endif %} + {% if object.logdata.size_ml %} +
Size
+
+ {{ object.logdata.size_oz }} oz +
+ {% endif %} + {% if object.logdata.roast_level_display %} +
Roast
+
{{ object.logdata.roast_level_display }}
+ {% endif %} + {% if object.logdata.brewing_method_display %} +
Method
+
{{ object.logdata.brewing_method_display }}
+ {% endif %} +
+
+{% endif %} +{% endif %} {% if object.media_type == "Task" and object.logdata.description %}

{{ object.logdata.description }}

{% endif %} diff --git a/vrobbler/urls.py b/vrobbler/urls.py index 5ee8ef9..894fa48 100644 --- a/vrobbler/urls.py +++ b/vrobbler/urls.py @@ -6,48 +6,63 @@ from oauth2_provider import urls as oauth2_urls from rest_framework import routers import vrobbler.apps.scrobbles.views as scrobbles_views - -from vrobbler.apps.discgolf import urls as discgolf_urls -from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet - +from vrobbler.apps.birds import urls as birds_urls +from vrobbler.apps.birds.api.views import BirdingLocationViewSet, BirdViewSet from vrobbler.apps.boardgames import urls as boardgame_urls from vrobbler.apps.boardgames.api.views import ( - BoardGameViewSet, BoardGameDesignerViewSet, - BoardGamePublisherViewSet, BoardGameLocationViewSet, + BoardGamePublisherViewSet, BoardGameVariantViewSet, + BoardGameViewSet, ) - from vrobbler.apps.books import urls as book_urls from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet - +from vrobbler.apps.bricksets import urls as bricksets_urls +from vrobbler.apps.bricksets.api.views import BrickSetViewSet +from vrobbler.apps.charts import urls as charts_urls +from vrobbler.apps.discgolf import urls as discgolf_urls +from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet +from vrobbler.apps.drinks import urls as drinks_urls +from vrobbler.apps.drinks.api.views import ( + BeerProducerViewSet, + BeerStyleViewSet, + BeerViewSet, + CoffeeRoasterViewSet, + CoffeeViewSet, + WineGrapeViewSet, + WineProducerViewSet, + WineRegionViewSet, + WineViewSet, +) +from vrobbler.apps.foods import urls as foods_urls +from vrobbler.apps.foods.api.views import FoodCategoryViewSet, FoodViewSet from vrobbler.apps.lifeevents import urls as lifeevents_urls from vrobbler.apps.lifeevents.api.views import LifeEventViewSet - from vrobbler.apps.locations import urls as locations_urls from vrobbler.apps.locations.api.views import GeoLocationViewSet - from vrobbler.apps.moods import urls as moods_urls from vrobbler.apps.moods.api.views import MoodViewSet - -from vrobbler.apps.foods import urls as foods_urls -from vrobbler.apps.foods.api.views import FoodViewSet, FoodCategoryViewSet - from vrobbler.apps.music import urls as music_urls from vrobbler.apps.music.api.views import ( AlbumViewSet, ArtistViewSet, TrackViewSet, ) - +from vrobbler.apps.people import urls as people_urls from vrobbler.apps.podcasts import urls as podcast_urls from vrobbler.apps.podcasts.api.views import ( - ProducerViewSet, - PodcastViewSet, PodcastEpisodeViewSet, + PodcastViewSet, + ProducerViewSet, +) +from vrobbler.apps.profiles import urls as profiles_urls +from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet +from vrobbler.apps.puzzles import urls as puzzles_urls +from vrobbler.apps.puzzles.api.views import ( + PuzzleManufacturerViewSet, + PuzzleViewSet, ) - from vrobbler.apps.scrobbles import urls as scrobble_urls from vrobbler.apps.scrobbles.api.views import ( AudioScrobblerTSVImportViewSet, @@ -55,7 +70,6 @@ from vrobbler.apps.scrobbles.api.views import ( LastFmImportViewSet, ScrobbleViewSet, ) - from vrobbler.apps.sports import urls as sports_urls from vrobbler.apps.sports.api.views import ( LeagueViewSet, @@ -67,50 +81,19 @@ from vrobbler.apps.sports.api.views import ( ) from vrobbler.apps.tasks import urls as tasks_urls from vrobbler.apps.tasks.api.views import TaskViewSet - -from vrobbler.apps.profiles import urls as profiles_urls -from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet - from vrobbler.apps.trails import urls as trails_urls from vrobbler.apps.trails.api.views import TrailViewSet - -from vrobbler.apps.beers import urls as beers_urls -from vrobbler.apps.beers.api.views import ( - BeerViewSet, - BeerProducerViewSet, - BeerStyleViewSet, -) - -from vrobbler.apps.birds import urls as birds_urls -from vrobbler.apps.birds.api.views import ( - BirdViewSet, - BirdingLocationViewSet, -) - -from vrobbler.apps.puzzles import urls as puzzles_urls -from vrobbler.apps.puzzles.api.views import ( - PuzzleViewSet, - PuzzleManufacturerViewSet, -) +from vrobbler.apps.trends import urls as trends_urls from vrobbler.apps.videogames import urls as videogame_urls - from vrobbler.apps.videos import urls as video_urls from vrobbler.apps.videos.api.views import ( ChannelViewSet, SeriesViewSet, VideoViewSet, ) - -from vrobbler.apps.bricksets import urls as bricksets_urls -from vrobbler.apps.bricksets.api.views import BrickSetViewSet - from vrobbler.apps.webpages import urls as webpages_urls from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet -from vrobbler.apps.people import urls as people_urls -from vrobbler.apps.charts import urls as charts_urls -from vrobbler.apps.trends import urls as trends_urls - # from vrobbler.apps.modern_ui import urls as modern_ui_urls router = routers.DefaultRouter() @@ -143,6 +126,12 @@ router.register(r"locations", GeoLocationViewSet) router.register(r"beers", BeerViewSet) router.register(r"beer-producers", BeerProducerViewSet) router.register(r"beer-styles", BeerStyleViewSet) +router.register(r"wines", WineViewSet) +router.register(r"wine-producers", WineProducerViewSet) +router.register(r"wine-regions", WineRegionViewSet) +router.register(r"wine-grapes", WineGrapeViewSet) +router.register(r"coffees", CoffeeViewSet) +router.register(r"coffee-roasters", CoffeeRoasterViewSet) router.register(r"boardgames", BoardGameViewSet) router.register(r"boardgame-designers", BoardGameDesignerViewSet) router.register(r"boardgame-publishers", BoardGamePublisherViewSet) @@ -175,7 +164,7 @@ urlpatterns = [ path("", include(sports_urls, namespace="sports")), path("", include(locations_urls, namespace="locations")), path("", include(trails_urls, namespace="trails")), - path("", include(beers_urls, namespace="beers")), + path("", include(drinks_urls, namespace="drinks")), path("", include(discgolf_urls, namespace="discgolf")), path("", include(foods_urls, namespace="foods")), path("", include(puzzles_urls, namespace="puzzles")),