Compare commits

..

14 Commits
59.1 ... main

Author SHA1 Message Date
c9298fe387 [release] Bump to version 60.0
All checks were successful
ci / test (push) Successful in 2m19s
ci / build-and-deploy (push) Successful in 41s
- Send water drinking notifications
- Add wine as a ScrobbleItem in a drinks app and move beers into drinks app
2026-07-14 00:13:17 -04:00
f7d99f8b55 [drinks] Add water drink notifications 2026-07-13 23:17:14 -04:00
6861e28ac6 [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
2026-07-13 19:12:59 -04:00
fdc18d5a5f [drinks] Rename beers to drinks and add new models 2026-07-13 13:26:28 -04:00
77d92f6c96 [release] Bump to version 59.5
All checks were successful
ci / test (push) Successful in 2m13s
ci / build-and-deploy (push) Successful in 38s
- Fix bug where all variants for board games are in form
2026-07-05 00:15:58 -04:00
d5d0eb6cd8 [boardgames] Make sure variants are filtered by game 2026-07-05 00:15:26 -04:00
d6f71e0761 [release] Bump to version 59.4
All checks were successful
ci / test (push) Successful in 2m14s
ci / build-and-deploy (push) Successful in 37s
- Fix bug in fetching expansions for board games
- Board games should have genres extracted from family data
2026-07-04 11:53:28 -04:00
b00ebf49dd [boardgames] Fix getting BGG id 2026-07-04 11:53:05 -04:00
2385e9c7bd [release] Bump to version 59.3
All checks were successful
ci / test (push) Successful in 2m18s
ci / build-and-deploy (push) Successful in 37s
- Exclude some board games from auto-expansion imports
- Should be able to add new variants to board games via the log data form
2026-07-04 11:41:49 -04:00
d78529efe2 [boardgames] Add genres and categories 2026-07-04 11:41:24 -04:00
f373e98e3d [boardgames] Skip CCG games for auto expansion download 2026-07-04 11:32:17 -04:00
7559ce7824 [boardgames] Add ability to add new variants to form 2026-07-04 11:28:41 -04:00
2c481bd53a [release] Bump to version 59.2
All checks were successful
ci / test (push) Successful in 2m20s
ci / build-and-deploy (push) Successful in 36s
- Fix test failure in discgolf app
2026-07-04 10:18:42 -04:00
0deb3ee634 [discgolf] Fix breaking tests 2026-07-04 10:18:17 -04:00
84 changed files with 4173 additions and 447 deletions

View File

@ -619,6 +619,76 @@ The Edit log form should have from top to bottom:
- Expansion ids (which should a multi-select widget of expansions for this game) - Expansion ids (which should a multi-select widget of expansions for this game)
- Location (which should be a drop down of BoardGameLocations for this user) - Location (which should be a drop down of BoardGameLocations for this user)
* Version 60.0 [2/2]
** DONE [#B] Send water drinking notifications :drinks:notifications:
:PROPERTIES:
:ID: 6c0db717-a0c7-34a1-57e1-941db0188875
:END:
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
:PROPERTIES:
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
:END:
* Version 59.5 [1/1]
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
:PROPERTIES:
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
:END:
* Version 59.4 [2/2]
** DONE Fix bug in fetching expansions for board games :boardgames:
:PROPERTIES:
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
:END:
#+begin_src python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
fetch_and_link_expansions(game, expansions)
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
expansion = BoardGame.find_or_create(str(exp_id))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
game = bgg.game(game_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
raise BGGApiError(msg)
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
#+end_src
** DONE Board games should have genres extracted from family data :boardgames:metadata:
:PROPERTIES:
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
:END:
* Version 59.3 [2/2]
** DONE Exclude some board games from auto-expansion imports :boardgames:
:PROPERTIES:
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
:END:
*** Description
Some board games, especially trading card games, have silly amounts of expansions.
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
games where expansions should not be download automatically. This exclusion should also auto-include
any games with "Collectible Card Games" in it's family.
** DONE Should be able to add new variants to board games via the log data form :boardgames:
:PROPERTIES:
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
:END:
* Version 59.2 [1/1]
** DONE Fix test failure in discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
:END:
* Version 59.1 [1/1] * Version 59.1 [1/1]
** DONE Fix bug when expansions have no image :boardgames:bug: ** DONE Fix bug when expansions have no image :boardgames:bug:
:PROPERTIES: :PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "59.1" version = "60.0"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -1,35 +0,0 @@
from beers.models import Beer, BeerProducer, BeerStyle
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
class BeerInline(admin.TabularInline):
model = Beer
extra = 0
@admin.register(BeerStyle)
class BeerStyle(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(BeerProducer)
class BeerProducer(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Beer)
class BeerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
raw_id_fields = ("styles", "producer")
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -1,20 +0,0 @@
from rest_framework import serializers
from beers.models import Beer, BeerProducer, BeerStyle
class BeerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Beer
fields = "__all__"
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerProducer
fields = "__all__"
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerStyle
fields = "__all__"

View File

@ -1,21 +0,0 @@
from rest_framework import permissions, viewsets
from beers.api import serializers
from beers import models
class BeerViewSet(viewsets.ModelViewSet):
queryset = models.Beer.objects.all().order_by("-created")
serializer_class = serializers.BeerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerProducerViewSet(viewsets.ModelViewSet):
queryset = models.BeerProducer.objects.all().order_by("-created")
serializer_class = serializers.BeerProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerStyleViewSet(viewsets.ModelViewSet):
queryset = models.BeerStyle.objects.all().order_by("-created")
serializer_class = serializers.BeerStyleSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -1,143 +0,0 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from beers.untappd import get_beer_from_untappd_id
from django.apps import apps
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class BeerLogData(BaseLogData):
rating: Optional[str] = None
class BeerStyle(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
def __str__(self):
return self.name
class BeerProducer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
untappd_id = models.CharField(max_length=255, **BNULL)
def find_or_create(cls, title: str) -> "BeerProducer":
return cls.objects.filter(title=title).first()
def __str__(self):
return self.name
class Beer(ScrobblableMixin):
description = models.TextField(**BNULL)
ibu = models.SmallIntegerField(**BNULL)
abv = models.FloatField(**BNULL)
styles = models.ManyToManyField(BeerStyle, related_name="styles")
non_alcoholic = models.BooleanField(default=False)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
beeradvocate_score = models.SmallIntegerField(**BNULL)
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
untappd_image_small = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
untappd_image_medium = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
untappd_id = models.CharField(max_length=255, **BNULL)
untappd_rating = models.FloatField(**BNULL)
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
def get_absolute_url(self) -> str:
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} by {self.producer}"
@property
def subtitle(self):
return self.producer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="beer")
@property
def beeradvocate_link(self) -> str:
link = ""
if self.producer and self.beeradvocate_id:
if self.beeradvocate_id:
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
return link
@property
def untappd_link(self) -> str:
link = ""
if self.untappd_id:
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.untappd_image:
url = self.untappd_image.url
return url
@property
def logdata_cls(self):
return BeerLogData
@classmethod
def find_or_create(cls, untappd_id: str) -> "Beer":
beer = cls.objects.filter(untappd_id=untappd_id).first()
if not beer:
beer_dict = get_beer_from_untappd_id(untappd_id)
producer_dict = {}
style_ids = []
for key in list(beer_dict.keys()):
if "producer__" in key:
pkey = key.replace("producer__", "")
producer_dict[pkey] = beer_dict.pop(key)
if "styles" in key:
for style in beer_dict.pop("styles"):
style_inst, created = BeerStyle.objects.get_or_create(
name=style
)
style_ids.append(style_inst.id)
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
beer_dict["producer_id"] = producer.id
beer = Beer.objects.create(**beer_dict)
for style_id in style_ids:
beer.styles.add(style_id)
return beer
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
"-timestamp"
)

View File

@ -1,14 +0,0 @@
from django.urls import path
from beers import views
app_name = "beers"
urlpatterns = [
path("beers/", views.BeerListView.as_view(), name="beer_list"),
path(
"beers/<slug:slug>/",
views.BeerDetailView.as_view(),
name="beer_detail",
),
]

View File

@ -1,11 +0,0 @@
from beers.models import Beer
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BeerListView(ScrobbleableListView):
model = Beer
class BeerDetailView(ScrobbleableDetailView):
model = Beer

View File

@ -0,0 +1,81 @@
import logging
import time
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Refresh board game metadata from BGG (categories→genres, families→tags)"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--force",
action="store_true",
help="Update all games even if they already have a published_date",
)
parser.add_argument(
"--batch-size",
type=int,
default=50,
help="Number of games to process per batch (default: 50)",
)
parser.add_argument(
"--sleep",
type=float,
default=1.0,
help="Seconds to sleep between API calls (default: 1.0)",
)
def handle(self, *args, **options):
from boardgames.models import BoardGame
commit = options["commit"]
force = options["force"]
batch_size = options["batch_size"]
sleep_secs = options["sleep"]
qs = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(bggeek_id="")
total = qs.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if not commit:
self.stdout.write(
"Dry run — no API calls will be made. Use --commit to run lookups."
)
return
enriched = 0
skipped = 0
for batch_num, offset in enumerate(range(0, total, batch_size)):
batch = qs[offset : offset + batch_size]
for game in batch:
try:
game.fix_metadata(force_update=force)
enriched += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" [SKIPPED] {game.title} (BGG {game.bggeek_id}): {e}"
)
)
skipped += 1
time.sleep(sleep_secs)
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}"
f"enriched: {enriched}, skipped: {skipped}"
)
self.stdout.write(
f"\nResults:\n"
f" Games enriched: {enriched}\n"
f" Games skipped: {skipped}"
)

View File

@ -28,8 +28,10 @@ class Command(BaseCommand):
commit = options.get("commit", False) commit = options.get("commit", False)
bggeek_id = options.get("bggeek_id") bggeek_id = options.get("bggeek_id")
games = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude( games = (
bggeek_id="" BoardGame.objects.exclude(bggeek_id__isnull=True)
.exclude(bggeek_id="")
.exclude(skip_expansions=True)
) )
if bggeek_id: if bggeek_id:
games = games.filter(bggeek_id=bggeek_id) games = games.filter(bggeek_id=bggeek_id)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-07-04 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0016_boardgamevariant"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="skip_expansions",
field=models.BooleanField(default=False),
),
]

View File

@ -93,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
@classmethod @classmethod
def override_fields(cls) -> dict: def override_fields(cls) -> dict:
from boardgames.widgets import VariantSelectWidget
from scrobbles.forms import NotesDictField from scrobbles.forms import NotesDictField
fields = {} fields = {}
@ -110,7 +111,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
"variant_ids": forms.ModelMultipleChoiceField( "variant_ids": forms.ModelMultipleChoiceField(
queryset=BoardGameVariant.objects.all(), queryset=BoardGameVariant.objects.all(),
required=False, required=False,
widget=forms.SelectMultiple(attrs={"size": 5}), widget=VariantSelectWidget(attrs={"size": 5}),
), ),
"expansion_ids": forms.ModelMultipleChoiceField( "expansion_ids": forms.ModelMultipleChoiceField(
queryset=BoardGame.objects.filter( queryset=BoardGame.objects.filter(
@ -299,6 +300,7 @@ class BoardGame(ScrobblableMixin):
expansion_for_boardgame = models.ForeignKey( expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING "self", **BNULL, on_delete=models.DO_NOTHING
) )
skip_expansions = models.BooleanField(default=False)
@property @property
def subtitle(self) -> str: def subtitle(self) -> str:
@ -349,8 +351,9 @@ class BoardGame(ScrobblableMixin):
data.pop("max_players") data.pop("max_players")
# Pop extra BGG metadata that isn't a model field # Pop extra BGG metadata that isn't a model field
categories = data.pop("categories", [])
families = data.pop("families", [])
data.pop("mechanics", None) data.pop("mechanics", None)
data.pop("categories", None)
data.pop("designers", None) data.pop("designers", None)
data.pop("publishers", None) data.pop("publishers", None)
data.pop("publisher", None) data.pop("publisher", None)
@ -366,13 +369,19 @@ class BoardGame(ScrobblableMixin):
) = BoardGamePublisher.objects.get_or_create(name=publisher_name) ) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
self.save() self.save()
for cat in categories:
self.genre.add(cat.strip())
for fam in families:
self.tags.add(fam.strip())
# Go get cover image if the URL is present # Go get cover image if the URL is present
if cover_url and not self.cover: if cover_url and not self.cover:
self.save_image_from_url(cover_url) self.save_image_from_url(cover_url)
from boardgames.utils import fetch_and_link_expansions from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(self, expansions) if not self.skip_expansions:
fetch_and_link_expansions(self, expansions)
def save_image_from_url(self, url): def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"} headers = {"User-Agent": "Vrobbler 0.11.12"}
@ -414,6 +423,7 @@ class BoardGame(ScrobblableMixin):
mechanics = bgg_data.pop("mechanics", []) mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", []) designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", []) categories = bgg_data.pop("categories", [])
families = bgg_data.pop("families", [])
publishers = bgg_data.pop("publishers", []) publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", []) publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url") or "" cover_url = bgg_data.pop("cover_url") or ""
@ -430,6 +440,18 @@ class BoardGame(ScrobblableMixin):
if publisher: if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher) publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
game.publisher = publisher game.publisher = publisher
skip_expansions = (
game.bggeek_id is not None
and str(game.bggeek_id).isdigit()
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
) or any(
c == "Collectible Card Games" for c in categories
)
if skip_expansions:
game.skip_expansions = True
game.save() game.save()
if designers: if designers:
@ -444,14 +466,20 @@ class BoardGame(ScrobblableMixin):
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name) publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
game.publishers.add(publisher) game.publishers.add(publisher)
if defer_expansions: for cat in categories:
from boardgames.tasks import fetch_board_game_expansions game.genre.add(cat.strip())
for fam in families:
game.tags.add(fam.strip())
fetch_board_game_expansions.delay(game.id, expansions) if expansions and not game.skip_expansions:
else: if defer_expansions:
from boardgames.utils import fetch_and_link_expansions from boardgames.tasks import fetch_board_game_expansions
fetch_and_link_expansions(game, expansions) fetch_board_game_expansions.delay(game.id, expansions)
else:
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, expansions)
return game return game

View File

@ -32,6 +32,7 @@ def lookup_boardgame_from_bgg(
) )
game_dict["mechanics"] = game.mechanics game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories game_dict["categories"] = game.categories
game_dict["families"] = game.families
game_dict["designers"] = game.designers game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers game_dict["publishers"] = game.publishers
if game.publishers: if game.publishers:

View File

