Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 681252b845 | |||
| f6a20b67a6 | |||
| c9298fe387 | |||
| f7d99f8b55 | |||
| 6861e28ac6 | |||
| fdc18d5a5f | |||
| 77d92f6c96 | |||
| d5d0eb6cd8 | |||
| d6f71e0761 | |||
| b00ebf49dd | |||
| 2385e9c7bd | |||
| d78529efe2 | |||
| f373e98e3d | |||
| 7559ce7824 | |||
| 2c481bd53a | |||
| 0deb3ee634 | |||
| 28a53d70eb | |||
| 98d4e8bacb | |||
| 8f97131b8d | |||
| 7c1f709f96 | |||
| 4b005e0e5b | |||
| d5dd63be0d | |||
| 5aa89b7e0a | |||
| cf444e8dd4 | |||
| a74a89c747 | |||
| 1695f7393e | |||
| 4468e68110 | |||
| da08eca4ab | |||
| 08752e30a4 | |||
| 619718c045 | |||
| cb23d5a5be | |||
| ec4c190e6c | |||
| 58126928c7 | |||
| c0d2881585 | |||
| 41a68291a4 | |||
| 0a411bedf4 |
@ -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
|
||||
@ -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:
|
||||
186
PROJECT.org
186
PROJECT.org
@ -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:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "58.3"
|
||||
version = "60.1"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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,
|
||||
]
|
||||
@ -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__"
|
||||
@ -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]
|
||||
@ -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"
|
||||
)
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
)
|
||||
@ -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)"
|
||||
)
|
||||
@ -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)"
|
||||
)
|
||||
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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})"
|
||||
|
||||
@ -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
|
||||
|
||||
29
vrobbler/apps/boardgames/tasks.py
Normal file
29
vrobbler/apps/boardgames/tasks.py
Normal 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),
|
||||
},
|
||||
)
|
||||
@ -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>
|
||||
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal file
226
vrobbler/apps/boardgames/tests/test_models.py
Normal 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
|
||||
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal 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 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_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"]
|
||||
@ -20,4 +20,9 @@ urlpatterns = [
|
||||
views.BoardGamePublisherDetailView.as_view(),
|
||||
name="publisher_detail",
|
||||
),
|
||||
path(
|
||||
"variants/ajax-create/",
|
||||
views.ajax_create_variant,
|
||||
name="ajax-create-variant",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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 A/Map 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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
9
vrobbler/apps/boardgames/widgets.py
Normal file
9
vrobbler/apps/boardgames/widgets.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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:
|
||||
|
||||
0
vrobbler/apps/drinks/__init__.py
Normal file
0
vrobbler/apps/drinks/__init__.py
Normal file
114
vrobbler/apps/drinks/admin.py
Normal file
114
vrobbler/apps/drinks/admin.py
Normal 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,
|
||||
]
|
||||
0
vrobbler/apps/drinks/api/__init__.py
Normal file
0
vrobbler/apps/drinks/api/__init__.py
Normal file
66
vrobbler/apps/drinks/api/serializers.py
Normal file
66
vrobbler/apps/drinks/api/serializers.py
Normal 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__"
|
||||
57
vrobbler/apps/drinks/api/views.py
Normal file
57
vrobbler/apps/drinks/api/views.py
Normal 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]
|
||||
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class BeersConfig(AppConfig):
|
||||
name = "beers"
|
||||
name = "drinks"
|
||||
130
vrobbler/apps/drinks/cellartracker.py
Normal file
130
vrobbler/apps/drinks/cellartracker.py
Normal 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
|
||||
0
vrobbler/apps/drinks/management/__init__.py
Normal file
0
vrobbler/apps/drinks/management/__init__.py
Normal 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"
|
||||
)
|
||||
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal file
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal 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"
|
||||
)
|
||||
)
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
@ -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(
|
||||
@ -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"),
|
||||
),
|
||||
]
|
||||
@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -7,7 +7,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"beers",
|
||||
"drinks",
|
||||
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||
),
|
||||
]
|
||||
@ -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 = [
|
||||
@ -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 = [
|
||||
@ -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 = [
|
||||
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
596
vrobbler/apps/drinks/models.py
Normal file
596
vrobbler/apps/drinks/models.py
Normal 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"
|
||||
)
|
||||
89
vrobbler/apps/drinks/roastdb.py
Normal file
89
vrobbler/apps/drinks/roastdb.py
Normal 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
|
||||
37
vrobbler/apps/drinks/urls.py
Normal file
37
vrobbler/apps/drinks/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
58
vrobbler/apps/drinks/views.py
Normal file
58
vrobbler/apps/drinks/views.py
Normal 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)
|
||||
131
vrobbler/apps/drinks/vivino.py
Normal file
131
vrobbler/apps/drinks/vivino.py
Normal 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
|
||||
@ -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
|
||||
@ -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 = ""
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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")
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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))"
|
||||
)
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||
· 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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">← 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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"))
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
Reference in New Issue
Block a user