Compare commits

..

29 Commits
58.4 ... main

Author SHA1 Message Date
6861e28ac6 [drinks] Add Drink as a scrobbleable media
Some checks are pending
ci / test (push) Has started running
ci / build-and-deploy (push) Blocked by required conditions
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
28a53d70eb [release] Bump to version 59.1
Some checks failed
ci / test (push) Failing after 1m50s
ci / build-and-deploy (push) Has been skipped
- Fix bug when expansions have no image
2026-07-04 10:10:22 -04:00
98d4e8bacb [boardgames] Fix bug when expansion has no image 2026-07-04 10:09:55 -04:00
8f97131b8d [release] Bump to version 59.0
All checks were successful
ci / test (push) Successful in 2m16s
ci / build-and-deploy (push) Successful in 48s
- Add BoardGameVariant model
- Lookup all Expansions for a game when creating it
- Board game expansion lookup should be async on URL scrobbles
2026-07-04 09:36:52 -04:00
7c1f709f96 [boardgames] Fix saving variants 2026-07-04 01:50:05 -04:00
4b005e0e5b [boardgames] Lookup expansions async for URL scrobbles
All checks were successful
ci / test (push) Successful in 2m27s
ci / build-and-deploy (push) Has been skipped
2026-07-04 01:47:15 -04:00
d5dd63be0d [boardgames] Add expansion fetching 2026-07-04 01:34:42 -04:00
5aa89b7e0a [boardgames] Add idea of board game variants 2026-07-04 01:16:14 -04:00
cf444e8dd4 [release] Bump to version 58.8
All checks were successful
ci / test (push) Successful in 2m16s
ci / build-and-deploy (push) Successful in 1m7s
- Clean up trend templates
2026-07-01 23:22:54 -04:00
a74a89c747 [trends] Clean up display 2026-07-01 23:22:38 -04:00
1695f7393e [release] Bump to version 58.7
All checks were successful
ci / test (push) Successful in 2m9s
ci / build-and-deploy (push) Successful in 33s
- Split up chart page between tables and maloja
- Fix CI so we don't double run deploys and builds
2026-06-30 16:30:16 -04:00
4468e68110 [charts] Split maloja charts out from tables 2026-06-30 16:29:56 -04:00
da08eca4ab [ci] Fix split in files 2026-06-30 16:26:07 -04:00
08752e30a4 [release] Bump to version 58.6
All checks were successful
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m17s
deploy / build-and-deploy (push) Successful in 34s
- Cleanup commands should check for broken images
2026-06-30 16:04:37 -04:00
619718c045 [charts] Fix chart page missing tables 2026-06-30 16:04:09 -04:00
cb23d5a5be [metadata] Fix cleanup scripts to check for dead images 2026-06-30 16:03:29 -04:00
ec4c190e6c [release] Bump to version 58.5
All checks were successful
build / test (push) Successful in 2m15s
deploy / test (push) Successful in 2m14s
deploy / build-and-deploy (push) Successful in 56s
- The maloja style charts are messed up
2026-06-30 15:02:16 -04:00
58126928c7 [charts] Fix maloja charts acting weird
Some checks failed
build / test (push) Has been cancelled
2026-06-30 15:01:57 -04:00
107 changed files with 5809 additions and 1008 deletions

View File

@ -1,68 +0,0 @@
name: build
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
VROBBLER_DATABASE_URL: sqlite:///test.db
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip/poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-py311-
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install deps
run: |
cp vrobbler.conf.test vrobbler.conf
poetry install --with test
- name: Pytest with coverage
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
- name: Notify success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler CI success" \
-H "Priority: low" \
-H "Tags: success,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
- name: Notify failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler CI failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone

View File

@ -1,8 +1,14 @@
name: deploy name: ci
on: on:
push: push:
branches: ["**"]
tags: ["*"] tags: ["*"]
pull_request:
concurrency:
group: ${{ gitea.workflow }}
cancel-in-progress: false
jobs: jobs:
test: test:
@ -68,6 +74,7 @@ jobs:
build-and-deploy: build-and-deploy:
needs: [test] needs: [test]
if: startsWith(gitea.ref, 'refs/tags/')
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** Scraper
* Backlog [0/23] :vrobbler:project:personal: * Backlog [1/25] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES: :PROPERTIES:
@ -604,6 +604,151 @@ a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types: ** TODO [#B] Is there way to create unique slugs for media instances :media_types:
** TODO [#A] Update how board game scrobbles work :boardgames:
*** Description
When we scrobble a board game from a BGG URL, instead of going to the media
detail page, we should go to the scrobble detail page, with the Edit Log form
expanded by default.
The Edit log form should have from top to bottom:
- Board/Variant (one or many BoardGameVariant in a multi-select widget)
- People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)
- 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)
** 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]
** DONE Fix bug when expansions have no image :boardgames:bug:
:PROPERTIES:
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
:END:
* Version 59.0 [3/3]
** DONE [#A] Add BoardGameVariant model :boardgames:
:PROPERTIES:
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
:END:
*** Description
Variants represent unique boards being used per scrobble or scenarios when
playing a game. Scrobbles of a board game may have one or more
boardgame_variant_ids assocaited with their log data, and a variant is created
for one specific board game.
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
:PROPERTIES:
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
:END:
*** Description
We don't want to blow up the BGG API, but if possible with not too
many calls, when we scrobble a board game, in order to allow
populating the "Expansions" multi select, we should fetch any
expansions for the board game when creating it for the first time.
We should also create a managemnt script to update existing board games.
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
:PROPERTIES:
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
:END:
* Version 58.8 [1/1]
** DONE [#B] Clean up trend templates :trends:templates:
:PROPERTIES:
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
:END:
* Version 58.7 [2/2]
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
:PROPERTIES:
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
:END:
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
:PROPERTIES:
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
:END:
* Version 58.6 [1/1]
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
:PROPERTIES:
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
:END:
* Version 58.5 [1/1]
** DONE [#A] The maloja style charts are messed up :templates:charts:
:PROPERTIES:
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
:END:
* Version 58.4 [2/2] * Version 58.4 [2/2]
** DONE [#B] Allow people all trends or individual trends :trends:profiles: ** DONE [#B] Allow people all trends or individual trends :trends:profiles:

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "58.4" version = "59.5"
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

@ -5,6 +5,7 @@ from boardgames.models import (
BoardGameLocation, BoardGameLocation,
BoardGamePublisher, BoardGamePublisher,
BoardGameDesigner, BoardGameDesigner,
BoardGameVariant,
) )
from scrobbles.admin import ScrobbleInline from scrobbles.admin import ScrobbleInline
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
ordering = ("-created",) ordering = ("-created",)
@admin.register(BoardGameVariant)
class BoardGameVariantAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"board_game",
"uuid",
)
raw_id_fields = ("board_game",)
search_fields = ("name", "board_game__title")
ordering = ("-created",)
@admin.register(BoardGame) @admin.register(BoardGame)
class BoardGameAdmin(admin.ModelAdmin): class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created" date_hierarchy = "created"

View File

@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__" fields = "__all__"
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameVariant
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer): class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta: class Meta:
model = models.BoardGame model = models.BoardGame

View File

@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
class BoardGameVariantViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameVariant.objects.all().order_by("-created")
serializer_class = serializers.BoardGameVariantSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameViewSet(viewsets.ModelViewSet): class BoardGameViewSet(viewsets.ModelViewSet):
queryset = models.BoardGame.objects.all().order_by("-created") queryset = models.BoardGame.objects.all().order_by("-created")
serializer_class = serializers.BoardGameSerializer serializer_class = serializers.BoardGameSerializer

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

@ -0,0 +1,63 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
board_scrobbles = Scrobble.objects.filter(
board_game__isnull=False,
log__board__isnull=False,
).exclude(log__board="")
total = board_scrobbles.count()
self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")
if total == 0:
return
updated = 0
for scrobble in board_scrobbles.iterator(chunk_size=100):
log = scrobble.log
board_value = log.pop("board", None)
if not board_value:
continue
variant_ids = board_names_to_variants(
scrobble.board_game, [board_value]
)
if variant_ids:
log["variant_ids"] = variant_ids
if commit:
Scrobble.objects.filter(pk=scrobble.pk).update(log=log)
updated += 1
else:
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(
f"Updated {updated} scrobbles (changes committed)"
)
)
else:
self.stdout.write(
f"Would update {updated} scrobbles (pass --commit to persist)"
)