@ -0,0 +1,97 @@
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% for option in group_choices %}
{% include option.template_name with widget=option %}
{% endfor %}
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-bs-toggle="modal" data-bs-target="#addVariantModal">
+ Add variant
</button>
<div class="modal fade" id="addVariantModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Variant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newVariantName" class="form-label">Name</label>
<input type="text" class="form-control" id="newVariantName" placeholder="e.g. Map A">
</div>
<div class="mb-3">
<label for="newVariantDescription" class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newVariantDescription">
</div>
<p class="text-danger d-none" id="variantError"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveVariantBtn">Add</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var select = document.getElementById('{{ widget.attrs.id }}');
if (!select) return;
var saveBtn = document.getElementById('saveVariantBtn');
if (!saveBtn) return;
var modalEl = document.getElementById('addVariantModal');
var nameInput = document.getElementById('newVariantName');
var descInput = document.getElementById('newVariantDescription');
var errorEl = document.getElementById('variantError');
saveBtn.addEventListener('click', function() {
var name = nameInput.value.trim();
if (!name) return;
var boardGameId = select.getAttribute('data-board-game-id');
var ajaxUrl = select.getAttribute('data-ajax-url');
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfToken) return;
errorEl.classList.add('d-none');
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken.value,
},
body: new URLSearchParams({
name: name,
description: descInput.value.trim(),
board_game_id: boardGameId,
}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
errorEl.textContent = data.error;
errorEl.classList.remove('d-none');
return;
}
var opt = document.createElement('option');
opt.value = data.id;
opt.textContent = data.name;
opt.selected = true;
select.appendChild(opt);
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
nameInput.value = '';
descInput.value = '';
})
.catch(function() {
errorEl.textContent = 'Failed to create variant';
errorEl.classList.remove('d-none');
});
});
})();
</script>

View File

@ -83,6 +83,7 @@ def _mock_bgg_game(bggeek_id, title, expansions=None):
playingtime = 60 playingtime = 60
mechanics = [] mechanics = []
categories = [] categories = []
families = []
designers = [] designers = []
publishers = [] publishers = []

View File

@ -20,4 +20,9 @@ urlpatterns = [
views.BoardGamePublisherDetailView.as_view(), views.BoardGamePublisherDetailView.as_view(),
name="publisher_detail", name="publisher_detail",
), ),
path(
"variants/ajax-create/",
views.ajax_create_variant,
name="ajax-create-variant",
),
] ]

View File

@ -44,6 +44,13 @@ def fetch_and_link_expansions(
"""Given a board game and a list of expansion dicts (with 'id' and 'name'), """Given a board game and a list of expansion dicts (with 'id' and 'name'),
find or create each expansion BoardGame and link it via expansion_for_boardgame. find or create each expansion BoardGame and link it via expansion_for_boardgame.
""" """
if board_game.skip_expansions:
logger.info(
"Skipping expansion fetch for board game with skip_expansions=True",
extra={"board_game_id": board_game.id, "title": board_game.title},
)
return
for exp_data in expansions_data: for exp_data in expansions_data:
exp_id = exp_data.get("id") exp_id = exp_data.get("id")
if not exp_id: if not exp_id:

View File

@ -1,6 +1,9 @@
import datetime import datetime
from django.http import JsonResponse
from django.utils import timezone from django.utils import timezone
from django.views import generic from django.views import generic
from django.views.decorators.http import require_POST
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
from scrobbles.views import ( from scrobbles.views import (
@ -10,6 +13,38 @@ from scrobbles.views import (
) )
@require_POST
def ajax_create_variant(request):
name = request.POST.get("name", "").strip()
board_game_id = request.POST.get("board_game_id")
description = request.POST.get("description", "").strip()
if not name or not board_game_id:
return JsonResponse({"error": "Name and board game are required"}, status=400)
try:
board_game_id = int(board_game_id)
except (ValueError, TypeError):
return JsonResponse({"error": "Invalid board game"}, status=400)
from boardgames.models import BoardGameVariant
variant = BoardGameVariant.objects.filter(
name__iexact=name,
board_game_id=board_game_id,
).first()
if variant:
return JsonResponse({"id": variant.id, "name": variant.name})
variant = BoardGameVariant.objects.create(
name=name,
board_game_id=board_game_id,
description=description or None,
)
return JsonResponse({"id": variant.id, "name": variant.name})
class BoardGameListView(ScrobbleableListView): class BoardGameListView(ScrobbleableListView):
model = BoardGame model = BoardGame

View File

@ -0,0 +1,9 @@
from django import forms
class VariantSelectWidget(forms.SelectMultiple):
template_name = "boardgames/widgets/variant_select.html"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
return context

View File

@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
def _parse_udisc_datetime(raw: str) -> datetime: def _parse_udisc_datetime(raw: str) -> datetime:
return parse_datetime(raw) dt = parse_datetime(raw)
if timezone.is_naive(dt):
return timezone.make_aware(dt)
return dt
def _resolve_player(name: str, user_id: int) -> Person: def _resolve_player(name: str, user_id: int) -> Person:

View File

@ -0,0 +1,114 @@
from django.contrib import admin
from drinks.models import (
Beer,
BeerProducer,
BeerStyle,
Coffee,
CoffeeRoaster,
Drink,
Wine,
WineGrape,
WineProducer,
WineRegion,
)
from scrobbles.admin import ScrobbleInline
class BeerInline(admin.TabularInline):
model = Beer
extra = 0
@admin.register(BeerStyle)
class BeerStyleAdmin(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(BeerProducer)
class BeerProducerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Beer)
class BeerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
raw_id_fields = ("styles", "producer")
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]
@admin.register(WineGrape)
class WineGrapeAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(WineRegion)
class WineRegionAdmin(admin.ModelAdmin):
search_fields = ("name", "country")
@admin.register(WineProducer)
class WineProducerAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(Wine)
class WineAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
"wine_type",
"vintage",
)
raw_id_fields = ("grapes", "region", "producer")
ordering = ("-created",)
search_fields = ("title",)
list_filter = ("wine_type",)
inlines = [
ScrobbleInline,
]
@admin.register(CoffeeRoaster)
class CoffeeRoasterAdmin(admin.ModelAdmin):
search_fields = ("name",)
@admin.register(Coffee)
class CoffeeAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
"origin",
)
raw_id_fields = ("roaster",)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]
@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,66 @@
from drinks.models import (
Beer,
BeerProducer,
BeerStyle,
Coffee,
CoffeeRoaster,
Wine,
WineGrape,
WineProducer,
WineRegion,
)
from rest_framework import serializers
class BeerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Beer
fields = "__all__"
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerProducer
fields = "__all__"
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerStyle
fields = "__all__"
class WineSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Wine
fields = "__all__"
class WineProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = WineProducer
fields = "__all__"
class WineRegionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = WineRegion
fields = "__all__"
class WineGrapeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = WineGrape
fields = "__all__"
class CoffeeSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Coffee
fields = "__all__"
class CoffeeRoasterSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = CoffeeRoaster
fields = "__all__"

View File

@ -0,0 +1,57 @@
from drinks import models
from drinks.api import serializers
from rest_framework import permissions, viewsets
class BeerViewSet(viewsets.ModelViewSet):
queryset = models.Beer.objects.all().order_by("-created")
serializer_class = serializers.BeerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerProducerViewSet(viewsets.ModelViewSet):
queryset = models.BeerProducer.objects.all().order_by("-created")
serializer_class = serializers.BeerProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerStyleViewSet(viewsets.ModelViewSet):
queryset = models.BeerStyle.objects.all().order_by("-created")
serializer_class = serializers.BeerStyleSerializer
permission_classes = [permissions.IsAuthenticated]
class WineViewSet(viewsets.ModelViewSet):
queryset = models.Wine.objects.all().order_by("-created")
serializer_class = serializers.WineSerializer
permission_classes = [permissions.IsAuthenticated]
class WineProducerViewSet(viewsets.ModelViewSet):
queryset = models.WineProducer.objects.all().order_by("-created")
serializer_class = serializers.WineProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class WineRegionViewSet(viewsets.ModelViewSet):
queryset = models.WineRegion.objects.all().order_by("-created")
serializer_class = serializers.WineRegionSerializer
permission_classes = [permissions.IsAuthenticated]
class WineGrapeViewSet(viewsets.ModelViewSet):
queryset = models.WineGrape.objects.all().order_by("-created")
serializer_class = serializers.WineGrapeSerializer
permission_classes = [permissions.IsAuthenticated]
class CoffeeViewSet(viewsets.ModelViewSet):
queryset = models.Coffee.objects.all().order_by("-created")
serializer_class = serializers.CoffeeSerializer
permission_classes = [permissions.IsAuthenticated]
class CoffeeRoasterViewSet(viewsets.ModelViewSet):
queryset = models.CoffeeRoaster.objects.all().order_by("-created")
serializer_class = serializers.CoffeeRoasterSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -2,4 +2,4 @@ from django.apps import AppConfig
class BeersConfig(AppConfig): class BeersConfig(AppConfig):
name = "beers" name = "drinks"

View File

