[drinks] Add Drink as a scrobbleable media
All checks were successful
ci / test (push) Successful in 2m48s
ci / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-07-13 19:09:32 -04:00
parent fdc18d5a5f
commit 6861e28ac6
15 changed files with 560 additions and 14 deletions

View File

@ -5,6 +5,7 @@ from drinks.models import (
BeerStyle, BeerStyle,
Coffee, Coffee,
CoffeeRoaster, CoffeeRoaster,
Drink,
Wine, Wine,
WineGrape, WineGrape,
WineProducer, WineProducer,
@ -97,3 +98,17 @@ class CoffeeAdmin(admin.ModelAdmin):
inlines = [ inlines = [
ScrobbleInline, ScrobbleInline,
] ]
@admin.register(Drink)
class DrinkAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -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",
},
),
]

View File

@ -75,7 +75,7 @@ class DrinkLogData(BaseLogData):
@property @property
def size_oz(self) -> Optional[float]: def size_oz(self) -> Optional[float]:
if self.size_ml: 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 return None
@property @property
@ -146,16 +146,27 @@ class CoffeeLogData(DrinkLogData):
class Drink(ScrobblableMixin): class Drink(ScrobblableMixin):
calories = models.PositiveIntegerField(**BNULL) calories = models.PositiveIntegerField(**BNULL)
is_alcoholic = models.BooleanField(default=False)
class Meta: class Meta:
abstract = True db_table = "drinks_drink"
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})
@property @property
def is_alcoholic(self) -> bool: def logdata_cls(self):
return True return DrinkLogData
# --- Beer models ---
class BeerStyle(TimeStampedModel): class BeerStyle(TimeStampedModel):
@ -188,11 +199,12 @@ class BeerProducer(TimeStampedModel):
db_table = "beers_beerproducer" db_table = "beers_beerproducer"
class Beer(Drink): class Beer(ScrobblableMixin):
description = models.TextField(**BNULL) description = models.TextField(**BNULL)
ibu = models.SmallIntegerField(**BNULL) ibu = models.SmallIntegerField(**BNULL)
abv = models.FloatField(**BNULL) abv = models.FloatField(**BNULL)
styles = models.ManyToManyField(BeerStyle, related_name="styles") styles = models.ManyToManyField(BeerStyle, related_name="styles")
calories = models.PositiveIntegerField(**BNULL)
non_alcoholic = models.BooleanField(default=False) non_alcoholic = models.BooleanField(default=False)
beeradvocate_id = models.CharField(max_length=255, **BNULL) beeradvocate_id = models.CharField(max_length=255, **BNULL)
beeradvocate_score = models.SmallIntegerField(**BNULL) beeradvocate_score = models.SmallIntegerField(**BNULL)
@ -322,8 +334,10 @@ class WineProducer(TimeStampedModel):
return self.name return self.name
class Wine(Drink): class Wine(ScrobblableMixin):
description = models.TextField(**BNULL) 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) wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)
grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True) grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)
region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL) region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)
@ -476,8 +490,9 @@ class CoffeeRoaster(TimeStampedModel):
return self.name return self.name
class Coffee(Drink): class Coffee(ScrobblableMixin):
description = models.TextField(**BNULL) description = models.TextField(**BNULL)
calories = models.PositiveIntegerField(**BNULL)
origin = models.CharField(max_length=255, **BNULL) origin = models.CharField(max_length=255, **BNULL)
roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL) roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL)
roastdb_id = models.CharField(max_length=255, **BNULL) roastdb_id = models.CharField(max_length=255, **BNULL)

View File

@ -23,4 +23,15 @@ urlpatterns = [
views.CoffeeDetailView.as_view(), views.CoffeeDetailView.as_view(),
name="coffee_detail", 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",
),
] ]

View File

@ -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 from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
@ -24,3 +27,32 @@ class CoffeeListView(ScrobbleableListView):
class CoffeeDetailView(ScrobbleableDetailView): class CoffeeDetailView(ScrobbleableDetailView):
model = Coffee 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)

View File

