This commit has no recorded session.
diff
commit 6861e28ac6e971563e849cfa1a45f41e1e51e1d8Author: Colin Powell <colin@unbl.ink>Date: Mon Jul 13 19:09:32 2026 -0400 [drinks] Add Drink as a scrobbleable media@@ -5,6 +5,7 @@ from drinks.models import ( BeerStyle, Coffee, CoffeeRoaster,+ Drink, Wine, WineGrape, WineProducer,@@ -97,3 +98,17 @@ class CoffeeAdmin(admin.ModelAdmin): inlines = [ ScrobbleInline, ]+++@admin.register(Drink)+class DrinkAdmin(admin.ModelAdmin):+ date_hierarchy = "created"+ list_display = (+ "uuid",+ "title",+ )+ ordering = ("-created",)+ search_fields = ("title",)+ inlines = [+ ScrobbleInline,+ ]@@ -0,0 +1,90 @@+# Generated by Django 4.2.29 on 2026-07-13 18:26++from django.db import migrations, models+import django_extensions.db.fields+import taggit.managers+import uuid+++class Migration(migrations.Migration):++ dependencies = [+ ("scrobbles", "0101_favoritemedia_coffee_favoritemedia_wine_and_more"),+ ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),+ ("drinks", "0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more"),+ ]++ operations = [+ migrations.AlterModelOptions(+ name="beerproducer",+ options={},+ ),+ migrations.AlterModelOptions(+ name="beerstyle",+ options={},+ ),+ migrations.AddField(+ model_name="wine",+ name="non_alcoholic",+ field=models.BooleanField(default=False),+ ),+ migrations.CreateModel(+ name="Drink",+ 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)),+ ("is_alcoholic", models.BooleanField(default=False)),+ (+ "genre",+ taggit.managers.TaggableManager(+ blank=True,+ help_text="A comma-separated list of tags.",+ through="scrobbles.ObjectWithGenres",+ to="scrobbles.Genre",+ verbose_name="Genre",+ ),+ ),+ (+ "tags",+ taggit.managers.TaggableManager(+ blank=True,+ help_text="A comma-separated list of tags.",+ through="taggit.TaggedItem",+ to="taggit.Tag",+ verbose_name="Tags",+ ),+ ),+ ],+ options={+ "db_table": "drinks_drink",+ },+ ),+ ]@@ -75,7 +75,7 @@ class DrinkLogData(BaseLogData): @property def size_oz(self) -> Optional[float]: if self.size_ml:- return round(self.size_ml / ML_PER_OZ, 1)+ return round(float(self.size_ml) / ML_PER_OZ, 1) return None @property@@ -146,16 +146,27 @@ class CoffeeLogData(DrinkLogData): class Drink(ScrobblableMixin): calories = models.PositiveIntegerField(**BNULL)+ is_alcoholic = models.BooleanField(default=False) class Meta:- abstract = True+ db_table = "drinks_drink" - @property- def is_alcoholic(self) -> bool:- return True+ def __str__(self):+ return self.title or "Drink"++ @classmethod+ def find_or_create_water(cls) -> "Drink":+ water, _ = cls.objects.get_or_create(+ title="Water", defaults={"calories": 0, "base_run_time_seconds": 120}+ )+ return water + def get_absolute_url(self) -> str:+ return reverse("drinks:drink_detail", kwargs={"slug": self.uuid}) -# --- Beer models ---+ @property+ def logdata_cls(self):+ return DrinkLogData class BeerStyle(TimeStampedModel):@@ -188,11 +199,12 @@ class BeerProducer(TimeStampedModel): db_table = "beers_beerproducer" -class Beer(Drink):+class Beer(ScrobblableMixin): description = models.TextField(**BNULL) ibu = models.SmallIntegerField(**BNULL) abv = models.FloatField(**BNULL) styles = models.ManyToManyField(BeerStyle, related_name="styles")+ calories = models.PositiveIntegerField(**BNULL) non_alcoholic = models.BooleanField(default=False) beeradvocate_id = models.CharField(max_length=255, **BNULL) beeradvocate_score = models.SmallIntegerField(**BNULL)@@ -322,8 +334,10 @@ class WineProducer(TimeStampedModel): return self.name -class Wine(Drink):+class Wine(ScrobblableMixin): description = models.TextField(**BNULL)+ calories = models.PositiveIntegerField(**BNULL)+ non_alcoholic = models.BooleanField(default=False) 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)@@ -476,8 +490,9 @@ class CoffeeRoaster(TimeStampedModel): return self.name -class Coffee(Drink):+class Coffee(ScrobblableMixin): description = models.TextField(**BNULL)+ calories = models.PositiveIntegerField(**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)@@ -23,4 +23,15 @@ urlpatterns = [ views.CoffeeDetailView.as_view(), name="coffee_detail", ),+ path("drinks/", views.DrinkListView.as_view(), name="drink_list"),+ path(+ "drinks/<slug:slug>/",+ views.DrinkDetailView.as_view(),+ name="drink_detail",+ ),+ path(+ "quick-water/",+ views.QuickWaterScrobbleView.as_view(),+ name="quick_water_scrobble",+ ), ]@@ -1,4 +1,7 @@-from drinks.models import Beer, Coffee, Wine+from django.contrib import messages+from django.http import HttpResponseRedirect+from django.views import View+from drinks.models import Beer, Coffee, Drink, Wine from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView @@ -24,3 +27,32 @@ class CoffeeListView(ScrobbleableListView): class CoffeeDetailView(ScrobbleableDetailView): model = Coffee+++class DrinkListView(ScrobbleableListView):+ model = Drink+++class DrinkDetailView(ScrobbleableDetailView):+ model = Drink+++class QuickWaterScrobbleView(View):+ def post(self, request, *args, **kwargs):+ if not request.user.is_authenticated:+ messages.error(request, "You must be logged in to scrobble water.")+ return HttpResponseRedirect("/")++ from scrobbles.scrobblers import manual_scrobble_water++ scrobble = manual_scrobble_water(user_id=request.user.id)++ if scrobble:+ messages.success(request, "Water scrobbled!")+ return HttpResponseRedirect(scrobble.redirect_url(request.user.id))++ messages.error(request, "Failed to scrobble water.")+ return HttpResponseRedirect("/")++ def get(self, request, *args, **kwargs):+ return self.post(request, *args, **kwargs)@@ -27,6 +27,7 @@ AUTO_FINISH_MEDIA = { "tracks": "Track", "videos": "Video", "moods": "Mood",+ "drinks": "Drink", } PLAY_AGAIN_MEDIA = {@@ -40,6 +41,9 @@ PLAY_AGAIN_MEDIA = { "locations": "GeoLocation", "trails": "Trail", "drinks": "Beer",+ "drinks": "Wine",+ "drinks": "Coffee",+ "drinks": "Drink", "puzzles": "Puzzle", "foods": "Food", "tasks": "Task",@@ -52,7 +56,7 @@ PLAY_AGAIN_MEDIA = { "discgolf": "DiscGolfCourse", } -DRINK_MODELS = ["Beer", "Wine", "Coffee"]+DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"] MEDIA_END_PADDING_SECONDS = { "Video": 3600, # 60 min@@ -93,6 +97,7 @@ MANUAL_SCROBBLE_FNS = { "-l": "manual_scrobble_brickset", "-c": "manual_scrobble_book", "-f": "manual_scrobble_food",+ "-d": "manual_scrobble_drink", "-h": "manual_scrobble_twitch_channel", "-dg": "manual_scrobble_discgolf", "-pp": "manual_scrobble_paper",@@ -0,0 +1,102 @@+# Generated by Django 4.2.29 on 2026-07-13 18:31++from django.db import migrations, models+import django.db.models.deletion+++class Migration(migrations.Migration):++ dependencies = [+ ("drinks", "0010_alter_beerproducer_options_alter_beerstyle_options_and_more"),+ ("scrobbles", "0101_favoritemedia_coffee_favoritemedia_wine_and_more"),+ ]++ operations = [+ migrations.AddField(+ model_name="favoritemedia",+ name="drink",+ field=models.ForeignKey(+ blank=True,+ null=True,+ on_delete=django.db.models.deletion.CASCADE,+ to="drinks.drink",+ ),+ ),+ migrations.AddField(+ model_name="scrobble",+ name="drink",+ field=models.ForeignKey(+ blank=True,+ null=True,+ on_delete=django.db.models.deletion.DO_NOTHING,+ to="drinks.drink",+ ),+ ),+ 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"),+ ("Drink", "Drink"),+ ("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="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"),+ ("Drink", "Drink"),+ ("Puzzle", "Puzzle"),+ ("Food", "Food"),+ ("Task", "Task"),+ ("WebPage", "Web Page"),+ ("LifeEvent", "Life event"),+ ("Mood", "Mood"),+ ("BrickSet", "Brick set"),+ ("Channel", "Channel"),+ ("BirdingLocation", "Birding location"),+ ("DiscGolfCourse", "Disc golf"),+ ],+ default="Video",+ max_length=20,+ ),+ ),+ ]@@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo import pendulum import pytz-from drinks.models import Beer, Wine, Coffee+from drinks.models import Beer, Wine, Coffee, Drink from birds.models import BirdingLocation from discgolf.models import DiscGolfCourse from boardgames.models import BoardGame@@ -686,6 +686,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = { "Beer": ("beer",), "Wine": ("wine",), "Coffee": ("coffee",),+ "Drink": ("drink",), "Puzzle": ("puzzle",), "Food": ("food",), "Task": ("task",),@@ -716,6 +717,7 @@ class ScrobbleQuerySet(models.QuerySet): "beer", "wine", "coffee",+ "drink", "puzzle", "food", "trail",@@ -769,6 +771,7 @@ class Scrobble(TimeStampedModel): BEER = "Beer", "Beer" WINE = "Wine", "Wine" COFFEE = "Coffee", "Coffee"+ DRINK = "Drink", "Drink" PUZZLE = "Puzzle", "Puzzle" FOOD = "Food", "Food" TASK = "Task", "Task"@@ -800,6 +803,7 @@ class Scrobble(TimeStampedModel): 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)+ drink = models.ForeignKey(Drink, 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)@@ -1382,6 +1386,8 @@ class Scrobble(TimeStampedModel): media_obj = self.wine if self.coffee: media_obj = self.coffee+ if self.drink:+ media_obj = self.drink if self.puzzle: media_obj = self.puzzle if self.task:@@ -1945,6 +1951,7 @@ class FavoriteMedia(TimeStampedModel): 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)+ drink = models.ForeignKey(Drink, 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)@@ -2003,6 +2010,8 @@ class FavoriteMedia(TimeStampedModel): media_obj = self.wine if self.coffee: media_obj = self.coffee+ if self.drink:+ media_obj = self.drink if self.puzzle: media_obj = self.puzzle if self.task:@@ -2037,6 +2046,7 @@ class FavoriteMedia(TimeStampedModel): "Beer": "beer", "Wine": "wine", "Coffee": "coffee",+ "Drink": "drink", "Puzzle": "puzzle", "Food": "food", "Trail": "trail",@@ -15,7 +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 drinks.models import Beer, Coffee, Wine, Drink, DrinkFormat from foods.models import Food from foods.sources.rscraper import RecipeScraperService from locations.constants import LOCATION_PROVIDERS@@ -1350,6 +1350,37 @@ def manual_scrobble_coffee( return Scrobble.create_or_update(coffee, user_id, scrobble_dict) +def manual_scrobble_water(+ user_id: int = None,+ source: str = "Vrobbler",+ action: Optional[str] = None,+):+ water = Drink.find_or_create_water()++ if not water:+ logger.error("Could not find or create Water instance")+ return++ scrobble_dict = {+ "user_id": user_id,+ "timestamp": timezone.now(),+ "playback_position_seconds": 0,+ "source": source,+ "log": {"format": DrinkFormat.BY_THE_GLASS, "size_ml": 250},+ }+ logger.info(+ "[vrobbler-scrobble] water scrobble request received",+ extra={+ "water_id": water.id,+ "user_id": user_id,+ "scrobble_dict": scrobble_dict,+ "media_type": Scrobble.MediaType.DRINK,+ },+ )++ return Scrobble.create_or_update(water, user_id, scrobble_dict)++ def manual_scrobble_puzzle( ipdb_id: str, user_id: int,@@ -1461,6 +1492,50 @@ def manual_scrobble_food( return Scrobble.create_or_update(food, user_id, scrobble_dict) +def manual_scrobble_drink(+ item_id: str,+ user_id: int,+ source: str = "Vrobbler",+ action: Optional[str] = None,+):+ match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)+ if not match:+ logger.info(+ "[manual_scrobble_drink] invalid format, expected 'Drink Name - calories'",+ extra={"item_id": item_id, "user_id": user_id},+ )+ return++ drink_name = match.group(1).strip()+ calories = int(match.group(2))++ drink = Drink.objects.filter(title=drink_name).first()+ if not drink:+ drink = Drink.objects.create(+ title=drink_name,+ calories=calories,+ )++ scrobble_dict = {+ "user_id": user_id,+ "timestamp": timezone.now(),+ "playback_position_seconds": 0,+ "source": source,+ }++ logger.info(+ "[scrobblers] manual drink scrobble request received",+ extra={+ "drink_id": drink.id,+ "user_id": user_id,+ "scrobble_dict": scrobble_dict,+ "media_type": Scrobble.MediaType.DRINK,+ },+ )++ return Scrobble.create_or_update(drink, user_id, scrobble_dict)++ def manual_scrobble_discgolf( item_id: str, user_id: int,@@ -1164,6 +1164,7 @@ def toggle_favorite(request, media_type, object_id): "Beer": ("drinks", "Beer"), "Wine": ("drinks", "Wine"), "Coffee": ("drinks", "Coffee"),+ "Water": ("drinks", "Water"), "Puzzle": ("puzzles", "Puzzle"), "Food": ("foods", "Food"), "Trail": ("trails", "Trail"),@@ -280,6 +280,12 @@ <div class="nav-item text-nowrap"> <a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a> </div>+ <div class="nav-item text-nowrap">+ <form method="post" action="{% url "drinks:quick_water_scrobble" %}" style="display:inline;">+ {% csrf_token %}+ <button type="submit" class="nav-link px-3 btn btn-link" style="text-decoration:none;">Drink water</button>+ </form>+ </div> <div class="nav-item text-nowrap"> <a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a> </div>@@ -0,0 +1,80 @@+{% extends "base_list.html" %}+{% load mathfilters %}+{% load static %}+{% load naturalduration %}++{% block title %}{{object.title}}{% endblock %}++{% block head_extra %}+<style>+ .cover img {+ width: 250px;+ }++ .cover {+ float: left;+ width: 252px;+ padding: 0;+ }++ .summary {+ float: left;+ width: 600px;+ margin-left: 10px;+ }+</style>+{% endblock %}++{% block lists %}++<div class="row">+ <div class="summary">+ {% if object.description%}+ <p>{{object.description|safe|linebreaks|truncatewords:160}}</p>+ <hr />+ {% endif %}+ <p style="float:right;">+ <a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>+ </p>+ </div>+</div>+<div class="row">+ <p>{{scrobbles.count}} scrobbles</p>+ <p>+ <a href="{{object.start_url}}">Drink again</a>+ </p>+</div>+<div class="row">+ <div class="col-md">+ <h3>Last scrobbles</h3>+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th scope="col">Date</th>+ <th scope="col">Format</th>+ <th scope="col">Size</th>+ </tr>+ </thead>+ <tbody>+ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}+ <tr>+ <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>+ <td>{{ scrobble.logdata.format_display }}</td>+ <td>+ {% if scrobble.logdata.size_ml %}+ {% if request.user.profile.volume_unit == "imperial" %}+ {{ scrobble.logdata.size_oz }} oz+ {% else %}+ {{ scrobble.logdata.size_ml }} mL+ {% endif %}+ {% endif %}+ </td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ </div>+</div>+{% endblock %}@@ -0,0 +1,23 @@+{% extends "base_list.html" %}++{% block title %}Drinks{% endblock %}++{% block head_extra %}+<style>+ dl { width: 210px; float:left; margin-right: 10px; }+ dt a { color:white; text-decoration: none; font-size:smaller; }+ img { height:200px; width: 200px; object-fit: cover; }+ dd .right { float:right; }+</style>+{% endblock %}++{% block lists %}+<div class="row">++ <div class="col-md">+ <div class="table-responsive">+ {% include "_scrobblable_list.html" %}+ </div>+ </div>+</div>+{% endblock %}@@ -0,0 +1,79 @@+{% extends "base_list.html" %}+{% load static %}++{% block title %}{{object.title}}{% endblock %}++{% block head_extra %}+<style>+ .cover img {+ width: 250px;+ }++ .cover {+ float: left;+ width: 252px;+ padding: 0;+ }++ .summary {+ float: left;+ width: 600px;+ margin-left: 10px;+ }+</style>+{% endblock %}++{% block lists %}++<div class="row">+ <div class="summary">+ {% if object.description%}+ <p>{{object.description|safe|linebreaks|truncatewords:160}}</p>+ <hr />+ {% endif %}+ </div>+</div>+<div class="row">+ <p>{{scrobbles.count}} scrobbles</p>+ <p>+ <a href="{{object.start_url}}">Drink again</a>+ </p>+</div>+<div class="row">+ <div class="col-md">+ <h3>Last scrobbles</h3>+ <div class="table-responsive">+ <table class="table table-striped table-sm">+ <thead>+ <tr>+ <th scope="col">Date</th>+ <th scope="col">Format</th>+ <th scope="col">Size</th>+ <th scope="col">Temperature</th>+ <th scope="col">Sparkling</th>+ </tr>+ </thead>+ <tbody>+ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}+ <tr>+ <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>+ <td>{{ scrobble.logdata.format_display }}</td>+ <td>+ {% if scrobble.logdata.size_ml %}+ {% if request.user.profile.volume_unit == "imperial" %}+ {{ scrobble.logdata.size_oz }} oz+ {% else %}+ {{ scrobble.logdata.size_ml }} mL+ {% endif %}+ {% endif %}+ </td>+ <td>{{ scrobble.logdata.temperature|title }}</td>+ <td>{% if scrobble.logdata.carbonated %}Yes{% elif scrobble.logdata.carbonated == False %}No{% endif %}</td>+ </tr>+ {% endfor %}+ </tbody>+ </table>+ </div>+ </div>+</div>+{% endblock %}@@ -93,6 +93,8 @@ <a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a> {% elif scrobble.coffee %} <a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>+ {% elif scrobble.water %}+ <a href="{% url 'drinks:water_detail' scrobble.water.uuid %}">{{ scrobble.water.title }}</a> {% elif scrobble.web_page %} <a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a> {% elif scrobble.podcast_episode %}