@ -0,0 +1,130 @@
import logging
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
CELLARTRACKER_URL = "https://www.cellartracker.com/wine.asp?iWine={cellartracker_id}"
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text(strip=True)
except AttributeError:
pass
return title
def get_description_from_soup(soup) -> str:
desc = ""
try:
desc = soup.find("div", class_="wine-notes").get_text(strip=True)
except AttributeError:
pass
return desc
def get_wine_type_from_soup(soup) -> str:
wine_type = ""
try:
wine_type_text = soup.find("td", string=lambda t: t and "Type" in t)
if wine_type_text:
wine_type = (
wine_type_text.find_next_sibling("td").get_text(strip=True).lower()
)
except AttributeError:
pass
return wine_type
def get_rating_from_soup(soup) -> float:
rating = None
try:
rating_text = soup.find("td", string=lambda t: t and "Score" in t)
if rating_text:
rating = float(rating_text.find_next_sibling("td").get_text(strip=True))
except (AttributeError, ValueError):
pass
return rating
def get_grapes_from_soup(soup) -> list[str]:
grapes = []
try:
grape_text = soup.find("td", string=lambda t: t and "Varietal" in t)
if grape_text:
grape_str = grape_text.find_next_sibling("td").get_text(strip=True)
grapes = [g.strip() for g in grape_str.split("/") if g.strip()]
except AttributeError:
pass
return grapes
def get_region_from_soup(soup) -> str:
region = ""
try:
region_text = soup.find("td", string=lambda t: t and "Region" in t)
if region_text:
region = region_text.find_next_sibling("td").get_text(strip=True)
except AttributeError:
pass
return region
def get_producer_from_soup(soup) -> str:
producer = ""
try:
producer_text = soup.find("td", string=lambda t: t and "Producer" in t)
if producer_text:
producer = producer_text.find_next_sibling("td").get_text(strip=True)
except AttributeError:
pass
return producer
def get_vintage_from_soup(soup) -> str:
vintage = ""
try:
vintage_text = soup.find("td", string=lambda t: t and "Vintage" in t)
if vintage_text:
vintage = vintage_text.find_next_sibling("td").get_text(strip=True)
except AttributeError:
pass
return vintage
def get_image_url_from_soup(soup) -> str:
image_url = ""
try:
image_url = soup.find("img", class_="wine-image")["src"]
except (AttributeError, KeyError, TypeError):
pass
return image_url
def get_wine_from_cellartracker_id(cellartracker_id: str) -> dict:
wine_url = CELLARTRACKER_URL.format(cellartracker_id=cellartracker_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(wine_url, headers=headers)
wine_dict = {"cellartracker_id": cellartracker_id}
if response.status_code != 200:
logger.warning(
"Bad response from cellartracker.com", extra={"response": response}
)
return wine_dict
soup = BeautifulSoup(response.text, "html.parser")
wine_dict["title"] = get_title_from_soup(soup)
wine_dict["description"] = get_description_from_soup(soup)
wine_dict["wine_type"] = get_wine_type_from_soup(soup)
wine_dict["cellartracker_rating"] = get_rating_from_soup(soup)
wine_dict["grapes"] = get_grapes_from_soup(soup)
wine_dict["region"] = get_region_from_soup(soup)
wine_dict["producer__name"] = get_producer_from_soup(soup)
wine_dict["vintage"] = get_vintage_from_soup(soup)
wine_dict["image_url"] = get_image_url_from_soup(soup)
return wine_dict

View File

@ -0,0 +1,507 @@
import random
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
from django.utils import timezone
from drinks.models import (
Beer,
BeerProducer,
BeerStyle,
BrewingMethod,
Coffee,
CoffeeRoaster,
DrinkFormat,
RoastLevel,
Wine,
WineGrape,
WineProducer,
WineRegion,
WineType,
)
from scrobbles.models import FavoriteMedia, Scrobble
User = get_user_model()
# Realistic demo data
BEER_PRODUCERS = [
("Trillium Brewing", "Canton, MA"),
("Tree House Brewing", "Charlton, MA"),
("The Alchemist", "Stowe, VT"),
("Hill Farmstead", "Greensboro, VT"),
("Other Half Brewing", "Brooklyn, NY"),
("Equilibrium Brewery", "Middletown, NY"),
("Monkish Brewing", "Torrance, CA"),
("Russian River Brewing", "Santa Rosa, CA"),
("Cellar Maker", "San Francisco, CA"),
("Definitive Brewing", "Portland, ME"),
]
BEER_STYLES = [
"New England IPA",
"Hazy Double IPA",
"West Coast IPA",
"Pale Ale",
"Stout",
"Imperial Stout",
"Porter",
"Saison",
"Pilsner",
"Lager",
"Gose",
"Sour Ale",
"Brown Ale",
"Barleywine",
"Wheat Ale",
]
BEERS = [
("Congress Street", "Trillium Brewing", ["New England IPA"], 6.8, 45),
("Julius", "Tree House Brewing", ["New England IPA"], 6.8, 50),
("Heady Topper", "The Alchemist", ["New England IPA"], 8.0, 70),
("Sip of Sunshine", "Hill Farmstead", ["New England IPA"], 8.0, 60),
("All Green Everything", "Other Half Brewing", ["Hazy Double IPA"], 10.0, 100),
("Moment of Clarity", "Equilibrium Brewery", ["New England IPA"], 8.5, 55),
("Tired Hands Collab", "Monkish Brewing", ["New England IPA"], 7.2, 40),
("Pliny the Elder", "Russian River Brewing", ["West Coast IPA"], 8.0, 100),
("Density", "Cellar Maker", ["Hazy Double IPA"], 8.2, 65),
("Bright Days Ahead", "Definitive Brewing", ["Pale Ale"], 5.5, 35),
("Bright", "Tree House Brewing", ["Pale Ale"], 5.2, 30),
("King Julius", "Tree House Brewing", ["Hazy Double IPA"], 9.0, 80),
("Very Hazy", "Other Half Brewing", ["New England IPA"], 7.5, 45),
("Galaxy Dry Hop", "Equilibrium Brewery", ["New England IPA"], 7.0, 50),
("DDH Foggier Window", "Monkish Brewing", ["Hazy Double IPA"], 8.0, 70),
]
WINE_PRODUCERS = [
("Domaine de la Romanée-Conti", "Burgundy, France"),
("Opus One", "Napa Valley, CA"),
("Penfolds", "South Australia"),
("Cloudy Bay", "Marlborough, New Zealand"),
("Antinori", "Tuscany, Italy"),
("Vega Sicilia", "Ribera del Duero, Spain"),
("Catena Zapata", "Mendoza, Argentina"),
("Concha y Toro", "Casablanca Valley, Chile"),
("Kim Crawford", "Hawke's Bay, New Zealand"),
("Meiomi", "Monterey County, CA"),
("Château d'Esclans", "Provence, France"),
("Veuve Clicquot", "Reims, France"),
("Giacomo Conterno", "Piemonte, Italy"),
("Château de Beaucastel", "Rhône, France"),
("Dr. Loosen", "Mosel, Germany"),
]
WINE_REGIONS = [
("Burgundy", "France"),
("Napa Valley", "USA"),
("Barossa Valley", "Australia"),
("Marlborough", "New Zealand"),
("Tuscany", "Italy"),
("Ribera del Duero", "Spain"),
("Mendoza", "Argentina"),
("Casablanca Valley", "Chile"),
("Hawke's Bay", "New Zealand"),
("Monterey County", "USA"),
("Willamette Valley", "USA"),
("Rioja", "Spain"),
("Piemonte", "Italy"),
("Champagne", "France"),
("Mosel", "Germany"),
("Provence", "France"),
("Rhône", "France"),
("Reims", "France"),
]
WINE_GRAPES = [
"Pinot Noir",
"Cabernet Sauvignon",
"Merlot",
"Syrah",
"Grenache",
"Chardonnay",
"Sauvignon Blanc",
"Riesling",
"Tempranillo",
"Malbec",
"Nebbiolo",
"Sangiovese",
"Zinfandel",
"Viognier",
"Mourvèdre",
]
WINES = [
(
"2021 Pinot Noir",
"Domaine de la Romanée-Conti",
"Burgundy",
"Pinot Noir",
"red",
"2021",
4.5,
),
(
"2019 Cabernet Sauvignon",
"Opus One",
"Napa Valley",
"Cabernet Sauvignon",
"red",
"2019",
4.8,
),
("2020 Shiraz", "Penfolds", "Barossa Valley", "Syrah", "red", "2020", 4.6),
(
"2022 Sauvignon Blanc",
"Cloudy Bay",
"Marlborough",
"Sauvignon Blanc",
"white",
"2022",
4.2,
),
("2020 Tignanello", "Antinori", "Tuscany", "Sangiovese", "red", "2020", 4.7),
(
"2018 Unico",
"Vega Sicilia",
"Ribera del Duero",
"Tempranillo",
"red",
"2018",
4.9,
),
("2021 Malbec", "Catena Zapata", "Mendoza", "Malbec", "red", "2021", 4.4),
(
"2022 Chardonnay",
"Concha y Toro",
"Casablanca Valley",
"Chardonnay",
"white",
"2022",
4.1,
),
(
"2021 Sauvignon Blanc",
"Kim Crawford",
"Hawke's Bay",
"Sauvignon Blanc",
"white",
"2021",
4.0,
),
("2020 Pinot Noir", "Meiomi", "Monterey County", "Pinot Noir", "red", "2020", 4.3),
("2021 Rosé", "Château d'Esclans", "Provence", "Grenache", "rosé", "2021", 4.2),
(
"2020 Champagne Brut",
"Veuve Clicquot",
"Champagne",
"Chardonnay",
"sparkling",
"NV",
4.5,
),
("2019 Barolo", "Giacomo Conterno", "Piemonte", "Nebbiolo", "red", "2019", 4.8),
(
"2020 Châteauneuf-du-Pape",
"Château de Beaucastel",
"Rhône",
"Grenache",
"red",
"2020",
4.6,
),
("2021 Riesling Spätlese", "Dr. Loosen", "Mosel", "Riesling", "white", "2021", 4.3),
]
COFFEE_ROASTERS = [
("Counter Culture", "Durham, NC"),
("Intelligentsia", "Chicago, IL"),
("Stumptown", "Portland, OR"),
("Blue Bottle", "Oakland, CA"),
(
"Onyx Coffee Lab",
"Rogers, AR",
),
("Verve", "Santa Cruz, CA"),
("Heart", "Portland, OR"),
("George Howell", "Boston, MA"),
("SEY", "Brooklyn, NY"),
("Black & White", "Raleigh, NC"),
]
COFFEES = [
("Hologram", "Counter Culture", "Ethiopia", "medium"),
("Big Trouble", "Counter Culture", "Colombia", "medium"),
("Black Cat Espresso", "Intelligentsia", "Brazil", "medium"),
("Hair Bender", "Intelligentsia", "Latin America", "dark"),
("Stumptown Hair Bender", "Stumptown", "Latin America", "medium"),
("Holler Mountain", "Stumptown", "Ethiopia", "medium"),
("Bella Donovan", "Blue Bottle", "Latin America", "medium"),
("Three Africas", "Blue Bottle", "Uganda", "light"),
("Monarch", "Onyx Coffee Lab", "Ethiopia", "light"),
("Geometry", "Onyx Coffee Lab", "Colombia", "medium"),
("Buena Vista", "Verve", "Guatemala", "medium"),
("Farmhouse", "Verve", "Costa Rica", "medium"),
("Decaf", "Heart", "Colombia", "medium"),
("Los Altos", "Heart", "Guatemala", "medium"),
("Hayes Valley Espresso", "George Howell", "Brazil", "medium"),
]
class Command(BaseCommand):
help = "Populate demo data for the drinks app with realistic scrobbles"
def add_arguments(self, parser):
parser.add_argument(
"--user",
type=str,
help="Username to create scrobbles for (default: creates 'demo')",
)
parser.add_argument(
"--days",
type=int,
default=30,
help="Number of days of history to generate (default: 30)",
)
parser.add_argument(
"--clear",
action="store_true",
help="Clear existing demo data before populating",
)
def handle(self, *args, **options):
username = options.get("user") or "demo"
days = options["days"]
clear = options["clear"]
user, _created = User.objects.get_or_create(
username=username,
defaults={"email": f"{username}@demo.local"},
)
if _created:
user.set_password("demo")
user.save()
self.stdout.write(self.style.SUCCESS(f"Created user '{username}'"))
else:
self.stdout.write(f"Using existing user '{username}'")
if clear:
self.stdout.write("Clearing existing demo drink scrobbles...")
Scrobble.objects.filter(
user=user,
media_type__in=["Beer", "Wine", "Coffee"],
).delete()
FavoriteMedia.objects.filter(user=user).delete()
self.stdout.write(self.style.SUCCESS("Cleared demo data"))
self.stdout.write("Creating beer styles...")
style_objects = {}
for style_name in BEER_STYLES:
style, _ = BeerStyle.objects.get_or_create(name=style_name)
style_objects[style_name] = style
self.stdout.write("Creating beer producers...")
producer_objects = {}
for name, location in BEER_PRODUCERS:
producer, _ = BeerProducer.objects.get_or_create(
name=name, defaults={"location": location}
)
producer_objects[name] = producer
self.stdout.write("Creating beers...")
beer_objects = []
for title, producer_name, styles, abv, ibu in BEERS:
beer, _ = Beer.objects.get_or_create(
untappd_id=title.lower().replace(" ", "-"),
defaults={
"title": title,
"abv": abv,
"ibu": ibu,
"producer_id": producer_objects[producer_name].id,
"description": f"A delicious {styles[0]} from {producer_name}.",
"base_run_time_seconds": 900,
"calories": int(abv * 30),
},
)
for style_name in styles:
beer.styles.add(style_objects[style_name])
beer_objects.append(beer)
self.stdout.write("Creating wine regions...")
region_objects = {}
for name, country in WINE_REGIONS:
region, _ = WineRegion.objects.get_or_create(
name=name, defaults={"country": country}
)
region_objects[name] = region
self.stdout.write("Creating wine producers...")
wine_producer_objects = {}
for name, location in WINE_PRODUCERS:
producer, _ = WineProducer.objects.get_or_create(
name=name, defaults={"location": location}
)
wine_producer_objects[name] = producer
self.stdout.write("Creating wine grapes...")
grape_objects = {}
for name in WINE_GRAPES:
grape, _ = WineGrape.objects.get_or_create(name=name)
grape_objects[name] = grape
self.stdout.write("Creating wines...")
wine_objects = []
for (
title,
producer_name,
region_name,
grape_name,
wine_type,
vintage,
rating,
) in WINES:
wine, _ = Wine.objects.get_or_create(
vivino_id=title.lower().replace(" ", "-"),
defaults={
"title": title,
"wine_type": wine_type,
"vintage": vintage,
"vivino_rating": rating,
"producer_id": wine_producer_objects[producer_name].id,
"region_id": region_objects[region_name].id,
"description": f"A fine {wine_type} from {producer_name}.",
"base_run_time_seconds": 3600,
"calories": 125,
},
)
wine.grapes.add(grape_objects[grape_name])
wine_objects.append(wine)
self.stdout.write("Creating coffee roasters...")
roaster_objects = {}
for name, location in COFFEE_ROASTERS:
roaster, _ = CoffeeRoaster.objects.get_or_create(
name=name, defaults={"location": location}
)
roaster_objects[name] = roaster
self.stdout.write("Creating coffees...")
coffee_objects = []
for title, roaster_name, origin, _roast_level in COFFEES:
coffee, _ = Coffee.objects.get_or_create(
roastdb_id=title.lower().replace(" ", "-"),
defaults={
"title": title,
"origin": origin,
"roaster_id": roaster_objects[roaster_name].id,
"description": f"Specialty coffee from {roaster_name}.",
"base_run_time_seconds": 1200,
"calories": 5,
},
)
coffee_objects.append(coffee)
self.stdout.write("Creating scrobbles...")
now = timezone.now()
scrobbles_created = 0
for day_offset in range(days):
day = now - timedelta(days=day_offset)
# Random number of drinks per day (1-5)
num_drinks = random.randint(1, 5)
for _ in range(num_drinks):
# Pick a random drink type weighted toward beers
drink_type = random.choices(
["beer", "wine", "coffee"], weights=[50, 25, 25]
)[0]
if drink_type == "beer":
media = random.choice(beer_objects)
media_type = Scrobble.MediaType.BEER
source = random.choice(["Untappd", "Vrobbler"])
log_data = {
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
"format": random.choice(
[
DrinkFormat.ALUMINUM_CAN,
DrinkFormat.GLASS_BOTTLE,
DrinkFormat.DRAFT,
]
),
"size_ml": random.choice([330, 355, 473, 500]),
}
elif drink_type == "wine":
media = random.choice(wine_objects)
media_type = Scrobble.MediaType.WINE
source = random.choice(["Vivino", "Vrobbler"])
log_data = {
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
"format": random.choice(
[
DrinkFormat.GLASS_BOTTLE,
DrinkFormat.BY_THE_GLASS,
DrinkFormat.BOX,
]
),
"size_ml": random.choice([150, 375, 750]),
}
else:
media = random.choice(coffee_objects)
media_type = Scrobble.MediaType.COFFEE
source = random.choice(["RoastDB", "Vrobbler"])
log_data = {
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
"format": random.choice(
[
DrinkFormat.GLASS_BOTTLE,
DrinkFormat.ALUMINUM_CAN,
DrinkFormat.BY_THE_GLASS,
]
),
"size_ml": random.choice([240, 355, 473]),
"roast_level": random.choice(RoastLevel.values),
"brewing_method": random.choice(BrewingMethod.values),
"single_origin_or_blend": random.choice(
["single origin", "blend"]
),
}
# Random time during the day
hour = random.randint(7, 22)
minute = random.randint(0, 59)
timestamp = day.replace(
hour=hour, minute=minute, second=0, microsecond=0
)
# Use the FK field name from the media class name
fk_field = f"{media.__class__.__name__[0].lower()}{media.__class__.__name__[1:]}"
# Simpler: just lowercase the class name
fk_field = media.__class__.__name__.lower()
scrobble = Scrobble.objects.create(
user=user,
media_type=media_type,
timestamp=timestamp,
playback_position_seconds=media.base_run_time_seconds or 900,
played_to_completion=True,
source=source,
log={"drink_log": log_data},
**{fk_field: media},
)
scrobbles_created += 1
# Some scrobbles get favorited
if random.random() < 0.15:
FavoriteMedia.toggle(media, user)
self.stdout.write(
self.style.SUCCESS(
f"\nDone! Created:\n"
f" {len(beer_objects)} beers\n"
f" {len(wine_objects)} wines\n"
f" {len(coffee_objects)} coffees\n"
f" {scrobbles_created} scrobbles over {days} days\n"
f" User: {username} / demo"
)
)

View File

@ -1,9 +1,10 @@
# Generated by Django 4.2.16 on 2024-10-22 21:26 # Generated by Django 4.2.16 on 2024-10-22 21:26
from django.db import migrations, models import uuid
import django_extensions.db.fields import django_extensions.db.fields
import taggit.managers import taggit.managers
import uuid from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -46,6 +47,7 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"db_table": "beers_beerproducer",
"get_latest_by": "modified", "get_latest_by": "modified",
"abstract": False, "abstract": False,
}, },
@ -127,6 +129,7 @@ class Migration(migrations.Migration):
), ),
], ],
options={ options={
"db_table": "beers_beer",
"abstract": False, "abstract": False,
}, },
), ),

View File

@ -1,13 +1,13 @@
# Generated by Django 4.2.16 on 2024-10-22 21:34 # Generated by Django 4.2.16 on 2024-10-22 21:34
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("beers", "0001_initial"), ("drinks", "0001_initial"),
] ]
operations = [ operations = [
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.DO_NOTHING, on_delete=django.db.models.deletion.DO_NOTHING,
to="beers.beerproducer", to="drinks.beerproducer",
), ),
), ),
migrations.AddField( migrations.AddField(

View File

@ -1,13 +1,13 @@
# Generated by Django 4.2.16 on 2024-10-22 21:47 # Generated by Django 4.2.16 on 2024-10-22 21:47
from django.db import migrations, models
import django_extensions.db.fields import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"), ("drinks", "0002_beer_beeradvocate_image_beer_producer_and_more"),
] ]
operations = [ operations = [
@ -38,6 +38,7 @@ class Migration(migrations.Migration):
("description", models.TextField(blank=True, null=True)), ("description", models.TextField(blank=True, null=True)),
], ],
options={ options={
"db_table": "beers_beerstyle",
"get_latest_by": "modified", "get_latest_by": "modified",
"abstract": False, "abstract": False,
}, },
@ -70,6 +71,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="beer", model_name="beer",
name="styles", name="styles",
field=models.ManyToManyField(to="beers.beerstyle"), field=models.ManyToManyField(to="drinks.beerstyle"),
), ),
] ]

View File

@ -1,13 +1,14 @@
# Generated by Django 4.2.16 on 2024-10-22 21:52 # Generated by Django 4.2.16 on 2024-10-22 21:52
from django.db import migrations, models
import uuid import uuid
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"), ("drinks", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
] ]
operations = [ operations = [
@ -41,7 +42,7 @@ class Migration(migrations.Migration):
model_name="beer", model_name="beer",
name="styles", name="styles",
field=models.ManyToManyField( field=models.ManyToManyField(
related_name="styles", to="beers.beerstyle" related_name="styles", to="drinks.beerstyle"
), ),
), ),
] ]

View File

