[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,
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,
]

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
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"
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
def is_alcoholic(self) -> bool:
return True
# --- Beer models ---
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)

View File

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

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

View File

@ -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",

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 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",

View File

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

View File

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

View File

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

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