View File

@ -0,0 +1,80 @@
import logging
from django.core.management.base import BaseCommand
from boardgames.models import BoardGame
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from boardgames.utils import fetch_and_link_expansions
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetch and link expansions for existing board games from BGG"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--bggeek-id",
type=str,
help="Only process a single game by BGG ID",
)
def handle(self, *args, **options):
commit = options.get("commit", False)
bggeek_id = options.get("bggeek_id")
games = (
BoardGame.objects.exclude(bggeek_id__isnull=True)
.exclude(bggeek_id="")
.exclude(skip_expansions=True)
)
if bggeek_id:
games = games.filter(bggeek_id=bggeek_id)
total = games.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if total == 0:
return
updated = 0
for game in games.iterator(chunk_size=100):
try:
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
except Exception as e:
self.stdout.write(
self.style.WARNING(
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
)
)
continue
expansions = data.get("expansions", [])
if not expansions:
continue
if commit:
fetch_and_link_expansions(game, expansions)
updated += 1
self.stdout.write(
f" Linked {len(expansions)} expansions to {game.title}"
)
else:
self.stdout.write(
f" Would link {len(expansions)} expansions to {game.title}"
)
updated += 1
if commit:
self.stdout.write(
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
)
else:
self.stdout.write(
f"Would update {updated} games (pass --commit to persist)"
)

View File

@ -0,0 +1,62 @@
# Generated by Django 4.2.29 on 2026-07-02 22:23
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
]
operations = [
migrations.CreateModel(
name="BoardGameVariant",
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"
),
),
("name", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
(
"board_game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="variants",
to="boardgames.boardgame",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

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

@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
difficulty: Optional[int] = None difficulty: Optional[int] = None
solo: Optional[bool] = None solo: Optional[bool] = None
two_handed: Optional[bool] = None two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None expansion_ids: Optional[list[int]] = None
moves: Optional[list] = None moves: Optional[list] = None
rated: Optional[str] = None rated: Optional[str] = None
speed: Optional[str] = None speed: Optional[str] = None
variant: Optional[str] = None variant: Optional[str] = None
variant_ids: Optional[list[int]] = None
lichess_id: Optional[int] = None lichess_id: Optional[int] = None
board: Optional[str] = None board: Optional[str] = None
rounds: Optional[int] = None rounds: Optional[int] = None
@ -92,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 = {}
@ -106,10 +108,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
required=False, required=False,
widget=forms.Select(), widget=forms.Select(),
), ),
"variant_ids": forms.ModelMultipleChoiceField(
queryset=BoardGameVariant.objects.all(),
required=False,
widget=VariantSelectWidget(attrs={"size": 5}),
),
"expansion_ids": forms.ModelMultipleChoiceField(
queryset=BoardGame.objects.filter(
expansion_for_boardgame__isnull=False
),
required=False,
widget=forms.SelectMultiple(attrs={"size": 5}),
),
} }
fields.update(custom_fields) fields.update(custom_fields)
return fields return fields
@cached_property
def variants(self) -> list["BoardGameVariant"]:
if not self.variant_ids:
return []
return list(
BoardGameVariant.objects.filter(id__in=self.variant_ids)
)
@cached_property @cached_property
def location(self): def location(self):
if not self.location_id: if not self.location_id:
@ -135,6 +157,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.board: if self.board:
html_parts.append(f'<div class="boardgame-board">{self.board}</div>') html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
if self.variants:
variant_names = ", ".join(v.name for v in self.variants)
html_parts.append(
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
)
if self.location: if self.location:
html_parts.append(f'<div class="boardgame-location">{self.location}</div>') html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
@ -272,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:
@ -306,18 +335,29 @@ class BoardGame(ScrobblableMixin):
if not data: if not data:
data = lookup_boardgame_from_bgg(str(self.bggeek_id)) data = lookup_boardgame_from_bgg(str(self.bggeek_id))
cover_url = data.pop("cover_url") expansions = data.pop("expansions", [])
year = data.pop("year_published") cover_url = data.pop("cover_url", "")
publisher_name = data.pop("publisher_name") year_published = data.pop("year_published", None)
if year_published is None:
year_published = data.pop("published_year", None)
publisher_name = data.pop("publisher_name", "")
if year: if year_published:
data["published_year"] = int(year) data["published_year"] = int(year_published)
if not data["min_players"]: if not data["min_players"]:
data.pop("min_players") data.pop("min_players")
if not data["min_players"]: if not data["min_players"]:
data.pop("max_players") data.pop("max_players")
# Pop extra BGG metadata that isn't a model field
categories = data.pop("categories", [])
families = data.pop("families", [])
data.pop("mechanics", None)
data.pop("designers", None)
data.pop("publishers", None)
data.pop("publisher", None)
# Fun trick for updating all fields at once # Fun trick for updating all fields at once
BoardGame.objects.filter(pk=self.id).update(**data) BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db() self.refresh_from_db()
@ -329,10 +369,20 @@ 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
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"}
r = requests.get(url, headers=headers) r = requests.get(url, headers=headers)
@ -341,7 +391,12 @@ class BoardGame(ScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True) self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod @classmethod
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame": def find_or_create(
cls,
lookup_id: str,
data: dict[str, Any] = {},
defer_expansions: bool = False,
) -> "BoardGame":
"""Given a Lookup ID (either BGG or BGA ID), return a board game object""" """Given a Lookup ID (either BGG or BGA ID), return a board game object"""
game = cls.objects.filter(bggeek_id=lookup_id).first() game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game: if not game:
@ -364,16 +419,19 @@ class BoardGame(ScrobblableMixin):
else: else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id) bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
expansions = bgg_data.pop("expansions", [])
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") cover_url = bgg_data.pop("cover_url") or ""
game = cls.objects.create(**bgg_data) game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url) if cover_url:
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False) game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True) game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False) game.no_points = data.get("noPoints", False)
@ -382,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:
@ -396,4 +466,33 @@ 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)
for cat in categories:
game.genre.add(cat.strip())
for fam in families:
game.tags.add(fam.strip())
if expansions and not game.skip_expansions:
if defer_expansions:
from boardgames.tasks import fetch_board_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
class BoardGameVariant(TimeStampedModel):
name = models.CharField(max_length=255)
board_game = models.ForeignKey(
BoardGame,
on_delete=models.CASCADE,
related_name="variants",
)
description = models.TextField(**BNULL)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
def __str__(self) -> str:
return f"{self.name} ({self.board_game.title})"

View File

@ -32,9 +32,13 @@ 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:
game_dict["publisher"] = game.publishers[0] game_dict["publisher"] = game.publishers[0]
game_dict["expansions"] = [
{"id": exp.id, "name": exp.name} for exp in game.expansions
]
return game_dict return game_dict

View File

@ -0,0 +1,29 @@
import logging
from celery import shared_task
logger = logging.getLogger(__name__)
@shared_task
def fetch_board_game_expansions(board_game_id, expansions_data):
from boardgames.models import BoardGame
from boardgames.utils import fetch_and_link_expansions
game = BoardGame.objects.filter(id=board_game_id).first()
if not game:
logger.warning(
"Board game not found for expansion linking",
extra={"board_game_id": board_game_id},
)
return
fetch_and_link_expansions(game, expansions_data)
logger.info(
"Linked expansions for board game",
extra={
"board_game_id": board_game_id,
"title": game.title,
"count": len(expansions_data),
},
)

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