@ -27,6 +27,7 @@ AUTO_FINISH_MEDIA = {
"tracks": "Track", "tracks": "Track",
"videos": "Video", "videos": "Video",
"moods": "Mood", "moods": "Mood",
"drinks": "Drink",
} }
PLAY_AGAIN_MEDIA = { PLAY_AGAIN_MEDIA = {
@ -40,6 +41,9 @@ PLAY_AGAIN_MEDIA = {
"locations": "GeoLocation", "locations": "GeoLocation",
"trails": "Trail", "trails": "Trail",
"drinks": "Beer", "drinks": "Beer",
"drinks": "Wine",
"drinks": "Coffee",
"drinks": "Drink",
"puzzles": "Puzzle", "puzzles": "Puzzle",
"foods": "Food", "foods": "Food",
"tasks": "Task", "tasks": "Task",
@ -52,7 +56,7 @@ PLAY_AGAIN_MEDIA = {
"discgolf": "DiscGolfCourse", "discgolf": "DiscGolfCourse",
} }
DRINK_MODELS = ["Beer", "Wine", "Coffee"] DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
MEDIA_END_PADDING_SECONDS = { MEDIA_END_PADDING_SECONDS = {
"Video": 3600, # 60 min "Video": 3600, # 60 min
@ -93,6 +97,7 @@ MANUAL_SCROBBLE_FNS = {
"-l": "manual_scrobble_brickset", "-l": "manual_scrobble_brickset",
"-c": "manual_scrobble_book", "-c": "manual_scrobble_book",
"-f": "manual_scrobble_food", "-f": "manual_scrobble_food",
"-d": "manual_scrobble_drink",
"-h": "manual_scrobble_twitch_channel", "-h": "manual_scrobble_twitch_channel",
"-dg": "manual_scrobble_discgolf", "-dg": "manual_scrobble_discgolf",
"-pp": "manual_scrobble_paper", "-pp": "manual_scrobble_paper",

View File

@ -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,
),
),
]

View File

@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo
import pendulum import pendulum
import pytz import pytz
from drinks.models import Beer, Wine, Coffee from drinks.models import Beer, Wine, Coffee, Drink
from birds.models import BirdingLocation from birds.models import BirdingLocation
from discgolf.models import DiscGolfCourse from discgolf.models import DiscGolfCourse
from boardgames.models import BoardGame from boardgames.models import BoardGame
@ -686,6 +686,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"Beer": ("beer",), "Beer": ("beer",),
"Wine": ("wine",), "Wine": ("wine",),
"Coffee": ("coffee",), "Coffee": ("coffee",),
"Drink": ("drink",),
"Puzzle": ("puzzle",), "Puzzle": ("puzzle",),
"Food": ("food",), "Food": ("food",),
"Task": ("task",), "Task": ("task",),
@ -716,6 +717,7 @@ class ScrobbleQuerySet(models.QuerySet):
"beer", "beer",
"wine", "wine",
"coffee", "coffee",
"drink",
"puzzle", "puzzle",
"food", "food",
"trail", "trail",
@ -769,6 +771,7 @@ class Scrobble(TimeStampedModel):
BEER = "Beer", "Beer" BEER = "Beer", "Beer"
WINE = "Wine", "Wine" WINE = "Wine", "Wine"
COFFEE = "Coffee", "Coffee" COFFEE = "Coffee", "Coffee"
DRINK = "Drink", "Drink"
PUZZLE = "Puzzle", "Puzzle" PUZZLE = "Puzzle", "Puzzle"
FOOD = "Food", "Food" FOOD = "Food", "Food"
TASK = "Task", "Task" TASK = "Task", "Task"
@ -800,6 +803,7 @@ class Scrobble(TimeStampedModel):
beer = models.ForeignKey(Beer, 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) wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)
coffee = models.ForeignKey(Coffee, 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) puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
food = models.ForeignKey(Food, 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) trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
@ -1382,6 +1386,8 @@ class Scrobble(TimeStampedModel):
media_obj = self.wine media_obj = self.wine
if self.coffee: if self.coffee:
media_obj = self.coffee media_obj = self.coffee
if self.drink:
media_obj = self.drink
if self.puzzle: if self.puzzle:
media_obj = self.puzzle media_obj = self.puzzle
if self.task: if self.task:
@ -1945,6 +1951,7 @@ class FavoriteMedia(TimeStampedModel):
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL) beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL) wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL)
coffee = models.ForeignKey(Coffee, 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) puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL) food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
trail = models.ForeignKey(Trail, 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 media_obj = self.wine
if self.coffee: if self.coffee:
media_obj = self.coffee media_obj = self.coffee
if self.drink:
media_obj = self.drink
if self.puzzle: if self.puzzle:
media_obj = self.puzzle media_obj = self.puzzle
if self.task: if self.task:
@ -2037,6 +2046,7 @@ class FavoriteMedia(TimeStampedModel):
"Beer": "beer", "Beer": "beer",
"Wine": "wine", "Wine": "wine",
"Coffee": "coffee", "Coffee": "coffee",
"Drink": "drink",
"Puzzle": "puzzle", "Puzzle": "puzzle",
"Food": "food", "Food": "food",
"Trail": "trail", "Trail": "trail",

View File

@ -15,7 +15,7 @@ from bricksets.models import BrickSet
from dateutil.parser import parse from dateutil.parser import parse
from discgolf.models import DiscGolfCourse from discgolf.models import DiscGolfCourse
from django.utils import timezone 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.models import Food
from foods.sources.rscraper import RecipeScraperService from foods.sources.rscraper import RecipeScraperService
from locations.constants import LOCATION_PROVIDERS from locations.constants import LOCATION_PROVIDERS
@ -1350,6 +1350,37 @@ def manual_scrobble_coffee(
return Scrobble.create_or_update(coffee, user_id, scrobble_dict) 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( def manual_scrobble_puzzle(
ipdb_id: str, ipdb_id: str,
user_id: int, user_id: int,
@ -1461,6 +1492,50 @@ def manual_scrobble_food(
return Scrobble.create_or_update(food, user_id, scrobble_dict) 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( def manual_scrobble_discgolf(
item_id: str, item_id: str,
user_id: int, user_id: int,

View File

@ -1164,6 +1164,7 @@ def toggle_favorite(request, media_type, object_id):
"Beer": ("drinks", "Beer"), "Beer": ("drinks", "Beer"),
"Wine": ("drinks", "Wine"), "Wine": ("drinks", "Wine"),
"Coffee": ("drinks", "Coffee"), "Coffee": ("drinks", "Coffee"),
"Water": ("drinks", "Water"),
"Puzzle": ("puzzles", "Puzzle"), "Puzzle": ("puzzles", "Puzzle"),
"Food": ("foods", "Food"), "Food": ("foods", "Food"),
"Trail": ("trails", "Trail"), "Trail": ("trails", "Trail"),

View File

@ -280,6 +280,12 @@
<div class="nav-item text-nowrap"> <div class="nav-item text-nowrap">
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a> <a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
</div> </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"> <div class="nav-item text-nowrap">
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a> <a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
</div> </div>

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -93,6 +93,8 @@
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a> <a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
{% elif scrobble.coffee %} {% elif scrobble.coffee %}
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a> <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 %} {% elif scrobble.web_page %}
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a> <a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
{% elif scrobble.podcast_episode %} {% elif scrobble.podcast_episode %}