Compare commits

...

36 Commits
58.3 ... main

Author SHA1 Message Date
681252b845 [release] Bump to version 60.1
All checks were successful
ci / test (push) Successful in 2m20s
ci / build-and-deploy (push) Successful in 1m38s
- Migrate coffee scrobbles to new Coffee model
2026-07-14 12:54:04 -04:00
f6a20b67a6 [drinks] Add coffee migration script 2026-07-14 12:53:27 -04:00
c9298fe387 [release] Bump to version 60.0
All checks were successful
ci / test (push) Successful in 2m19s
ci / build-and-deploy (push) Successful in 41s
- Send water drinking notifications
- Add wine as a ScrobbleItem in a drinks app and move beers into drinks app
2026-07-14 00:13:17 -04:00
f7d99f8b55 [drinks] Add water drink notifications 2026-07-13 23:17:14 -04:00
6861e28ac6 [drinks] Add Drink as a scrobbleable media
All checks were successful
ci / test (push) Successful in 2m48s
ci / build-and-deploy (push) Has been skipped
2026-07-13 19:12:59 -04:00
fdc18d5a5f [drinks] Rename beers to drinks and add new models 2026-07-13 13:26:28 -04:00
77d92f6c96 [release] Bump to version 59.5
All checks were successful
ci / test (push) Successful in 2m13s
ci / build-and-deploy (push) Successful in 38s
- Fix bug where all variants for board games are in form
2026-07-05 00:15:58 -04:00
d5d0eb6cd8 [boardgames] Make sure variants are filtered by game 2026-07-05 00:15:26 -04:00
d6f71e0761 [release] Bump to version 59.4
All checks were successful
ci / test (push) Successful in 2m14s
ci / build-and-deploy (push) Successful in 37s
- Fix bug in fetching expansions for board games
- Board games should have genres extracted from family data
2026-07-04 11:53:28 -04:00
b00ebf49dd [boardgames] Fix getting BGG id 2026-07-04 11:53:05 -04:00
2385e9c7bd [release] Bump to version 59.3
All checks were successful
ci / test (push) Successful in 2m18s
ci / build-and-deploy (push) Successful in 37s
- Exclude some board games from auto-expansion imports
- Should be able to add new variants to board games via the log data form
2026-07-04 11:41:49 -04:00
d78529efe2 [boardgames] Add genres and categories 2026-07-04 11:41:24 -04:00
f373e98e3d [boardgames] Skip CCG games for auto expansion download 2026-07-04 11:32:17 -04:00
7559ce7824 [boardgames] Add ability to add new variants to form 2026-07-04 11:28:41 -04:00
2c481bd53a [release] Bump to version 59.2
All checks were successful
ci / test (push) Successful in 2m20s
ci / build-and-deploy (push) Successful in 36s
- Fix test failure in discgolf app
2026-07-04 10:18:42 -04:00
0deb3ee634 [discgolf] Fix breaking tests 2026-07-04 10:18:17 -04:00
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
c0d2881585 [release] Bump to version 58.4
All checks were successful
build / test (push) Successful in 2m13s
deploy / test (push) Successful in 2m10s
deploy / build-and-deploy (push) Successful in 53s
- Allow people all trends or individual trends
- Fix a bug in board game scorelog data
2026-06-25 20:02:34 -04:00
41a68291a4 [trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s
2026-06-25 18:58:23 -04:00
0a411bedf4 [boardgames] Fix bug in logdata
All checks were successful
build / test (push) Successful in 2m23s
2026-06-24 19:09:43 -04:00
124 changed files with 6676 additions and 1041 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:
push:
branches: ["**"]
tags: ["*"]
pull_request:
concurrency:
group: ${{ gitea.workflow }}
cancel-in-progress: false
jobs:
test:
@ -68,6 +74,7 @@ jobs:
build-and-deploy:
needs: [test]
if: startsWith(gitea.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/22] :vrobbler:project:personal:
* Backlog [0/24] :vrobbler:project:personal:
** 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:
:PROPERTIES:
@ -604,6 +604,190 @@ a helper method to create board game scrobbles given a json blob. It's
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 [#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)
* Version 60.1 [1/1]
** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:
:PROPERTIES:
:ID: edaf1200-b58e-8512-b2a2-c8bc25bed7a0
:END:
*** Description
Food ID 10 is "French Press Coffee with Sugar" and we should convert all of
these scrobbles to scrobbles of a Coffee, but to figure out which coffee, we can
use scrobbles of Task "Chore" where the title is "Roast coffee" and the notes
are not null. In this case, the first note on each one has a Coffee title like
"Mexico - Veracruz Huatusco" where the Origin is Mexico and the title is
"Veracruz Huatusco"
We should create a new Coffee for each Chore with the title of "Roast Coffee"
and then for each "French Press Coffee with Sugar", swap the Food ID 10 for the
Coffee ID of the latest Coffee created from a Chore. If a new Chore is found,
start updating the French Press scrobbles with a newly created Coffee.
If we don't have a roast log in the Chore tasks before a French Press entry, we should use the "Unknown Roast" Coffee title and skip all the othe metadata.
* Version 60.0 [2/2]
** DONE [#B] Send water drinking notifications :drinks:notifications:
:PROPERTIES:
:ID: 6c0db717-a0c7-34a1-57e1-941db0188875
:END:
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
:PROPERTIES:
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
:END:
* Version 59.5 [1/1]
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
:PROPERTIES:
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
:END:
* Version 59.4 [2/2]
** DONE Fix bug in fetching expansions for board games :boardgames:
:PROPERTIES:
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
:END:
#+begin_src python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
fetch_and_link_expansions(game, expansions)
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
expansion = BoardGame.find_or_create(str(exp_id))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
game = bgg.game(game_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
raise BGGApiError(msg)
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
#+end_src
** DONE Board games should have genres extracted from family data :boardgames:metadata:
:PROPERTIES:
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
:END:
* Version 59.3 [2/2]
** DONE Exclude some board games from auto-expansion imports :boardgames:
:PROPERTIES:
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
:END:
*** Description
Some board games, especially trading card games, have silly amounts of expansions.
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
games where expansions should not be download automatically. This exclusion should also auto-include
any games with "Collectible Card Games" in it's family.
** DONE Should be able to add new variants to board games via the log data form :boardgames:
:PROPERTIES:
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
:END:
* Version 59.2 [1/1]
** DONE Fix test failure in discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
:END:
* Version 59.1 [1/1]
** 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]
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
:PROPERTIES:
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
:END:
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
:PROPERTIES:
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
:END:
* Version 58.3 [1/1]
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "58.3"
version = "60.1"
description = ""
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,
BoardGamePublisher,
BoardGameDesigner,
BoardGameVariant,
)
from scrobbles.admin import ScrobbleInline
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
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)
class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created"

View File

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

View File

@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
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):
queryset = models.BoardGame.objects.all().order_by("-created")
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
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
expansion_ids: Optional[list[int]] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
variant_ids: Optional[list[int]] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
@ -92,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
@classmethod
def override_fields(cls) -> dict:
from boardgames.widgets import VariantSelectWidget
from scrobbles.forms import NotesDictField
fields = {}
@ -106,10 +108,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
required=False,
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)
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
def location(self):
if not self.location_id:
@ -120,7 +142,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
def player_log(self) -> str:
if self.players:
return ", ".join(
[BoardGameScoreLogData(**player).__str__() for player in self.players]
[
BoardGameScoreLogData(
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
).__str__()
for player in self.players
]
)
return ""
@ -130,13 +157,21 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.board:
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:
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
if self.players:
players_html = []
for player_data in self.players:
player = BoardGameScoreLogData(**player_data)
player = BoardGameScoreLogData(
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
)
player_info = player.name
if player.score:
player_info += f" ({player.score})"
@ -265,6 +300,7 @@ class BoardGame(ScrobblableMixin):
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
skip_expansions = models.BooleanField(default=False)
@property
def subtitle(self) -> str:
@ -299,18 +335,29 @@ class BoardGame(ScrobblableMixin):
if not data:
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
cover_url = data.pop("cover_url")
year = data.pop("year_published")
publisher_name = data.pop("publisher_name")
expansions = data.pop("expansions", [])
cover_url = data.pop("cover_url", "")
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:
data["published_year"] = int(year)
if year_published:
data["published_year"] = int(year_published)
if not data["min_players"]:
data.pop("min_players")
if not data["min_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
BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db()
@ -322,10 +369,20 @@ class BoardGame(ScrobblableMixin):
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
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
if cover_url and not self.cover:
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):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
@ -334,7 +391,12 @@ class BoardGame(ScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
@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"""
game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game:
@ -357,16 +419,19 @@ class BoardGame(ScrobblableMixin):
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
expansions = bgg_data.pop("expansions", [])
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
families = bgg_data.pop("families", [])
publishers = bgg_data.pop("publishers", [])
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.save_image_from_url(cover_url)
if cover_url:
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False)
@ -375,6 +440,18 @@ class BoardGame(ScrobblableMixin):
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=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()
if designers:
@ -389,4 +466,33 @@ class BoardGame(ScrobblableMixin):
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
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
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["categories"] = game.categories
game_dict["families"] = game.families
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
if game.publishers:
game_dict["publisher"] = game.publishers[0]
game_dict["expansions"] = [
{"id": exp.id, "name": exp.name} for exp in game.expansions
]
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(),
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
from django.http import JsonResponse
from django.utils import timezone
from django.views import generic
from django.views.decorators.http import require_POST
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
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):
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",
]
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 = {
"cover": lambda b: not bool(b.cover),
"cover": _cover_missing_or_broken,
"summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None,

View File

@ -3,6 +3,7 @@ from charts.views import (
BirdsChartView,
ChartDetailView,
ChartRecordView,
MalojaChartsView,
SpotifyTracksView,
)
from django.urls import path
@ -11,6 +12,7 @@ app_name = "charts"
urlpatterns = [
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/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
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_day"] = current_day
context["chart_keys"] = {
"today": "Today",
"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:
# Resolve date parameters
if date_param:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
context["period"] = "historical"
context["year"] = year
context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week
context["day"] = day
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
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"] = {
"artist": list(
self.get_charts_for_period(
user,
"artist",
year=year,
month=month,
week=week,
day=day,
)
),
"album": list(
self.get_charts_for_period(
user,
"album",
year=year,
month=month,
week=week,
day=day,
)
),
"track": list(
self.get_charts_for_period(
user,
"track",
year=year,
month=month,
week=week,
day=day,
)
),
"tv_series": list(
self.get_charts_for_period(
user,
"tv_series",
year=year,
month=month,
week=week,
day=day,
)
),
"video": list(
self.get_charts_for_period(
user,
"video",
year=year,
month=month,
week=week,
day=day,
)
),
"board_game": list(
self.get_charts_for_period(
user,
"board_game",
year=year,
month=month,
week=week,
day=day,
)
),
"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,
)
),
}
# List-group tables default to week-level when no date param (matches active tab)
if not date_param:
list_year = current_year
list_month = None
list_week = current_week
list_day = None
else:
list_year = year
list_month = month
list_week = week
list_day = day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"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", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"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
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"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
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
}
bird_data = self.get_bird_chart_data(
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 = {
"artist": ("🎤", "Top Artists"),
"album": ("💿", "Top Albums"),

View File

@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
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:

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):
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,474 @@
from bisect import bisect_right
from django.core.management.base import BaseCommand
from drinks.models import BrewingMethod, Coffee, CoffeeRoaster, RoastLevel
from scrobbles.models import FavoriteMedia, Scrobble
BASE_RUN_TIME_SECONDS = 2400
# French Press (Food ID 10)
FOOD_ID_FRENCH_PRESS = 10
UNKNOWN_ROAST_TITLE = "Unknown Roast"
TIMBERWYCK_ROASTER_ID = 1
FRENCH_PRESS_CALORIES = 50
FRENCH_PRESS_BASE_TAGS = {"sweet maria", "green coffee"}
# Medium Roast (Food ID 27)
FOOD_ID_MEDIUM_ROAST = 27
MEDIUM_ROAST_TITLE = "Medium Roast"
MEDIUM_ROAST_ROASTER_NAME = "Dunkin' Donuts"
MEDIUM_ROAST_CALORIES = 150
MEDIUM_ROAST_BASE_TAGS = {"dunkin"}
# Lavazza (Food ID 12)
FOOD_ID_LAVAZZA = 12
LAVAZZA_TITLE = "Qualità Oro"
LAVAZZA_ROASTER_NAME = "Lavazza"
LAVAZZA_ORIGIN = "Blended"
LAVAZZA_CALORIES = 50
LAVAZZA_BASE_TAGS = {"lavazza", "espresso"}
# Drip Coffee (Food ID 61)
FOOD_ID_DRIP_COFFEE = 61
DRIP_COFFEE_CALORIES = 50
class Command(BaseCommand):
help = (
"Migrate French Press, Medium Roast, and Lavazza food scrobbles "
"to Coffee scrobbles."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without making changes",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(
self.style.WARNING("DRY RUN - no changes will be saved\n")
)
# French Press migration
self.stdout.write(self.style.NOTICE("=== French Press Coffee ==="))
french_migrated, french_skipped = self._migrate_french_press(dry_run)
# Medium Roast migration
self.stdout.write(self.style.NOTICE("\n=== Medium Roast ==="))
medium_migrated, medium_skipped = self._migrate_medium_roast(dry_run)
# Lavazza migration
self.stdout.write(self.style.NOTICE("\n=== Lavazza ==="))
lavazza_migrated, lavazza_skipped = self._migrate_lavazza(dry_run)
# Drip Coffee migration
self.stdout.write(self.style.NOTICE("\n=== Drip Coffee ==="))
drip_migrated, drip_skipped = self._migrate_drip_coffee(dry_run)
self.stdout.write("")
total_migrated = (
french_migrated + medium_migrated + lavazza_migrated + drip_migrated
)
total_skipped = french_skipped + medium_skipped + lavazza_skipped + drip_skipped
if dry_run:
self.stdout.write(
self.style.WARNING(
f"Dry run: would migrate {total_migrated} scrobbles, "
f"skip {total_skipped} already migrated. "
f"Use without --dry-run to apply."
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f"Migrated {total_migrated} scrobbles, skipped {total_skipped} "
f"already migrated."
)
)
def _migrate_french_press(self, dry_run):
roast_coffees = self._build_roast_coffee_timeline()
if not roast_coffees:
self.stdout.write(
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
)
return 0, 0
unknown_roast = self._get_or_create_unknown_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_FRENCH_PRESS)
favorite_foods = self._get_favorite_foods(FOOD_ID_FRENCH_PRESS)
timestamps = [ts for ts, _ in roast_coffees]
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
idx = bisect_right(timestamps, scrobble.timestamp) - 1
if idx >= 0:
_, coffee = roast_coffees[idx]
else:
coffee = unknown_roast
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.FRENCH_PRESS
log["roast_level"] = RoastLevel.FULL_CITY_PLUS
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_favorites(
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
)
return migrated, skipped
def _migrate_medium_roast(self, dry_run):
coffee = self._get_or_create_medium_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_MEDIUM_ROAST)
favorite_foods = self._get_favorite_foods(FOOD_ID_MEDIUM_ROAST)
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.DRIP
log["roast_level"] = RoastLevel.FULL_CITY
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_medium_roast_favorites(favorite_foods, coffee, dry_run)
return migrated, skipped
def _migrate_lavazza(self, dry_run):
coffee = self._get_or_create_lavazza(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_LAVAZZA)
favorite_foods = self._get_favorite_foods(FOOD_ID_LAVAZZA)
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.MOKA_POT
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_lavazza_favorites(favorite_foods, coffee, dry_run)
return migrated, skipped
def _migrate_lavazza_favorites(self, favorites, coffee, dry_run):
updated = 0
for fav in favorites:
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _migrate_drip_coffee(self, dry_run):
roast_coffees = self._build_roast_coffee_timeline()
if not roast_coffees:
self.stdout.write(
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
)
return 0, 0
unknown_roast = self._get_or_create_unknown_roast(dry_run)
food_scrobbles = self._get_food_scrobbles(FOOD_ID_DRIP_COFFEE)
favorite_foods = self._get_favorite_foods(FOOD_ID_DRIP_COFFEE)
timestamps = [ts for ts, _ in roast_coffees]
migrated = 0
skipped = 0
for scrobble in food_scrobbles.iterator():
if scrobble.coffee_id is not None:
skipped += 1
continue
idx = bisect_right(timestamps, scrobble.timestamp) - 1
if idx >= 0:
_, coffee = roast_coffees[idx]
else:
coffee = unknown_roast
log = scrobble.log or {}
log["brewing_method"] = BrewingMethod.DRIP
log["roast_level"] = RoastLevel.CITY
self.stdout.write(
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
f"Coffee '{coffee}' ({scrobble.timestamp})"
)
if not dry_run:
scrobble.coffee = coffee
scrobble.food = None
scrobble.media_type = Scrobble.MediaType.COFFEE
scrobble.log = log
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
migrated += 1
self._migrate_drip_coffee_favorites(
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
)
return migrated, skipped
def _migrate_drip_coffee_favorites(
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
):
updated = 0
for fav in favorites:
idx = bisect_right(timestamps, fav.created) - 1
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _get_or_create_lavazza(self, dry_run):
roaster, _ = CoffeeRoaster.objects.get_or_create(name=LAVAZZA_ROASTER_NAME)
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster": roaster,
"calories": LAVAZZA_CALORIES,
"origin": LAVAZZA_ORIGIN,
}
if dry_run:
try:
return Coffee.objects.get(title=LAVAZZA_TITLE, roaster=roaster)
except Coffee.DoesNotExist:
return Coffee(title=LAVAZZA_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=LAVAZZA_TITLE,
roaster=roaster,
defaults=defaults,
)
if created:
tags = set(LAVAZZA_TITLE.lower().split()) | LAVAZZA_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(f" Created Coffee: '{LAVAZZA_TITLE}' (tags={tags})")
return coffee
def _migrate_medium_roast_favorites(self, favorites, coffee, dry_run):
updated = 0
for fav in favorites:
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)
def _build_roast_coffee_timeline(self):
"""Find Task scrobbles where task title='Chore' and log title='Roast coffee'.
Returns a list of (timestamp, Coffee) sorted by timestamp.
"""
task_scrobbles = (
Scrobble.objects.filter(
media_type=Scrobble.MediaType.TASK,
task__title="Chore",
log__title="Roast coffee",
)
.exclude(log__notes=None)
.exclude(log__notes=[])
.order_by("timestamp")
)
coffees = []
for scrobble in task_scrobbles:
notes = scrobble.log.get("notes") or []
if not notes:
continue
first_note = notes[0] if isinstance(notes, list) else str(notes)
origin, title = self._parse_coffee_name(first_note)
coffee = self._get_or_create_coffee(title, origin)
coffees.append((scrobble.timestamp, coffee))
return coffees
def _parse_coffee_name(self, note):
"""Parse 'Mexico - Veracruz Huatusco' into (origin, title)."""
if " - " in note:
parts = note.split(" - ", 1)
return parts[0].strip(), parts[1].strip()
return None, note.strip()
def _get_or_create_coffee(self, title, origin):
coffee, created = Coffee.objects.get_or_create(
title=title,
origin=origin,
defaults={
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster_id": TIMBERWYCK_ROASTER_ID,
"calories": FRENCH_PRESS_CALORIES,
},
)
if created:
tags = set(title.lower().split()) | FRENCH_PRESS_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(
f" Created Coffee: '{title}' (origin={origin}, tags={tags})"
)
return coffee
def _get_or_create_unknown_roast(self, dry_run):
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster_id": TIMBERWYCK_ROASTER_ID,
"calories": FRENCH_PRESS_CALORIES,
}
if dry_run:
try:
return Coffee.objects.get(title=UNKNOWN_ROAST_TITLE)
except Coffee.DoesNotExist:
return Coffee(title=UNKNOWN_ROAST_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=UNKNOWN_ROAST_TITLE,
defaults=defaults,
)
if created:
tags = {"unknown", "roast"} | FRENCH_PRESS_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(
f" Created Coffee: '{UNKNOWN_ROAST_TITLE}' (tags={tags})"
)
return coffee
def _get_or_create_medium_roast(self, dry_run):
roaster, _ = CoffeeRoaster.objects.get_or_create(name=MEDIUM_ROAST_ROASTER_NAME)
defaults = {
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
"roaster": roaster,
"calories": MEDIUM_ROAST_CALORIES,
"origin": None,
}
if dry_run:
try:
return Coffee.objects.get(title=MEDIUM_ROAST_TITLE, roaster=roaster)
except Coffee.DoesNotExist:
return Coffee(title=MEDIUM_ROAST_TITLE, **defaults)
coffee, created = Coffee.objects.get_or_create(
title=MEDIUM_ROAST_TITLE,
roaster=roaster,
defaults=defaults,
)
if created:
tags = set(MEDIUM_ROAST_TITLE.lower().split()) | MEDIUM_ROAST_BASE_TAGS
coffee.tags.add(*tags)
self.stdout.write(f" Created Coffee: '{MEDIUM_ROAST_TITLE}' (tags={tags})")
return coffee
def _get_food_scrobbles(self, food_id):
return Scrobble.objects.filter(
food_id=food_id,
media_type=Scrobble.MediaType.FOOD,
).order_by("timestamp")
def _get_favorite_foods(self, food_id):
return FavoriteMedia.objects.filter(
food_id=food_id,
media_type=Scrobble.MediaType.FOOD,
)
def _migrate_favorites(
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
):
updated = 0
for fav in favorites:
idx = bisect_right(timestamps, fav.created) - 1
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
self.stdout.write(
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
)
if not dry_run:
fav.coffee = coffee
fav.food = None
fav.media_type = Scrobble.MediaType.COFFEE
fav.save(update_fields=["coffee", "food", "media_type"])
updated += 1
if updated:
self.stdout.write(
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
)

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=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
from django.db import migrations, models
import uuid
import django_extensions.db.fields
import taggit.managers
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
@ -46,6 +47,7 @@ class Migration(migrations.Migration):
),
],
options={
"db_table": "beers_beerproducer",
"get_latest_by": "modified",
"abstract": False,
},
@ -127,6 +129,7 @@ class Migration(migrations.Migration):
),
],
options={
"db_table": "beers_beer",
"abstract": False,
},
),

View File

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

View File

@ -1,13 +1,13 @@
# Generated by Django 4.2.16 on 2024-10-22 21:47
from django.db import migrations, models
import django_extensions.db.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
("drinks", "0002_beer_beeradvocate_image_beer_producer_and_more"),
]
operations = [
@ -38,6 +38,7 @@ class Migration(migrations.Migration):
("description", models.TextField(blank=True, null=True)),
],
options={
"db_table": "beers_beerstyle",
"get_latest_by": "modified",
"abstract": False,
},
@ -70,6 +71,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="beer",
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
from django.db import migrations, models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
("drinks", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
]
operations = [
@ -41,7 +42,7 @@ class Migration(migrations.Migration):
model_name="beer",
name="styles",
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 = [
(
"beers",
"drinks",
"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):
dependencies = [
('beers', '0005_alter_beer_run_time_seconds'),
('drinks', '0005_alter_beer_run_time_seconds'),
]
operations = [

View File

@ -1,14 +1,14 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("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 = [

View File

@ -1,14 +1,14 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0075_add_channel_scrobble"),
("beers", "0007_beer_tags"),
("drinks", "0007_beer_tags"),
]
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,596 @@
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"
MUG = "mug", "Mug"
class RoastLevel(models.TextChoices):
CINNAMON = "cinnamon", "Cinnamon"
CITY = "city", "City"
CITY_PLUS = "city_plus", "City+"
FULL_CITY = "full_city", "Full City"
FULL_CITY_PLUS = "full_city_plus", "Full City+"
VIENNA = "vienna", "Vienna"
FRENCH = "french", "French"
SPANISH = "spanish", "Spanish"
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 profiles.models import UserProfile
from scrobbles.constants import Visibility
from scrobbles.models import Scrobble
@ -42,6 +41,11 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit",
"live_now_playing",
"weigh_in_units",
"volume_unit",
"default_water_format",
"default_water_size",
"water_reminder_enabled",
"trends_disabled",
]
widgets = {
"lastfm_password": forms.PasswordInput(render_value=True),
@ -50,9 +54,7 @@ class UserProfileForm(forms.ModelForm):
}
MEDIA_TYPE_LABELS = {
mt.value: mt.label for mt in Scrobble.MediaType
}
MEDIA_TYPE_LABELS = {mt.value: mt.label for mt in Scrobble.MediaType}
INHERIT = ""

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.29 on 2026-06-25 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0039_userprofile_live_now_playing"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="disabled_trends",
field=models.JSONField(
blank=True,
default=list,
help_text="List of trend slugs the user has disabled",
),
),
migrations.AddField(
model_name="userprofile",
name="trends_disabled",
field=models.BooleanField(default=False),
),
]

View File

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

View File

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

View File

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

View File

@ -1,14 +1,16 @@
from zoneinfo import ZoneInfo
import pendulum
from django.utils import timezone
import logging
from zoneinfo import ZoneInfo
import pendulum
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES
VISIBILITY_CHOICES = (
("public", "Public"),
("shared", "Shared"),
@ -26,6 +28,23 @@ class WeighUnit(models.TextChoices):
IMPERIAL = "imperial", "Imperial (lbs, in)"
class VolumeUnit(models.TextChoices):
METRIC = "metric", "Metric (mL, L)"
IMPERIAL = "imperial", "Imperial (oz)"
class WaterFormat(models.TextChoices):
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
KEG = "keg", "Keg"
BY_THE_GLASS = "by_the_glass", "By the Glass"
DRAFT = "draft", "Draft"
GROWLER = "growler", "Growler"
BOX = "box", "Box"
class UserProfile(TimeStampedModel):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
timezone = models.CharField(
@ -71,11 +90,13 @@ class UserProfile(TimeStampedModel):
mopidy_api_url = models.CharField(max_length=255, **BNULL)
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",
)
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')",
)
@ -93,7 +114,7 @@ class UserProfile(TimeStampedModel):
media_type_visibility = models.JSONField(
default=dict,
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)
@ -106,6 +127,33 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC,
)
volume_unit = models.CharField(
max_length=16,
choices=VolumeUnit.choices,
default=VolumeUnit.METRIC,
)
default_water_format = models.CharField(
max_length=20,
choices=WaterFormat.choices,
default=WaterFormat.BY_THE_GLASS,
)
default_water_size = models.PositiveIntegerField(
default=250,
help_text="Default water scrobble size in mL",
)
water_reminder_enabled = models.BooleanField(default=False)
trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField(
default=list,
blank=True,
help_text="List of trend slugs the user has disabled",
)
def __str__(self):
return f"User profile for {self.user}"
@ -145,7 +193,11 @@ class UserProfile(TimeStampedModel):
from django.conf import settings
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
if self.timezone_change_log:

View File

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

View File

@ -1,12 +1,16 @@
from django.db import models
from enum import Enum
from django.db import models
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
class Visibility(models.TextChoices):
PUBLIC = "public", "Public"
SHARED = "shared", "Shared"
PRIVATE = "private", "Private"
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = {
@ -23,6 +27,7 @@ AUTO_FINISH_MEDIA = {
"tracks": "Track",
"videos": "Video",
"moods": "Mood",
"drinks": "Drink",
}
PLAY_AGAIN_MEDIA = {
@ -35,7 +40,10 @@ PLAY_AGAIN_MEDIA = {
"boardgames": "BoardGame",
"locations": "GeoLocation",
"trails": "Trail",
"beers": "Beer",
"drinks": "Beer",
"drinks": "Wine",
"drinks": "Coffee",
"drinks": "Drink",
"puzzles": "Puzzle",
"foods": "Food",
"tasks": "Task",
@ -48,6 +56,8 @@ PLAY_AGAIN_MEDIA = {
"discgolf": "DiscGolfCourse",
}
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
MEDIA_END_PADDING_SECONDS = {
"Video": 3600, # 60 min
}
@ -59,6 +69,8 @@ SCROBBLE_CONTENT_URLS = {
"-s": ["https://www.thesportsdb.com/event/"],
"-g": ["https://boardgamegeek.com/boardgame/"],
"-u": ["https://untappd.com/"],
"-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],
"-co": ["https://roastdb.com/"],
"-b": ["https://www.amazon.com/"],
"-t": ["https://app.todoist.com/app/task/{id}"],
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
@ -77,12 +89,15 @@ MANUAL_SCROBBLE_FNS = {
"-i": "manual_scrobble_video",
"-g": "manual_scrobble_board_game",
"-u": "manual_scrobble_beer",
"-wi": "manual_scrobble_wine",
"-co": "manual_scrobble_coffee",
"-w": "manual_scrobble_webpage",
"-t": "manual_scrobble_task",
"-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset",
"-c": "manual_scrobble_book",
"-f": "manual_scrobble_food",
"-d": "manual_scrobble_drink",
"-h": "manual_scrobble_twitch_channel",
"-dg": "manual_scrobble_discgolf",
"-pp": "manual_scrobble_paper",

View File

@ -0,0 +1,9 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
class Command(BaseCommand):
def handle(self, *args, **options):
sent_count = send_water_reminder_notifications()
print(f"Sent {sent_count} water reminder notifications")

View File

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

View File

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

View File

@ -19,7 +19,7 @@ class Migration(migrations.Migration):
("puzzles", "0006_alter_puzzle_genre"),
("videogames", "0015_alter_videogame_genre"),
("lifeevents", "0005_alter_lifeevent_genre"),
("beers", "0008_alter_beer_genre"),
("drinks", "0008_alter_beer_genre"),
("foods", "0007_alter_food_genre"),
("tasks", "0007_alter_task_genre"),
("books", "0036_alter_book_genre_alter_paper_genre"),
@ -98,7 +98,7 @@ class Migration(migrations.Migration):
blank=True,
null=True,
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 pytz
from beers.models import Beer
from drinks.models import Beer, Wine, Coffee, Drink
from birds.models import BirdingLocation
from discgolf.models import DiscGolfCourse
from boardgames.models import BoardGame
@ -684,6 +684,9 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"GeoLocation": ("geo_location",),
"Trail": ("trail",),
"Beer": ("beer",),
"Wine": ("wine",),
"Coffee": ("coffee",),
"Drink": ("drink",),
"Puzzle": ("puzzle",),
"Food": ("food",),
"Task": ("task",),
@ -712,6 +715,9 @@ class ScrobbleQuerySet(models.QuerySet):
"board_game",
"geo_location",
"beer",
"wine",
"coffee",
"drink",
"puzzle",
"food",
"trail",
@ -763,6 +769,9 @@ class Scrobble(TimeStampedModel):
GEO_LOCATION = "GeoLocation", "GeoLocation"
TRAIL = "Trail", "Trail"
BEER = "Beer", "Beer"
WINE = "Wine", "Wine"
COFFEE = "Coffee", "Coffee"
DRINK = "Drink", "Drink"
PUZZLE = "Puzzle", "Puzzle"
FOOD = "Food", "Food"
TASK = "Task", "Task"
@ -792,6 +801,9 @@ class Scrobble(TimeStampedModel):
board_game = models.ForeignKey(BoardGame, 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)
wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)
coffee = models.ForeignKey(Coffee, on_delete=models.DO_NOTHING, **BNULL)
drink = models.ForeignKey(Drink, on_delete=models.DO_NOTHING, **BNULL)
puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
@ -1220,7 +1232,10 @@ class Scrobble(TimeStampedModel):
if self.is_long_play:
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
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
@ -1367,6 +1382,12 @@ class Scrobble(TimeStampedModel):
media_obj = self.trail
if 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:
media_obj = self.puzzle
if self.task:
@ -1928,6 +1949,9 @@ class FavoriteMedia(TimeStampedModel):
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
geo_location = models.ForeignKey(GeoLocation, 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)
food = models.ForeignKey(Food, 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
if 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:
media_obj = self.puzzle
if self.task:
@ -2014,6 +2044,9 @@ class FavoriteMedia(TimeStampedModel):
"BoardGame": "board_game",
"GeoLocation": "geo_location",
"Beer": "beer",
"Wine": "wine",
"Coffee": "coffee",
"Drink": "drink",
"Puzzle": "puzzle",
"Food": "food",
"Trail": "trail",

View File

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

View File

@ -6,8 +6,8 @@ from typing import Any, Optional
import pendulum
import pytz
import requests
from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from boardgames.utils import board_names_to_variants
from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData, Paper
from books.utils import parse_readcomicsonline_uri
@ -15,6 +15,7 @@ from bricksets.models import BrickSet
from dateutil.parser import parse
from discgolf.models import DiscGolfCourse
from django.utils import timezone
from drinks.models import Beer, Coffee, Drink, DrinkFormat, Wine
from foods.models import Food
from foods.sources.rscraper import RecipeScraperService
from locations.constants import LOCATION_PROVIDERS
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
source: str = "BGG",
action: Optional[str] = None,
) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id)
boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
if not boardgame:
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):
log_data["rounds"] = play_dict.get("rounds")
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"] = []
for score_dict in play_dict.get("playerScores", []):
@ -645,6 +648,30 @@ def manual_scrobble_from_url(
item_id = url
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)
@ -763,9 +790,7 @@ def todoist_scrobble_task(
todoist_task["title"] = todoist_task.pop("description")
todoist_task["description"] = todoist_task.pop("details")
labels = todoist_task.pop("todoist_label_list", [])
todoist_task["labels"] = [
l for l in labels if l.lower() != "inprogress"
]
todoist_task["labels"] = [l for l in labels if l.lower() != "inprogress"]
todoist_task.pop("todoist_type")
todoist_task.pop("todoist_event")
@ -930,7 +955,9 @@ def emacs_scrobble_task(
task_data.pop("notes", None)
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["orgmode_id"] = task_data.pop("source_id")
@ -1253,6 +1280,115 @@ def manual_scrobble_beer(
return Scrobble.create_or_update(beer, user_id, scrobble_dict)
def manual_scrobble_wine(
vivino_id: str = None,
cellartracker_id: str = None,
user_id: int = None,
source: str = "Vivino",
action: Optional[str] = None,
):
wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)
if not wine:
logger.error(
f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"
)
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] wine scrobble request received",
extra={
"wine_id": wine.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.WINE,
},
)
return Scrobble.create_or_update(wine, user_id, scrobble_dict)
def manual_scrobble_coffee(
roastdb_id: str = None,
roastdb_url: str = None,
user_id: int = None,
source: str = "RoastDB",
action: Optional[str] = None,
):
slug = None
if roastdb_url and not roastdb_id:
slug = roastdb_url.rstrip("/").split("/")[-1]
coffee = Coffee.find_or_create(roastdb_id=roastdb_id, slug=slug)
if not coffee:
logger.error(f"No coffee found for RoastDB ID {roastdb_id}")
return
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] coffee scrobble request received",
extra={
"coffee_id": coffee.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.COFFEE,
},
)
return Scrobble.create_or_update(coffee, user_id, scrobble_dict)
def manual_scrobble_water(
user_id: int = None,
source: str = "Vrobbler",
action: Optional[str] = None,
):
water = Drink.find_or_create_water()
if not water:
logger.error("Could not find or create Water instance")
return
water_format = DrinkFormat.BY_THE_GLASS
water_size_ml = 250
profile = UserProfile.objects.filter(user_id=user_id).first()
if profile:
water_format = profile.default_water_format or water_format
water_size_ml = profile.default_water_size or water_size_ml
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
"log": {"format": water_format, "size_ml": water_size_ml},
}
logger.info(
"[vrobbler-scrobble] water scrobble request received",
extra={
"water_id": water.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.DRINK,
},
)
return Scrobble.create_or_update(water, user_id, scrobble_dict)
def manual_scrobble_puzzle(
ipdb_id: str,
user_id: int,
@ -1364,6 +1500,50 @@ def manual_scrobble_food(
return Scrobble.create_or_update(food, user_id, scrobble_dict)
def manual_scrobble_drink(
item_id: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
if not match:
logger.info(
"[manual_scrobble_drink] invalid format, expected 'Drink Name - calories'",
extra={"item_id": item_id, "user_id": user_id},
)
return
drink_name = match.group(1).strip()
calories = int(match.group(2))
drink = Drink.objects.filter(title=drink_name).first()
if not drink:
drink = Drink.objects.create(
title=drink_name,
calories=calories,
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[scrobblers] manual drink scrobble request received",
extra={
"drink_id": drink.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.DRINK,
},
)
return Scrobble.create_or_update(drink, user_id, scrobble_dict)
def manual_scrobble_discgolf(
item_id: str,
user_id: int,

View File

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

View File

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

View File

@ -4,10 +4,9 @@ import logging
from datetime import datetime, timedelta
from uuid import uuid4
import requests
import pendulum
import pytz
import requests
from dateutil.relativedelta import relativedelta
from django.apps import apps
from django.contrib import messages
@ -26,6 +25,7 @@ class BearerTokenAuthentication(TokenAuthentication):
keyword = "Bearer"
from boardgames.models import BoardGame, BoardGameVariant
from django.http import (
FileResponse,
Http404,
@ -33,7 +33,7 @@ from django.http import (
JsonResponse,
)
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.dateformat import DateFormat
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 scrobbles.api import serializers
from scrobbles.constants import (
DRINK_MODELS,
LONG_PLAY_MEDIA,
MANUAL_SCROBBLE_FNS,
PLAY_AGAIN_MEDIA,
Visibility,
)
from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.constants import Visibility
from scrobbles.sqids import decode_scrobble_share
from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
@ -90,6 +90,7 @@ from scrobbles.models import (
UDiscCSVImport,
)
from scrobbles.scrobblers import *
from scrobbles.sqids import decode_scrobble_share
from scrobbles.tasks import (
process_koreader_import,
process_lastfm_import,
@ -210,14 +211,20 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
media = self.object
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
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:
context_data["long_play_total_seconds"] = completed.long_play_seconds
context_data["long_play_finished_date"] = completed.timestamp
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:
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
return context_data
@ -235,7 +242,8 @@ class RecentScrobbleList(ListView):
scrobble = manual_scrobble_from_url(
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)
def get_context_data(self, **kwargs):
@ -610,6 +618,18 @@ class ManualScrobbleView(FormView):
item_str = form.cleaned_data.get("item_id")
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:
key = item_str[:3]
item_id = item_str[4:]
@ -888,8 +908,15 @@ def scrobble_start(request, media_uuid):
media_obj = None
for app, model in PLAY_AGAIN_MEDIA.items():
media_model = apps.get_model(app_label=app, model_name=model)
media_obj = media_model.objects.filter(uuid=media_uuid).first()
if app == "drinks":
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:
break
@ -1134,7 +1161,10 @@ def toggle_favorite(request, media_type, object_id):
"VideoGame": ("videogames", "VideoGame"),
"BoardGame": ("boardgames", "BoardGame"),
"GeoLocation": ("locations", "GeoLocation"),
"Beer": ("beers", "Beer"),
"Beer": ("drinks", "Beer"),
"Wine": ("drinks", "Wine"),
"Coffee": ("drinks", "Coffee"),
"Water": ("drinks", "Water"),
"Puzzle": ("puzzles", "Puzzle"),
"Food": ("foods", "Food"),
"Trail": ("trails", "Trail"),
@ -1235,6 +1265,29 @@ class ScrobbleDetailView(DetailView):
def get_form_class(self):
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):
FormClass = self.get_form_class()
@ -1245,12 +1298,42 @@ class ScrobbleDetailView(DetailView):
else:
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):
self.object = self.get_object()
FormClass = self.get_form_class()
form = FormClass(request.POST)
self._update_board_game_widgets(form)
if form.is_valid():
data = form.cleaned_data.copy()
@ -1263,6 +1346,12 @@ class ScrobbleDetailView(DetailView):
if data.get("with_people_ids") is not None:
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:
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
if self.object.is_long_play and fk_field:
all_scrobbles = Scrobble.objects.filter(
user=user, **{fk_field: media_obj}
all_scrobbles = Scrobble.objects.filter(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:
context["long_play_total_seconds"] = completed.long_play_seconds
context["long_play_finished_date"] = completed.timestamp
else:
latest_finished = all_scrobbles.filter(
played_to_completion=True
).order_by("-timestamp").first()
latest_finished = (
all_scrobbles.filter(played_to_completion=True)
.order_by("-timestamp")
.first()
)
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
return context
@ -1926,8 +2019,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
scrobble_filter = {"scrobble__played_to_completion": True}
def get_items(self, user, start_date, end_date):
from boardgames.models import BoardGame
return super().get_items(user, start_date, end_date, BoardGame)

View File

@ -38,15 +38,36 @@ class Command(BaseCommand):
overall_start = timezone.now()
ok_count = 0
fail_count = 0
skipped_count = 0
for user in users:
total_trends = len(TREND_REGISTRY)
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
try:
profile = user.profile
if profile.trends_disabled:
self.stdout.write(
self.style.WARNING(
f" {user} ({user.id}): trends disabled globally, skipping"
)
)
skipped_count += len(TREND_REGISTRY)
continue
disabled_trends = set(profile.disabled_trends or [])
except Exception:
disabled_trends = set()
active_slugs = [
s for s in TREND_REGISTRY if s not in disabled_trends
]
total_trends = len(active_slugs)
self.stdout.write(
f" {user} ({user.id}): {total_trends} trends ("
f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..."
)
user_start = timezone.now()
user_ok = 0
user_fail = 0
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
for idx, slug in enumerate(active_slugs, start=1):
periods = get_supported_periods(slug)
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
for period in periods:
@ -76,7 +97,7 @@ class Command(BaseCommand):
overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f"Done! {ok_count} OK, {fail_count} failed "
f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped "
f"({overall_elapsed:.1f}s across {total_users} user(s))"
)
)

View File

@ -26,7 +26,17 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id)
return
total = len(TREND_REGISTRY)
try:
profile = user.profile
if profile.trends_disabled:
logger.info("User %s (%d) has trends disabled, skipping", user, user_id)
return
disabled = set(profile.disabled_trends or [])
except Exception:
disabled = set()
active_slugs = [s for s in TREND_REGISTRY if s not in disabled]
total = len(active_slugs)
logger.info(
"Computing %d trends for user %s (%d)",
total,
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
user_id,
)
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
for idx, slug in enumerate(active_slugs, start=1):
compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug):
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return
try:
profile = user.profile
if profile.trends_disabled:
logger.info(
"User %d has trends disabled, skipping '%s'", user_id, slug
)
return
if slug in (profile.disabled_trends or []):
logger.info(
"User %d has trend '%s' disabled, skipping", user_id, slug
)
return
except Exception:
pass
periods = get_supported_periods(slug)
for period in periods:

View File

@ -1,45 +1,78 @@
<div class="row">
<div class="col-12">
{% if data.distribution %}
{{ data.distribution|json_script:"activity-distribution-data" }}
<p class="text-muted mb-3">
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Media Type</th>
<th class="text-end">Total</th>
<th class="text-end">Completed</th>
<th class="text-end">%</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.distribution.0.count %}
{% for entry in data.distribution %}
<tr>
<td>{{ entry.media_type }}</td>
<td class="text-end">{{ entry.count }}</td>
<td class="text-end">{{ entry.completed }}</td>
<td class="text-end">{{ entry.pct }}%</td>
<td style="width: 30%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.pct }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
</div>
<canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
<script>
(function() {
var el = document.getElementById('activity-distribution-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('activityDistChart');
var ctx = canvas.getContext('2d');
var W = canvas.width;
var rowH = 34;
var labelW = 140;
var barLeft = labelW + 8;
var barRight = W - 80;
var barMaxW = barRight - barLeft;
var padTop = 8;
var maxCount = data[0].count;
// Set canvas height based on data
canvas.height = data.length * rowH + padTop;
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
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 %}
<p class="text-muted">No activity data found.</p>
{% endif %}

View File

@ -1,43 +1,74 @@
<div class="row">
<div class="col-12">
{% if data.moods %}
{{ data.moods|json_script:"mood-distribution-data" }}
<p class="text-muted mb-3">
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
&middot; Positive: <strong>{{ data.positive_count }}</strong>
&middot; Negative: <strong>{{ data.negative_count }}</strong>
</p>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Mood</th>
<th class="text-end">Count</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with max=data.moods.0.count %}
{% for entry in data.moods %}
<tr>
<td>{{ entry.mood }}</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {% widthratio entry.count max 100 %}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
</tbody>
</table>
</div>
<canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
<script>
(function() {
var el = document.getElementById('mood-distribution-data');
if (!el) return;
var data = JSON.parse(el.textContent);
if (!data.length) return;
var canvas = document.getElementById('moodDistChart');
var ctx = canvas.getContext('2d');
var rowH = 34;
var labelW = 140;
var barLeft = labelW + 8;
var barRight = canvas.width - 80;
var barMaxW = barRight - barLeft;
var padTop = 8;
canvas.height = data.length * rowH + padTop;
var maxCount = data[0].count;
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
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 %}
<p class="text-muted">No mood distribution data found.</p>
{% endif %}

View File

@ -1,37 +1,105 @@
<div class="row">
<div class="col-12">
{% if data.trajectory %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Date</th>
<th class="text-end">Avg Quality</th>
<th class="text-end">Check-ins</th>
<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>
{{ data.trajectory|json_script:"mood-trajectory-data" }}
<div class="d-flex flex-column align-items-center">
<canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
<div class="d-flex gap-4 mt-2 small text-muted">
<span>← Earlier</span>
<span>Later →</span>
</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 %}
<p class="text-muted">No mood check-in data found.</p>
{% endif %}

View File

@ -1,50 +1,90 @@
<div class="row">
<div class="col-12">
{% if data.hours %}
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Hour</th>
<th class="text-end">Scrobbles</th>
<th>Distribution</th>
</tr>
</thead>
<tbody>
{% with total=data.hours|dictsortreversed:"count"|first %}
{% with max_count=total.count %}
{% for entry in data.hours %}
<tr>
<td>
{% if entry.hour == 0 %}
12 AM
{% elif entry.hour < 12 %}
{{ entry.hour }} AM
{% elif entry.hour == 12 %}
12 PM
{% else %}
{{ entry.hour|add:"-12" }} PM
{% endif %}
</td>
<td class="text-end">{{ entry.count }}</td>
<td style="width: 40%;">
{% if max_count > 0 %}
<div class="progress" style="height: 12px;">
<div class="progress-bar" role="progressbar"
style="width: {{ entry.count|floatformat:0 }}%;"
aria-valuenow="{{ entry.count }}"
aria-valuemin="0" aria-valuemax="{{ max_count }}">
</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endwith %}
{% endwith %}
</tbody>
</table>
{{ data.hours|json_script:"peak-hours-data" }}
<div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
<div>
<canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
</div>
<div class="table-responsive" style="max-height:360px; overflow-y:auto;">
<table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
<thead>
<tr>
<th style="width:14px; padding-right:0;"></th>
<th>Hour</th>
<th class="text-end">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for entry in data.hours %}
<tr>
<td class="p-1 text-center">
<span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
</td>
<td>
{% if entry.hour == 0 %}
12 AM
{% elif entry.hour < 12 %}
{{ entry.hour }} AM
{% elif entry.hour == 12 %}
12 PM
{% else %}
{{ entry.hour|add:"-12" }} PM
{% endif %}
</td>
<td class="text-end">{{ entry.count }}</td>
</tr>
{% endfor %}
</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 %}
<p class="text-muted">No activity data found.</p>
{% endif %}

View File

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

View File

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

View File

@ -3,6 +3,10 @@
{% block title %}{{ trend.title }}{% endblock %}
{% block lists %}
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% else %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a>
@ -35,13 +39,21 @@
{% if computed_at %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% endif %}
<div class="mt-2">
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="d-inline">
{% csrf_token %}
{% if trend_disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable this trend</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable this trend</button>
{% endif %}
</form>
</div>
</div>
</div>
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% elif data is None %}
{% if data is None %}
<div class="alert alert-info">
No data computed yet for this period. Trends are updated once daily, check back later.
</div>
@ -85,5 +97,6 @@
{% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -25,11 +25,23 @@
</a>
</h5>
<p class="card-text text-muted">{{ trend.description }}</p>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<div>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</div>
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="m-0">
{% csrf_token %}
{% if trend.disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
{% endif %}
</form>
</div>
</div>
</div>
</div>

View File

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

View File

@ -1,9 +1,18 @@
from django.urls import path
from trends.views import TrendDetailView, TrendListView
from trends.views import (
ToggleTrendDisabledView,
TrendDetailView,
TrendListView,
)
app_name = "trends"
urlpatterns = [
path("trends/", TrendListView.as_view(), name="trends-home"),
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
path(
"trends/<slug:trend_slug>/toggle/",
ToggleTrendDisabledView.as_view(),
name="trend-toggle-disabled",
),
]

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from django.utils import timezone
from trends.models import PERIOD_CHOICES, TrendResult
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__)
@ -35,6 +36,13 @@ TREND_PERIOD_OVERRIDES = {
}
def get_disabled_trends(user):
profile = user.profile
if profile.trends_disabled:
return set(TREND_REGISTRY.keys())
return set(profile.disabled_trends or [])
def get_supported_periods(trend_slug):
if trend_slug in TREND_PERIOD_OVERRIDES:
slugs = TREND_PERIOD_OVERRIDES[trend_slug]

View File

@ -1,8 +1,11 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import TemplateView, View
from trends.models import TrendResult
from trends.trends import TREND_REGISTRY
from trends.utils import get_period_nav, get_supported_periods
from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods
TREND_METADATA = {
"activity-distribution": {
@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
disabled = get_disabled_trends(self.request.user)
results = TrendResult.objects.filter(
user=self.request.user,
).order_by("trend_slug", "-computed_at")
@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
trends = []
for slug in TREND_REGISTRY:
if slug in disabled:
continue
meta = TREND_METADATA.get(slug, {})
result = latest_by_slug.get(slug)
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
trends.append(
{
"slug": slug,
@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""),
"computed_at": result.computed_at if result else None,
"has_data": result is not None,
"disabled": is_disabled,
}
)
ctx["trends"] = trends
@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["computed_at"] = None
ctx["data"] = None
disabled = get_disabled_trends(self.request.user)
ctx["trend_disabled"] = slug in disabled
ctx["globally_disabled"] = self.request.user.profile.trends_disabled
return ctx
class ToggleTrendDisabledView(LoginRequiredMixin, View):
def post(self, request, trend_slug):
profile = request.user.profile
disabled = set(profile.disabled_trends or [])
if trend_slug in disabled:
disabled.discard(trend_slug)
messages.success(request, f"Trend re-enabled.")
else:
disabled.add(trend_slug)
messages.success(request, f"Trend disabled.")
profile.disabled_trends = list(disabled)
profile.save(update_fields=["disabled_trends"])
return HttpResponseRedirect(
request.META.get("HTTP_REFERER", reverse("trends:trends-home"))
)

View File

@ -17,8 +17,8 @@ MISSING_ALL = [
]
MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover),
"screenshot": lambda g: not bool(g.screenshot),
"cover": lambda g: _image_missing_or_broken(g, "cover"),
"screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
"summary": lambda g: not g.summary,
"rating": lambda g: g.rating 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):
if not flags:
return False
@ -103,6 +113,7 @@ class Command(BaseCommand):
if all_missing:
flags = MISSING_ALL
fix_broken_images = True
if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write(

View File

@ -30,6 +30,19 @@ class Command(BaseCommand):
action="store_true",
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):
from videos.models import Channel
@ -38,6 +51,7 @@ class Command(BaseCommand):
dry_run = options["dry_run"]
youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"]
needs_metadata = options["needs_metadata"]
qs = Channel.objects.all()
@ -45,29 +59,61 @@ class Command(BaseCommand):
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
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:
qs = qs.filter(
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
).exclude(youtube_id="", twitch_id="")
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:
for channel in qs.iterator():
for channel in all_channels:
source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id
status = f"({source}: {identifier})"
if self._has_broken_image(channel):
status += " [image BROKEN]"
self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
f" [DRY RUN] Would fix {channel.name} {status}"
)
return
updated = 0
errors = 0
for channel in qs.iterator():
for channel in all_channels:
try:
with transaction.atomic():
channel.fix_metadata(force=force)
channel.fix_metadata(force=force or self._has_broken_image(channel))
updated += 1
source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")

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