@ -7,7 +7,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
( (
"beers", "drinks",
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more", "0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
), ),
] ]

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('beers', '0005_alter_beer_run_time_seconds'), ('drinks', '0005_alter_beer_run_time_seconds'),
] ]
operations = [ operations = [

View File

@ -1,14 +1,14 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25 # Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers import taggit.managers
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"), ("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("beers", "0006_remove_beer_run_time_seconds_and_more"), ("drinks", "0006_remove_beer_run_time_seconds_and_more"),
] ]
operations = [ operations = [

View File

@ -1,14 +1,14 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49 # Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers import taggit.managers
from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("scrobbles", "0075_add_channel_scrobble"), ("scrobbles", "0075_add_channel_scrobble"),
("beers", "0007_beer_tags"), ("drinks", "0007_beer_tags"),
] ]
operations = [ operations = [

View File

@ -0,0 +1,400 @@
# Generated by Django 4.2.29 on 2026-07-13 14:33
import uuid
import django.db.models.deletion
import django_extensions.db.fields
import taggit.managers
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("drinks", "0008_alter_beer_genre"),
]
operations = [
migrations.CreateModel(
name="CoffeeRoaster",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
("location", models.CharField(blank=True, max_length=255, null=True)),
("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="WineGrape",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("name", models.CharField(max_length=255)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="WineProducer",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
("location", models.CharField(blank=True, max_length=255, null=True)),
("vivino_id", models.CharField(blank=True, max_length=255, null=True)),
(
"cellartracker_id",
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="WineRegion",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("name", models.CharField(max_length=255)),
("country", models.CharField(blank=True, max_length=255, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.AddField(
model_name="beer",
name="calories",
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="beer",
name="producer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.beerproducer",
),
),
migrations.AlterField(
model_name="beer",
name="styles",
field=models.ManyToManyField(related_name="styles", to="drinks.beerstyle"),
),
migrations.AlterField(
model_name="beer",
name="untappd_image",
field=models.ImageField(blank=True, null=True, upload_to="drinks/untappd/"),
),
migrations.CreateModel(
name="Wine",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
("calories", models.PositiveIntegerField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"wine_type",
models.CharField(
blank=True,
choices=[
("red", "Red"),
("white", "White"),
("rosé", "Rosé"),
("sparkling", "Sparkling"),
("dessert", "Dessert"),
("fortified", "Fortified"),
("orange", "Orange"),
],
max_length=20,
null=True,
),
),
("vintage", models.CharField(blank=True, max_length=255, null=True)),
("vivino_id", models.CharField(blank=True, max_length=255, null=True)),
("vivino_rating", models.FloatField(blank=True, null=True)),
(
"vivino_image",
models.ImageField(
blank=True, null=True, upload_to="drinks/vivino/"
),
),
(
"cellartracker_id",
models.CharField(blank=True, max_length=255, null=True),
),
("cellartracker_rating", models.FloatField(blank=True, null=True)),
(
"cellartracker_image",
models.ImageField(
blank=True, null=True, upload_to="drinks/cellartracker/"
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"grapes",
models.ManyToManyField(
blank=True, related_name="wines", to="drinks.winegrape"
),
),
(
"producer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.wineproducer",
),
),
(
"region",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.wineregion",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="Coffee",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
("calories", models.PositiveIntegerField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
("origin", models.CharField(blank=True, max_length=255, null=True)),
("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),
(
"roastdb_image",
models.ImageField(
blank=True, null=True, upload_to="drinks/roastdb/"
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"roaster",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.coffeeroaster",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

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

@ -0,0 +1,590 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
import requests
from django.apps import apps
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from drinks.cellartracker import get_wine_from_cellartracker_id
from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb
from drinks.untappd import get_beer_from_untappd_id
from drinks.vivino import get_wine_from_vivino_id
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class WineType(models.TextChoices):
RED = "red", "Red"
WHITE = "white", "White"
ROSE = "rosé", "Rosé"
SPARKLING = "sparkling", "Sparkling"
DESSERT = "dessert", "Dessert"
FORTIFIED = "fortified", "Fortified"
ORANGE = "orange", "Orange"
class DrinkFormat(models.TextChoices):
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
KEG = "keg", "Keg"
BY_THE_GLASS = "by_the_glass", "By the Glass"
DRAFT = "draft", "Draft"
GROWLER = "growler", "Growler"
BOX = "box", "Box"
class RoastLevel(models.TextChoices):
LIGHT = "light", "Light"
MEDIUM = "medium", "Medium"
DARK = "dark", "Dark"
class BrewingMethod(models.TextChoices):
POUROVER = "pourover", "Pour-over"
DRIP = "drip", "Drip"
FRENCH_PRESS = "french_press", "French Press"
ESPRESSO = "espresso", "Espresso"
AEROPRESS = "aeropress", "AeroPress"
KEURIG = "keurig", "Keurig"
NESCAPRESSO = "nespresso", "Nespresso"
COLD_BREW = "cold_brew", "Cold Brew"
MOKA_POT = "moka_pot", "Moka Pot"
SIPHON = "siphon", "Siphon"
TURKISH = "turkish", "Turkish"
CHEMEX = "chemex", "Chemex"
ML_PER_OZ = 29.5735
@dataclass
class DrinkLogData(BaseLogData):
rating: Optional[str] = None
format: Optional[str] = None
size_ml: Optional[int] = None
@property
def size_oz(self) -> Optional[float]:
if self.size_ml:
return round(float(self.size_ml) / ML_PER_OZ, 1)
return None
@property
def format_display(self) -> str:
if self.format:
return DrinkFormat(self.format).label
return ""
@classmethod
def override_fields(cls) -> dict:
from django import forms
return {
"format": forms.ChoiceField(
choices=[("", "---------")] + list(DrinkFormat.choices),
required=False,
),
}
@dataclass
class BeerLogData(DrinkLogData):
pass
@dataclass
class WineLogData(DrinkLogData):
pass
@dataclass
class CoffeeLogData(DrinkLogData):
roast_level: Optional[str] = None
brewing_method: Optional[str] = None
single_origin_or_blend: Optional[str] = None
@property
def roast_level_display(self) -> str:
if self.roast_level:
return RoastLevel(self.roast_level).label
return ""
@property
def brewing_method_display(self) -> str:
if self.brewing_method:
return BrewingMethod(self.brewing_method).label
return ""
@classmethod
def override_fields(cls) -> dict:
from django import forms
fields = super().override_fields()
fields.update(
{
"roast_level": forms.ChoiceField(
choices=[("", "---------")] + list(RoastLevel.choices),
required=False,
),
"brewing_method": forms.ChoiceField(
choices=[("", "---------")] + list(BrewingMethod.choices),
required=False,
),
}
)
return fields
class Drink(ScrobblableMixin):
calories = models.PositiveIntegerField(**BNULL)
is_alcoholic = models.BooleanField(default=False)
class Meta:
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 logdata_cls(self):
return DrinkLogData
class BeerStyle(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
def __str__(self):
return self.name
class Meta:
db_table = "beers_beerstyle"
class BeerProducer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
untappd_id = models.CharField(max_length=255, **BNULL)
def find_or_create(cls, title: str) -> "BeerProducer":
return cls.objects.filter(title=title).first()
def __str__(self):
return self.name
class Meta:
db_table = "beers_beerproducer"
class Beer(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)
untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)
untappd_image_small = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
untappd_image_medium = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
untappd_id = models.CharField(max_length=255, **BNULL)
untappd_rating = models.FloatField(**BNULL)
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
def get_absolute_url(self) -> str:
return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} by {self.producer}"
@property
def subtitle(self):
return self.producer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="beer")
@property
def beeradvocate_link(self) -> str:
link = ""
if self.producer and self.beeradvocate_id:
if self.beeradvocate_id:
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
return link
@property
def untappd_link(self) -> str:
link = ""
if self.untappd_id:
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.untappd_image:
url = self.untappd_image.url
return url
@property
def logdata_cls(self):
return BeerLogData
@classmethod
def find_or_create(cls, untappd_id: str) -> "Beer":
beer = cls.objects.filter(untappd_id=untappd_id).first()
if not beer:
beer_dict = get_beer_from_untappd_id(untappd_id)
producer_dict = {}
style_ids = []
for key in list(beer_dict.keys()):
if "producer__" in key:
pkey = key.replace("producer__", "")
producer_dict[pkey] = beer_dict.pop(key)
if "styles" in key:
for style in beer_dict.pop("styles"):
style_inst, created = BeerStyle.objects.get_or_create(
name=style
)
style_ids.append(style_inst.id)
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
beer_dict["producer_id"] = producer.id
beer = Beer.objects.create(**beer_dict)
for style_id in style_ids:
beer.styles.add(style_id)
return beer
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
"-timestamp"
)
class Meta:
db_table = "beers_beer"
# --- Wine models ---
class WineGrape(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
def __str__(self):
return self.name
class WineRegion(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
country = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
class WineProducer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
vivino_id = models.CharField(max_length=255, **BNULL)
cellartracker_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
class Wine(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)
producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)
vintage = models.CharField(max_length=255, **BNULL)
vivino_id = models.CharField(max_length=255, **BNULL)
vivino_rating = models.FloatField(**BNULL)
vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)
vivino_image_small = ImageSpecField(
source="vivino_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
vivino_image_medium = ImageSpecField(
source="vivino_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
cellartracker_id = models.CharField(max_length=255, **BNULL)
cellartracker_rating = models.FloatField(**BNULL)
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)
def get_absolute_url(self) -> str:
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} by {self.producer}"
@property
def subtitle(self):
return self.producer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="wine")
@property
def vivino_link(self) -> str:
if self.vivino_id:
return f"https://www.vivino.com/{self.vivino_id}"
return ""
@property
def cellartracker_link(self) -> str:
if self.cellartracker_id:
return (
f"https://www.cellartracker.com/wine.asp?iWine={self.cellartracker_id}"
)
return ""
@property
def primary_image_url(self) -> str:
if self.vivino_image:
return self.vivino_image.url
if self.cellartracker_image:
return self.cellartracker_image.url
return ""
@property
def logdata_cls(self):
return WineLogData
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.vivino_image.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(
cls, vivino_id: str = None, cellartracker_id: str = None
) -> "Wine":
wine = None
if vivino_id:
wine = cls.objects.filter(vivino_id=vivino_id).first()
if not wine and cellartracker_id:
wine = cls.objects.filter(cellartracker_id=cellartracker_id).first()
if wine:
return wine
wine_dict = None
if vivino_id:
wine_dict = get_wine_from_vivino_id(vivino_id)
elif cellartracker_id:
wine_dict = get_wine_from_cellartracker_id(cellartracker_id)
if not wine_dict or not wine_dict.get("title"):
return None
producer_name = wine_dict.pop("producer__name", "")
region_name = wine_dict.pop("region", "")
grape_names = wine_dict.pop("grapes", [])
image_url = wine_dict.pop("image_url", "")
producer = None
if producer_name:
producer, _ = WineProducer.objects.get_or_create(name=producer_name)
region = None
if region_name:
region, _ = WineRegion.objects.get_or_create(name=region_name)
wine = cls.objects.create(
title=wine_dict.get("title", ""),
description=wine_dict.get("description", ""),
wine_type=wine_dict.get("wine_type", ""),
vintage=wine_dict.get("vintage", ""),
vivino_id=wine_dict.get("vivino_id", vivino_id),
vivino_rating=wine_dict.get("vivino_rating"),
cellartracker_id=wine_dict.get("cellartracker_id", cellartracker_id),
cellartracker_rating=wine_dict.get("cellartracker_rating"),
producer=producer,
region=region,
)
for grape_name in grape_names:
grape, _ = WineGrape.objects.get_or_create(name=grape_name)
wine.grapes.add(grape)
if image_url:
try:
wine.save_image_from_url(image_url)
except Exception:
pass
return wine
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(
"-timestamp"
)
# --- Coffee models ---
class CoffeeRoaster(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
roastdb_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.name
class Coffee(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)
roastdb_image = models.ImageField(upload_to="drinks/roastdb/", **BNULL)
roastdb_image_small = ImageSpecField(
source="roastdb_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
roastdb_image_medium = ImageSpecField(
source="roastdb_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def get_absolute_url(self) -> str:
return reverse("drinks:coffee_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} by {self.roaster}"
@property
def subtitle(self):
return self.roaster.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="coffee")
@property
def primary_image_url(self) -> str:
if self.roastdb_image:
return self.roastdb_image.url
return ""
@property
def logdata_cls(self):
return CoffeeLogData
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.roastdb_image.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(cls, roastdb_id: str = None, slug: str = None) -> "Coffee":
coffee = None
if roastdb_id:
coffee = cls.objects.filter(roastdb_id=roastdb_id).first()
if not coffee and slug:
coffee = cls.objects.filter(roastdb_id=slug).first()
if coffee:
return coffee
coffee_dict = None
if slug:
coffee_dict = get_coffee_from_slug(slug)
elif roastdb_id:
coffee_dict = get_coffee_from_slug(roastdb_id)
if not coffee_dict or not coffee_dict.get("title"):
return None
roaster_name = coffee_dict.pop("roaster__name", "")
image_url = coffee_dict.pop("image_url", "")
roaster = None
if roaster_name:
roaster, _ = CoffeeRoaster.objects.get_or_create(name=roaster_name)
coffee = cls.objects.create(
title=coffee_dict.get("title", ""),
description=coffee_dict.get("description", ""),
origin=coffee_dict.get("origin", ""),
roastdb_id=slug or roastdb_id,
roaster=roaster,
)
if image_url:
try:
coffee.save_image_from_url(image_url)
except Exception:
pass
return coffee
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, coffee=self).order_by(
"-timestamp"
)

View File

@ -0,0 +1,89 @@
import json
import logging
import re
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
ROASTDB_URL = "https://www.roastdb.com/beans/{slug}"
ROASTDB_SEARCH_URL = "https://www.roastdb.com/explore?q={query}"
def get_coffee_from_slug(slug: str) -> dict:
coffee_url = ROASTDB_URL.format(slug=slug)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(coffee_url, headers=headers)
coffee_dict = {"slug": slug}
if response.status_code != 200:
logger.warning("Bad response from roastdb.com", extra={"response": response})
return coffee_dict
soup = BeautifulSoup(response.text, "html.parser")
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string)
if data.get("@type") == "Product":
coffee_dict["title"] = data.get("name", "")
coffee_dict["description"] = data.get("description", "")
coffee_dict["image_url"] = data.get("image", "")
brand = data.get("brand", {})
coffee_dict["roaster__name"] = brand.get("name", "")
origin = data.get("countryOfOrigin", {})
coffee_dict["origin"] = origin.get("name", "")
break
except (json.JSONDecodeError, AttributeError):
continue
if not coffee_dict.get("title"):
bean_data = _extract_initial_bean(soup)
if bean_data:
coffee_dict["title"] = bean_data.get("name", "")
coffee_dict["description"] = bean_data.get("description", "")
coffee_dict["origin"] = bean_data.get("origin_country", "")
coffee_dict["roaster__name"] = bean_data.get("roaster_name", "")
image_urls = bean_data.get("image_urls", [])
if image_urls:
coffee_dict["image_url"] = image_urls[0]
return coffee_dict
def _extract_initial_bean(soup) -> dict | None:
for script in soup.find_all("script"):
if script.string and "initialBean" in (script.string or ""):
match = re.search(r'"initialBean"\s*:\s*(\{.*?\})\s*\}', script.string)
if match:
try:
return json.loads(match.group(1))
except json.JSONDecodeError:
pass
return None
def search_coffee_on_roastdb(query: str) -> list[dict]:
search_url = ROASTDB_SEARCH_URL.format(query=query)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(search_url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from roastdb.com search", extra={"response": response}
)
return []
soup = BeautifulSoup(response.text, "html.parser")
results = []
for link in soup.find_all("a", href=True):
href = link["href"]
if href.startswith("/beans/"):
slug = href.split("/beans/", 1)[1].rstrip("/")
title = link.get_text(strip=True)
if slug and title:
results.append({"slug": slug, "title": title})
return results

View File

@ -0,0 +1,37 @@
from django.urls import path
from drinks import views
app_name = "drinks"
urlpatterns = [
path("beers/", views.BeerListView.as_view(), name="beer_list"),
path(
"beers/<slug:slug>/",
views.BeerDetailView.as_view(),
name="beer_detail",
),
path("wines/", views.WineListView.as_view(), name="wine_list"),
path(
"wines/<slug:slug>/",
views.WineDetailView.as_view(),
name="wine_detail",
),
path("coffees/", views.CoffeeListView.as_view(), name="coffee_list"),
path(
"coffees/<slug:slug>/",
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

@ -0,0 +1,58 @@
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
class BeerListView(ScrobbleableListView):
model = Beer
class BeerDetailView(ScrobbleableDetailView):
model = Beer
class WineListView(ScrobbleableListView):
model = Wine
class WineDetailView(ScrobbleableDetailView):
model = Wine
class CoffeeListView(ScrobbleableListView):
model = Coffee
class CoffeeDetailView(ScrobbleableDetailView):
model = Coffee
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

@ -0,0 +1,131 @@
import logging
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
VIVINO_URL = "https://www.vivino.com/{vivino_id}"
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("span", class_="winePageHeader__title").get_text(strip=True)
except AttributeError:
pass
return title
def get_description_from_soup(soup) -> str:
desc = ""
try:
desc = soup.find("div", class_="winePageHeader__description").get_text(
strip=True
)
except AttributeError:
pass
return desc
def get_wine_type_from_soup(soup) -> str:
wine_type = ""
try:
wine_type = (
soup.find("span", class_="winePageHeader__wine-type")
.get_text(strip=True)
.lower()
)
except AttributeError:
pass
return wine_type
def get_rating_from_soup(soup) -> float:
rating = None
try:
rating = float(
soup.find("div", class_="winePageHeader__ratingValue").get_text(strip=True)
)
except (AttributeError, ValueError):
pass
return rating
def get_grapes_from_soup(soup) -> list[str]:
grapes = []
try:
grape_elements = soup.find_all("a", class_="wineLink")
grapes = [
g.get_text(strip=True) for g in grape_elements if g.get_text(strip=True)
]
except AttributeError:
pass
return grapes
def get_region_from_soup(soup) -> str:
region = ""
try:
region = soup.find("span", class_="winePageHeader__region").get_text(strip=True)
except AttributeError:
pass
return region
def get_producer_from_soup(soup) -> str:
producer = ""
try:
producer = soup.find("span", class_="winePageHeader__producer").get_text(
strip=True
)
except AttributeError:
pass
return producer
def get_vintage_from_soup(soup) -> str:
vintage = ""
try:
vintage = soup.find("span", class_="winePageHeader__vintage").get_text(
strip=True
)
except AttributeError:
pass
return vintage
def get_image_url_from_soup(soup) -> str:
image_url = ""
try:
image_url = soup.find("img", class_="winePageHeader__image")["src"]
except (AttributeError, KeyError, TypeError):
pass
return image_url
def get_wine_from_vivino_id(vivino_id: str) -> dict:
wine_url = VIVINO_URL.format(vivino_id=vivino_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(wine_url, headers=headers)
wine_dict = {"vivino_id": vivino_id}
if response.status_code != 200:
logger.warning(
"Bad response from vivino.com",
extra={"status_code": response.status_code, "vivino_id": vivino_id},
)
return wine_dict
soup = BeautifulSoup(response.text, "html.parser")
wine_dict["title"] = get_title_from_soup(soup)
wine_dict["description"] = get_description_from_soup(soup)
wine_dict["wine_type"] = get_wine_type_from_soup(soup)
wine_dict["vivino_rating"] = get_rating_from_soup(soup)
wine_dict["grapes"] = get_grapes_from_soup(soup)
wine_dict["region"] = get_region_from_soup(soup)
wine_dict["producer__name"] = get_producer_from_soup(soup)
wine_dict["vintage"] = get_vintage_from_soup(soup)
wine_dict["image_url"] = get_image_url_from_soup(soup)
return wine_dict

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from profiles.models import UserProfile from profiles.models import UserProfile
from scrobbles.constants import Visibility from scrobbles.constants import Visibility
from scrobbles.models import Scrobble from scrobbles.models import Scrobble
@ -42,6 +41,10 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit", "home_scrobble_limit",
"live_now_playing", "live_now_playing",
"weigh_in_units", "weigh_in_units",
"volume_unit",
"default_water_format",
"default_water_size",
"water_reminder_enabled",
"trends_disabled", "trends_disabled",
] ]
widgets = { widgets = {
@ -51,9 +54,7 @@ class UserProfileForm(forms.ModelForm):
} }
MEDIA_TYPE_LABELS = { MEDIA_TYPE_LABELS = {mt.value: mt.label for mt in Scrobble.MediaType}
mt.value: mt.label for mt in Scrobble.MediaType
}
INHERIT = "" INHERIT = ""

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.29 on 2026-07-13 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0040_userprofile_trends_disabled"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="volume_unit",
field=models.CharField(
choices=[("metric", "Metric (mL, L)"), ("imperial", "Imperial (oz)")],
default="metric",
max_length=16,
),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.29 on 2026-07-14 00:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0041_userprofile_volume_unit"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_water_format",
field=models.CharField(
choices=[
("aluminum_can", "Aluminum Can"),
("glass_bottle", "Glass Bottle"),
("plastic_bottle", "Plastic Bottle"),
("metal_bottle", "Metal Bottle"),
("keg", "Keg"),
("by_the_glass", "By the Glass"),
("draft", "Draft"),
("growler", "Growler"),
("box", "Box"),
],
default="by_the_glass",
max_length=20,
),
),
migrations.AddField(
model_name="userprofile",
name="default_water_size",
field=models.PositiveIntegerField(
default=250, help_text="Default water scrobble size in mL"
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-07-14 03:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0042_add_water_defaults"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="water_reminder_enabled",
field=models.BooleanField(default=False),
),
]

View File

@ -1,14 +1,16 @@
from zoneinfo import ZoneInfo
import pendulum
from django.utils import timezone
import logging import logging
from zoneinfo import ZoneInfo
import pendulum
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils import timezone
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES from profiles.constants import PRETTY_TIMEZONE_CHOICES
VISIBILITY_CHOICES = ( VISIBILITY_CHOICES = (
("public", "Public"), ("public", "Public"),
("shared", "Shared"), ("shared", "Shared"),
@ -26,6 +28,23 @@ class WeighUnit(models.TextChoices):
IMPERIAL = "imperial", "Imperial (lbs, in)" IMPERIAL = "imperial", "Imperial (lbs, in)"
class VolumeUnit(models.TextChoices):
METRIC = "metric", "Metric (mL, L)"
IMPERIAL = "imperial", "Imperial (oz)"
class WaterFormat(models.TextChoices):
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
KEG = "keg", "Keg"
BY_THE_GLASS = "by_the_glass", "By the Glass"
DRAFT = "draft", "Draft"
GROWLER = "growler", "Growler"
BOX = "box", "Box"
class UserProfile(TimeStampedModel): class UserProfile(TimeStampedModel):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile") user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
timezone = models.CharField( timezone = models.CharField(
@ -71,11 +90,13 @@ class UserProfile(TimeStampedModel):
mopidy_api_url = models.CharField(max_length=255, **BNULL) mopidy_api_url = models.CharField(max_length=255, **BNULL)
favorites_mopidy_playlist = models.CharField( favorites_mopidy_playlist = models.CharField(
max_length=255, **BNULL, max_length=255,
**BNULL,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8", help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
) )
monthly_mopidy_playlist_pattern = models.CharField( monthly_mopidy_playlist_pattern = models.CharField(
max_length=255, **BNULL, max_length=255,
**BNULL,
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')", help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
) )
@ -93,7 +114,7 @@ class UserProfile(TimeStampedModel):
media_type_visibility = models.JSONField( media_type_visibility = models.JSONField(
default=dict, default=dict,
blank=True, blank=True,
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}", help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
) )
home_scrobble_limit = models.IntegerField(default=20) home_scrobble_limit = models.IntegerField(default=20)
@ -106,6 +127,25 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC, default=WeighUnit.METRIC,
) )
volume_unit = models.CharField(
max_length=16,
choices=VolumeUnit.choices,
default=VolumeUnit.METRIC,
)
default_water_format = models.CharField(
max_length=20,
choices=WaterFormat.choices,
default=WaterFormat.BY_THE_GLASS,
)
default_water_size = models.PositiveIntegerField(
default=250,
help_text="Default water scrobble size in mL",
)
water_reminder_enabled = models.BooleanField(default=False)
trends_disabled = models.BooleanField(default=False) trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField( disabled_trends = models.JSONField(
@ -153,7 +193,11 @@ class UserProfile(TimeStampedModel):
from django.conf import settings from django.conf import settings
server_tz = ZoneInfo(settings.TIME_ZONE) server_tz = ZoneInfo(settings.TIME_ZONE)
ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz) ref_dt = (
timestamp
if timestamp.tzinfo is not None
else timestamp.replace(tzinfo=server_tz)
)
timezone = self.tzinfo timezone = self.tzinfo
if self.timezone_change_log: if self.timezone_change_log:

View File

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from scrobbles.mixins import Genre
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
BGStatsImport, BGStatsImport,
@ -14,7 +14,6 @@ from scrobbles.models import (
TrailGPXImport, TrailGPXImport,
UDiscCSVImport, UDiscCSVImport,
) )
from scrobbles.mixins import Genre
class ScrobbleInline(admin.TabularInline): class ScrobbleInline(admin.TabularInline):
@ -182,6 +181,8 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
"brick_set", "brick_set",
"trail", "trail",
"beer", "beer",
"wine",
"coffee",
"web_page", "web_page",
"life_event", "life_event",
"birding_location", "birding_location",

View File

@ -1,12 +1,16 @@
from django.db import models
from enum import Enum from enum import Enum
from django.db import models
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"] JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
class Visibility(models.TextChoices): class Visibility(models.TextChoices):
PUBLIC = "public", "Public" PUBLIC = "public", "Public"
SHARED = "shared", "Shared" SHARED = "shared", "Shared"
PRIVATE = "private", "Private" PRIVATE = "private", "Private"
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"] JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = { LONG_PLAY_MEDIA = {
@ -23,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 = {
@ -35,7 +40,10 @@ PLAY_AGAIN_MEDIA = {
"boardgames": "BoardGame", "boardgames": "BoardGame",
"locations": "GeoLocation", "locations": "GeoLocation",
"trails": "Trail", "trails": "Trail",
"beers": "Beer", "drinks": "Beer",
"drinks": "Wine",
"drinks": "Coffee",
"drinks": "Drink",
"puzzles": "Puzzle", "puzzles": "Puzzle",
"foods": "Food", "foods": "Food",
"tasks": "Task", "tasks": "Task",
@ -48,6 +56,8 @@ PLAY_AGAIN_MEDIA = {
"discgolf": "DiscGolfCourse", "discgolf": "DiscGolfCourse",
} }
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
MEDIA_END_PADDING_SECONDS = { MEDIA_END_PADDING_SECONDS = {
"Video": 3600, # 60 min "Video": 3600, # 60 min
} }
@ -59,6 +69,8 @@ SCROBBLE_CONTENT_URLS = {
"-s": ["https://www.thesportsdb.com/event/"], "-s": ["https://www.thesportsdb.com/event/"],
"-g": ["https://boardgamegeek.com/boardgame/"], "-g": ["https://boardgamegeek.com/boardgame/"],
"-u": ["https://untappd.com/"], "-u": ["https://untappd.com/"],
"-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],
"-co": ["https://roastdb.com/"],
"-b": ["https://www.amazon.com/"], "-b": ["https://www.amazon.com/"],
"-t": ["https://app.todoist.com/app/task/{id}"], "-t": ["https://app.todoist.com/app/task/{id}"],
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="], "-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
@ -77,12 +89,15 @@ MANUAL_SCROBBLE_FNS = {
"-i": "manual_scrobble_video", "-i": "manual_scrobble_video",
"-g": "manual_scrobble_board_game", "-g": "manual_scrobble_board_game",
"-u": "manual_scrobble_beer", "-u": "manual_scrobble_beer",
"-wi": "manual_scrobble_wine",
"-co": "manual_scrobble_coffee",
"-w": "manual_scrobble_webpage", "-w": "manual_scrobble_webpage",
"-t": "manual_scrobble_task", "-t": "manual_scrobble_task",
"-p": "manual_scrobble_puzzle", "-p": "manual_scrobble_puzzle",
"-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,9 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
class Command(BaseCommand):
def handle(self, *args, **options):
sent_count = send_water_reminder_notifications()
print(f"Sent {sent_count} water reminder notifications")

View File

@ -18,16 +18,32 @@ class ScrobbleToolset(MCPToolset):
qs = ( qs = (
Scrobble.objects.filter(user=self.request.user) Scrobble.objects.filter(user=self.request.user)
.select_related( .select_related(
"video", "track", "book", "video_game", "board_game", "video",
"beer", "puzzle", "food", "trail", "task", "web_page", "track",
"life_event", "mood", "brick_set", "podcast_episode", "book",
"sport_event", "geo_location", "birding_location", "video_game",
"disc_golf_course", "channel", "board_game",
"beer",
"puzzle",
"food",
"trail",
"task",
"web_page",
"life_event",
"mood",
"brick_set",
"podcast_episode",
"sport_event",
"geo_location",
"birding_location",
"disc_golf_course",
"channel",
) )
.order_by("-timestamp") .order_by("-timestamp")
) )
from django.utils import timezone from django.utils import timezone
import datetime import datetime
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days)) qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
if media_type: if media_type:
qs = qs.filter(media_type=media_type) qs = qs.filter(media_type=media_type)
@ -47,6 +63,7 @@ class ScrobbleToolset(MCPToolset):
) -> list[dict]: ) -> list[dict]:
"""Search scrobbles by text in their log data or related media titles.""" """Search scrobbles by text in their log data or related media titles."""
from django.db.models import Q from django.db.models import Q
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp") qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
if media_type: if media_type:
qs = qs.filter(media_type=media_type) qs = qs.filter(media_type=media_type)
@ -74,6 +91,7 @@ class ScrobbleToolset(MCPToolset):
) -> list[dict]: ) -> list[dict]:
"""Get scrobbles for a specific date (YYYY-MM-DD format).""" """Get scrobbles for a specific date (YYYY-MM-DD format)."""
import datetime import datetime
try: try:
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date() dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
except ValueError: except ValueError:
@ -86,9 +104,7 @@ class ScrobbleToolset(MCPToolset):
qs = qs.filter(media_type=media_type) qs = qs.filter(media_type=media_type)
return [_scrobble_to_dict(s) for s in qs] return [_scrobble_to_dict(s) for s in qs]
def get_in_progress_scrobbles( def get_in_progress_scrobbles(self, media_type: str | None = None) -> list[dict]:
self, media_type: str | None = None
) -> list[dict]:
"""Get scrobbles currently in progress (started but not finished). """Get scrobbles currently in progress (started but not finished).
These are long-play items like books, video games, brick sets, or tasks.""" These are long-play items like books, video games, brick sets, or tasks."""
qs = Scrobble.objects.filter( qs = Scrobble.objects.filter(
@ -122,70 +138,124 @@ class MediaToolset(MCPToolset):
def get_book(self, uuid: str) -> dict | None: def get_book(self, uuid: str) -> dict | None:
"""Get a book by UUID.""" """Get a book by UUID."""
from books.models import Book from books.models import Book
try: try:
b = Book.objects.get(uuid=uuid) b = Book.objects.get(uuid=uuid)
except Book.DoesNotExist: except Book.DoesNotExist:
return None return None
return _media_to_dict(b, fields=["title", "pages", "language", return _media_to_dict(
"first_publish_year", "isbn_13", b,
"publisher", "summary"]) fields=[
"title",
"pages",
"language",
"first_publish_year",
"isbn_13",
"publisher",
"summary",
],
)
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]: def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
"""List books, optionally filtered by author name.""" """List books, optionally filtered by author name."""
from books.models import Book from books.models import Book
qs = Book.objects.all().order_by("title") qs = Book.objects.all().order_by("title")
if author: if author:
qs = qs.filter(authors__name__icontains=author) qs = qs.filter(authors__name__icontains=author)
return [_media_to_dict(b, fields=["title", "pages", "language", return [
"first_publish_year", "isbn_13", _media_to_dict(
"publisher"]) for b in qs[:limit]] b,
fields=[
"title",
"pages",
"language",
"first_publish_year",
"isbn_13",
"publisher",
],
)
for b in qs[:limit]
]
def get_track(self, uuid: str) -> dict | None: def get_track(self, uuid: str) -> dict | None:
"""Get a music track by UUID.""" """Get a music track by UUID."""
from music.models import Track from music.models import Track
try: try:
t = Track.objects.select_related("artist_fk").get(uuid=uuid) t = Track.objects.select_related("artist_fk").get(uuid=uuid)
except Track.DoesNotExist: except Track.DoesNotExist:
return None return None
return _media_to_dict(t, fields=["title", "base_run_time_seconds", return _media_to_dict(
"artist_fk__name", "genre"]) t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
)
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]: def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
"""List music tracks, optionally filtered by artist name.""" """List music tracks, optionally filtered by artist name."""
from music.models import Track from music.models import Track
qs = Track.objects.select_related("artist_fk").all().order_by("title") qs = Track.objects.select_related("artist_fk").all().order_by("title")
if artist: if artist:
qs = qs.filter(artist_fk__name__icontains=artist) qs = qs.filter(artist_fk__name__icontains=artist)
return [_media_to_dict(t, fields=["title", "base_run_time_seconds", return [
"artist_fk__name", "genre"]) _media_to_dict(
for t in qs[:limit]] t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
)
for t in qs[:limit]
]
def get_video(self, uuid: str) -> dict | None: def get_video(self, uuid: str) -> dict | None:
"""Get a video by UUID.""" """Get a video by UUID."""
from videos.models import Video from videos.models import Video
try: try:
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid) v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
except Video.DoesNotExist: except Video.DoesNotExist:
return None return None
return _media_to_dict(v, fields=["title", "year", "overview", return _media_to_dict(
"imdb_id", "imdb_rating", v,
"tv_series__name", "channel__title", fields=[
"season_number", "episode_number"]) "title",
"year",
"overview",
"imdb_id",
"imdb_rating",
"tv_series__name",
"channel__title",
"season_number",
"episode_number",
],
)
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]: def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
"""List videos, optionally filtered by series name.""" """List videos, optionally filtered by series name."""
from videos.models import Video from videos.models import Video
qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")
qs = (
Video.objects.select_related("tv_series", "channel").all().order_by("title")
)
if series: if series:
qs = qs.filter(tv_series__name__icontains=series) qs = qs.filter(tv_series__name__icontains=series)
return [_media_to_dict(v, fields=["title", "year", "overview", return [
"tv_series__name", "channel__title", _media_to_dict(
"season_number", "episode_number"]) v,
for v in qs[:limit]] fields=[
"title",
"year",
"overview",
"tv_series__name",
"channel__title",
"season_number",
"episode_number",
],
)
for v in qs[:limit]
]
def get_board_game(self, uuid: str) -> dict | None: def get_board_game(self, uuid: str) -> dict | None:
"""Get a board game by UUID.""" """Get a board game by UUID."""
from boardgames.models import BoardGame from boardgames.models import BoardGame
try: try:
bg = BoardGame.objects.get(uuid=uuid) bg = BoardGame.objects.get(uuid=uuid)
except BoardGame.DoesNotExist: except BoardGame.DoesNotExist:
@ -195,34 +265,69 @@ class MediaToolset(MCPToolset):
def list_board_games(self, limit: int = 20) -> list[dict]: def list_board_games(self, limit: int = 20) -> list[dict]:
"""List board games.""" """List board games."""
from boardgames.models import BoardGame from boardgames.models import BoardGame
qs = BoardGame.objects.all().order_by("title")[:limit] qs = BoardGame.objects.all().order_by("title")[:limit]
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs] return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
def get_podcast_episode(self, uuid: str) -> dict | None: def get_podcast_episode(self, uuid: str) -> dict | None:
"""Get a podcast episode by UUID.""" """Get a podcast episode by UUID."""
from podcasts.models import PodcastEpisode from podcasts.models import PodcastEpisode
try: try:
pe = PodcastEpisode.objects.select_related("podcast", "producer").get( pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
uuid=uuid uuid=uuid
) )
except PodcastEpisode.DoesNotExist: except PodcastEpisode.DoesNotExist:
return None return None
return _media_to_dict(pe, fields=["title", "podcast__title", return _media_to_dict(
"producer__name", "base_run_time_seconds"]) pe,
fields=[
"title",
"podcast__title",
"producer__name",
"base_run_time_seconds",
],
)
def get_beer(self, uuid: str) -> dict | None: def get_beer(self, uuid: str) -> dict | None:
"""Get a beer by UUID.""" """Get a beer by UUID."""
from beers.models import Beer from drinks.models import Beer
try: try:
b = Beer.objects.select_related("style", "producer").get(uuid=uuid) b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
except Beer.DoesNotExist: except Beer.DoesNotExist:
return None return None
return _media_to_dict(b, fields=["title", "style__name", return _media_to_dict(
"producer__name", "abv"]) b, fields=["title", "style__name", "producer__name", "abv"]
)
def get_wine(self, uuid: str) -> dict | None:
"""Get a wine by UUID."""
from drinks.models import Wine
try:
w = Wine.objects.select_related("region", "producer").get(uuid=uuid)
except Wine.DoesNotExist:
return None
return _media_to_dict(
w,
fields=["title", "wine_type", "vintage", "producer__name", "region__name"],
)
def get_coffee(self, uuid: str) -> dict | None:
"""Get a coffee by UUID."""
from drinks.models import Coffee
try:
c = Coffee.objects.select_related("roaster").get(uuid=uuid)
except Coffee.DoesNotExist:
return None
return _media_to_dict(c, fields=["title", "origin", "roaster__name"])
def get_brick_set(self, uuid: str) -> dict | None: def get_brick_set(self, uuid: str) -> dict | None:
"""Get a brick set (LEGO) by UUID.""" """Get a brick set (LEGO) by UUID."""
from bricksets.models import BrickSet from bricksets.models import BrickSet
try: try:
bs = BrickSet.objects.get(uuid=uuid) bs = BrickSet.objects.get(uuid=uuid)
except BrickSet.DoesNotExist: except BrickSet.DoesNotExist:
@ -232,26 +337,27 @@ class MediaToolset(MCPToolset):
def get_video_game(self, uuid: str) -> dict | None: def get_video_game(self, uuid: str) -> dict | None:
"""Get a video game by UUID.""" """Get a video game by UUID."""
from videogames.models import VideoGame from videogames.models import VideoGame
try: try:
vg = VideoGame.objects.get(uuid=uuid) vg = VideoGame.objects.get(uuid=uuid)
except VideoGame.DoesNotExist: except VideoGame.DoesNotExist:
return None return None
return _media_to_dict(vg, fields=["title", "genre", return _media_to_dict(vg, fields=["title", "genre", "base_run_time_seconds"])
"base_run_time_seconds"])
def get_puzzle(self, uuid: str) -> dict | None: def get_puzzle(self, uuid: str) -> dict | None:
"""Get a puzzle by UUID.""" """Get a puzzle by UUID."""
from puzzles.models import Puzzle from puzzles.models import Puzzle
try: try:
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid) p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
except Puzzle.DoesNotExist: except Puzzle.DoesNotExist:
return None return None
return _media_to_dict(p, fields=["title", "piece_count", return _media_to_dict(p, fields=["title", "piece_count", "manufacturer__name"])
"manufacturer__name"])
def get_web_page(self, uuid: str) -> dict | None: def get_web_page(self, uuid: str) -> dict | None:
"""Get a web page by UUID.""" """Get a web page by UUID."""
from webpages.models import WebPage from webpages.models import WebPage
try: try:
wp = WebPage.objects.select_related("domain").get(uuid=uuid) wp = WebPage.objects.select_related("domain").get(uuid=uuid)
except WebPage.DoesNotExist: except WebPage.DoesNotExist:
@ -261,6 +367,7 @@ class MediaToolset(MCPToolset):
def get_task(self, uuid: str) -> dict | None: def get_task(self, uuid: str) -> dict | None:
"""Get a task by UUID.""" """Get a task by UUID."""
from tasks.models import Task from tasks.models import Task
try: try:
t = Task.objects.get(uuid=uuid) t = Task.objects.get(uuid=uuid)
except Task.DoesNotExist: except Task.DoesNotExist:
@ -270,6 +377,7 @@ class MediaToolset(MCPToolset):
def get_trail(self, uuid: str) -> dict | None: def get_trail(self, uuid: str) -> dict | None:
"""Get a trail by UUID.""" """Get a trail by UUID."""
from trails.models import Trail from trails.models import Trail
try: try:
t = Trail.objects.get(uuid=uuid) t = Trail.objects.get(uuid=uuid)
except Trail.DoesNotExist: except Trail.DoesNotExist:
@ -279,6 +387,7 @@ class MediaToolset(MCPToolset):
def get_geo_location(self, uuid: str) -> dict | None: def get_geo_location(self, uuid: str) -> dict | None:
"""Get a geo location by UUID.""" """Get a geo location by UUID."""
from locations.models import GeoLocation from locations.models import GeoLocation
try: try:
gl = GeoLocation.objects.get(uuid=uuid) gl = GeoLocation.objects.get(uuid=uuid)
except GeoLocation.DoesNotExist: except GeoLocation.DoesNotExist:
@ -288,6 +397,7 @@ class MediaToolset(MCPToolset):
def get_life_event(self, uuid: str) -> dict | None: def get_life_event(self, uuid: str) -> dict | None:
"""Get a life event by UUID.""" """Get a life event by UUID."""
from lifeevents.models import LifeEvent from lifeevents.models import LifeEvent
try: try:
le = LifeEvent.objects.get(uuid=uuid) le = LifeEvent.objects.get(uuid=uuid)
except LifeEvent.DoesNotExist: except LifeEvent.DoesNotExist:
@ -297,6 +407,7 @@ class MediaToolset(MCPToolset):
def get_mood(self, uuid: str) -> dict | None: def get_mood(self, uuid: str) -> dict | None:
"""Get a mood entry by UUID.""" """Get a mood entry by UUID."""
from moods.models import Mood from moods.models import Mood
try: try:
m = Mood.objects.get(uuid=uuid) m = Mood.objects.get(uuid=uuid)
except Mood.DoesNotExist: except Mood.DoesNotExist:
@ -306,6 +417,7 @@ class MediaToolset(MCPToolset):
def get_food(self, uuid: str) -> dict | None: def get_food(self, uuid: str) -> dict | None:
"""Get a food entry by UUID.""" """Get a food entry by UUID."""
from foods.models import Food from foods.models import Food
try: try:
f = Food.objects.select_related("category").get(uuid=uuid) f = Food.objects.select_related("category").get(uuid=uuid)
except Food.DoesNotExist: except Food.DoesNotExist:
@ -315,16 +427,20 @@ class MediaToolset(MCPToolset):
def get_bird_sighting(self, uuid: str) -> dict | None: def get_bird_sighting(self, uuid: str) -> dict | None:
"""Get a bird sighting by UUID.""" """Get a bird sighting by UUID."""
from birds.models import BirdSighting from birds.models import BirdSighting
try: try:
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid) bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
except BirdSighting.DoesNotExist: except BirdSighting.DoesNotExist:
return None return None
return _media_to_dict(bs, fields=["title", "bird__common_name", return _media_to_dict(
"bird__scientific_name", "location"]) bs,
fields=["title", "bird__common_name", "bird__scientific_name", "location"],
)
def get_disc_golf_course(self, uuid: str) -> dict | None: def get_disc_golf_course(self, uuid: str) -> dict | None:
"""Get a disc golf course by UUID.""" """Get a disc golf course by UUID."""
from discgolf.models import DiscGolfCourse from discgolf.models import DiscGolfCourse
try: try:
dg = DiscGolfCourse.objects.get(uuid=uuid) dg = DiscGolfCourse.objects.get(uuid=uuid)
except DiscGolfCourse.DoesNotExist: except DiscGolfCourse.DoesNotExist:
@ -434,16 +550,15 @@ def _scrobble_to_dict(s: Scrobble) -> dict:
def _scrobble_related_to_dict(s: Scrobble) -> dict | None: def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
if s.video: if s.video:
return _media_to_dict(s.video, fields=["title", "year", "imdb_id", return _media_to_dict(
"imdb_rating"]) s.video, fields=["title", "year", "imdb_id", "imdb_rating"]
)
if s.track: if s.track:
return _media_to_dict(s.track, fields=["title", return _media_to_dict(s.track, fields=["title", "base_run_time_seconds"])
"base_run_time_seconds"])
if s.book: if s.book:
return _media_to_dict(s.book, fields=["title", "pages"]) return _media_to_dict(s.book, fields=["title", "pages"])
if s.video_game: if s.video_game:
return _media_to_dict(s.video_game, fields=["title", return _media_to_dict(s.video_game, fields=["title", "base_run_time_seconds"])
"base_run_time_seconds"])
if s.board_game: if s.board_game:
return _media_to_dict(s.board_game, fields=["title"]) return _media_to_dict(s.board_game, fields=["title"])
if s.beer: if s.beer:
@ -453,8 +568,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
if s.food: if s.food:
return _media_to_dict(s.food, fields=["title"]) return _media_to_dict(s.food, fields=["title"])
if s.trail: if s.trail:
return _media_to_dict(s.trail, fields=["title", return _media_to_dict(s.trail, fields=["title", "base_run_time_seconds"])
"base_run_time_seconds"])
if s.task: if s.task:
return _media_to_dict(s.task, fields=["title"]) return _media_to_dict(s.task, fields=["title"])
if s.web_page: if s.web_page:
@ -470,8 +584,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
if s.sport_event: if s.sport_event:
return {"title": str(s.sport_event)} return {"title": str(s.sport_event)}
if s.geo_location: if s.geo_location:
return _media_to_dict(s.geo_location, fields=["title", "latitude", return _media_to_dict(s.geo_location, fields=["title", "latitude", "longitude"])
"longitude"])
if s.birding_location: if s.birding_location:
return _media_to_dict(s.birding_location, fields=["title"]) return _media_to_dict(s.birding_location, fields=["title"])
if s.disc_golf_course: if s.disc_golf_course:

View File

@ -7,7 +7,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("beers", "0001_initial"), ("drinks", "0001_initial"),
("scrobbles", "0065_alter_scrobble_log"), ("scrobbles", "0065_alter_scrobble_log"),
] ]
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.DO_NOTHING, on_delete=django.db.models.deletion.DO_NOTHING,
to="beers.beer", to="drinks.beer",
), ),
), ),
migrations.AlterField( migrations.AlterField(

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
("puzzles", "0006_alter_puzzle_genre"), ("puzzles", "0006_alter_puzzle_genre"),
("videogames", "0015_alter_videogame_genre"), ("videogames", "0015_alter_videogame_genre"),
("lifeevents", "0005_alter_lifeevent_genre"), ("lifeevents", "0005_alter_lifeevent_genre"),
("beers", "0008_alter_beer_genre"), ("drinks", "0008_alter_beer_genre"),
("foods", "0007_alter_food_genre"), ("foods", "0007_alter_food_genre"),
("tasks", "0007_alter_task_genre"), ("tasks", "0007_alter_task_genre"),
("books", "0036_alter_book_genre_alter_paper_genre"), ("books", "0036_alter_book_genre_alter_paper_genre"),
@ -98,7 +98,7 @@ class Migration(migrations.Migration):
blank=True, blank=True,
null=True, null=True,
on_delete=django.db.models.deletion.CASCADE, on_delete=django.db.models.deletion.CASCADE,
to="beers.beer", to="drinks.beer",
), ),
), ),
( (

View File

@ -0,0 +1,140 @@
# Generated by Django 4.2.29 on 2026-07-13 14:33
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("drinks", "0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more"),
("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),
]
operations = [
migrations.AddField(
model_name="favoritemedia",
name="coffee",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="drinks.coffee",
),
),
migrations.AddField(
model_name="favoritemedia",
name="wine",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="drinks.wine",
),
),
migrations.AddField(
model_name="scrobble",
name="coffee",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.coffee",
),
),
migrations.AddField(
model_name="scrobble",
name="wine",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.wine",
),
),
migrations.AlterField(
model_name="favoritemedia",
name="beer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="drinks.beer",
),
),
migrations.AlterField(
model_name="favoritemedia",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
],
max_length=20,
),
),
migrations.AlterField(
model_name="scrobble",
name="beer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="drinks.beer",
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
],
default="Video",
max_length=20,
),
),
]

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 beers.models import Beer 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
@ -684,6 +684,9 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"GeoLocation": ("geo_location",), "GeoLocation": ("geo_location",),
"Trail": ("trail",), "Trail": ("trail",),
"Beer": ("beer",), "Beer": ("beer",),
"Wine": ("wine",),
"Coffee": ("coffee",),
"Drink": ("drink",),
"Puzzle": ("puzzle",), "Puzzle": ("puzzle",),
"Food": ("food",), "Food": ("food",),
"Task": ("task",), "Task": ("task",),
@ -712,6 +715,9 @@ class ScrobbleQuerySet(models.QuerySet):
"board_game", "board_game",
"geo_location", "geo_location",
"beer", "beer",
"wine",
"coffee",
"drink",
"puzzle", "puzzle",
"food", "food",
"trail", "trail",
@ -763,6 +769,9 @@ class Scrobble(TimeStampedModel):
GEO_LOCATION = "GeoLocation", "GeoLocation" GEO_LOCATION = "GeoLocation", "GeoLocation"
TRAIL = "Trail", "Trail" TRAIL = "Trail", "Trail"
BEER = "Beer", "Beer" BEER = "Beer", "Beer"
WINE = "Wine", "Wine"
COFFEE = "Coffee", "Coffee"
DRINK = "Drink", "Drink"
PUZZLE = "Puzzle", "Puzzle" PUZZLE = "Puzzle", "Puzzle"
FOOD = "Food", "Food" FOOD = "Food", "Food"
TASK = "Task", "Task" TASK = "Task", "Task"
@ -792,6 +801,9 @@ class Scrobble(TimeStampedModel):
board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL) board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL) geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL) 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) 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)
@ -1220,7 +1232,10 @@ class Scrobble(TimeStampedModel):
if self.is_long_play: if self.is_long_play:
long_play_secs = 0 long_play_secs = 0
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete: if (
self.long_play_last_scrobble
and not self.long_play_last_scrobble.long_play_complete
):
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0 long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100) percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
@ -1367,6 +1382,12 @@ class Scrobble(TimeStampedModel):
media_obj = self.trail media_obj = self.trail
if self.beer: if self.beer:
media_obj = self.beer media_obj = self.beer
if self.wine:
media_obj = self.wine
if 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:
@ -1928,6 +1949,9 @@ class FavoriteMedia(TimeStampedModel):
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL) board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL) geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
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)
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)
@ -1982,6 +2006,12 @@ class FavoriteMedia(TimeStampedModel):
media_obj = self.trail media_obj = self.trail
if self.beer: if self.beer:
media_obj = self.beer media_obj = self.beer
if self.wine:
media_obj = self.wine
if 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:
@ -2014,6 +2044,9 @@ class FavoriteMedia(TimeStampedModel):
"BoardGame": "board_game", "BoardGame": "board_game",
"GeoLocation": "geo_location", "GeoLocation": "geo_location",
"Beer": "beer", "Beer": "beer",
"Wine": "wine",
"Coffee": "coffee",
"Drink": "drink",
"Puzzle": "puzzle", "Puzzle": "puzzle",
"Food": "food", "Food": "food",
"Trail": "trail", "Trail": "trail",

