sessions / 6861e28ac6e971563e849cfa1a45f41e1e51e1d8

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

This commit has no recorded session.

diff

ignore whitespace

commit 6861e28ac6e971563e849cfa1a45f41e1e51e1d8Author: Colin Powell <colin@unbl.ink>Date:   Mon Jul 13 19:09:32 2026 -0400    [drinks] Add Drink as a scrobbleable mediadiff --git a/vrobbler/apps/drinks/admin.py b/vrobbler/apps/drinks/admin.pyindex fc90c82..82eb960 100644--- a/vrobbler/apps/drinks/admin.py+++ b/vrobbler/apps/drinks/admin.py@@ -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,+    ]diff --git a/vrobbler/apps/drinks/migrations/0010_alter_beerproducer_options_alter_beerstyle_options_and_more.py b/vrobbler/apps/drinks/migrations/0010_alter_beerproducer_options_alter_beerstyle_options_and_more.pynew file mode 100644index 0000000..5a9147c--- /dev/null+++ b/vrobbler/apps/drinks/migrations/0010_alter_beerproducer_options_alter_beerstyle_options_and_more.py@@ -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",+            },+        ),+    ]diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.pyindex 47eb643..b1ecbe1 100644--- a/vrobbler/apps/drinks/models.py+++ b/vrobbler/apps/drinks/models.py@@ -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)diff --git a/vrobbler/apps/drinks/urls.py b/vrobbler/apps/drinks/urls.pyindex 2f7b3b1..4b2fb86 100644--- a/vrobbler/apps/drinks/urls.py+++ b/vrobbler/apps/drinks/urls.py@@ -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",+    ), ]diff --git a/vrobbler/apps/drinks/views.py b/vrobbler/apps/drinks/views.pyindex e29ad39..427ff0a 100644--- a/vrobbler/apps/drinks/views.py+++ b/vrobbler/apps/drinks/views.py@@ -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)diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex 88d1fad..c89cbdf 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -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",diff --git a/vrobbler/apps/scrobbles/migrations/0102_favoritemedia_drink_scrobble_drink_and_more.py b/vrobbler/apps/scrobbles/migrations/0102_favoritemedia_drink_scrobble_drink_and_more.pynew file mode 100644index 0000000..5eeda83--- /dev/null+++ b/vrobbler/apps/scrobbles/migrations/0102_favoritemedia_drink_scrobble_drink_and_more.py@@ -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,+            ),+        ),+    ]diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.pyindex 0f2e359..d7550fc 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 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",diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex 86b0888..a501015 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -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,diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex f171cfa..95558ce 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -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"),diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.htmlindex d839749..4978d65 100644--- a/vrobbler/templates/base.html+++ b/vrobbler/templates/base.html@@ -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>diff --git a/vrobbler/templates/drinks/drink_detail.html b/vrobbler/templates/drinks/drink_detail.htmlnew file mode 100644index 0000000..97c5baa--- /dev/null+++ b/vrobbler/templates/drinks/drink_detail.html@@ -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 %}diff --git a/vrobbler/templates/drinks/drink_list.html b/vrobbler/templates/drinks/drink_list.htmlnew file mode 100644index 0000000..801c067--- /dev/null+++ b/vrobbler/templates/drinks/drink_list.html@@ -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 %}diff --git a/vrobbler/templates/drinks/water_detail.html b/vrobbler/templates/drinks/water_detail.htmlnew file mode 100644index 0000000..c0e67b1--- /dev/null+++ b/vrobbler/templates/drinks/water_detail.html@@ -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 %}diff --git a/vrobbler/templates/scrobbles/_scrobble_all_content.html b/vrobbler/templates/scrobbles/_scrobble_all_content.htmlindex f0fe55a..c95873a 100644--- a/vrobbler/templates/scrobbles/_scrobble_all_content.html+++ b/vrobbler/templates/scrobbles/_scrobble_all_content.html@@ -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 %}