@ -0,0 +1,226 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
User = get_user_model()
@pytest.mark.django_db
def test_board_game_variant_creation():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
description="A test variant",
)
assert variant.name == "Test Variant"
assert variant.board_game == game
assert variant.description == "A test variant"
assert variant.uuid is not None
@pytest.mark.django_db
def test_board_game_variant_str():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert str(variant) == "Test Variant (Test Game)"
@pytest.mark.django_db
def test_board_game_variant_optional_description():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
assert variant.description is None
@pytest.mark.django_db
def test_board_game_variant_related_name():
game = BoardGame.objects.create(title="Test Game")
variant1 = BoardGameVariant.objects.create(
name="Variant 1",
board_game=game,
)
variant2 = BoardGameVariant.objects.create(
name="Variant 2",
board_game=game,
)
assert list(game.variants.all()) == [variant1, variant2]
@pytest.mark.django_db
def test_board_game_variant_cascade_delete():
game = BoardGame.objects.create(title="Test Game")
variant = BoardGameVariant.objects.create(
name="Test Variant",
board_game=game,
)
game.delete()
assert BoardGameVariant.objects.count() == 0
def _mock_bgg_game(bggeek_id, title, expansions=None):
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
class FakeGame:
id = bggeek_id
name = title
description = f"Description of {title}"
yearpublished = 2020
image = "https://example.com/cover.jpg"
minplayers = 1
maxplayers = 4
minage = 8
rating_average = 7.5
bgg_rank = 100
playingtime = 60
mechanics = []
categories = []
families = []
designers = []
publishers = []
@property
def expansions(self):
if expansions is None:
return []
return expansions
return FakeGame()
def _mock_bgg_client(return_game):
"""Return a callable that creates a fake BGGClient instance."""
class FakeBGGClient:
def __init__(self, access_token=None):
pass
def game(self, game_id=None, name=None):
return return_game
return FakeBGGClient()
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_links_expansions(mock_bgg, mock_get):
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
exp1_game = _mock_bgg_game("200", "Expansion 1")
exp2_game = _mock_bgg_game("201", "Expansion 2")
mock_bgg.side_effect = [
_mock_bgg_client(base_game),
_mock_bgg_client(exp1_game),
_mock_bgg_client(exp2_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game = BoardGame.find_or_create("100")
assert game.title == "Base Game"
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
assert expansions.count() == 2
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
mock_bgg.assert_not_called()
result = BoardGame.find_or_create("100")
assert result == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fetch_and_link_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_game = _mock_bgg_game("200", "Expansion 1")
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
from boardgames.utils import fetch_and_link_expansions
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
exp_game = _mock_bgg_game("200", "Expansion 1")
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
# second call = find_or_create inside fetch_and_link_expansions for expansion
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
game.fix_metadata(force_update=True)
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game
@pytest.mark.django_db
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
call_command("fetch_expansions")
captured = capsys.readouterr()
assert "Would link 1 expansions" in captured.out
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
@pytest.mark.django_db
@patch("boardgames.models.requests.get")
@patch("boardgames.sources.bgg.BGGClient")
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
exp_game = _mock_bgg_game("200", "Expansion 1")
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
mock_bgg.side_effect = [
_mock_bgg_client(base_with_exp),
_mock_bgg_client(exp_game),
]
mock_get.return_value.status_code = 200
mock_get.return_value.content = b"fake_image_data"
call_command("fetch_expansions", commit=True)
captured = capsys.readouterr()
assert "Updated 1 games" in captured.out
expansion = BoardGame.objects.get(bggeek_id="200")
assert expansion.expansion_for_boardgame == game

View File

@ -0,0 +1,106 @@
import pytest
from django.contrib.auth import get_user_model
from boardgames.models import BoardGame, BoardGameVariant
from boardgames.utils import board_names_to_variants
from scrobbles.models import Scrobble
User = get_user_model()
@pytest.mark.django_db
def test_board_names_to_variants_creates_variant():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
variant = BoardGameVariant.objects.get(id=ids[0])
assert variant.name == "Map A"
assert variant.board_game == game
@pytest.mark.django_db
def test_board_names_to_variants_reuses_existing():
game = BoardGame.objects.create(title="Test Game")
existing = BoardGameVariant.objects.create(
name="Map A", board_game=game
)
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert ids[0] == existing.id
@pytest.mark.django_db
def test_board_names_to_variants_multiple_names():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A", "Map B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_splits_fullwidth_slash():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map AMap B"])
assert len(ids) == 2
names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))
assert names == {"Map A", "Map B"}
@pytest.mark.django_db
def test_board_names_to_variants_skips_empty_parts():
game = BoardGame.objects.create(title="Test Game")
ids = board_names_to_variants(game, ["Map A"])
assert len(ids) == 1
assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"
@pytest.mark.django_db
def test_board_names_to_variants_different_games_independent():
game1 = BoardGame.objects.create(title="Game 1")
game2 = BoardGame.objects.create(title="Game 2")
ids1 = board_names_to_variants(game1, ["Map A"])
ids2 = board_names_to_variants(game2, ["Map A"])
assert ids1 != ids2
assert BoardGameVariant.objects.count() == 2
@pytest.mark.django_db
def test_management_command_dry_run(capsys):
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants")
captured = capsys.readouterr()
assert "Would update 1 scrobbles" in captured.out
scrobble.refresh_from_db()
assert "board" in scrobble.log
@pytest.mark.django_db
def test_management_command_commit():
from django.core.management import call_command
game = BoardGame.objects.create(title="Test Game")
user = User.objects.create(username="tester")
scrobble = Scrobble.objects.create(
user=user,
board_game=game,
log={"board": "Map A"},
)
call_command("convert_board_to_variants", commit=True)
scrobble.refresh_from_db()
assert "board" not in scrobble.log
assert "variant_ids" in scrobble.log
variant = BoardGameVariant.objects.get(board_game=game, name="Map A")
assert variant.id in scrobble.log["variant_ids"]

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

@ -0,0 +1,69 @@
import logging
from typing import Any
from boardgames.models import BoardGame, BoardGameVariant
logger = logging.getLogger(__name__)
def board_names_to_variants(
board_game: BoardGame, board_names: list[str]
) -> list[int]:
"""Given a board game and a list of board/scenario names, find or create
BoardGameVariant records and return their IDs.
Splits each name on the full-width slash ```` so that a single field
containing ``Map AMap B`` produces two separate variants.
"""
variant_ids: list[int] = []
for raw_name in board_names:
for part in raw_name.split(""):
name = part.strip()
if not name:
continue
variant, was_created = BoardGameVariant.objects.get_or_create(
board_game=board_game,
name=name,
)
logger.debug(
"Resolved board variant",
extra={
"board_game_id": board_game.id,
"variant_name": name,
"variant_id": variant.id,
"was_created": was_created,
},
)
variant_ids.append(variant.id)
return variant_ids
def fetch_and_link_expansions(
board_game: BoardGame, expansions_data: list[dict[str, Any]]
) -> None:
"""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.
"""
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:
exp_id = exp_data.get("id")
if not exp_id:
continue
expansion = BoardGame.find_or_create(str(exp_id))
if expansion and expansion.id != board_game.id:
expansion.expansion_for_boardgame = board_game
expansion.save(update_fields=["expansion_for_boardgame"])
logger.info(
"Linked expansion to board game",
extra={
"board_game_id": board_game.id,
"expansion_id": expansion.id,
"expansion_name": expansion.title,
},
)

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