View File

@ -1,6 +1,6 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import requests
import requests
from django.conf import settings from django.conf import settings
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
from django.urls import reverse from django.urls import reverse
@ -119,3 +119,26 @@ class MoodNtfyNotification(BasicNtfyNotification):
"Click": self.click_url, "Click": self.click_url,
}, },
) )
class WaterReminderNtfyNotification(BasicNtfyNotification):
def __init__(self, profile, **kwargs):
super().__init__(profile)
self.ntfy_str: str = "Time to drink some water!"
self.click_url = self.url_tmpl.format(
path=reverse("drinks:quick_water_scrobble")
)
self.title = "Water Reminder"
def send(self):
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
requests.post(
self.profile.ntfy_url,
data=self.ntfy_str.encode(encoding="utf-8"),
headers={
"Title": self.title,
"Priority": "high",
"Tags": "droplet, check",
"Click": self.click_url,
},
)

View File

@ -6,7 +6,6 @@ from typing import Any, Optional
import pendulum import pendulum
import pytz import pytz
import requests import requests
from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from boardgames.utils import board_names_to_variants from boardgames.utils import board_names_to_variants
from books.constants import READCOMICSONLINE_URL from books.constants import READCOMICSONLINE_URL
@ -16,6 +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, Drink, DrinkFormat, Wine
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
@ -648,6 +648,30 @@ def manual_scrobble_from_url(
item_id = url item_id = url
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key] scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
if content_key == "-wi":
if "cellartracker.com" in url:
return manual_scrobble_wine(
cellartracker_id=item_id,
user_id=user_id,
source="CellarTracker",
action=action,
)
else:
return manual_scrobble_wine(
vivino_id=item_id,
user_id=user_id,
source="Vivino",
action=action,
)
elif content_key == "-co":
return manual_scrobble_coffee(
roastdb_url=url,
user_id=user_id,
source="RoastDB",
action=action,
)
return eval(scrobble_fn)(item_id, user_id, source=source, action=action) return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
@ -766,9 +790,7 @@ def todoist_scrobble_task(
todoist_task["title"] = todoist_task.pop("description") todoist_task["title"] = todoist_task.pop("description")
todoist_task["description"] = todoist_task.pop("details") todoist_task["description"] = todoist_task.pop("details")
labels = todoist_task.pop("todoist_label_list", []) labels = todoist_task.pop("todoist_label_list", [])
todoist_task["labels"] = [ todoist_task["labels"] = [l for l in labels if l.lower() != "inprogress"]
l for l in labels if l.lower() != "inprogress"
]
todoist_task.pop("todoist_type") todoist_task.pop("todoist_type")
todoist_task.pop("todoist_event") todoist_task.pop("todoist_event")
@ -933,7 +955,9 @@ def emacs_scrobble_task(
task_data.pop("notes", None) task_data.pop("notes", None)
task_data["title"] = task_data.pop("description") task_data["title"] = task_data.pop("description")
task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description") task_data["description"] = _extract_org_section(
task_data.pop("body"), "*** Description"
)
task_data["labels"] = task_data.pop("labels") task_data["labels"] = task_data.pop("labels")
task_data["orgmode_id"] = task_data.pop("source_id") task_data["orgmode_id"] = task_data.pop("source_id")
@ -1256,6 +1280,115 @@ def manual_scrobble_beer(
return Scrobble.create_or_update(beer, user_id, scrobble_dict) return Scrobble.create_or_update(beer, user_id, scrobble_dict)
def manual_scrobble_wine(
vivino_id: str = None,
cellartracker_id: str = None,
user_id: int = None,
source: str = "Vivino",
action: Optional[str] = None,
):
wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)
if not wine:
logger.error(
f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"
)
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] wine scrobble request received",
extra={
"wine_id": wine.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.WINE,
},
)
return Scrobble.create_or_update(wine, user_id, scrobble_dict)
def manual_scrobble_coffee(
roastdb_id: str = None,
roastdb_url: str = None,
user_id: int = None,
source: str = "RoastDB",
action: Optional[str] = None,
):
slug = None
if roastdb_url and not roastdb_id:
slug = roastdb_url.rstrip("/").split("/")[-1]
coffee = Coffee.find_or_create(roastdb_id=roastdb_id, slug=slug)
if not coffee:
logger.error(f"No coffee found for RoastDB ID {roastdb_id}")
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] coffee scrobble request received",
extra={
"coffee_id": coffee.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.COFFEE,
},
)
return Scrobble.create_or_update(coffee, user_id, scrobble_dict)
def manual_scrobble_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
water_format = DrinkFormat.BY_THE_GLASS
water_size_ml = 250
profile = UserProfile.objects.filter(user_id=user_id).first()
if profile:
water_format = profile.default_water_format or water_format
water_size_ml = profile.default_water_size or water_size_ml
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
"log": {"format": water_format, "size_ml": water_size_ml},
}
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,
@ -1367,6 +1500,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

@ -276,15 +276,11 @@ def push_scrobble_to_archivebox(scrobble_id):
scrobble = Scrobble.objects.filter(id=scrobble_id).first() scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble: if not scrobble:
logger.warning( logger.warning("Scrobble %s not found for archivebox push", scrobble_id)
"Scrobble %s not found for archivebox push", scrobble_id
)
return return
webpage = scrobble.web_page webpage = scrobble.web_page
if not webpage: if not webpage:
logger.warning( logger.warning("Scrobble %s has no web_page for archivebox push", scrobble_id)
"Scrobble %s has no web_page for archivebox push", scrobble_id
)
return return
webpage.push_to_archivebox(scrobble.user) webpage.push_to_archivebox(scrobble.user)
@ -582,6 +578,14 @@ def send_mood_checkin():
send_mood_checkin_reminders() send_mood_checkin_reminders()
@shared_task
def send_water_reminder():
"""Send water reminder notifications during daylight hours."""
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
send_water_reminder_notifications()
@shared_task @shared_task
def backfill_scrobble_sentiment(): def backfill_scrobble_sentiment():
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron).""" """Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""

View File

@ -1,7 +1,6 @@
import hashlib import hashlib
import html import html
import logging import logging
import requests
import re import re
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
@ -10,6 +9,7 @@ from zoneinfo import ZoneInfo
import pendulum import pendulum
import pytz import pytz
import requests
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models from django.db import models
@ -23,6 +23,7 @@ from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import ( from scrobbles.notifications import (
MoodNtfyNotification, MoodNtfyNotification,
ScrobbleNtfyNotification, ScrobbleNtfyNotification,
WaterReminderNtfyNotification,
) )
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
@ -154,8 +155,7 @@ def import_lastfm_for_all_users(restart=False):
last_processed = lfm_import.processed_finished last_processed = lfm_import.processed_finished
else: else:
logger.info( logger.info(
"No existing LastFM import for user %s, " "No existing LastFM import for user %s, " "starting a full parse",
"starting a full parse",
user_id, user_id,
) )
last_processed = None last_processed = None
@ -350,6 +350,20 @@ def send_mood_checkin_reminders() -> int:
return notifications_sent return notifications_sent
def send_water_reminder_notifications() -> int:
"""Send water reminders to users with the setting enabled, during daylight hours.
Only sends if the user's local time is between 6 AM and 11 PM."""
notifications_sent = 0
for profile in UserProfile.objects.filter(water_reminder_enabled=True):
local_hour = timezone.localtime(timezone.now(), profile.tzinfo).hour
if 6 <= local_hour <= 22:
WaterReminderNtfyNotification(profile).send()
notifications_sent += 1
return notifications_sent
def extract_domain(url): def extract_domain(url):
parsed_url = urlparse(url) parsed_url = urlparse(url)
domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1] domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1]
@ -460,7 +474,8 @@ def _ensure_mopidy_playlist(profile):
pass pass
result = _mopidy_rpc( result = _mopidy_rpc(
profile, "core.playlists.create", profile,
"core.playlists.create",
{"name": playlist_name, "uri_scheme": "m3u"}, {"name": playlist_name, "uri_scheme": "m3u"},
) )
logger.info( logger.info(
@ -588,7 +603,8 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
if playlist and playlist.get("uri"): if playlist and playlist.get("uri"):
existing_tracks = playlist.get("tracks") or [] existing_tracks = playlist.get("tracks") or []
filtered = [ filtered = [
t for t in existing_tracks t
for t in existing_tracks
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri) if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
] ]
if len(filtered) == len(existing_tracks): if len(filtered) == len(existing_tracks):
@ -639,7 +655,8 @@ def _ensure_mopidy_playlist_by_name(profile, playlist_name):
pass pass
result = _mopidy_rpc( result = _mopidy_rpc(
profile, "core.playlists.create", profile,
"core.playlists.create",
{"name": playlist_name, "uri_scheme": "m3u"}, {"name": playlist_name, "uri_scheme": "m3u"},
) )
return result return result

View File

@ -4,10 +4,9 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import uuid4 from uuid import uuid4
import requests
import pendulum import pendulum
import pytz import pytz
import requests
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.apps import apps from django.apps import apps
from django.contrib import messages from django.contrib import messages
@ -26,6 +25,7 @@ class BearerTokenAuthentication(TokenAuthentication):
keyword = "Bearer" keyword = "Bearer"
from boardgames.models import BoardGame, BoardGameVariant
from django.http import ( from django.http import (
FileResponse, FileResponse,
Http404, Http404,
@ -33,7 +33,7 @@ from django.http import (
JsonResponse, JsonResponse,
) )
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils import timezone from django.utils import timezone
from django.utils.dateformat import DateFormat from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
@ -66,14 +66,14 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from scrobbles.api import serializers from scrobbles.api import serializers
from scrobbles.constants import ( from scrobbles.constants import (
DRINK_MODELS,
LONG_PLAY_MEDIA, LONG_PLAY_MEDIA,
MANUAL_SCROBBLE_FNS, MANUAL_SCROBBLE_FNS,
PLAY_AGAIN_MEDIA, PLAY_AGAIN_MEDIA,
Visibility,
) )
from scrobbles.export import export_scrobbles from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.constants import Visibility
from scrobbles.sqids import decode_scrobble_share
from scrobbles.models import ( from scrobbles.models import (
AudioScrobblerTSVImport, AudioScrobblerTSVImport,
BGStatsImport, BGStatsImport,
@ -90,6 +90,7 @@ from scrobbles.models import (
UDiscCSVImport, UDiscCSVImport,
) )
from scrobbles.scrobblers import * from scrobbles.scrobblers import *
from scrobbles.sqids import decode_scrobble_share
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
process_lastfm_import, process_lastfm_import,
@ -210,14 +211,20 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
media = self.object media = self.object
if hasattr(media, "is_long_play_media") and media.is_long_play_media(): if hasattr(media, "is_long_play_media") and media.is_long_play_media():
qs = media.scrobble_set.filter(user=self.request.user) qs = media.scrobble_set.filter(user=self.request.user)
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first() completed = (
qs.filter(long_play_complete=True).order_by("-timestamp").first()
)
if completed and completed.long_play_seconds: if completed and completed.long_play_seconds:
context_data["long_play_total_seconds"] = completed.long_play_seconds context_data["long_play_total_seconds"] = completed.long_play_seconds
context_data["long_play_finished_date"] = completed.timestamp context_data["long_play_finished_date"] = completed.timestamp
else: else:
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first() latest_finished = (
qs.filter(played_to_completion=True).order_by("-timestamp").first()
)
if latest_finished and latest_finished.long_play_seconds: if latest_finished and latest_finished.long_play_seconds:
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds context_data["long_play_total_seconds"] = (
latest_finished.long_play_seconds
)
context_data["long_play_finished_date"] = None context_data["long_play_finished_date"] = None
return context_data return context_data
@ -235,7 +242,8 @@ class RecentScrobbleList(ListView):
scrobble = manual_scrobble_from_url( scrobble = manual_scrobble_from_url(
scrobble_url, self.request.user.id, source, action scrobble_url, self.request.user.id, source, action
) )
return HttpResponseRedirect(scrobble.redirect_url(user.id)) if scrobble:
return HttpResponseRedirect(scrobble.redirect_url(user.id))
return super().get(*args, **kwargs) return super().get(*args, **kwargs)
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
@ -610,6 +618,18 @@ class ManualScrobbleView(FormView):
item_str = form.cleaned_data.get("item_id") item_str = form.cleaned_data.get("item_id")
logger.debug(f"Looking for scrobblable media with input {item_str}") logger.debug(f"Looking for scrobblable media with input {item_str}")
if item_str.startswith("http://") or item_str.startswith("https://"):
scrobble = manual_scrobble_from_url(
item_str, self.request.user.id, source="Vrobbler"
)
if scrobble:
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
messages.error(
self.request,
"Could not scrobble from URL. The source may be unavailable.",
)
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS: if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
key = item_str[:3] key = item_str[:3]
item_id = item_str[4:] item_id = item_str[4:]
@ -888,8 +908,15 @@ def scrobble_start(request, media_uuid):
media_obj = None media_obj = None
for app, model in PLAY_AGAIN_MEDIA.items(): for app, model in PLAY_AGAIN_MEDIA.items():
media_model = apps.get_model(app_label=app, model_name=model) if app == "drinks":
media_obj = media_model.objects.filter(uuid=media_uuid).first() for drink_model in DRINK_MODELS:
media_model = apps.get_model(app_label=app, model_name=drink_model)
media_obj = media_model.objects.filter(uuid=media_uuid).first()
if media_obj:
break
else:
media_model = apps.get_model(app_label=app, model_name=model)
media_obj = media_model.objects.filter(uuid=media_uuid).first()
if media_obj: if media_obj:
break break
@ -1134,7 +1161,10 @@ def toggle_favorite(request, media_type, object_id):
"VideoGame": ("videogames", "VideoGame"), "VideoGame": ("videogames", "VideoGame"),
"BoardGame": ("boardgames", "BoardGame"), "BoardGame": ("boardgames", "BoardGame"),
"GeoLocation": ("locations", "GeoLocation"), "GeoLocation": ("locations", "GeoLocation"),
"Beer": ("beers", "Beer"), "Beer": ("drinks", "Beer"),
"Wine": ("drinks", "Wine"),
"Coffee": ("drinks", "Coffee"),
"Water": ("drinks", "Water"),
"Puzzle": ("puzzles", "Puzzle"), "Puzzle": ("puzzles", "Puzzle"),
"Food": ("foods", "Food"), "Food": ("foods", "Food"),
"Trail": ("trails", "Trail"), "Trail": ("trails", "Trail"),
@ -1235,15 +1265,29 @@ class ScrobbleDetailView(DetailView):
def get_form_class(self): def get_form_class(self):
return self.object.media_obj.logdata_cls().form() return self.object.media_obj.logdata_cls().form()
def _update_expansion_ids_queryset(self, form): def _update_board_game_widgets(self, form):
from boardgames.models import BoardGame if not isinstance(self.object.media_obj, BoardGame):
return
if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields: if "expansion_ids" in form.fields:
expansions = BoardGame.objects.filter( expansions = BoardGame.objects.filter(
expansion_for_boardgame=self.object.media_obj expansion_for_boardgame=self.object.media_obj
) )
form.fields["expansion_ids"].queryset = expansions form.fields["expansion_ids"].queryset = expansions
if "variant_ids" in form.fields:
form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(
board_game=self.object.media_obj
)
form.fields["variant_ids"].widget.attrs[
"data-board-game-id"
] = self.object.media_obj.id
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
self.request.build_absolute_uri(
reverse("boardgames:ajax-create-variant")
)
)
def get_form(self): def get_form(self):
FormClass = self.get_form_class() FormClass = self.get_form_class()
@ -1255,14 +1299,41 @@ class ScrobbleDetailView(DetailView):
log["notes"] = self.object.logdata.notes_as_str(separator="\n") log["notes"] = self.object.logdata.notes_as_str(separator="\n")
form = FormClass(initial=log) form = FormClass(initial=log)
self._update_expansion_ids_queryset(form) self._update_board_game_widgets(form)
drink_types = ("Beer", "Wine", "Coffee")
if self.object.media_type in drink_types and "size_ml" in form.fields:
user = self.request.user
use_oz = hasattr(user, "profile") and user.profile.volume_unit == "imperial"
if use_oz:
form.fields["size_ml"].label = "Size (oz)"
original_clean = form.fields["size_ml"].clean
def clean_size_ml_oz(value):
val = original_clean(value)
if val is not None and val != "":
from drinks.models import ML_PER_OZ
return round(float(val) * ML_PER_OZ)
return val
form.fields["size_ml"].clean = clean_size_ml_oz
if log.get("size_ml"):
from drinks.models import ML_PER_OZ
form.fields["size_ml"].initial = round(
log["size_ml"] / ML_PER_OZ, 1
)
else:
form.fields["size_ml"].label = "Size (mL)"
return form return form
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = self.get_object() self.object = self.get_object()
FormClass = self.get_form_class() FormClass = self.get_form_class()
form = FormClass(request.POST) form = FormClass(request.POST)
self._update_expansion_ids_queryset(form) self._update_board_game_widgets(form)
if form.is_valid(): if form.is_valid():
data = form.cleaned_data.copy() data = form.cleaned_data.copy()
@ -1364,21 +1435,25 @@ class ScrobbleDetailView(DetailView):
context["has_mopidy_uri"] = False context["has_mopidy_uri"] = False
if self.object.is_long_play and fk_field: if self.object.is_long_play and fk_field:
all_scrobbles = Scrobble.objects.filter( all_scrobbles = Scrobble.objects.filter(user=user, **{fk_field: media_obj})
user=user, **{fk_field: media_obj} completed = (
all_scrobbles.filter(long_play_complete=True)
.order_by("-timestamp")
.first()
) )
completed = all_scrobbles.filter(
long_play_complete=True
).order_by("-timestamp").first()
if completed and completed.long_play_seconds: if completed and completed.long_play_seconds:
context["long_play_total_seconds"] = completed.long_play_seconds context["long_play_total_seconds"] = completed.long_play_seconds
context["long_play_finished_date"] = completed.timestamp context["long_play_finished_date"] = completed.timestamp
else: else:
latest_finished = all_scrobbles.filter( latest_finished = (
played_to_completion=True all_scrobbles.filter(played_to_completion=True)
).order_by("-timestamp").first() .order_by("-timestamp")
.first()
)
if latest_finished and latest_finished.long_play_seconds: if latest_finished and latest_finished.long_play_seconds:
context["long_play_total_seconds"] = latest_finished.long_play_seconds context["long_play_total_seconds"] = (
latest_finished.long_play_seconds
)
context["long_play_finished_date"] = None context["long_play_finished_date"] = None
return context return context
@ -1944,8 +2019,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
scrobble_filter = {"scrobble__played_to_completion": True} scrobble_filter = {"scrobble__played_to_completion": True}
def get_items(self, user, start_date, end_date): def get_items(self, user, start_date, end_date):
from boardgames.models import BoardGame
return super().get_items(user, start_date, end_date, BoardGame) return super().get_items(user, start_date, end_date, BoardGame)

View File

@ -188,6 +188,10 @@ CELERY_BEAT_SCHEDULE = {
"task": "scrobbles.tasks.send_mood_checkin", "task": "scrobbles.tasks.send_mood_checkin",
"schedule": crontab(hour="*/4", minute=0), "schedule": crontab(hour="*/4", minute=0),
}, },
"send-water-reminder": {
"task": "scrobbles.tasks.send_water_reminder",
"schedule": crontab(hour="*/3", minute=0),
},
"backfill-scrobble-sentiment": { "backfill-scrobble-sentiment": {
"task": "scrobbles.tasks.backfill_scrobble_sentiment", "task": "scrobbles.tasks.backfill_scrobble_sentiment",
"schedule": crontab(minute="0"), "schedule": crontab(minute="0"),
@ -229,7 +233,7 @@ INSTALLED_APPS = [
"webpages", "webpages",
"tasks", "tasks",
"trails", "trails",
"beers", "drinks",
"puzzles", "puzzles",
"foods", "foods",
"lifeevents", "lifeevents",
@ -405,6 +409,12 @@ else:
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st") SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
SKIP_AUTO_EXPANSION_DOWNLOAD = [
int(x.strip())
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
if x.strip().isdigit()
]
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
LOG_TYPE = "json" if JSON_LOGGING else "log" LOG_TYPE = "json" if JSON_LOGGING else "log"

View File

@ -280,6 +280,9 @@
<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">
<a class="nav-link px-3" href="{% url "drinks:quick_water_scrobble" %}">Drink water</a>
</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

@ -46,6 +46,20 @@
<p> <p>
<a href="{{object.start_url}}">Play again</a> <a href="{{object.start_url}}">Play again</a>
</p> </p>
{% if object.genre.all %}
<p>Genres:
{% for tag in object.genre.all %}
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
{% if object.tags.all %}
<p>Tags:
{% for tag in object.tags.all %}
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
</div> </div>
{% if charts %} {% if charts %}
<div class="row"> <div class="row">

View File

@ -52,12 +52,24 @@
<thead> <thead>
<tr> <tr>
<th scope="col">Date</th> <th scope="col">Date</th>
<th scope="col">Format</th>
<th scope="col">Size</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
<tr> <tr>
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td> <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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -0,0 +1,81 @@
{% 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 %}
</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">Roast</th>
<th scope="col">Method</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.roast_level_display }}</td>
<td>{{ scrobble.logdata.brewing_method_display }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Coffee{% 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,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

@ -0,0 +1,85 @@
{% 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;">
{% if object.vivino_link %}
<a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>
{% endif %}
{% if object.cellartracker_link %}
<a href="{{object.cellartracker_link}}">CellarTracker</a>
{% endif %}
</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,24 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Wines{% 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

@ -156,7 +156,7 @@
<p>No board games today</p> <p>No board games today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'beers:beer_list' %}">Beers</a></h3> <h3><a href="{% url 'drinks:beer_list' %}">Beers</a></h3>
{% if Beer %} {% if Beer %}
{% with scrobbles=Beer count=Beer_count time=Beer_time %} {% with scrobbles=Beer count=Beer_count time=Beer_time %}
{% include "scrobbles/_scrobble_table.html" %} {% include "scrobbles/_scrobble_table.html" %}
@ -165,6 +165,24 @@
<p>No beer today</p> <p>No beer today</p>
{% endif %} {% endif %}
<h3><a href="{% url 'drinks:wine_list' %}">Wines</a></h3>
{% if Wine %}
{% with scrobbles=Wine count=Wine_count time=Wine_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No wine today</p>
{% endif %}
<h3><a href="{% url 'drinks:coffee_list' %}">Coffee</a></h3>
{% if Coffee %}
{% with scrobbles=Coffee count=Coffee_count time=Coffee_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No coffee today</p>
{% endif %}
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3> <h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
{% if BrickSet %} {% if BrickSet %}
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %} {% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}

View File

@ -88,7 +88,13 @@
{% elif scrobble.food %} {% elif scrobble.food %}
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a> <a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
{% elif scrobble.beer %} {% elif scrobble.beer %}
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a> <a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
{% elif scrobble.wine %}
<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 %} {% 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 %}

View File

@ -175,6 +175,36 @@
</dl> </dl>
</div> </div>
{% endif %} {% endif %}
{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}
{% if object.logdata.format_display or object.logdata.size_ml %}
<div class="mb-3">
<dl class="row" style="max-width: 400px;">
{% if object.logdata.format_display %}
<dt class="col-sm-5">Format</dt>
<dd class="col-sm-7">{{ object.logdata.format_display }}</dd>
{% endif %}
{% if object.logdata.size_ml %}
<dt class="col-sm-5">Size</dt>
<dd class="col-sm-7">
{% if request.user.profile.volume_unit == "imperial" %}
{{ object.logdata.size_oz }} oz
{% else %}
{{ object.logdata.size_ml }} mL
{% endif %}
</dd>
{% endif %}
{% if object.logdata.roast_level_display %}
<dt class="col-sm-5">Roast</dt>
<dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>
{% endif %}
{% if object.logdata.brewing_method_display %}
<dt class="col-sm-5">Method</dt>
<dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>
{% endif %}
</dl>
</div>
{% endif %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.description %} {% if object.media_type == "Task" and object.logdata.description %}
<p>{{ object.logdata.description }}</p> <p>{{ object.logdata.description }}</p>
{% endif %} {% endif %}

View File

@ -82,7 +82,11 @@
{% elif scrobble.food %} {% elif scrobble.food %}
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a> <a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
{% elif scrobble.beer %} {% elif scrobble.beer %}
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a> <a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
{% elif scrobble.wine %}
<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.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 %}

View File

@ -107,6 +107,32 @@
</dl> </dl>
</div> </div>
{% endif %} {% endif %}
{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}
{% if object.logdata.format_display or object.logdata.size_ml %}
<div class="mb-3">
<dl class="row" style="max-width: 400px;">
{% if object.logdata.format_display %}
<dt class="col-sm-5">Format</dt>
<dd class="col-sm-7">{{ object.logdata.format_display }}</dd>
{% endif %}
{% if object.logdata.size_ml %}
<dt class="col-sm-5">Size</dt>
<dd class="col-sm-7">
{{ object.logdata.size_oz }} oz
</dd>
{% endif %}
{% if object.logdata.roast_level_display %}
<dt class="col-sm-5">Roast</dt>
<dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>
{% endif %}
{% if object.logdata.brewing_method_display %}
<dt class="col-sm-5">Method</dt>
<dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>
{% endif %}
</dl>
</div>
{% endif %}
{% endif %}
{% if object.media_type == "Task" and object.logdata.description %} {% if object.media_type == "Task" and object.logdata.description %}
<p>{{ object.logdata.description }}</p> <p>{{ object.logdata.description }}</p>
{% endif %} {% endif %}

View File

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