@ -18,8 +18,17 @@ MISSING_ALL = [
"publish_year", "publish_year",
] ]
def _cover_missing_or_broken(book) -> bool:
if not bool(book.cover):
return True
try:
return not book.cover.storage.exists(book.cover.name)
except Exception:
return True
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda b: not bool(b.cover), "cover": _cover_missing_or_broken,
"summary": lambda b: not b.summary, "summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10, "isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None, "pages": lambda b: b.pages is None,

View File

@ -3,6 +3,7 @@ from charts.views import (
BirdsChartView, BirdsChartView,
ChartDetailView, ChartDetailView,
ChartRecordView, ChartRecordView,
MalojaChartsView,
SpotifyTracksView, SpotifyTracksView,
) )
from django.urls import path from django.urls import path
@ -11,6 +12,7 @@ app_name = "charts"
urlpatterns = [ urlpatterns = [
path("charts/", ChartRecordView.as_view(), name="charts-home"), path("charts/", ChartRecordView.as_view(), name="charts-home"),
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"), path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"), path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"), path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),

View File

@ -114,175 +114,13 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week context["current_week"] = current_week
context["current_day"] = current_day context["current_day"] = current_day
context["chart_keys"] = { # Resolve date parameters
"today": "Today", if date_param:
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"album": {
"today": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
),
"all": list(self.get_charts_for_period(user, "album")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else:
parts = date_param.split("-") parts = date_param.split("-")
year = int(parts[0]) year = int(parts[0])
week = None week = None
month = None month = None
day = None day = None
if len(parts) >= 2 and parts[1].startswith("W"): if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W")) week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]: elif len(parts) >= 2 and parts[1]:
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
month = int(parts[1]) month = int(parts[1])
except ValueError: except ValueError:
pass pass
if len(parts) >= 3: if len(parts) >= 3:
if parts[2].startswith("W"): if parts[2].startswith("W"):
week = int(parts[2].lstrip("W")) week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"): elif not parts[2].startswith("W"):
day = int(parts[2]) day = int(parts[2])
context["period"] = "historical" context["period"] = "historical"
context["year"] = year context["year"] = year
context["month"] = month context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week context["week"] = week
context["day"] = day context["day"] = day
period_str = str(year) period_str = str(year)
if month: if month:
period_str = f"{calendar.month_name[month]} {period_str}" period_str = f"{calendar.month_name[month]} {period_str}"
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
if day: if day:
period_str = f"{calendar.month_name[month]} {day}, {year}" period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str context["period_str"] = period_str
else:
year = current_year
month = current_month
week = current_week
day = current_day
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = { # List-group tables default to week-level when no date param (matches active tab)
"artist": list( if not date_param:
self.get_charts_for_period( list_year = current_year
user, list_month = None
"artist", list_week = current_week
year=year, list_day = None
month=month, else:
week=week, list_year = year
day=day, list_month = month
) list_week = week
), list_day = day
"album": list(
self.get_charts_for_period( context["charts"] = {
user, "artist": list(
"album", self.get_charts_for_period(
year=year, user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "album": list(
) self.get_charts_for_period(
), user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"track": list( )
self.get_charts_for_period( ),
user, "track": list(
"track", self.get_charts_for_period(
year=year, user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "tv_series": list(
) self.get_charts_for_period(
), user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"tv_series": list( )
self.get_charts_for_period( ),
user, "video": list(
"tv_series", self.get_charts_for_period(
year=year, user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "board_game": list(
) self.get_charts_for_period(
), user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"video": list( )
self.get_charts_for_period( ),
user, "book": list(
"video", self.get_charts_for_period(
year=year, user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "food": list(
) self.get_charts_for_period(
), user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"board_game": list( )
self.get_charts_for_period( ),
user, "podcast": list(
"board_game", self.get_charts_for_period(
year=year, user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
month=month, )
week=week, ),
day=day, "trail": list(
) self.get_charts_for_period(
), user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
"book": list( )
self.get_charts_for_period( ),
user, }
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
bird_data = self.get_bird_chart_data( bird_data = self.get_bird_chart_data(
user, user,
@ -628,6 +436,57 @@ class ChartRecordView(TemplateView):
} }
class MalojaChartsView(ChartRecordView):
"""Three maloja-themed image grid widgets (artists, albums, TV series)
with Today/Week/Month/Year/All tabs. Each tab computes its own period
from the current date — no query param needed."""
template_name = "charts/maloja_charts.html"
def get_context_data(self, **kwargs):
context = super(ChartRecordView, self).get_context_data(**kwargs)
user = self.request.user
if not user.is_authenticated:
context["maloja_charts"] = {}
context["chart_keys"] = {}
return context
now = timezone.now()
now = now_user_timezone(user.profile)
today = now.date()
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
tab_params = {
"today": {"year": today.year, "month": today.month, "day": today.day},
"week": {"year": today.year, "week": today.isocalendar()[1]},
"month": {"year": today.year, "month": today.month},
"year": {"year": today.year},
}
maloja_charts = {}
for media_type in ("artist", "album", "tv_series"):
tabs = {}
for key in ("today", "week", "month", "year"):
tabs[key] = list(
self.get_charts_for_period(user, media_type, **tab_params[key])
)
tabs["all"] = list(
self.get_charts_for_period(user, media_type)
)
maloja_charts[media_type] = tabs
context["maloja_charts"] = maloja_charts
return context
MEDIA_TYPE_LABELS = { MEDIA_TYPE_LABELS = {
"artist": ("🎤", "Top Artists"), "artist": ("🎤", "Top Artists"),
"album": ("💿", "Top Albums"), "album": ("💿", "Top Albums"),

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

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

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

@ -0,0 +1,202 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, transaction
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enrich artist and album metadata (covers, thumbnails) from MusicBrainz and TheAudioDB"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing cover image and metadata",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--artists",
action="store_true",
help="Only process artists",
)
parser.add_argument(
"--albums",
action="store_true",
help="Only process albums",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process items missing metadata or with broken images",
)
def _has_broken_image(self, obj, field_name: str) -> bool:
field = getattr(obj, field_name, None)
if not field or not field.name:
return False
try:
return not field.storage.exists(field.name)
except Exception:
return True
def handle(self, *args, **options):
from music.models import Album, Artist
force = options["force"]
dry_run = options["dry_run"]
only_artists = options["artists"]
only_albums = options["albums"]
needs_metadata = options["needs_metadata"]
if not only_artists and not only_albums:
only_artists = only_albums = True
updated_total = 0
errors_total = 0
if only_artists:
updated_total += self._process_artists(force, dry_run, needs_metadata)
errors_total = 0 # reset per section
if only_albums:
updated_total += self._process_albums(force, dry_run, needs_metadata)
self.stdout.write(
self.style.SUCCESS(f"\nDone! {updated_total} items processed")
)
def _get_artists(self, needs_metadata: bool):
from music.models import Artist
qs = Artist.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(theaudiodb_id__isnull=True)
| models.Q(theaudiodb_id="")
| models.Q(thumbnail__isnull=True)
| models.Q(thumbnail="")
)
broken = []
if needs_metadata:
broken_qs = Artist.objects.exclude(
models.Q(thumbnail__isnull=True) | models.Q(thumbnail=""),
)
for artist in broken_qs.iterator():
if self._has_broken_image(artist, "thumbnail"):
broken.append(artist)
return list(qs) + broken
def _get_albums(self, needs_metadata: bool):
from music.models import Album
qs = Album.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
| models.Q(cover_image="default-image-replace-me")
)
broken = []
if needs_metadata:
broken_qs = Album.objects.exclude(
models.Q(cover_image__isnull=True) | models.Q(cover_image=""),
)
for album in broken_qs.iterator():
if self._has_broken_image(album, "cover_image"):
broken.append(album)
return list(qs) + broken
def _process_artists(self, force, dry_run, needs_metadata):
from music.models import Artist
artists = self._get_artists(needs_metadata) if needs_metadata else list(Artist.objects.all())
total = len(artists)
self.stdout.write(f"Processing {total} artists")
if dry_run:
for artist in artists:
has_tadb = bool(artist.theaudiodb_id)
has_thumb = bool(artist.thumbnail)
thumb_broken = self._has_broken_image(artist, "thumbnail")
status = f"theaudiodb_id={'' if has_tadb else ''}"
if thumb_broken:
status += ", thumbnail=BROKEN"
elif has_thumb:
status += ", thumbnail=✓"
else:
status += ", thumbnail=✗"
self.stdout.write(f" [DRY RUN] Would fix {artist.name} ({status})")
return 0
updated = 0
errors = 0
for artist in artists:
try:
with transaction.atomic():
artist.fix_metadata(
force_update=force or self._has_broken_image(artist, "thumbnail")
)
updated += 1
self.stdout.write(f" [ARTIST {updated}/{total}] {artist.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating artist {artist.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nArtists done! {updated} updated, {errors} errors")
)
return updated
def _process_albums(self, force, dry_run, needs_metadata):
from music.models import Album
albums = self._get_albums(needs_metadata) if needs_metadata else list(Album.objects.all())
total = len(albums)
self.stdout.write(f"Processing {total} albums")
if dry_run:
for album in albums:
has_cover = bool(album.cover_image)
cover_broken = self._has_broken_image(album, "cover_image")
if cover_broken:
status = "cover=BROKEN"
elif has_cover:
status = "cover=✓"
else:
status = "cover=✗"
self.stdout.write(f" [DRY RUN] Would fix {album.name} ({status})")
return 0
updated = 0
errors = 0
for album in albums:
try:
with transaction.atomic():
if self._has_broken_image(album, "cover_image") or force:
album.fetch_artwork(force=True)
else:
album.fix_metadata()
updated += 1
self.stdout.write(f" [ALBUM {updated}/{total}] {album.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating album {album.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nAlbums done! {updated} updated, {errors} errors")
)
return updated

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,7 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit", "home_scrobble_limit",
"live_now_playing", "live_now_playing",
"weigh_in_units", "weigh_in_units",
"volume_unit",
"trends_disabled", "trends_disabled",
] ]
widgets = { widgets = {
@ -51,9 +51,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

@ -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,11 @@ 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 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 +78,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 +102,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 +115,12 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC, default=WeighUnit.METRIC,
) )
volume_unit = models.CharField(
max_length=16,
choices=VolumeUnit.choices,
default=VolumeUnit.METRIC,
)
trends_disabled = models.BooleanField(default=False) trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField( disabled_trends = models.JSONField(
@ -153,7 +168,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

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

@ -6,8 +6,8 @@ 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 books.constants import READCOMICSONLINE_URL from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData, Paper from books.models import Book, BookLogData, BookPageLogData, Paper
from books.utils import parse_readcomicsonline_uri from books.utils import parse_readcomicsonline_uri
@ -15,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, Wine, Drink, DrinkFormat
from foods.models import Food from foods.models import Food
from foods.sources.rscraper import RecipeScraperService from foods.sources.rscraper import RecipeScraperService
from locations.constants import LOCATION_PROVIDERS from locations.constants import LOCATION_PROVIDERS
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
source: str = "BGG", source: str = "BGG",
action: Optional[str] = None, action: Optional[str] = None,
) -> Scrobble | None: ) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id) boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
if not boardgame: if not boardgame:
logger.error(f"No board game found for ID {bggeek_id}") logger.error(f"No board game found for ID {bggeek_id}")
@ -495,7 +496,9 @@ def email_scrobble_board_game(
if play_dict.get("rounds", False): if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds") log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False): if play_dict.get("board", False):
log_data["board"] = play_dict.get("board") log_data["variant_ids"] = board_names_to_variants(
base_game, [play_dict.get("board")]
)
log_data["players"] = [] log_data["players"] = []
for score_dict in play_dict.get("playerScores", []): for score_dict in play_dict.get("playerScores", []):
@ -645,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)
@ -763,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")
@ -930,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")
@ -1253,6 +1280,107 @@ 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
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
"log": {"format": DrinkFormat.BY_THE_GLASS, "size_ml": 250},
}
logger.info(
"[vrobbler-scrobble] water scrobble request received",
extra={
"water_id": water.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.DRINK,
},
)
return Scrobble.create_or_update(water, user_id, scrobble_dict)
def manual_scrobble_puzzle( def manual_scrobble_puzzle(
ipdb_id: str, ipdb_id: str,
user_id: int, user_id: int,
@ -1364,6 +1492,50 @@ def manual_scrobble_food(
return Scrobble.create_or_update(food, user_id, scrobble_dict) return Scrobble.create_or_update(food, user_id, scrobble_dict)
def manual_scrobble_drink(
item_id: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
if not match:
logger.info(
"[manual_scrobble_drink] invalid format, expected 'Drink Name - calories'",
extra={"item_id": item_id, "user_id": user_id},
)
return
drink_name = match.group(1).strip()
calories = int(match.group(2))
drink = Drink.objects.filter(title=drink_name).first()
if not drink:
drink = Drink.objects.create(
title=drink_name,
calories=calories,
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[scrobblers] manual drink scrobble request received",
extra={
"drink_id": drink.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.DRINK,
},
)
return Scrobble.create_or_update(drink, user_id, scrobble_dict)
def manual_scrobble_discgolf( def manual_scrobble_discgolf(
item_id: str, item_id: str,
user_id: int, user_id: int,

View File

@ -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,6 +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_board_game_widgets(self, form):
if not isinstance(self.object.media_obj, BoardGame):
return
if "expansion_ids" in form.fields:
expansions = BoardGame.objects.filter(
expansion_for_boardgame=self.object.media_obj
)
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()
@ -1245,12 +1298,42 @@ class ScrobbleDetailView(DetailView):
else: else:
log["notes"] = self.object.logdata.notes_as_str(separator="\n") log["notes"] = self.object.logdata.notes_as_str(separator="\n")
return FormClass(initial=log) form = FormClass(initial=log)
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
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_board_game_widgets(form)
if form.is_valid(): if form.is_valid():
data = form.cleaned_data.copy() data = form.cleaned_data.copy()
@ -1263,6 +1346,12 @@ class ScrobbleDetailView(DetailView):
if data.get("with_people_ids") is not None: if data.get("with_people_ids") is not None:
data["with_people_ids"] = [p.id for p in data["with_people_ids"]] data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
if data.get("expansion_ids") is not None:
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
if data.get("variant_ids") is not None:
data["variant_ids"] = [v.id for v in data["variant_ids"]]
if data.get("mood_reason_ids") is not None: if data.get("mood_reason_ids") is not None:
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]] data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
@ -1346,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
@ -1926,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

@ -1,45 +1,78 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.distribution %} {% if data.distribution %}
{{ data.distribution|json_script:"activity-distribution-data" }}
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong> Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
</p> </p>
<div class="table-responsive"> <canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
<table class="table table-striped table-sm"> <script>
<thead> (function() {
<tr> var el = document.getElementById('activity-distribution-data');
<th>Media Type</th> if (!el) return;
<th class="text-end">Total</th> var data = JSON.parse(el.textContent);
<th class="text-end">Completed</th> if (!data.length) return;
<th class="text-end">%</th> var canvas = document.getElementById('activityDistChart');
<th>Distribution</th> var ctx = canvas.getContext('2d');
</tr> var W = canvas.width;
</thead> var rowH = 34;
<tbody> var labelW = 140;
{% with max=data.distribution.0.count %} var barLeft = labelW + 8;
{% for entry in data.distribution %} var barRight = W - 80;
<tr> var barMaxW = barRight - barLeft;
<td>{{ entry.media_type }}</td> var padTop = 8;
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td> var maxCount = data[0].count;
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;"> // Set canvas height based on data
{% if max > 0 %} canvas.height = data.length * rowH + padTop;
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar" function roundRect(x, y, w, h, r) {
style="width: {{ entry.pct }}%;" ctx.beginPath();
aria-valuenow="{{ entry.count }}" ctx.moveTo(x + r, y);
aria-valuemin="0" aria-valuemax="{{ max }}"> ctx.lineTo(x + w - r, y);
</div> ctx.quadraticCurveTo(x + w, y, x + w, y + r);
</div> ctx.lineTo(x + w, y + h - r);
{% endif %} ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
</td> ctx.lineTo(x + r, y + h);
</tr> ctx.quadraticCurveTo(x, y + h, x, y + h - r);
{% endfor %} ctx.lineTo(x, y + r);
{% endwith %} ctx.quadraticCurveTo(x, y, x + r, y);
</tbody> ctx.closePath();
</table> }
</div>
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
// Label
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
// Bar background
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
// Bar fill — green (top) to red (bottom)
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
// Count + percentage label on the right
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No activity data found.</p> <p class="text-muted">No activity data found.</p>
{% endif %} {% endif %}

View File

@ -1,43 +1,74 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.moods %} {% if data.moods %}
{{ data.moods|json_script:"mood-distribution-data" }}
<p class="text-muted mb-3"> <p class="text-muted mb-3">
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong> Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
&middot; Positive: <strong>{{ data.positive_count }}</strong> &middot; Positive: <strong>{{ data.positive_count }}</strong>
&middot; Negative: <strong>{{ data.negative_count }}</strong> &middot; Negative: <strong>{{ data.negative_count }}</strong>
</p> </p>
<div class="table-responsive"> <canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
<table class="table table-striped table-sm"> <script>
<thead> (function() {
<tr> var el = document.getElementById('mood-distribution-data');
<th>Mood</th> if (!el) return;
<th class="text-end">Count</th> var data = JSON.parse(el.textContent);
<th>Distribution</th> if (!data.length) return;
</tr> var canvas = document.getElementById('moodDistChart');
</thead> var ctx = canvas.getContext('2d');
<tbody> var rowH = 34;
{% with max=data.moods.0.count %} var labelW = 140;
{% for entry in data.moods %} var barLeft = labelW + 8;
<tr> var barRight = canvas.width - 80;
<td>{{ entry.mood }}</td> var barMaxW = barRight - barLeft;
<td class="text-end">{{ entry.count }}</td> var padTop = 8;
<td style="width: 40%;">
{% if max > 0 %} canvas.height = data.length * rowH + padTop;
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar" var maxCount = data[0].count;
style="width: {% widthratio entry.count max 100 %}%;"
aria-valuenow="{{ entry.count }}" function roundRect(x, y, w, h, r) {
aria-valuemin="0" aria-valuemax="{{ max }}"> ctx.beginPath();
</div> ctx.moveTo(x + r, y);
</div> ctx.lineTo(x + w - r, y);
{% endif %} ctx.quadraticCurveTo(x + w, y, x + w, y + r);
</td> ctx.lineTo(x + w, y + h - r);
</tr> ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
{% endfor %} ctx.lineTo(x + r, y + h);
{% endwith %} ctx.quadraticCurveTo(x, y + h, x, y + h - r);
</tbody> ctx.lineTo(x, y + r);
</table> ctx.quadraticCurveTo(x, y, x + r, y);
</div> ctx.closePath();
}
data.forEach(function(entry, i) {
var y = padTop + i * rowH;
var pct = entry.count / maxCount;
var barW = pct * barMaxW;
ctx.fillStyle = '#374151';
ctx.font = '13px sans-serif';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
ctx.fillStyle = '#f3f4f6';
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
ctx.fill();
var hue = 120 * (1 - i / (data.length - 1 || 1));
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
ctx.fill();
ctx.fillStyle = '#374151';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No mood distribution data found.</p> <p class="text-muted">No mood distribution data found.</p>
{% endif %} {% endif %}

View File

@ -1,37 +1,105 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.trajectory %} {% if data.trajectory %}
<div class="table-responsive"> {{ data.trajectory|json_script:"mood-trajectory-data" }}
<table class="table table-striped table-sm"> <div class="d-flex flex-column align-items-center">
<thead> <canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
<tr> <div class="d-flex gap-4 mt-2 small text-muted">
<th>Date</th> <span>← Earlier</span>
<th class="text-end">Avg Quality</th> <span>Later →</span>
<th class="text-end">Check-ins</th> </div>
<th>Mood Bar</th>
</tr>
</thead>
<tbody>
{% for entry in data.trajectory %}
<tr>
<td>{{ entry.date }}</td>
<td class="text-end">{{ entry.avg_quality }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
<div class="progress" style="height: 16px;">
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
role="progressbar"
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
aria-valuenow="{{ entry.avg_quality }}"
aria-valuemin="1" aria-valuemax="7">
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<script>
(function() {
var el = document.getElementById('mood-trajectory-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('moodTrajectoryChart');
var ctx = canvas.getContext('2d');
var W = canvas.width, H = canvas.height;
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
var plotW = W - pad.left - pad.right;
var plotH = H - pad.top - pad.bottom;
var yMin = 1, yMax = 7;
var yRange = yMax - yMin;
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
function xPos(i) {
return pad.left + (i / (data.length - 1 || 1)) * plotW;
}
function yPos(val) {
return pad.top + (1 - (val - yMin) / yRange) * plotH;
}
// Background grid
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 0.5;
for (var q = 1; q <= 7; q++) {
var y = yPos(q);
ctx.beginPath();
ctx.moveTo(pad.left, y);
ctx.lineTo(W - pad.right, y);
ctx.stroke();
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'right';
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
}
// Reference line at neutral (4)
var neutralY = yPos(4);
ctx.strokeStyle = '#d1d5db';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pad.left, neutralY);
ctx.lineTo(W - pad.right, neutralY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#9ca3af';
ctx.font = '11px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
// Line chart
ctx.beginPath();
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.strokeStyle = '#6366f1';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.stroke();
// Gradient fill below the line
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
ctx.lineTo(xPos(0), yPos(yMin));
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Dots
data.forEach(function(d, i) {
var x = xPos(i);
var y = yPos(d.avg_quality);
ctx.beginPath();
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
ctx.fillStyle = '#6366f1';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No mood check-in data found.</p> <p class="text-muted">No mood check-in data found.</p>
{% endif %} {% endif %}

View File

@ -1,50 +1,90 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.hours %} {% if data.hours %}
<div class="table-responsive"> {{ data.hours|json_script:"peak-hours-data" }}
<table class="table table-striped table-sm"> <div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
<thead> <div>
<tr> <canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
<th>Hour</th> </div>
<th class="text-end">Scrobbles</th> <div class="table-responsive" style="max-height:360px; overflow-y:auto;">
<th>Distribution</th> <table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
</tr> <thead>
</thead> <tr>
<tbody> <th style="width:14px; padding-right:0;"></th>
{% with total=data.hours|dictsortreversed:"count"|first %} <th>Hour</th>
{% with max_count=total.count %} <th class="text-end">Scrobbles</th>
{% for entry in data.hours %} </tr>
<tr> </thead>
<td> <tbody>
{% if entry.hour == 0 %} {% for entry in data.hours %}
12 AM <tr>
{% elif entry.hour < 12 %} <td class="p-1 text-center">
{{ entry.hour }} AM <span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
{% elif entry.hour == 12 %} </td>
12 PM <td>
{% else %} {% if entry.hour == 0 %}
{{ entry.hour|add:"-12" }} PM 12 AM
{% endif %} {% elif entry.hour < 12 %}
</td> {{ entry.hour }} AM
<td class="text-end">{{ entry.count }}</td> {% elif entry.hour == 12 %}
<td style="width: 40%;"> 12 PM
{% if max_count > 0 %} {% else %}
<div class="progress" style="height: 12px;"> {{ entry.hour|add:"-12" }} PM
<div class="progress-bar" role="progressbar" {% endif %}
style="width: {{ entry.count|floatformat:0 }}%;" </td>
aria-valuenow="{{ entry.count }}" <td class="text-end">{{ entry.count }}</td>
aria-valuemin="0" aria-valuemax="{{ max_count }}"> </tr>
</div> {% endfor %}
</div> </tbody>
{% endif %} </table>
</td> </div>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
</div> </div>
<script>
(function() {
var dataEl = document.getElementById('peak-hours-data');
if (!dataEl) return;
var data = JSON.parse(dataEl.textContent);
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
var canvas = document.getElementById('peakHoursChart');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var cx = canvas.width / 2;
var cy = canvas.height / 2;
var radius = Math.min(cx, cy) - 10;
var startAngle = -Math.PI / 2;
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
var range = maxCount - minCount || 1;
function countToHue(count) {
var t = (count - minCount) / range;
return 120 * t;
}
data.forEach(function(entry, i) {
var sliceAngle = (entry.count / total) * 2 * Math.PI;
var hue = countToHue(entry.count);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.stroke();
startAngle += sliceAngle;
});
document.querySelectorAll('.legend-swatch').forEach(function(el) {
var idx = parseInt(el.getAttribute('data-idx'), 10);
var entry = data[idx];
var hue = countToHue(entry.count);
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
});
})();
</script>
{% else %} {% else %}
<p class="text-muted">No activity data found.</p> <p class="text-muted">No activity data found.</p>
{% endif %} {% endif %}

View File

@ -1,31 +1,45 @@
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
{% if data.total and data.total > 0 %} {% if data.total and data.total > 0 %}
<h5>Overall</h5> <div class="mb-4">
<div class="table-responsive mb-4"> {% for slug, info in data.categories.items %}
<table class="table table-striped table-sm"> {% if forloop.first %}
<thead> <div class="card text-center border-0 bg-light mb-3">
<tr> <div class="card-body py-4">
<th>Category</th> {% if slug == "early_bird" %}
<th class="text-end">Scrobbles</th> <div class="display-1">🌅</div>
<th class="text-end">%</th> {% elif slug == "day_jay" %}
</tr> <div class="display-1">☀️</div>
</thead> {% else %}
<tbody> <div class="display-1">🌙</div>
{% for slug, info in data.categories.items %} {% endif %}
<tr> <h3 class="mt-2">{{ info.label }}</h3>
<td>{{ info.label }}</td> <div class="display-4 fw-bold">{{ info.pct }}%</div>
<td class="text-end">{{ info.count }}</td> <div class="text-muted">{{ info.count }} scrobbles</div>
<td class="text-end">{{ info.pct }}%</td> </div>
</tr> </div>
{% endfor %} {% else %}
<tr class="table-secondary"> <div class="card text-center border-0 bg-light mb-2">
<td><strong>Total</strong></td> <div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
<td class="text-end"><strong>{{ data.total }}</strong></td> <div class="d-flex align-items-center gap-3">
<td class="text-end"></td> {% if slug == "early_bird" %}
</tr> <span style="font-size:1.5rem;">🌅</span>
</tbody> {% elif slug == "day_jay" %}
</table> <span style="font-size:1.5rem;">☀️</span>
{% else %}
<span style="font-size:1.5rem;">🌙</span>
{% endif %}
<h5 class="mb-0">{{ info.label }}</h5>
</div>
<div class="text-end">
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
<br><small class="text-muted">{{ info.count }} scrobbles</small>
</div>
</div>
</div>
{% endif %}
{% endfor %}
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
</div> </div>
<h5>By Media Type</h5> <h5>By Media Type</h5>

View File

@ -1,38 +1,27 @@
<div class="row"> <div class="row g-3">
<div class="col-12"> {% if data %}
{% if data %} {% for mt, info in data.items %}
<div class="table-responsive"> <div class="col-6 col-md-4 col-lg-3 col-xl-2">
<table class="table table-striped table-sm"> <div class="card text-center h-100">
<thead> <div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
<tr> {% if info.change_pct > 0 %}
<th>Media Type</th> <div class="display-3 lh-1 text-success"></div>
<th class="text-end">Recent ({{ current_period_label }})</th> <div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
<th class="text-end">Previous ({{ current_period_label }})</th> {% elif info.change_pct < 0 %}
<th class="text-end">Change</th> <div class="display-3 lh-1 text-danger"></div>
</tr> <div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
</thead> {% else %}
<tbody> <div class="display-3 lh-1 text-muted"></div>
{% for mt, info in data.items %} <div class="fs-6 text-muted mt-1">0%</div>
<tr> {% endif %}
<td>{{ mt }}</td> <div class="fw-bold mt-2">{{ mt }}</div>
<td class="text-end">{{ info.recent }}</td> </div>
<td class="text-end">{{ info.previous }}</td> </div>
<td class="text-end">
{% if info.change_pct > 0 %}
<span class="text-success">+{{ info.change_pct }}%</span>
{% elif info.change_pct < 0 %}
<span class="text-danger">{{ info.change_pct }}%</span>
{% else %}
<span class="text-muted">0%</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} {% endfor %}
<p class="text-muted">No trending data found.</p> {% else %}
{% endif %} <div class="col-12">
</div> <p class="text-muted">No trending data found.</p>
</div>
{% endif %}
</div> </div>

View File

@ -59,18 +59,21 @@ def compute_time_of_day_categories(user, period="last_30"):
if slug: if slug:
cat_counts[slug] += count cat_counts[slug] += count
mt_total += count mt_total += count
by_media_type[mt] = { mt_categories = {}
"total": mt_total,
"categories": {},
}
for slug in CATEGORIES: for slug in CATEGORIES:
c = cat_counts[slug] c = cat_counts[slug]
by_media_type[mt]["categories"][slug] = { mt_categories[slug] = {
"count": c, "count": c,
"pct": round((c / mt_total * 100), 1) if mt_total else 0, "pct": round((c / mt_total * 100), 1) if mt_total else 0,
"label": CATEGORIES[slug]["label"], "label": CATEGORIES[slug]["label"],
} }
grand_totals[slug] += c grand_totals[slug] += c
by_media_type[mt] = {
"total": mt_total,
"categories": dict(
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
),
}
grand_total += mt_total grand_total += mt_total
categories = {} categories = {}
@ -81,6 +84,9 @@ def compute_time_of_day_categories(user, period="last_30"):
"pct": round((c / grand_total * 100), 1) if grand_total else 0, "pct": round((c / grand_total * 100), 1) if grand_total else 0,
"label": CATEGORIES[slug]["label"], "label": CATEGORIES[slug]["label"],
} }
categories = dict(
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
)
return { return {
"categories": categories, "categories": categories,

View File

@ -17,8 +17,8 @@ MISSING_ALL = [
] ]
MISSING_GROUPS = { MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover), "cover": lambda g: _image_missing_or_broken(g, "cover"),
"screenshot": lambda g: not bool(g.screenshot), "screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
"summary": lambda g: not g.summary, "summary": lambda g: not g.summary,
"rating": lambda g: g.rating is None, "rating": lambda g: g.rating is None,
"release_date": lambda g: g.release_date is None, "release_date": lambda g: g.release_date is None,
@ -28,6 +28,16 @@ MISSING_GROUPS = {
} }
def _image_missing_or_broken(game, field_name) -> bool:
field = getattr(game, field_name)
if not bool(field):
return True
try:
return not field.storage.exists(field.name)
except Exception:
return True
def _game_matches(game, flags): def _game_matches(game, flags):
if not flags: if not flags:
return False return False
@ -103,6 +113,7 @@ class Command(BaseCommand):
if all_missing: if all_missing:
flags = MISSING_ALL flags = MISSING_ALL
fix_broken_images = True
if not flags and not game_id and not force and not fix_broken_images: if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write( self.stdout.write(

View File

@ -30,6 +30,19 @@ class Command(BaseCommand):
action="store_true", action="store_true",
help="Only process channels with a twitch_id", help="Only process channels with a twitch_id",
) )
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
)
def _has_broken_image(self, channel) -> bool:
if not channel.cover_image or not channel.cover_image.name:
return False
try:
return not channel.cover_image.storage.exists(channel.cover_image.name)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Channel from videos.models import Channel
@ -38,6 +51,7 @@ class Command(BaseCommand):
dry_run = options["dry_run"] dry_run = options["dry_run"]
youtube_only = options["youtube_only"] youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"] twitch_only = options["twitch_only"]
needs_metadata = options["needs_metadata"]
qs = Channel.objects.all() qs = Channel.objects.all()
@ -45,29 +59,61 @@ class Command(BaseCommand):
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="") qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only: elif twitch_only:
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="") qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
elif needs_metadata:
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
qs = qs.filter(
no_id
| models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
)
else: else:
qs = qs.filter( qs = qs.filter(
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False) models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
).exclude(youtube_id="", twitch_id="") ).exclude(youtube_id="", twitch_id="")
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} channels") self.stdout.write(f"Processing {total} channels from DB filter")
broken_channels = []
if needs_metadata:
broken_qs = Channel.objects.filter(
cover_image__isnull=False,
).exclude(
cover_image="",
)
if youtube_only:
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
for channel in broken_qs.iterator():
if self._has_broken_image(channel):
broken_channels.append(channel)
all_channels = list(qs) + broken_channels
total = len(all_channels)
self.stdout.write(f"Total channels to process: {total}")
if dry_run: if dry_run:
for channel in qs.iterator(): for channel in all_channels:
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id identifier = channel.youtube_id or channel.twitch_id
status = f"({source}: {identifier})"
if self._has_broken_image(channel):
status += " [image BROKEN]"
self.stdout.write( self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})" f" [DRY RUN] Would fix {channel.name} {status}"
) )
return return
updated = 0 updated = 0
errors = 0 errors = 0
for channel in qs.iterator(): for channel in all_channels:
try: try:
with transaction.atomic(): with transaction.atomic():
channel.fix_metadata(force=force) channel.fix_metadata(force=force or self._has_broken_image(channel))
updated += 1 updated += 1
source = "youtube" if channel.youtube_id else "twitch" source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})") self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")

View File

@ -1,4 +1,5 @@
import logging import logging
import os
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import models, transaction from django.db import models, transaction
@ -28,9 +29,18 @@ class Command(BaseCommand):
parser.add_argument( parser.add_argument(
"--needs-metadata", "--needs-metadata",
action="store_true", action="store_true",
help="Only process series missing imdb_id or cover image", help="Only process series missing imdb_id or with broken cover image",
) )
def _has_broken_image(self, series) -> bool:
"""Check if a series has a cover_image set but the file is missing."""
if not series.cover_image:
return False
try:
return not os.path.exists(series.cover_image.path)
except Exception:
return True
def handle(self, *args, **options): def handle(self, *args, **options):
from videos.models import Series from videos.models import Series
@ -51,25 +61,50 @@ class Command(BaseCommand):
) )
total = qs.count() total = qs.count()
self.stdout.write(f"Processing {total} series") self.stdout.write(f"Processing {total} series from DB filter")
# Also find series with broken cover images
broken_image_series = []
if needs_metadata:
broken_qs = Series.objects.filter(
cover_image__isnull=False,
).exclude(
models.Q(imdb_id__isnull=True)
| models.Q(imdb_id="")
| models.Q(cover_image__isnull=True)
| models.Q(cover_image=""),
)
if imdb_id:
broken_qs = broken_qs.filter(imdb_id=imdb_id)
for series in broken_qs.iterator():
if self._has_broken_image(series):
broken_image_series.append(series)
all_series = list(qs) + broken_image_series
total = len(all_series)
self.stdout.write(f"Total series to process: {total}")
if dry_run: if dry_run:
for series in qs.iterator(): for series in all_series:
has_imdb = bool(series.imdb_id) has_imdb = bool(series.imdb_id)
has_image = bool(series.cover_image) has_image = bool(series.cover_image)
self.stdout.write( image_broken = self._has_broken_image(series)
f" [DRY RUN] Would fix {series.name}" status = f"imdb_id={'' if has_imdb else ''}"
f" (imdb_id={'' if has_imdb else ''}" if image_broken:
f", image={'' if has_image else ''})" status += ", image=BROKEN"
) elif has_image:
status += ", image=✓"
else:
status += ", image=✗"
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
return return
updated = 0 updated = 0
errors = 0 errors = 0
for series in qs.iterator(): for series in all_series:
try: try:
with transaction.atomic(): with transaction.atomic():
series.fix_metadata(force_update=force) series.fix_metadata(force_update=force or self._has_broken_image(series))
updated += 1 updated += 1
self.stdout.write(f" [{updated}/{total}] {series.name}") self.stdout.write(f" [{updated}/{total}] {series.name}")
except Exception as e: except Exception as e:

View File

@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York") TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")] CSRF_TRUSTED_ORIGINS = [
os.getenv(
"VROBBLER_TRUSTED_ORIGINS",
"http://localhost:8000",
),
"https://dev-vrobbler.lab.unbl.ink",
]
X_FRAME_OPTIONS = "SAMEORIGIN" X_FRAME_OPTIONS = "SAMEORIGIN"
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None) REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
@ -223,7 +229,7 @@ INSTALLED_APPS = [
"webpages", "webpages",
"tasks", "tasks",
"trails", "trails",
"beers", "drinks",
"puzzles", "puzzles",
"foods", "foods",
"lifeevents", "lifeevents",
@ -399,6 +405,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,12 @@
<div class="nav-item text-nowrap"> <div class="nav-item text-nowrap">
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a> <a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
</div> </div>
<div class="nav-item text-nowrap">
<form method="post" action="{% url "drinks:quick_water_scrobble" %}" style="display:inline;">
{% csrf_token %}
<button type="submit" class="nav-link px-3 btn btn-link" style="text-decoration:none;">Drink water</button>
</form>
</div>
<div class="nav-item text-nowrap"> <div class="nav-item text-nowrap">
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a> <a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
</div> </div>

View File

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

@ -120,9 +120,67 @@
</div> </div>
{% endif %} {% endif %}
{% include "scrobbles/_top_charts.html" %} <div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:maloja-charts' %}" class="btn btn-sm btn-outline-secondary">🎨 Maloja Widgets</a>
</div>
</div>
<div class="row mt-4">
{% if charts.artist %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎤 Top Artists</h3>
<ul class="list-group">
{% for chart in charts.artist|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'artist' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>💿 Top Albums</h3>
<ul class="list-group">
{% for chart in charts.album|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'album' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📺 Top TV Series</h3>
<ul class="list-group">
{% for chart in charts.tv_series|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
<div class="row">
{% if charts.track %} {% if charts.track %}
<div class="col-md-6 col-lg-4 chart-section"> <div class="col-md-6 col-lg-4 chart-section">
<h3>🎵 Top Tracks</h3> <h3>🎵 Top Tracks</h3>

View File

@ -0,0 +1,45 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Maloja Widgets{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom: 100px; }
h2 { padding-top: 20px; }
.nav-tabs { cursor: pointer; }
.image-wrapper { contain: content; }
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
.caption {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 90%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 75%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-small {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 60%;
color: white; background: rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
{% block grid_view_button %}{% endblock %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">&larr; Full Charts</a>
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
</div>
</div>
{% include "scrobbles/_top_charts.html" %}
{% endblock %}

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

Some files were not shown because too many files have changed in this diff Show More