Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@ -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:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches: ["**"]
|
||||||
tags: ["*"]
|
tags: ["*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@ -68,6 +74,7 @@ jobs:
|
|||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
needs: [test]
|
needs: [test]
|
||||||
|
if: startsWith(gitea.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
147
PROJECT.org
147
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
|||||||
*** Metadata sources
|
*** Metadata sources
|
||||||
**** Scraper
|
**** Scraper
|
||||||
|
|
||||||
* Backlog [0/23] :vrobbler:project:personal:
|
* Backlog [1/25] :vrobbler:project:personal:
|
||||||
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
|
||||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
@ -604,6 +604,151 @@ a helper method to create board game scrobbles given a json blob. It's
|
|||||||
independent of the email flow it was originally creatdd for
|
independent of the email flow it was originally creatdd for
|
||||||
|
|
||||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||||
|
** TODO [#A] Update how board game scrobbles work :boardgames:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
When we scrobble a board game from a BGG URL, instead of going to the media
|
||||||
|
detail page, we should go to the scrobble detail page, with the Edit Log form
|
||||||
|
expanded by default.
|
||||||
|
|
||||||
|
The Edit log form should have from top to bottom:
|
||||||
|
|
||||||
|
- Board/Variant (one or many BoardGameVariant in a multi-select widget)
|
||||||
|
- People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)
|
||||||
|
- Expansion ids (which should a multi-select widget of expansions for this game)
|
||||||
|
- Location (which should be a drop down of BoardGameLocations for this user)
|
||||||
|
|
||||||
|
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
|
||||||
|
:END:
|
||||||
|
* Version 59.5 [1/1]
|
||||||
|
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.4 [2/2]
|
||||||
|
** DONE Fix bug in fetching expansions for board games :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
|
||||||
|
:END:
|
||||||
|
|
||||||
|
#+begin_src python
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
|
||||||
|
fetch_and_link_expansions(game, expansions)
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
|
||||||
|
expansion = BoardGame.find_or_create(str(exp_id))
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
|
||||||
|
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
|
||||||
|
game = bgg.game(game_id=lookup_id)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
|
||||||
|
raise BGGApiError(msg)
|
||||||
|
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** DONE Board games should have genres extracted from family data :boardgames:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 59.3 [2/2]
|
||||||
|
** DONE Exclude some board games from auto-expansion imports :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Some board games, especially trading card games, have silly amounts of expansions.
|
||||||
|
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
|
||||||
|
games where expansions should not be download automatically. This exclusion should also auto-include
|
||||||
|
any games with "Collectible Card Games" in it's family.
|
||||||
|
|
||||||
|
** DONE Should be able to add new variants to board games via the log data form :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.2 [1/1]
|
||||||
|
** DONE Fix test failure in discgolf app :discgolf:tests:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.1 [1/1]
|
||||||
|
** DONE Fix bug when expansions have no image :boardgames:bug:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.0 [3/3]
|
||||||
|
** DONE [#A] Add BoardGameVariant model :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Variants represent unique boards being used per scrobble or scenarios when
|
||||||
|
playing a game. Scrobbles of a board game may have one or more
|
||||||
|
boardgame_variant_ids assocaited with their log data, and a variant is created
|
||||||
|
for one specific board game.
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
We don't want to blow up the BGG API, but if possible with not too
|
||||||
|
many calls, when we scrobble a board game, in order to allow
|
||||||
|
populating the "Expansions" multi select, we should fetch any
|
||||||
|
expansions for the board game when creating it for the first time.
|
||||||
|
|
||||||
|
We should also create a managemnt script to update existing board games.
|
||||||
|
|
||||||
|
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.8 [1/1]
|
||||||
|
** DONE [#B] Clean up trend templates :trends:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.7 [2/2]
|
||||||
|
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.6 [1/1]
|
||||||
|
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.5 [1/1]
|
||||||
|
** DONE [#A] The maloja style charts are messed up :templates:charts:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 58.4 [2/2]
|
* Version 58.4 [2/2]
|
||||||
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
|
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "58.4"
|
version = "59.5"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
BoardGameLocation,
|
||||||
BoardGamePublisher,
|
BoardGamePublisher,
|
||||||
BoardGameDesigner,
|
BoardGameDesigner,
|
||||||
|
BoardGameVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
from scrobbles.admin import ScrobbleInline
|
from scrobbles.admin import ScrobbleInline
|
||||||
@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
|
|||||||
ordering = ("-created",)
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(BoardGameVariant)
|
||||||
|
class BoardGameVariantAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"board_game",
|
||||||
|
"uuid",
|
||||||
|
)
|
||||||
|
raw_id_fields = ("board_game",)
|
||||||
|
search_fields = ("name", "board_game__title")
|
||||||
|
ordering = ("-created",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(BoardGame)
|
@admin.register(BoardGame)
|
||||||
class BoardGameAdmin(admin.ModelAdmin):
|
class BoardGameAdmin(admin.ModelAdmin):
|
||||||
date_hierarchy = "created"
|
date_hierarchy = "created"
|
||||||
|
|||||||
@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
|
|||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = models.BoardGameVariant
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.BoardGame
|
model = models.BoardGame
|
||||||
|
|||||||
@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):
|
|||||||
permission_classes = [permissions.IsAuthenticated]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
|
class BoardGameVariantViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = models.BoardGameVariant.objects.all().order_by("-created")
|
||||||
|
serializer_class = serializers.BoardGameVariantSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
|
||||||
class BoardGameViewSet(viewsets.ModelViewSet):
|
class BoardGameViewSet(viewsets.ModelViewSet):
|
||||||
queryset = models.BoardGame.objects.all().order_by("-created")
|
queryset = models.BoardGame.objects.all().order_by("-created")
|
||||||
serializer_class = serializers.BoardGameSerializer
|
serializer_class = serializers.BoardGameSerializer
|
||||||
|
|||||||
@ -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
|
difficulty: Optional[int] = None
|
||||||
solo: Optional[bool] = None
|
solo: Optional[bool] = None
|
||||||
two_handed: Optional[bool] = None
|
two_handed: Optional[bool] = None
|
||||||
expansion_ids: Optional[int] = None
|
expansion_ids: Optional[list[int]] = None
|
||||||
moves: Optional[list] = None
|
moves: Optional[list] = None
|
||||||
rated: Optional[str] = None
|
rated: Optional[str] = None
|
||||||
speed: Optional[str] = None
|
speed: Optional[str] = None
|
||||||
variant: Optional[str] = None
|
variant: Optional[str] = None
|
||||||
|
variant_ids: Optional[list[int]] = None
|
||||||
lichess_id: Optional[int] = None
|
lichess_id: Optional[int] = None
|
||||||
board: Optional[str] = None
|
board: Optional[str] = None
|
||||||
rounds: Optional[int] = None
|
rounds: Optional[int] = None
|
||||||
@ -92,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def override_fields(cls) -> dict:
|
def override_fields(cls) -> dict:
|
||||||
|
from boardgames.widgets import VariantSelectWidget
|
||||||
from scrobbles.forms import NotesDictField
|
from scrobbles.forms import NotesDictField
|
||||||
|
|
||||||
fields = {}
|
fields = {}
|
||||||
@ -106,10 +108,30 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=forms.Select(),
|
widget=forms.Select(),
|
||||||
),
|
),
|
||||||
|
"variant_ids": forms.ModelMultipleChoiceField(
|
||||||
|
queryset=BoardGameVariant.objects.all(),
|
||||||
|
required=False,
|
||||||
|
widget=VariantSelectWidget(attrs={"size": 5}),
|
||||||
|
),
|
||||||
|
"expansion_ids": forms.ModelMultipleChoiceField(
|
||||||
|
queryset=BoardGame.objects.filter(
|
||||||
|
expansion_for_boardgame__isnull=False
|
||||||
|
),
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={"size": 5}),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
fields.update(custom_fields)
|
fields.update(custom_fields)
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def variants(self) -> list["BoardGameVariant"]:
|
||||||
|
if not self.variant_ids:
|
||||||
|
return []
|
||||||
|
return list(
|
||||||
|
BoardGameVariant.objects.filter(id__in=self.variant_ids)
|
||||||
|
)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def location(self):
|
def location(self):
|
||||||
if not self.location_id:
|
if not self.location_id:
|
||||||
@ -135,6 +157,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
|||||||
if self.board:
|
if self.board:
|
||||||
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
|
||||||
|
|
||||||
|
if self.variants:
|
||||||
|
variant_names = ", ".join(v.name for v in self.variants)
|
||||||
|
html_parts.append(
|
||||||
|
f'<div class="boardgame-variants">Variants: {variant_names}</div>'
|
||||||
|
)
|
||||||
|
|
||||||
if self.location:
|
if self.location:
|
||||||
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
|
||||||
|
|
||||||
@ -272,6 +300,7 @@ class BoardGame(ScrobblableMixin):
|
|||||||
expansion_for_boardgame = models.ForeignKey(
|
expansion_for_boardgame = models.ForeignKey(
|
||||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||||
)
|
)
|
||||||
|
skip_expansions = models.BooleanField(default=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def subtitle(self) -> str:
|
def subtitle(self) -> str:
|
||||||
@ -306,18 +335,29 @@ class BoardGame(ScrobblableMixin):
|
|||||||
if not data:
|
if not data:
|
||||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||||
|
|
||||||
cover_url = data.pop("cover_url")
|
expansions = data.pop("expansions", [])
|
||||||
year = data.pop("year_published")
|
cover_url = data.pop("cover_url", "")
|
||||||
publisher_name = data.pop("publisher_name")
|
year_published = data.pop("year_published", None)
|
||||||
|
if year_published is None:
|
||||||
|
year_published = data.pop("published_year", None)
|
||||||
|
publisher_name = data.pop("publisher_name", "")
|
||||||
|
|
||||||
if year:
|
if year_published:
|
||||||
data["published_year"] = int(year)
|
data["published_year"] = int(year_published)
|
||||||
|
|
||||||
if not data["min_players"]:
|
if not data["min_players"]:
|
||||||
data.pop("min_players")
|
data.pop("min_players")
|
||||||
if not data["min_players"]:
|
if not data["min_players"]:
|
||||||
data.pop("max_players")
|
data.pop("max_players")
|
||||||
|
|
||||||
|
# Pop extra BGG metadata that isn't a model field
|
||||||
|
categories = data.pop("categories", [])
|
||||||
|
families = data.pop("families", [])
|
||||||
|
data.pop("mechanics", None)
|
||||||
|
data.pop("designers", None)
|
||||||
|
data.pop("publishers", None)
|
||||||
|
data.pop("publisher", None)
|
||||||
|
|
||||||
# Fun trick for updating all fields at once
|
# Fun trick for updating all fields at once
|
||||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||||
self.refresh_from_db()
|
self.refresh_from_db()
|
||||||
@ -329,10 +369,20 @@ class BoardGame(ScrobblableMixin):
|
|||||||
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
self.genre.add(cat.strip())
|
||||||
|
for fam in families:
|
||||||
|
self.tags.add(fam.strip())
|
||||||
|
|
||||||
# Go get cover image if the URL is present
|
# Go get cover image if the URL is present
|
||||||
if cover_url and not self.cover:
|
if cover_url and not self.cover:
|
||||||
self.save_image_from_url(cover_url)
|
self.save_image_from_url(cover_url)
|
||||||
|
|
||||||
|
from boardgames.utils import fetch_and_link_expansions
|
||||||
|
|
||||||
|
if not self.skip_expansions:
|
||||||
|
fetch_and_link_expansions(self, expansions)
|
||||||
|
|
||||||
def save_image_from_url(self, url):
|
def save_image_from_url(self, url):
|
||||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
r = requests.get(url, headers=headers)
|
r = requests.get(url, headers=headers)
|
||||||
@ -341,7 +391,12 @@ class BoardGame(ScrobblableMixin):
|
|||||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
def find_or_create(
|
||||||
|
cls,
|
||||||
|
lookup_id: str,
|
||||||
|
data: dict[str, Any] = {},
|
||||||
|
defer_expansions: bool = False,
|
||||||
|
) -> "BoardGame":
|
||||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||||
if not game:
|
if not game:
|
||||||
@ -364,16 +419,19 @@ class BoardGame(ScrobblableMixin):
|
|||||||
else:
|
else:
|
||||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||||
|
|
||||||
|
expansions = bgg_data.pop("expansions", [])
|
||||||
mechanics = bgg_data.pop("mechanics", [])
|
mechanics = bgg_data.pop("mechanics", [])
|
||||||
designers = bgg_data.pop("designers", [])
|
designers = bgg_data.pop("designers", [])
|
||||||
categories = bgg_data.pop("categories", [])
|
categories = bgg_data.pop("categories", [])
|
||||||
|
families = bgg_data.pop("families", [])
|
||||||
publishers = bgg_data.pop("publishers", [])
|
publishers = bgg_data.pop("publishers", [])
|
||||||
publisher = bgg_data.pop("publisher", [])
|
publisher = bgg_data.pop("publisher", [])
|
||||||
cover_url = bgg_data.pop("cover_url")
|
cover_url = bgg_data.pop("cover_url") or ""
|
||||||
|
|
||||||
game = cls.objects.create(**bgg_data)
|
game = cls.objects.create(**bgg_data)
|
||||||
|
|
||||||
game.save_image_from_url(cover_url)
|
if cover_url:
|
||||||
|
game.save_image_from_url(cover_url)
|
||||||
game.cooperative = data.get("cooperative", False)
|
game.cooperative = data.get("cooperative", False)
|
||||||
game.highest_wins = data.get("highestWins", True)
|
game.highest_wins = data.get("highestWins", True)
|
||||||
game.no_points = data.get("noPoints", False)
|
game.no_points = data.get("noPoints", False)
|
||||||
@ -382,6 +440,18 @@ class BoardGame(ScrobblableMixin):
|
|||||||
if publisher:
|
if publisher:
|
||||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||||
game.publisher = publisher
|
game.publisher = publisher
|
||||||
|
|
||||||
|
skip_expansions = (
|
||||||
|
game.bggeek_id is not None
|
||||||
|
and str(game.bggeek_id).isdigit()
|
||||||
|
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
|
||||||
|
) or any(
|
||||||
|
c == "Collectible Card Games" for c in categories
|
||||||
|
)
|
||||||
|
|
||||||
|
if skip_expansions:
|
||||||
|
game.skip_expansions = True
|
||||||
|
|
||||||
game.save()
|
game.save()
|
||||||
|
|
||||||
if designers:
|
if designers:
|
||||||
@ -396,4 +466,33 @@ class BoardGame(ScrobblableMixin):
|
|||||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||||
game.publishers.add(publisher)
|
game.publishers.add(publisher)
|
||||||
|
|
||||||
|
for cat in categories:
|
||||||
|
game.genre.add(cat.strip())
|
||||||
|
for fam in families:
|
||||||
|
game.tags.add(fam.strip())
|
||||||
|
|
||||||
|
if expansions and not game.skip_expansions:
|
||||||
|
if defer_expansions:
|
||||||
|
from boardgames.tasks import fetch_board_game_expansions
|
||||||
|
|
||||||
|
fetch_board_game_expansions.delay(game.id, expansions)
|
||||||
|
else:
|
||||||
|
from boardgames.utils import fetch_and_link_expansions
|
||||||
|
|
||||||
|
fetch_and_link_expansions(game, expansions)
|
||||||
|
|
||||||
return game
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
class BoardGameVariant(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
board_game = models.ForeignKey(
|
||||||
|
BoardGame,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="variants",
|
||||||
|
)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.name} ({self.board_game.title})"
|
||||||
|
|||||||
@ -32,9 +32,13 @@ def lookup_boardgame_from_bgg(
|
|||||||
)
|
)
|
||||||
game_dict["mechanics"] = game.mechanics
|
game_dict["mechanics"] = game.mechanics
|
||||||
game_dict["categories"] = game.categories
|
game_dict["categories"] = game.categories
|
||||||
|
game_dict["families"] = game.families
|
||||||
game_dict["designers"] = game.designers
|
game_dict["designers"] = game.designers
|
||||||
game_dict["publishers"] = game.publishers
|
game_dict["publishers"] = game.publishers
|
||||||
if game.publishers:
|
if game.publishers:
|
||||||
game_dict["publisher"] = game.publishers[0]
|
game_dict["publisher"] = game.publishers[0]
|
||||||
|
game_dict["expansions"] = [
|
||||||
|
{"id": exp.id, "name": exp.name} for exp in game.expansions
|
||||||
|
]
|
||||||
|
|
||||||
return game_dict
|
return game_dict
|
||||||
|
|||||||
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(),
|
views.BoardGamePublisherDetailView.as_view(),
|
||||||
name="publisher_detail",
|
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
|
import datetime
|
||||||
|
|
||||||
|
from django.http import JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views import generic
|
from django.views import generic
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.views import (
|
from scrobbles.views import (
|
||||||
@ -10,6 +13,38 @@ from scrobbles.views import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def ajax_create_variant(request):
|
||||||
|
name = request.POST.get("name", "").strip()
|
||||||
|
board_game_id = request.POST.get("board_game_id")
|
||||||
|
description = request.POST.get("description", "").strip()
|
||||||
|
|
||||||
|
if not name or not board_game_id:
|
||||||
|
return JsonResponse({"error": "Name and board game are required"}, status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
board_game_id = int(board_game_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return JsonResponse({"error": "Invalid board game"}, status=400)
|
||||||
|
|
||||||
|
from boardgames.models import BoardGameVariant
|
||||||
|
|
||||||
|
variant = BoardGameVariant.objects.filter(
|
||||||
|
name__iexact=name,
|
||||||
|
board_game_id=board_game_id,
|
||||||
|
).first()
|
||||||
|
if variant:
|
||||||
|
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||||
|
|
||||||
|
variant = BoardGameVariant.objects.create(
|
||||||
|
name=name,
|
||||||
|
board_game_id=board_game_id,
|
||||||
|
description=description or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||||
|
|
||||||
|
|
||||||
class BoardGameListView(ScrobbleableListView):
|
class BoardGameListView(ScrobbleableListView):
|
||||||
model = BoardGame
|
model = BoardGame
|
||||||
|
|
||||||
|
|||||||
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",
|
"publish_year",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _cover_missing_or_broken(book) -> bool:
|
||||||
|
if not bool(book.cover):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return not book.cover.storage.exists(book.cover.name)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
MISSING_GROUPS = {
|
MISSING_GROUPS = {
|
||||||
"cover": lambda b: not bool(b.cover),
|
"cover": _cover_missing_or_broken,
|
||||||
"summary": lambda b: not b.summary,
|
"summary": lambda b: not b.summary,
|
||||||
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
|
||||||
"pages": lambda b: b.pages is None,
|
"pages": lambda b: b.pages is None,
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from charts.views import (
|
|||||||
BirdsChartView,
|
BirdsChartView,
|
||||||
ChartDetailView,
|
ChartDetailView,
|
||||||
ChartRecordView,
|
ChartRecordView,
|
||||||
|
MalojaChartsView,
|
||||||
SpotifyTracksView,
|
SpotifyTracksView,
|
||||||
)
|
)
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
@ -11,6 +12,7 @@ app_name = "charts"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||||
|
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
|
||||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||||
|
|||||||
@ -114,175 +114,13 @@ class ChartRecordView(TemplateView):
|
|||||||
context["current_week"] = current_week
|
context["current_week"] = current_week
|
||||||
context["current_day"] = current_day
|
context["current_day"] = current_day
|
||||||
|
|
||||||
context["chart_keys"] = {
|
# Resolve date parameters
|
||||||
"today": "Today",
|
if date_param:
|
||||||
"week": "This Week",
|
|
||||||
"month": "This Month",
|
|
||||||
"year": "This Year",
|
|
||||||
"all": "All Time",
|
|
||||||
}
|
|
||||||
|
|
||||||
context["maloja_charts"] = {
|
|
||||||
"artist": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"artist",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
day=current_day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"artist",
|
|
||||||
year=current_year,
|
|
||||||
week=current_week,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"artist",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "artist", year=current_year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "artist")),
|
|
||||||
},
|
|
||||||
"album": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"album",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
day=current_day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "album", year=current_year, week=current_week
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"album",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "album", year=current_year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "album")),
|
|
||||||
},
|
|
||||||
"tv_series": {
|
|
||||||
"today": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"tv_series",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
day=current_day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"week": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"tv_series",
|
|
||||||
year=current_year,
|
|
||||||
week=current_week,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"month": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"tv_series",
|
|
||||||
year=current_year,
|
|
||||||
month=current_month,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"year": list(
|
|
||||||
self.get_charts_for_period(user, "tv_series", year=current_year)
|
|
||||||
),
|
|
||||||
"all": list(self.get_charts_for_period(user, "tv_series")),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if not date_param:
|
|
||||||
context["period"] = "current"
|
|
||||||
context["year"] = current_year
|
|
||||||
context["month"] = current_month
|
|
||||||
context["month_name"] = calendar.month_name[current_month]
|
|
||||||
context["week"] = current_week
|
|
||||||
context["day"] = current_day
|
|
||||||
|
|
||||||
context["charts"] = {
|
|
||||||
"artist": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "artist", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"album": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "album", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"track": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "track", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"tv_series": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "tv_series", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"video": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "video", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"board_game": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "board_game", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"book": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "book", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"food": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "food", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"podcast": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "podcast", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"trail": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user, "trail", year=current_year, limit=20
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
parts = date_param.split("-")
|
parts = date_param.split("-")
|
||||||
year = int(parts[0])
|
year = int(parts[0])
|
||||||
|
|
||||||
week = None
|
week = None
|
||||||
month = None
|
month = None
|
||||||
day = None
|
day = None
|
||||||
|
|
||||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||||
week = int(parts[1].lstrip("W"))
|
week = int(parts[1].lstrip("W"))
|
||||||
elif len(parts) >= 2 and parts[1]:
|
elif len(parts) >= 2 and parts[1]:
|
||||||
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
|
|||||||
month = int(parts[1])
|
month = int(parts[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if len(parts) >= 3:
|
if len(parts) >= 3:
|
||||||
if parts[2].startswith("W"):
|
if parts[2].startswith("W"):
|
||||||
week = int(parts[2].lstrip("W"))
|
week = int(parts[2].lstrip("W"))
|
||||||
elif not parts[2].startswith("W"):
|
elif not parts[2].startswith("W"):
|
||||||
day = int(parts[2])
|
day = int(parts[2])
|
||||||
|
|
||||||
context["period"] = "historical"
|
context["period"] = "historical"
|
||||||
context["year"] = year
|
context["year"] = year
|
||||||
context["month"] = month
|
context["month"] = month
|
||||||
context["month_name"] = calendar.month_name[month] if month else None
|
context["month_name"] = calendar.month_name[month] if month else None
|
||||||
context["week"] = week
|
context["week"] = week
|
||||||
context["day"] = day
|
context["day"] = day
|
||||||
|
|
||||||
period_str = str(year)
|
period_str = str(year)
|
||||||
if month:
|
if month:
|
||||||
period_str = f"{calendar.month_name[month]} {period_str}"
|
period_str = f"{calendar.month_name[month]} {period_str}"
|
||||||
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
|
|||||||
if day:
|
if day:
|
||||||
period_str = f"{calendar.month_name[month]} {day}, {year}"
|
period_str = f"{calendar.month_name[month]} {day}, {year}"
|
||||||
context["period_str"] = period_str
|
context["period_str"] = period_str
|
||||||
|
else:
|
||||||
|
year = current_year
|
||||||
|
month = current_month
|
||||||
|
week = current_week
|
||||||
|
day = current_day
|
||||||
|
context["period"] = "current"
|
||||||
|
context["year"] = current_year
|
||||||
|
context["month"] = current_month
|
||||||
|
context["month_name"] = calendar.month_name[current_month]
|
||||||
|
context["week"] = current_week
|
||||||
|
context["day"] = current_day
|
||||||
|
|
||||||
context["charts"] = {
|
# List-group tables default to week-level when no date param (matches active tab)
|
||||||
"artist": list(
|
if not date_param:
|
||||||
self.get_charts_for_period(
|
list_year = current_year
|
||||||
user,
|
list_month = None
|
||||||
"artist",
|
list_week = current_week
|
||||||
year=year,
|
list_day = None
|
||||||
month=month,
|
else:
|
||||||
week=week,
|
list_year = year
|
||||||
day=day,
|
list_month = month
|
||||||
)
|
list_week = week
|
||||||
),
|
list_day = day
|
||||||
"album": list(
|
|
||||||
self.get_charts_for_period(
|
context["charts"] = {
|
||||||
user,
|
"artist": list(
|
||||||
"album",
|
self.get_charts_for_period(
|
||||||
year=year,
|
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
month=month,
|
)
|
||||||
week=week,
|
),
|
||||||
day=day,
|
"album": list(
|
||||||
)
|
self.get_charts_for_period(
|
||||||
),
|
user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
"track": list(
|
)
|
||||||
self.get_charts_for_period(
|
),
|
||||||
user,
|
"track": list(
|
||||||
"track",
|
self.get_charts_for_period(
|
||||||
year=year,
|
user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
month=month,
|
)
|
||||||
week=week,
|
),
|
||||||
day=day,
|
"tv_series": list(
|
||||||
)
|
self.get_charts_for_period(
|
||||||
),
|
user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
"tv_series": list(
|
)
|
||||||
self.get_charts_for_period(
|
),
|
||||||
user,
|
"video": list(
|
||||||
"tv_series",
|
self.get_charts_for_period(
|
||||||
year=year,
|
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
month=month,
|
)
|
||||||
week=week,
|
),
|
||||||
day=day,
|
"board_game": list(
|
||||||
)
|
self.get_charts_for_period(
|
||||||
),
|
user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
"video": list(
|
)
|
||||||
self.get_charts_for_period(
|
),
|
||||||
user,
|
"book": list(
|
||||||
"video",
|
self.get_charts_for_period(
|
||||||
year=year,
|
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
month=month,
|
)
|
||||||
week=week,
|
),
|
||||||
day=day,
|
"food": list(
|
||||||
)
|
self.get_charts_for_period(
|
||||||
),
|
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
"board_game": list(
|
)
|
||||||
self.get_charts_for_period(
|
),
|
||||||
user,
|
"podcast": list(
|
||||||
"board_game",
|
self.get_charts_for_period(
|
||||||
year=year,
|
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
month=month,
|
)
|
||||||
week=week,
|
),
|
||||||
day=day,
|
"trail": list(
|
||||||
)
|
self.get_charts_for_period(
|
||||||
),
|
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
|
||||||
"book": list(
|
)
|
||||||
self.get_charts_for_period(
|
),
|
||||||
user,
|
}
|
||||||
"book",
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
week=week,
|
|
||||||
day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"food": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"food",
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
week=week,
|
|
||||||
day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"podcast": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"podcast",
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
week=week,
|
|
||||||
day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"trail": list(
|
|
||||||
self.get_charts_for_period(
|
|
||||||
user,
|
|
||||||
"trail",
|
|
||||||
year=year,
|
|
||||||
month=month,
|
|
||||||
week=week,
|
|
||||||
day=day,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
bird_data = self.get_bird_chart_data(
|
bird_data = self.get_bird_chart_data(
|
||||||
user,
|
user,
|
||||||
@ -628,6 +436,57 @@ class ChartRecordView(TemplateView):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MalojaChartsView(ChartRecordView):
|
||||||
|
"""Three maloja-themed image grid widgets (artists, albums, TV series)
|
||||||
|
with Today/Week/Month/Year/All tabs. Each tab computes its own period
|
||||||
|
from the current date — no query param needed."""
|
||||||
|
|
||||||
|
template_name = "charts/maloja_charts.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(ChartRecordView, self).get_context_data(**kwargs)
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
if not user.is_authenticated:
|
||||||
|
context["maloja_charts"] = {}
|
||||||
|
context["chart_keys"] = {}
|
||||||
|
return context
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
now = now_user_timezone(user.profile)
|
||||||
|
today = now.date()
|
||||||
|
|
||||||
|
context["chart_keys"] = {
|
||||||
|
"today": "Today",
|
||||||
|
"week": "This Week",
|
||||||
|
"month": "This Month",
|
||||||
|
"year": "This Year",
|
||||||
|
"all": "All Time",
|
||||||
|
}
|
||||||
|
|
||||||
|
tab_params = {
|
||||||
|
"today": {"year": today.year, "month": today.month, "day": today.day},
|
||||||
|
"week": {"year": today.year, "week": today.isocalendar()[1]},
|
||||||
|
"month": {"year": today.year, "month": today.month},
|
||||||
|
"year": {"year": today.year},
|
||||||
|
}
|
||||||
|
|
||||||
|
maloja_charts = {}
|
||||||
|
for media_type in ("artist", "album", "tv_series"):
|
||||||
|
tabs = {}
|
||||||
|
for key in ("today", "week", "month", "year"):
|
||||||
|
tabs[key] = list(
|
||||||
|
self.get_charts_for_period(user, media_type, **tab_params[key])
|
||||||
|
)
|
||||||
|
tabs["all"] = list(
|
||||||
|
self.get_charts_for_period(user, media_type)
|
||||||
|
)
|
||||||
|
maloja_charts[media_type] = tabs
|
||||||
|
|
||||||
|
context["maloja_charts"] = maloja_charts
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
MEDIA_TYPE_LABELS = {
|
MEDIA_TYPE_LABELS = {
|
||||||
"artist": ("🎤", "Top Artists"),
|
"artist": ("🎤", "Top Artists"),
|
||||||
"album": ("💿", "Top Albums"),
|
"album": ("💿", "Top Albums"),
|
||||||
|
|||||||
@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _parse_udisc_datetime(raw: str) -> datetime:
|
def _parse_udisc_datetime(raw: str) -> datetime:
|
||||||
return parse_datetime(raw)
|
dt = parse_datetime(raw)
|
||||||
|
if timezone.is_naive(dt):
|
||||||
|
return timezone.make_aware(dt)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
def _resolve_player(name: str, user_id: int) -> Person:
|
def _resolve_player(name: str, user_id: int) -> Person:
|
||||||
|
|||||||
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):
|
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
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={"drink_log": log_data},
|
||||||
|
**{fk_field: media},
|
||||||
|
)
|
||||||
|
scrobbles_created += 1
|
||||||
|
|
||||||
|
# Some scrobbles get favorited
|
||||||
|
if random.random() < 0.15:
|
||||||
|
FavoriteMedia.toggle(media, user)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\nDone! Created:\n"
|
||||||
|
f" {len(beer_objects)} beers\n"
|
||||||
|
f" {len(wine_objects)} wines\n"
|
||||||
|
f" {len(coffee_objects)} coffees\n"
|
||||||
|
f" {scrobbles_created} scrobbles over {days} days\n"
|
||||||
|
f" User: {username} / demo"
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -1,9 +1,10 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||||
|
|
||||||
from django.db import migrations, models
|
import uuid
|
||||||
|
|
||||||
import django_extensions.db.fields
|
import django_extensions.db.fields
|
||||||
import taggit.managers
|
import taggit.managers
|
||||||
import uuid
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
@ -46,6 +47,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
"db_table": "beers_beerproducer",
|
||||||
"get_latest_by": "modified",
|
"get_latest_by": "modified",
|
||||||
"abstract": False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
@ -127,6 +129,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
"db_table": "beers_beer",
|
||||||
"abstract": False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -1,13 +1,13 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("beers", "0001_initial"),
|
("drinks", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
to="beers.beerproducer",
|
to="drinks.beerproducer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@ -1,13 +1,13 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django_extensions.db.fields
|
import django_extensions.db.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
("drinks", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -38,6 +38,7 @@ class Migration(migrations.Migration):
|
|||||||
("description", models.TextField(blank=True, null=True)),
|
("description", models.TextField(blank=True, null=True)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
|
"db_table": "beers_beerstyle",
|
||||||
"get_latest_by": "modified",
|
"get_latest_by": "modified",
|
||||||
"abstract": False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
@ -70,6 +71,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="beer",
|
model_name="beer",
|
||||||
name="styles",
|
name="styles",
|
||||||
field=models.ManyToManyField(to="beers.beerstyle"),
|
field=models.ManyToManyField(to="drinks.beerstyle"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -1,13 +1,14 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
("drinks", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -41,7 +42,7 @@ class Migration(migrations.Migration):
|
|||||||
model_name="beer",
|
model_name="beer",
|
||||||
name="styles",
|
name="styles",
|
||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
related_name="styles", to="beers.beerstyle"
|
related_name="styles", to="drinks.beerstyle"
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -7,7 +7,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
(
|
(
|
||||||
"beers",
|
"drinks",
|
||||||
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -6,7 +6,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('beers', '0005_alter_beer_run_time_seconds'),
|
('drinks', '0005_alter_beer_run_time_seconds'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -1,14 +1,14 @@
|
|||||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
import taggit.managers
|
import taggit.managers
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||||
("beers", "0006_remove_beer_run_time_seconds_and_more"),
|
("drinks", "0006_remove_beer_run_time_seconds_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -1,14 +1,14 @@
|
|||||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
import taggit.managers
|
import taggit.managers
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("scrobbles", "0075_add_channel_scrobble"),
|
("scrobbles", "0075_add_channel_scrobble"),
|
||||||
("beers", "0007_beer_tags"),
|
("drinks", "0007_beer_tags"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@ -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
590
vrobbler/apps/drinks/models.py
Normal file
590
vrobbler/apps/drinks/models.py
Normal file
@ -0,0 +1,590 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from drinks.cellartracker import get_wine_from_cellartracker_id
|
||||||
|
from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb
|
||||||
|
from drinks.untappd import get_beer_from_untappd_id
|
||||||
|
from drinks.vivino import get_wine_from_vivino_id
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import BaseLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WineType(models.TextChoices):
|
||||||
|
RED = "red", "Red"
|
||||||
|
WHITE = "white", "White"
|
||||||
|
ROSE = "rosé", "Rosé"
|
||||||
|
SPARKLING = "sparkling", "Sparkling"
|
||||||
|
DESSERT = "dessert", "Dessert"
|
||||||
|
FORTIFIED = "fortified", "Fortified"
|
||||||
|
ORANGE = "orange", "Orange"
|
||||||
|
|
||||||
|
|
||||||
|
class DrinkFormat(models.TextChoices):
|
||||||
|
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
||||||
|
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
||||||
|
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
||||||
|
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
||||||
|
KEG = "keg", "Keg"
|
||||||
|
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
GROWLER = "growler", "Growler"
|
||||||
|
BOX = "box", "Box"
|
||||||
|
|
||||||
|
|
||||||
|
class RoastLevel(models.TextChoices):
|
||||||
|
LIGHT = "light", "Light"
|
||||||
|
MEDIUM = "medium", "Medium"
|
||||||
|
DARK = "dark", "Dark"
|
||||||
|
|
||||||
|
|
||||||
|
class BrewingMethod(models.TextChoices):
|
||||||
|
POUROVER = "pourover", "Pour-over"
|
||||||
|
DRIP = "drip", "Drip"
|
||||||
|
FRENCH_PRESS = "french_press", "French Press"
|
||||||
|
ESPRESSO = "espresso", "Espresso"
|
||||||
|
AEROPRESS = "aeropress", "AeroPress"
|
||||||
|
KEURIG = "keurig", "Keurig"
|
||||||
|
NESCAPRESSO = "nespresso", "Nespresso"
|
||||||
|
COLD_BREW = "cold_brew", "Cold Brew"
|
||||||
|
MOKA_POT = "moka_pot", "Moka Pot"
|
||||||
|
SIPHON = "siphon", "Siphon"
|
||||||
|
TURKISH = "turkish", "Turkish"
|
||||||
|
CHEMEX = "chemex", "Chemex"
|
||||||
|
|
||||||
|
|
||||||
|
ML_PER_OZ = 29.5735
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrinkLogData(BaseLogData):
|
||||||
|
rating: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
size_ml: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_oz(self) -> Optional[float]:
|
||||||
|
if self.size_ml:
|
||||||
|
return round(float(self.size_ml) / ML_PER_OZ, 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_display(self) -> str:
|
||||||
|
if self.format:
|
||||||
|
return DrinkFormat(self.format).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def override_fields(cls) -> dict:
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(DrinkFormat.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BeerLogData(DrinkLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WineLogData(DrinkLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoffeeLogData(DrinkLogData):
|
||||||
|
roast_level: Optional[str] = None
|
||||||
|
brewing_method: Optional[str] = None
|
||||||
|
single_origin_or_blend: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roast_level_display(self) -> str:
|
||||||
|
if self.roast_level:
|
||||||
|
return RoastLevel(self.roast_level).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brewing_method_display(self) -> str:
|
||||||
|
if self.brewing_method:
|
||||||
|
return BrewingMethod(self.brewing_method).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def override_fields(cls) -> dict:
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
fields = super().override_fields()
|
||||||
|
fields.update(
|
||||||
|
{
|
||||||
|
"roast_level": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(RoastLevel.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
"brewing_method": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(BrewingMethod.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class Drink(ScrobblableMixin):
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
is_alcoholic = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "drinks_drink"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title or "Drink"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create_water(cls) -> "Drink":
|
||||||
|
water, _ = cls.objects.get_or_create(
|
||||||
|
title="Water", defaults={"calories": 0, "base_run_time_seconds": 120}
|
||||||
|
)
|
||||||
|
return water
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:drink_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return DrinkLogData
|
||||||
|
|
||||||
|
|
||||||
|
class BeerStyle(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beerstyle"
|
||||||
|
|
||||||
|
|
||||||
|
class BeerProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beerproducer"
|
||||||
|
|
||||||
|
|
||||||
|
class Beer(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
ibu = models.SmallIntegerField(**BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||||
|
untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)
|
||||||
|
untappd_image_small = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
untappd_image_medium = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_rating = models.FloatField(**BNULL)
|
||||||
|
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.producer}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.producer.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beeradvocate_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.beeradvocate_id:
|
||||||
|
if self.beeradvocate_id:
|
||||||
|
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def untappd_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.untappd_id:
|
||||||
|
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.untappd_image:
|
||||||
|
url = self.untappd_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BeerLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||||
|
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||||
|
|
||||||
|
if not beer:
|
||||||
|
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||||
|
producer_dict = {}
|
||||||
|
style_ids = []
|
||||||
|
for key in list(beer_dict.keys()):
|
||||||
|
if "producer__" in key:
|
||||||
|
pkey = key.replace("producer__", "")
|
||||||
|
producer_dict[pkey] = beer_dict.pop(key)
|
||||||
|
if "styles" in key:
|
||||||
|
for style in beer_dict.pop("styles"):
|
||||||
|
style_inst, created = BeerStyle.objects.get_or_create(
|
||||||
|
name=style
|
||||||
|
)
|
||||||
|
style_ids.append(style_inst.id)
|
||||||
|
|
||||||
|
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||||
|
beer_dict["producer_id"] = producer.id
|
||||||
|
beer = Beer.objects.create(**beer_dict)
|
||||||
|
for style_id in style_ids:
|
||||||
|
beer.styles.add(style_id)
|
||||||
|
|
||||||
|
return beer
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beer"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wine models ---
|
||||||
|
|
||||||
|
|
||||||
|
class WineGrape(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class WineRegion(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
country = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class WineProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Wine(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)
|
||||||
|
grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)
|
||||||
|
region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
vintage = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_rating = models.FloatField(**BNULL)
|
||||||
|
vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)
|
||||||
|
vivino_image_small = ImageSpecField(
|
||||||
|
source="vivino_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
vivino_image_medium = ImageSpecField(
|
||||||
|
source="vivino_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
cellartracker_rating = models.FloatField(**BNULL)
|
||||||
|
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.producer}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.producer.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="wine")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def vivino_link(self) -> str:
|
||||||
|
if self.vivino_id:
|
||||||
|
return f"https://www.vivino.com/{self.vivino_id}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cellartracker_link(self) -> str:
|
||||||
|
if self.cellartracker_id:
|
||||||
|
return (
|
||||||
|
f"https://www.cellartracker.com/wine.asp?iWine={self.cellartracker_id}"
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
if self.vivino_image:
|
||||||
|
return self.vivino_image.url
|
||||||
|
if self.cellartracker_image:
|
||||||
|
return self.cellartracker_image.url
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return WineLogData
|
||||||
|
|
||||||
|
def save_image_from_url(self, url):
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
|
self.vivino_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(
|
||||||
|
cls, vivino_id: str = None, cellartracker_id: str = None
|
||||||
|
) -> "Wine":
|
||||||
|
wine = None
|
||||||
|
if vivino_id:
|
||||||
|
wine = cls.objects.filter(vivino_id=vivino_id).first()
|
||||||
|
if not wine and cellartracker_id:
|
||||||
|
wine = cls.objects.filter(cellartracker_id=cellartracker_id).first()
|
||||||
|
|
||||||
|
if wine:
|
||||||
|
return wine
|
||||||
|
|
||||||
|
wine_dict = None
|
||||||
|
if vivino_id:
|
||||||
|
wine_dict = get_wine_from_vivino_id(vivino_id)
|
||||||
|
elif cellartracker_id:
|
||||||
|
wine_dict = get_wine_from_cellartracker_id(cellartracker_id)
|
||||||
|
|
||||||
|
if not wine_dict or not wine_dict.get("title"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
producer_name = wine_dict.pop("producer__name", "")
|
||||||
|
region_name = wine_dict.pop("region", "")
|
||||||
|
grape_names = wine_dict.pop("grapes", [])
|
||||||
|
image_url = wine_dict.pop("image_url", "")
|
||||||
|
|
||||||
|
producer = None
|
||||||
|
if producer_name:
|
||||||
|
producer, _ = WineProducer.objects.get_or_create(name=producer_name)
|
||||||
|
|
||||||
|
region = None
|
||||||
|
if region_name:
|
||||||
|
region, _ = WineRegion.objects.get_or_create(name=region_name)
|
||||||
|
|
||||||
|
wine = cls.objects.create(
|
||||||
|
title=wine_dict.get("title", ""),
|
||||||
|
description=wine_dict.get("description", ""),
|
||||||
|
wine_type=wine_dict.get("wine_type", ""),
|
||||||
|
vintage=wine_dict.get("vintage", ""),
|
||||||
|
vivino_id=wine_dict.get("vivino_id", vivino_id),
|
||||||
|
vivino_rating=wine_dict.get("vivino_rating"),
|
||||||
|
cellartracker_id=wine_dict.get("cellartracker_id", cellartracker_id),
|
||||||
|
cellartracker_rating=wine_dict.get("cellartracker_rating"),
|
||||||
|
producer=producer,
|
||||||
|
region=region,
|
||||||
|
)
|
||||||
|
|
||||||
|
for grape_name in grape_names:
|
||||||
|
grape, _ = WineGrape.objects.get_or_create(name=grape_name)
|
||||||
|
wine.grapes.add(grape)
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
try:
|
||||||
|
wine.save_image_from_url(image_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return wine
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Coffee models ---
|
||||||
|
|
||||||
|
|
||||||
|
class CoffeeRoaster(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
roastdb_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Coffee(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
origin = models.CharField(max_length=255, **BNULL)
|
||||||
|
roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
roastdb_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
roastdb_image = models.ImageField(upload_to="drinks/roastdb/", **BNULL)
|
||||||
|
roastdb_image_small = ImageSpecField(
|
||||||
|
source="roastdb_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
roastdb_image_medium = ImageSpecField(
|
||||||
|
source="roastdb_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:coffee_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.roaster}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.roaster.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="coffee")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
if self.roastdb_image:
|
||||||
|
return self.roastdb_image.url
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return CoffeeLogData
|
||||||
|
|
||||||
|
def save_image_from_url(self, url):
|
||||||
|
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||||
|
r = requests.get(url, headers=headers)
|
||||||
|
if r.status_code == 200:
|
||||||
|
fname = f"{self.title}_{self.uuid}.jpg"
|
||||||
|
self.roastdb_image.save(fname, ContentFile(r.content), save=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, roastdb_id: str = None, slug: str = None) -> "Coffee":
|
||||||
|
coffee = None
|
||||||
|
if roastdb_id:
|
||||||
|
coffee = cls.objects.filter(roastdb_id=roastdb_id).first()
|
||||||
|
if not coffee and slug:
|
||||||
|
coffee = cls.objects.filter(roastdb_id=slug).first()
|
||||||
|
if coffee:
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
coffee_dict = None
|
||||||
|
if slug:
|
||||||
|
coffee_dict = get_coffee_from_slug(slug)
|
||||||
|
elif roastdb_id:
|
||||||
|
coffee_dict = get_coffee_from_slug(roastdb_id)
|
||||||
|
|
||||||
|
if not coffee_dict or not coffee_dict.get("title"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
roaster_name = coffee_dict.pop("roaster__name", "")
|
||||||
|
image_url = coffee_dict.pop("image_url", "")
|
||||||
|
|
||||||
|
roaster = None
|
||||||
|
if roaster_name:
|
||||||
|
roaster, _ = CoffeeRoaster.objects.get_or_create(name=roaster_name)
|
||||||
|
|
||||||
|
coffee = cls.objects.create(
|
||||||
|
title=coffee_dict.get("title", ""),
|
||||||
|
description=coffee_dict.get("description", ""),
|
||||||
|
origin=coffee_dict.get("origin", ""),
|
||||||
|
roastdb_id=slug or roastdb_id,
|
||||||
|
roaster=roaster,
|
||||||
|
)
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
try:
|
||||||
|
coffee.save_image_from_url(image_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, coffee=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
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 django import forms
|
||||||
|
|
||||||
from profiles.models import UserProfile
|
from profiles.models import UserProfile
|
||||||
from scrobbles.constants import Visibility
|
from scrobbles.constants import Visibility
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
@ -42,6 +41,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"home_scrobble_limit",
|
"home_scrobble_limit",
|
||||||
"live_now_playing",
|
"live_now_playing",
|
||||||
"weigh_in_units",
|
"weigh_in_units",
|
||||||
|
"volume_unit",
|
||||||
"trends_disabled",
|
"trends_disabled",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
@ -51,9 +51,7 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
MEDIA_TYPE_LABELS = {
|
MEDIA_TYPE_LABELS = {mt.value: mt.label for mt in Scrobble.MediaType}
|
||||||
mt.value: mt.label for mt in Scrobble.MediaType
|
|
||||||
}
|
|
||||||
|
|
||||||
INHERIT = ""
|
INHERIT = ""
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,14 +1,16 @@
|
|||||||
from zoneinfo import ZoneInfo
|
|
||||||
import pendulum
|
|
||||||
from django.utils import timezone
|
|
||||||
import logging
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pendulum
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from encrypted_field import EncryptedField
|
from encrypted_field import EncryptedField
|
||||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||||
|
|
||||||
VISIBILITY_CHOICES = (
|
VISIBILITY_CHOICES = (
|
||||||
("public", "Public"),
|
("public", "Public"),
|
||||||
("shared", "Shared"),
|
("shared", "Shared"),
|
||||||
@ -26,6 +28,11 @@ class WeighUnit(models.TextChoices):
|
|||||||
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeUnit(models.TextChoices):
|
||||||
|
METRIC = "metric", "Metric (mL, L)"
|
||||||
|
IMPERIAL = "imperial", "Imperial (oz)"
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(TimeStampedModel):
|
class UserProfile(TimeStampedModel):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
timezone = models.CharField(
|
timezone = models.CharField(
|
||||||
@ -71,11 +78,13 @@ class UserProfile(TimeStampedModel):
|
|||||||
|
|
||||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||||
favorites_mopidy_playlist = models.CharField(
|
favorites_mopidy_playlist = models.CharField(
|
||||||
max_length=255, **BNULL,
|
max_length=255,
|
||||||
|
**BNULL,
|
||||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
)
|
)
|
||||||
monthly_mopidy_playlist_pattern = models.CharField(
|
monthly_mopidy_playlist_pattern = models.CharField(
|
||||||
max_length=255, **BNULL,
|
max_length=255,
|
||||||
|
**BNULL,
|
||||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -93,7 +102,7 @@ class UserProfile(TimeStampedModel):
|
|||||||
media_type_visibility = models.JSONField(
|
media_type_visibility = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
|
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
|
||||||
)
|
)
|
||||||
|
|
||||||
home_scrobble_limit = models.IntegerField(default=20)
|
home_scrobble_limit = models.IntegerField(default=20)
|
||||||
@ -106,6 +115,12 @@ class UserProfile(TimeStampedModel):
|
|||||||
default=WeighUnit.METRIC,
|
default=WeighUnit.METRIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
volume_unit = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=VolumeUnit.choices,
|
||||||
|
default=VolumeUnit.METRIC,
|
||||||
|
)
|
||||||
|
|
||||||
trends_disabled = models.BooleanField(default=False)
|
trends_disabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
disabled_trends = models.JSONField(
|
disabled_trends = models.JSONField(
|
||||||
@ -153,7 +168,11 @@ class UserProfile(TimeStampedModel):
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
server_tz = ZoneInfo(settings.TIME_ZONE)
|
server_tz = ZoneInfo(settings.TIME_ZONE)
|
||||||
ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz)
|
ref_dt = (
|
||||||
|
timestamp
|
||||||
|
if timestamp.tzinfo is not None
|
||||||
|
else timestamp.replace(tzinfo=server_tz)
|
||||||
|
)
|
||||||
|
|
||||||
timezone = self.tzinfo
|
timezone = self.tzinfo
|
||||||
if self.timezone_change_log:
|
if self.timezone_change_log:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from scrobbles.mixins import Genre
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
@ -14,7 +14,6 @@ from scrobbles.models import (
|
|||||||
TrailGPXImport,
|
TrailGPXImport,
|
||||||
UDiscCSVImport,
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.mixins import Genre
|
|
||||||
|
|
||||||
|
|
||||||
class ScrobbleInline(admin.TabularInline):
|
class ScrobbleInline(admin.TabularInline):
|
||||||
@ -182,6 +181,8 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
|||||||
"brick_set",
|
"brick_set",
|
||||||
"trail",
|
"trail",
|
||||||
"beer",
|
"beer",
|
||||||
|
"wine",
|
||||||
|
"coffee",
|
||||||
"web_page",
|
"web_page",
|
||||||
"life_event",
|
"life_event",
|
||||||
"birding_location",
|
"birding_location",
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
from django.db import models
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||||
|
|
||||||
|
|
||||||
class Visibility(models.TextChoices):
|
class Visibility(models.TextChoices):
|
||||||
PUBLIC = "public", "Public"
|
PUBLIC = "public", "Public"
|
||||||
SHARED = "shared", "Shared"
|
SHARED = "shared", "Shared"
|
||||||
PRIVATE = "private", "Private"
|
PRIVATE = "private", "Private"
|
||||||
|
|
||||||
|
|
||||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||||
|
|
||||||
LONG_PLAY_MEDIA = {
|
LONG_PLAY_MEDIA = {
|
||||||
@ -23,6 +27,7 @@ AUTO_FINISH_MEDIA = {
|
|||||||
"tracks": "Track",
|
"tracks": "Track",
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
"moods": "Mood",
|
"moods": "Mood",
|
||||||
|
"drinks": "Drink",
|
||||||
}
|
}
|
||||||
|
|
||||||
PLAY_AGAIN_MEDIA = {
|
PLAY_AGAIN_MEDIA = {
|
||||||
@ -35,7 +40,10 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"boardgames": "BoardGame",
|
"boardgames": "BoardGame",
|
||||||
"locations": "GeoLocation",
|
"locations": "GeoLocation",
|
||||||
"trails": "Trail",
|
"trails": "Trail",
|
||||||
"beers": "Beer",
|
"drinks": "Beer",
|
||||||
|
"drinks": "Wine",
|
||||||
|
"drinks": "Coffee",
|
||||||
|
"drinks": "Drink",
|
||||||
"puzzles": "Puzzle",
|
"puzzles": "Puzzle",
|
||||||
"foods": "Food",
|
"foods": "Food",
|
||||||
"tasks": "Task",
|
"tasks": "Task",
|
||||||
@ -48,6 +56,8 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"discgolf": "DiscGolfCourse",
|
"discgolf": "DiscGolfCourse",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
"Video": 3600, # 60 min
|
"Video": 3600, # 60 min
|
||||||
}
|
}
|
||||||
@ -59,6 +69,8 @@ SCROBBLE_CONTENT_URLS = {
|
|||||||
"-s": ["https://www.thesportsdb.com/event/"],
|
"-s": ["https://www.thesportsdb.com/event/"],
|
||||||
"-g": ["https://boardgamegeek.com/boardgame/"],
|
"-g": ["https://boardgamegeek.com/boardgame/"],
|
||||||
"-u": ["https://untappd.com/"],
|
"-u": ["https://untappd.com/"],
|
||||||
|
"-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],
|
||||||
|
"-co": ["https://roastdb.com/"],
|
||||||
"-b": ["https://www.amazon.com/"],
|
"-b": ["https://www.amazon.com/"],
|
||||||
"-t": ["https://app.todoist.com/app/task/{id}"],
|
"-t": ["https://app.todoist.com/app/task/{id}"],
|
||||||
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
||||||
@ -77,12 +89,15 @@ MANUAL_SCROBBLE_FNS = {
|
|||||||
"-i": "manual_scrobble_video",
|
"-i": "manual_scrobble_video",
|
||||||
"-g": "manual_scrobble_board_game",
|
"-g": "manual_scrobble_board_game",
|
||||||
"-u": "manual_scrobble_beer",
|
"-u": "manual_scrobble_beer",
|
||||||
|
"-wi": "manual_scrobble_wine",
|
||||||
|
"-co": "manual_scrobble_coffee",
|
||||||
"-w": "manual_scrobble_webpage",
|
"-w": "manual_scrobble_webpage",
|
||||||
"-t": "manual_scrobble_task",
|
"-t": "manual_scrobble_task",
|
||||||
"-p": "manual_scrobble_puzzle",
|
"-p": "manual_scrobble_puzzle",
|
||||||
"-l": "manual_scrobble_brickset",
|
"-l": "manual_scrobble_brickset",
|
||||||
"-c": "manual_scrobble_book",
|
"-c": "manual_scrobble_book",
|
||||||
"-f": "manual_scrobble_food",
|
"-f": "manual_scrobble_food",
|
||||||
|
"-d": "manual_scrobble_drink",
|
||||||
"-h": "manual_scrobble_twitch_channel",
|
"-h": "manual_scrobble_twitch_channel",
|
||||||
"-dg": "manual_scrobble_discgolf",
|
"-dg": "manual_scrobble_discgolf",
|
||||||
"-pp": "manual_scrobble_paper",
|
"-pp": "manual_scrobble_paper",
|
||||||
|
|||||||
@ -18,16 +18,32 @@ class ScrobbleToolset(MCPToolset):
|
|||||||
qs = (
|
qs = (
|
||||||
Scrobble.objects.filter(user=self.request.user)
|
Scrobble.objects.filter(user=self.request.user)
|
||||||
.select_related(
|
.select_related(
|
||||||
"video", "track", "book", "video_game", "board_game",
|
"video",
|
||||||
"beer", "puzzle", "food", "trail", "task", "web_page",
|
"track",
|
||||||
"life_event", "mood", "brick_set", "podcast_episode",
|
"book",
|
||||||
"sport_event", "geo_location", "birding_location",
|
"video_game",
|
||||||
"disc_golf_course", "channel",
|
"board_game",
|
||||||
|
"beer",
|
||||||
|
"puzzle",
|
||||||
|
"food",
|
||||||
|
"trail",
|
||||||
|
"task",
|
||||||
|
"web_page",
|
||||||
|
"life_event",
|
||||||
|
"mood",
|
||||||
|
"brick_set",
|
||||||
|
"podcast_episode",
|
||||||
|
"sport_event",
|
||||||
|
"geo_location",
|
||||||
|
"birding_location",
|
||||||
|
"disc_golf_course",
|
||||||
|
"channel",
|
||||||
)
|
)
|
||||||
.order_by("-timestamp")
|
.order_by("-timestamp")
|
||||||
)
|
)
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
|
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
|
||||||
if media_type:
|
if media_type:
|
||||||
qs = qs.filter(media_type=media_type)
|
qs = qs.filter(media_type=media_type)
|
||||||
@ -47,6 +63,7 @@ class ScrobbleToolset(MCPToolset):
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Search scrobbles by text in their log data or related media titles."""
|
"""Search scrobbles by text in their log data or related media titles."""
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
|
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
|
||||||
if media_type:
|
if media_type:
|
||||||
qs = qs.filter(media_type=media_type)
|
qs = qs.filter(media_type=media_type)
|
||||||
@ -74,6 +91,7 @@ class ScrobbleToolset(MCPToolset):
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Get scrobbles for a specific date (YYYY-MM-DD format)."""
|
"""Get scrobbles for a specific date (YYYY-MM-DD format)."""
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -86,9 +104,7 @@ class ScrobbleToolset(MCPToolset):
|
|||||||
qs = qs.filter(media_type=media_type)
|
qs = qs.filter(media_type=media_type)
|
||||||
return [_scrobble_to_dict(s) for s in qs]
|
return [_scrobble_to_dict(s) for s in qs]
|
||||||
|
|
||||||
def get_in_progress_scrobbles(
|
def get_in_progress_scrobbles(self, media_type: str | None = None) -> list[dict]:
|
||||||
self, media_type: str | None = None
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Get scrobbles currently in progress (started but not finished).
|
"""Get scrobbles currently in progress (started but not finished).
|
||||||
These are long-play items like books, video games, brick sets, or tasks."""
|
These are long-play items like books, video games, brick sets, or tasks."""
|
||||||
qs = Scrobble.objects.filter(
|
qs = Scrobble.objects.filter(
|
||||||
@ -122,70 +138,124 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_book(self, uuid: str) -> dict | None:
|
def get_book(self, uuid: str) -> dict | None:
|
||||||
"""Get a book by UUID."""
|
"""Get a book by UUID."""
|
||||||
from books.models import Book
|
from books.models import Book
|
||||||
|
|
||||||
try:
|
try:
|
||||||
b = Book.objects.get(uuid=uuid)
|
b = Book.objects.get(uuid=uuid)
|
||||||
except Book.DoesNotExist:
|
except Book.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(b, fields=["title", "pages", "language",
|
return _media_to_dict(
|
||||||
"first_publish_year", "isbn_13",
|
b,
|
||||||
"publisher", "summary"])
|
fields=[
|
||||||
|
"title",
|
||||||
|
"pages",
|
||||||
|
"language",
|
||||||
|
"first_publish_year",
|
||||||
|
"isbn_13",
|
||||||
|
"publisher",
|
||||||
|
"summary",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
|
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
|
||||||
"""List books, optionally filtered by author name."""
|
"""List books, optionally filtered by author name."""
|
||||||
from books.models import Book
|
from books.models import Book
|
||||||
|
|
||||||
qs = Book.objects.all().order_by("title")
|
qs = Book.objects.all().order_by("title")
|
||||||
if author:
|
if author:
|
||||||
qs = qs.filter(authors__name__icontains=author)
|
qs = qs.filter(authors__name__icontains=author)
|
||||||
return [_media_to_dict(b, fields=["title", "pages", "language",
|
return [
|
||||||
"first_publish_year", "isbn_13",
|
_media_to_dict(
|
||||||
"publisher"]) for b in qs[:limit]]
|
b,
|
||||||
|
fields=[
|
||||||
|
"title",
|
||||||
|
"pages",
|
||||||
|
"language",
|
||||||
|
"first_publish_year",
|
||||||
|
"isbn_13",
|
||||||
|
"publisher",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for b in qs[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
def get_track(self, uuid: str) -> dict | None:
|
def get_track(self, uuid: str) -> dict | None:
|
||||||
"""Get a music track by UUID."""
|
"""Get a music track by UUID."""
|
||||||
from music.models import Track
|
from music.models import Track
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t = Track.objects.select_related("artist_fk").get(uuid=uuid)
|
t = Track.objects.select_related("artist_fk").get(uuid=uuid)
|
||||||
except Track.DoesNotExist:
|
except Track.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(t, fields=["title", "base_run_time_seconds",
|
return _media_to_dict(
|
||||||
"artist_fk__name", "genre"])
|
t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
|
||||||
|
)
|
||||||
|
|
||||||
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
|
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
|
||||||
"""List music tracks, optionally filtered by artist name."""
|
"""List music tracks, optionally filtered by artist name."""
|
||||||
from music.models import Track
|
from music.models import Track
|
||||||
|
|
||||||
qs = Track.objects.select_related("artist_fk").all().order_by("title")
|
qs = Track.objects.select_related("artist_fk").all().order_by("title")
|
||||||
if artist:
|
if artist:
|
||||||
qs = qs.filter(artist_fk__name__icontains=artist)
|
qs = qs.filter(artist_fk__name__icontains=artist)
|
||||||
return [_media_to_dict(t, fields=["title", "base_run_time_seconds",
|
return [
|
||||||
"artist_fk__name", "genre"])
|
_media_to_dict(
|
||||||
for t in qs[:limit]]
|
t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
|
||||||
|
)
|
||||||
|
for t in qs[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
def get_video(self, uuid: str) -> dict | None:
|
def get_video(self, uuid: str) -> dict | None:
|
||||||
"""Get a video by UUID."""
|
"""Get a video by UUID."""
|
||||||
from videos.models import Video
|
from videos.models import Video
|
||||||
|
|
||||||
try:
|
try:
|
||||||
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
|
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
|
||||||
except Video.DoesNotExist:
|
except Video.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(v, fields=["title", "year", "overview",
|
return _media_to_dict(
|
||||||
"imdb_id", "imdb_rating",
|
v,
|
||||||
"tv_series__name", "channel__title",
|
fields=[
|
||||||
"season_number", "episode_number"])
|
"title",
|
||||||
|
"year",
|
||||||
|
"overview",
|
||||||
|
"imdb_id",
|
||||||
|
"imdb_rating",
|
||||||
|
"tv_series__name",
|
||||||
|
"channel__title",
|
||||||
|
"season_number",
|
||||||
|
"episode_number",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
|
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
|
||||||
"""List videos, optionally filtered by series name."""
|
"""List videos, optionally filtered by series name."""
|
||||||
from videos.models import Video
|
from videos.models import Video
|
||||||
qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")
|
|
||||||
|
qs = (
|
||||||
|
Video.objects.select_related("tv_series", "channel").all().order_by("title")
|
||||||
|
)
|
||||||
if series:
|
if series:
|
||||||
qs = qs.filter(tv_series__name__icontains=series)
|
qs = qs.filter(tv_series__name__icontains=series)
|
||||||
return [_media_to_dict(v, fields=["title", "year", "overview",
|
return [
|
||||||
"tv_series__name", "channel__title",
|
_media_to_dict(
|
||||||
"season_number", "episode_number"])
|
v,
|
||||||
for v in qs[:limit]]
|
fields=[
|
||||||
|
"title",
|
||||||
|
"year",
|
||||||
|
"overview",
|
||||||
|
"tv_series__name",
|
||||||
|
"channel__title",
|
||||||
|
"season_number",
|
||||||
|
"episode_number",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for v in qs[:limit]
|
||||||
|
]
|
||||||
|
|
||||||
def get_board_game(self, uuid: str) -> dict | None:
|
def get_board_game(self, uuid: str) -> dict | None:
|
||||||
"""Get a board game by UUID."""
|
"""Get a board game by UUID."""
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bg = BoardGame.objects.get(uuid=uuid)
|
bg = BoardGame.objects.get(uuid=uuid)
|
||||||
except BoardGame.DoesNotExist:
|
except BoardGame.DoesNotExist:
|
||||||
@ -195,34 +265,69 @@ class MediaToolset(MCPToolset):
|
|||||||
def list_board_games(self, limit: int = 20) -> list[dict]:
|
def list_board_games(self, limit: int = 20) -> list[dict]:
|
||||||
"""List board games."""
|
"""List board games."""
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
|
|
||||||
qs = BoardGame.objects.all().order_by("title")[:limit]
|
qs = BoardGame.objects.all().order_by("title")[:limit]
|
||||||
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
|
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
|
||||||
|
|
||||||
def get_podcast_episode(self, uuid: str) -> dict | None:
|
def get_podcast_episode(self, uuid: str) -> dict | None:
|
||||||
"""Get a podcast episode by UUID."""
|
"""Get a podcast episode by UUID."""
|
||||||
from podcasts.models import PodcastEpisode
|
from podcasts.models import PodcastEpisode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
|
pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
|
||||||
uuid=uuid
|
uuid=uuid
|
||||||
)
|
)
|
||||||
except PodcastEpisode.DoesNotExist:
|
except PodcastEpisode.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(pe, fields=["title", "podcast__title",
|
return _media_to_dict(
|
||||||
"producer__name", "base_run_time_seconds"])
|
pe,
|
||||||
|
fields=[
|
||||||
|
"title",
|
||||||
|
"podcast__title",
|
||||||
|
"producer__name",
|
||||||
|
"base_run_time_seconds",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def get_beer(self, uuid: str) -> dict | None:
|
def get_beer(self, uuid: str) -> dict | None:
|
||||||
"""Get a beer by UUID."""
|
"""Get a beer by UUID."""
|
||||||
from beers.models import Beer
|
from drinks.models import Beer
|
||||||
|
|
||||||
try:
|
try:
|
||||||
b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
|
b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
|
||||||
except Beer.DoesNotExist:
|
except Beer.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(b, fields=["title", "style__name",
|
return _media_to_dict(
|
||||||
"producer__name", "abv"])
|
b, fields=["title", "style__name", "producer__name", "abv"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_wine(self, uuid: str) -> dict | None:
|
||||||
|
"""Get a wine by UUID."""
|
||||||
|
from drinks.models import Wine
|
||||||
|
|
||||||
|
try:
|
||||||
|
w = Wine.objects.select_related("region", "producer").get(uuid=uuid)
|
||||||
|
except Wine.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return _media_to_dict(
|
||||||
|
w,
|
||||||
|
fields=["title", "wine_type", "vintage", "producer__name", "region__name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_coffee(self, uuid: str) -> dict | None:
|
||||||
|
"""Get a coffee by UUID."""
|
||||||
|
from drinks.models import Coffee
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = Coffee.objects.select_related("roaster").get(uuid=uuid)
|
||||||
|
except Coffee.DoesNotExist:
|
||||||
|
return None
|
||||||
|
return _media_to_dict(c, fields=["title", "origin", "roaster__name"])
|
||||||
|
|
||||||
def get_brick_set(self, uuid: str) -> dict | None:
|
def get_brick_set(self, uuid: str) -> dict | None:
|
||||||
"""Get a brick set (LEGO) by UUID."""
|
"""Get a brick set (LEGO) by UUID."""
|
||||||
from bricksets.models import BrickSet
|
from bricksets.models import BrickSet
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bs = BrickSet.objects.get(uuid=uuid)
|
bs = BrickSet.objects.get(uuid=uuid)
|
||||||
except BrickSet.DoesNotExist:
|
except BrickSet.DoesNotExist:
|
||||||
@ -232,26 +337,27 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_video_game(self, uuid: str) -> dict | None:
|
def get_video_game(self, uuid: str) -> dict | None:
|
||||||
"""Get a video game by UUID."""
|
"""Get a video game by UUID."""
|
||||||
from videogames.models import VideoGame
|
from videogames.models import VideoGame
|
||||||
|
|
||||||
try:
|
try:
|
||||||
vg = VideoGame.objects.get(uuid=uuid)
|
vg = VideoGame.objects.get(uuid=uuid)
|
||||||
except VideoGame.DoesNotExist:
|
except VideoGame.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(vg, fields=["title", "genre",
|
return _media_to_dict(vg, fields=["title", "genre", "base_run_time_seconds"])
|
||||||
"base_run_time_seconds"])
|
|
||||||
|
|
||||||
def get_puzzle(self, uuid: str) -> dict | None:
|
def get_puzzle(self, uuid: str) -> dict | None:
|
||||||
"""Get a puzzle by UUID."""
|
"""Get a puzzle by UUID."""
|
||||||
from puzzles.models import Puzzle
|
from puzzles.models import Puzzle
|
||||||
|
|
||||||
try:
|
try:
|
||||||
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
|
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
|
||||||
except Puzzle.DoesNotExist:
|
except Puzzle.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(p, fields=["title", "piece_count",
|
return _media_to_dict(p, fields=["title", "piece_count", "manufacturer__name"])
|
||||||
"manufacturer__name"])
|
|
||||||
|
|
||||||
def get_web_page(self, uuid: str) -> dict | None:
|
def get_web_page(self, uuid: str) -> dict | None:
|
||||||
"""Get a web page by UUID."""
|
"""Get a web page by UUID."""
|
||||||
from webpages.models import WebPage
|
from webpages.models import WebPage
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wp = WebPage.objects.select_related("domain").get(uuid=uuid)
|
wp = WebPage.objects.select_related("domain").get(uuid=uuid)
|
||||||
except WebPage.DoesNotExist:
|
except WebPage.DoesNotExist:
|
||||||
@ -261,6 +367,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_task(self, uuid: str) -> dict | None:
|
def get_task(self, uuid: str) -> dict | None:
|
||||||
"""Get a task by UUID."""
|
"""Get a task by UUID."""
|
||||||
from tasks.models import Task
|
from tasks.models import Task
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t = Task.objects.get(uuid=uuid)
|
t = Task.objects.get(uuid=uuid)
|
||||||
except Task.DoesNotExist:
|
except Task.DoesNotExist:
|
||||||
@ -270,6 +377,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_trail(self, uuid: str) -> dict | None:
|
def get_trail(self, uuid: str) -> dict | None:
|
||||||
"""Get a trail by UUID."""
|
"""Get a trail by UUID."""
|
||||||
from trails.models import Trail
|
from trails.models import Trail
|
||||||
|
|
||||||
try:
|
try:
|
||||||
t = Trail.objects.get(uuid=uuid)
|
t = Trail.objects.get(uuid=uuid)
|
||||||
except Trail.DoesNotExist:
|
except Trail.DoesNotExist:
|
||||||
@ -279,6 +387,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_geo_location(self, uuid: str) -> dict | None:
|
def get_geo_location(self, uuid: str) -> dict | None:
|
||||||
"""Get a geo location by UUID."""
|
"""Get a geo location by UUID."""
|
||||||
from locations.models import GeoLocation
|
from locations.models import GeoLocation
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gl = GeoLocation.objects.get(uuid=uuid)
|
gl = GeoLocation.objects.get(uuid=uuid)
|
||||||
except GeoLocation.DoesNotExist:
|
except GeoLocation.DoesNotExist:
|
||||||
@ -288,6 +397,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_life_event(self, uuid: str) -> dict | None:
|
def get_life_event(self, uuid: str) -> dict | None:
|
||||||
"""Get a life event by UUID."""
|
"""Get a life event by UUID."""
|
||||||
from lifeevents.models import LifeEvent
|
from lifeevents.models import LifeEvent
|
||||||
|
|
||||||
try:
|
try:
|
||||||
le = LifeEvent.objects.get(uuid=uuid)
|
le = LifeEvent.objects.get(uuid=uuid)
|
||||||
except LifeEvent.DoesNotExist:
|
except LifeEvent.DoesNotExist:
|
||||||
@ -297,6 +407,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_mood(self, uuid: str) -> dict | None:
|
def get_mood(self, uuid: str) -> dict | None:
|
||||||
"""Get a mood entry by UUID."""
|
"""Get a mood entry by UUID."""
|
||||||
from moods.models import Mood
|
from moods.models import Mood
|
||||||
|
|
||||||
try:
|
try:
|
||||||
m = Mood.objects.get(uuid=uuid)
|
m = Mood.objects.get(uuid=uuid)
|
||||||
except Mood.DoesNotExist:
|
except Mood.DoesNotExist:
|
||||||
@ -306,6 +417,7 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_food(self, uuid: str) -> dict | None:
|
def get_food(self, uuid: str) -> dict | None:
|
||||||
"""Get a food entry by UUID."""
|
"""Get a food entry by UUID."""
|
||||||
from foods.models import Food
|
from foods.models import Food
|
||||||
|
|
||||||
try:
|
try:
|
||||||
f = Food.objects.select_related("category").get(uuid=uuid)
|
f = Food.objects.select_related("category").get(uuid=uuid)
|
||||||
except Food.DoesNotExist:
|
except Food.DoesNotExist:
|
||||||
@ -315,16 +427,20 @@ class MediaToolset(MCPToolset):
|
|||||||
def get_bird_sighting(self, uuid: str) -> dict | None:
|
def get_bird_sighting(self, uuid: str) -> dict | None:
|
||||||
"""Get a bird sighting by UUID."""
|
"""Get a bird sighting by UUID."""
|
||||||
from birds.models import BirdSighting
|
from birds.models import BirdSighting
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
|
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
|
||||||
except BirdSighting.DoesNotExist:
|
except BirdSighting.DoesNotExist:
|
||||||
return None
|
return None
|
||||||
return _media_to_dict(bs, fields=["title", "bird__common_name",
|
return _media_to_dict(
|
||||||
"bird__scientific_name", "location"])
|
bs,
|
||||||
|
fields=["title", "bird__common_name", "bird__scientific_name", "location"],
|
||||||
|
)
|
||||||
|
|
||||||
def get_disc_golf_course(self, uuid: str) -> dict | None:
|
def get_disc_golf_course(self, uuid: str) -> dict | None:
|
||||||
"""Get a disc golf course by UUID."""
|
"""Get a disc golf course by UUID."""
|
||||||
from discgolf.models import DiscGolfCourse
|
from discgolf.models import DiscGolfCourse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dg = DiscGolfCourse.objects.get(uuid=uuid)
|
dg = DiscGolfCourse.objects.get(uuid=uuid)
|
||||||
except DiscGolfCourse.DoesNotExist:
|
except DiscGolfCourse.DoesNotExist:
|
||||||
@ -434,16 +550,15 @@ def _scrobble_to_dict(s: Scrobble) -> dict:
|
|||||||
|
|
||||||
def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
||||||
if s.video:
|
if s.video:
|
||||||
return _media_to_dict(s.video, fields=["title", "year", "imdb_id",
|
return _media_to_dict(
|
||||||
"imdb_rating"])
|
s.video, fields=["title", "year", "imdb_id", "imdb_rating"]
|
||||||
|
)
|
||||||
if s.track:
|
if s.track:
|
||||||
return _media_to_dict(s.track, fields=["title",
|
return _media_to_dict(s.track, fields=["title", "base_run_time_seconds"])
|
||||||
"base_run_time_seconds"])
|
|
||||||
if s.book:
|
if s.book:
|
||||||
return _media_to_dict(s.book, fields=["title", "pages"])
|
return _media_to_dict(s.book, fields=["title", "pages"])
|
||||||
if s.video_game:
|
if s.video_game:
|
||||||
return _media_to_dict(s.video_game, fields=["title",
|
return _media_to_dict(s.video_game, fields=["title", "base_run_time_seconds"])
|
||||||
"base_run_time_seconds"])
|
|
||||||
if s.board_game:
|
if s.board_game:
|
||||||
return _media_to_dict(s.board_game, fields=["title"])
|
return _media_to_dict(s.board_game, fields=["title"])
|
||||||
if s.beer:
|
if s.beer:
|
||||||
@ -453,8 +568,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
|||||||
if s.food:
|
if s.food:
|
||||||
return _media_to_dict(s.food, fields=["title"])
|
return _media_to_dict(s.food, fields=["title"])
|
||||||
if s.trail:
|
if s.trail:
|
||||||
return _media_to_dict(s.trail, fields=["title",
|
return _media_to_dict(s.trail, fields=["title", "base_run_time_seconds"])
|
||||||
"base_run_time_seconds"])
|
|
||||||
if s.task:
|
if s.task:
|
||||||
return _media_to_dict(s.task, fields=["title"])
|
return _media_to_dict(s.task, fields=["title"])
|
||||||
if s.web_page:
|
if s.web_page:
|
||||||
@ -470,8 +584,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
|||||||
if s.sport_event:
|
if s.sport_event:
|
||||||
return {"title": str(s.sport_event)}
|
return {"title": str(s.sport_event)}
|
||||||
if s.geo_location:
|
if s.geo_location:
|
||||||
return _media_to_dict(s.geo_location, fields=["title", "latitude",
|
return _media_to_dict(s.geo_location, fields=["title", "latitude", "longitude"])
|
||||||
"longitude"])
|
|
||||||
if s.birding_location:
|
if s.birding_location:
|
||||||
return _media_to_dict(s.birding_location, fields=["title"])
|
return _media_to_dict(s.birding_location, fields=["title"])
|
||||||
if s.disc_golf_course:
|
if s.disc_golf_course:
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import django.db.models.deletion
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("beers", "0001_initial"),
|
("drinks", "0001_initial"),
|
||||||
("scrobbles", "0065_alter_scrobble_log"),
|
("scrobbles", "0065_alter_scrobble_log"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
to="beers.beer",
|
to="drinks.beer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
|
|||||||
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
|||||||
("puzzles", "0006_alter_puzzle_genre"),
|
("puzzles", "0006_alter_puzzle_genre"),
|
||||||
("videogames", "0015_alter_videogame_genre"),
|
("videogames", "0015_alter_videogame_genre"),
|
||||||
("lifeevents", "0005_alter_lifeevent_genre"),
|
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||||
("beers", "0008_alter_beer_genre"),
|
("drinks", "0008_alter_beer_genre"),
|
||||||
("foods", "0007_alter_food_genre"),
|
("foods", "0007_alter_food_genre"),
|
||||||
("tasks", "0007_alter_task_genre"),
|
("tasks", "0007_alter_task_genre"),
|
||||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||||
@ -98,7 +98,7 @@ class Migration(migrations.Migration):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
to="beers.beer",
|
to="drinks.beer",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
|||||||
@ -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 pendulum
|
||||||
import pytz
|
import pytz
|
||||||
from beers.models import Beer
|
from drinks.models import Beer, Wine, Coffee, Drink
|
||||||
from birds.models import BirdingLocation
|
from birds.models import BirdingLocation
|
||||||
from discgolf.models import DiscGolfCourse
|
from discgolf.models import DiscGolfCourse
|
||||||
from boardgames.models import BoardGame
|
from boardgames.models import BoardGame
|
||||||
@ -684,6 +684,9 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
|||||||
"GeoLocation": ("geo_location",),
|
"GeoLocation": ("geo_location",),
|
||||||
"Trail": ("trail",),
|
"Trail": ("trail",),
|
||||||
"Beer": ("beer",),
|
"Beer": ("beer",),
|
||||||
|
"Wine": ("wine",),
|
||||||
|
"Coffee": ("coffee",),
|
||||||
|
"Drink": ("drink",),
|
||||||
"Puzzle": ("puzzle",),
|
"Puzzle": ("puzzle",),
|
||||||
"Food": ("food",),
|
"Food": ("food",),
|
||||||
"Task": ("task",),
|
"Task": ("task",),
|
||||||
@ -712,6 +715,9 @@ class ScrobbleQuerySet(models.QuerySet):
|
|||||||
"board_game",
|
"board_game",
|
||||||
"geo_location",
|
"geo_location",
|
||||||
"beer",
|
"beer",
|
||||||
|
"wine",
|
||||||
|
"coffee",
|
||||||
|
"drink",
|
||||||
"puzzle",
|
"puzzle",
|
||||||
"food",
|
"food",
|
||||||
"trail",
|
"trail",
|
||||||
@ -763,6 +769,9 @@ class Scrobble(TimeStampedModel):
|
|||||||
GEO_LOCATION = "GeoLocation", "GeoLocation"
|
GEO_LOCATION = "GeoLocation", "GeoLocation"
|
||||||
TRAIL = "Trail", "Trail"
|
TRAIL = "Trail", "Trail"
|
||||||
BEER = "Beer", "Beer"
|
BEER = "Beer", "Beer"
|
||||||
|
WINE = "Wine", "Wine"
|
||||||
|
COFFEE = "Coffee", "Coffee"
|
||||||
|
DRINK = "Drink", "Drink"
|
||||||
PUZZLE = "Puzzle", "Puzzle"
|
PUZZLE = "Puzzle", "Puzzle"
|
||||||
FOOD = "Food", "Food"
|
FOOD = "Food", "Food"
|
||||||
TASK = "Task", "Task"
|
TASK = "Task", "Task"
|
||||||
@ -792,6 +801,9 @@ class Scrobble(TimeStampedModel):
|
|||||||
board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
|
board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
|
geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
coffee = models.ForeignKey(Coffee, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
drink = models.ForeignKey(Drink, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
|
puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
|
food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
@ -1220,7 +1232,10 @@ class Scrobble(TimeStampedModel):
|
|||||||
|
|
||||||
if self.is_long_play:
|
if self.is_long_play:
|
||||||
long_play_secs = 0
|
long_play_secs = 0
|
||||||
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:
|
if (
|
||||||
|
self.long_play_last_scrobble
|
||||||
|
and not self.long_play_last_scrobble.long_play_complete
|
||||||
|
):
|
||||||
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
||||||
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
||||||
|
|
||||||
@ -1367,6 +1382,12 @@ class Scrobble(TimeStampedModel):
|
|||||||
media_obj = self.trail
|
media_obj = self.trail
|
||||||
if self.beer:
|
if self.beer:
|
||||||
media_obj = self.beer
|
media_obj = self.beer
|
||||||
|
if self.wine:
|
||||||
|
media_obj = self.wine
|
||||||
|
if self.coffee:
|
||||||
|
media_obj = self.coffee
|
||||||
|
if self.drink:
|
||||||
|
media_obj = self.drink
|
||||||
if self.puzzle:
|
if self.puzzle:
|
||||||
media_obj = self.puzzle
|
media_obj = self.puzzle
|
||||||
if self.task:
|
if self.task:
|
||||||
@ -1928,6 +1949,9 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
||||||
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
||||||
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
||||||
|
wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL)
|
||||||
|
coffee = models.ForeignKey(Coffee, on_delete=models.CASCADE, **BNULL)
|
||||||
|
drink = models.ForeignKey(Drink, on_delete=models.CASCADE, **BNULL)
|
||||||
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
||||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
||||||
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
||||||
@ -1982,6 +2006,12 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
media_obj = self.trail
|
media_obj = self.trail
|
||||||
if self.beer:
|
if self.beer:
|
||||||
media_obj = self.beer
|
media_obj = self.beer
|
||||||
|
if self.wine:
|
||||||
|
media_obj = self.wine
|
||||||
|
if self.coffee:
|
||||||
|
media_obj = self.coffee
|
||||||
|
if self.drink:
|
||||||
|
media_obj = self.drink
|
||||||
if self.puzzle:
|
if self.puzzle:
|
||||||
media_obj = self.puzzle
|
media_obj = self.puzzle
|
||||||
if self.task:
|
if self.task:
|
||||||
@ -2014,6 +2044,9 @@ class FavoriteMedia(TimeStampedModel):
|
|||||||
"BoardGame": "board_game",
|
"BoardGame": "board_game",
|
||||||
"GeoLocation": "geo_location",
|
"GeoLocation": "geo_location",
|
||||||
"Beer": "beer",
|
"Beer": "beer",
|
||||||
|
"Wine": "wine",
|
||||||
|
"Coffee": "coffee",
|
||||||
|
"Drink": "drink",
|
||||||
"Puzzle": "puzzle",
|
"Puzzle": "puzzle",
|
||||||
"Food": "food",
|
"Food": "food",
|
||||||
"Trail": "trail",
|
"Trail": "trail",
|
||||||
|
|||||||
@ -6,8 +6,8 @@ from typing import Any, Optional
|
|||||||
import pendulum
|
import pendulum
|
||||||
import pytz
|
import pytz
|
||||||
import requests
|
import requests
|
||||||
from beers.models import Beer
|
|
||||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
|
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
|
||||||
|
from boardgames.utils import board_names_to_variants
|
||||||
from books.constants import READCOMICSONLINE_URL
|
from books.constants import READCOMICSONLINE_URL
|
||||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||||
from books.utils import parse_readcomicsonline_uri
|
from books.utils import parse_readcomicsonline_uri
|
||||||
@ -15,6 +15,7 @@ from bricksets.models import BrickSet
|
|||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
from discgolf.models import DiscGolfCourse
|
from discgolf.models import DiscGolfCourse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from drinks.models import Beer, Coffee, Wine, Drink, DrinkFormat
|
||||||
from foods.models import Food
|
from foods.models import Food
|
||||||
from foods.sources.rscraper import RecipeScraperService
|
from foods.sources.rscraper import RecipeScraperService
|
||||||
from locations.constants import LOCATION_PROVIDERS
|
from locations.constants import LOCATION_PROVIDERS
|
||||||
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
|
|||||||
source: str = "BGG",
|
source: str = "BGG",
|
||||||
action: Optional[str] = None,
|
action: Optional[str] = None,
|
||||||
) -> Scrobble | None:
|
) -> Scrobble | None:
|
||||||
boardgame = BoardGame.find_or_create(bggeek_id)
|
boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
|
||||||
|
|
||||||
if not boardgame:
|
if not boardgame:
|
||||||
logger.error(f"No board game found for ID {bggeek_id}")
|
logger.error(f"No board game found for ID {bggeek_id}")
|
||||||
@ -495,7 +496,9 @@ def email_scrobble_board_game(
|
|||||||
if play_dict.get("rounds", False):
|
if play_dict.get("rounds", False):
|
||||||
log_data["rounds"] = play_dict.get("rounds")
|
log_data["rounds"] = play_dict.get("rounds")
|
||||||
if play_dict.get("board", False):
|
if play_dict.get("board", False):
|
||||||
log_data["board"] = play_dict.get("board")
|
log_data["variant_ids"] = board_names_to_variants(
|
||||||
|
base_game, [play_dict.get("board")]
|
||||||
|
)
|
||||||
|
|
||||||
log_data["players"] = []
|
log_data["players"] = []
|
||||||
for score_dict in play_dict.get("playerScores", []):
|
for score_dict in play_dict.get("playerScores", []):
|
||||||
@ -645,6 +648,30 @@ def manual_scrobble_from_url(
|
|||||||
item_id = url
|
item_id = url
|
||||||
|
|
||||||
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
||||||
|
|
||||||
|
if content_key == "-wi":
|
||||||
|
if "cellartracker.com" in url:
|
||||||
|
return manual_scrobble_wine(
|
||||||
|
cellartracker_id=item_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source="CellarTracker",
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return manual_scrobble_wine(
|
||||||
|
vivino_id=item_id,
|
||||||
|
user_id=user_id,
|
||||||
|
source="Vivino",
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
elif content_key == "-co":
|
||||||
|
return manual_scrobble_coffee(
|
||||||
|
roastdb_url=url,
|
||||||
|
user_id=user_id,
|
||||||
|
source="RoastDB",
|
||||||
|
action=action,
|
||||||
|
)
|
||||||
|
|
||||||
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
||||||
|
|
||||||
|
|
||||||
@ -763,9 +790,7 @@ def todoist_scrobble_task(
|
|||||||
todoist_task["title"] = todoist_task.pop("description")
|
todoist_task["title"] = todoist_task.pop("description")
|
||||||
todoist_task["description"] = todoist_task.pop("details")
|
todoist_task["description"] = todoist_task.pop("details")
|
||||||
labels = todoist_task.pop("todoist_label_list", [])
|
labels = todoist_task.pop("todoist_label_list", [])
|
||||||
todoist_task["labels"] = [
|
todoist_task["labels"] = [l for l in labels if l.lower() != "inprogress"]
|
||||||
l for l in labels if l.lower() != "inprogress"
|
|
||||||
]
|
|
||||||
todoist_task.pop("todoist_type")
|
todoist_task.pop("todoist_type")
|
||||||
todoist_task.pop("todoist_event")
|
todoist_task.pop("todoist_event")
|
||||||
|
|
||||||
@ -930,7 +955,9 @@ def emacs_scrobble_task(
|
|||||||
|
|
||||||
task_data.pop("notes", None)
|
task_data.pop("notes", None)
|
||||||
task_data["title"] = task_data.pop("description")
|
task_data["title"] = task_data.pop("description")
|
||||||
task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description")
|
task_data["description"] = _extract_org_section(
|
||||||
|
task_data.pop("body"), "*** Description"
|
||||||
|
)
|
||||||
task_data["labels"] = task_data.pop("labels")
|
task_data["labels"] = task_data.pop("labels")
|
||||||
|
|
||||||
task_data["orgmode_id"] = task_data.pop("source_id")
|
task_data["orgmode_id"] = task_data.pop("source_id")
|
||||||
@ -1253,6 +1280,107 @@ def manual_scrobble_beer(
|
|||||||
return Scrobble.create_or_update(beer, user_id, scrobble_dict)
|
return Scrobble.create_or_update(beer, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_wine(
|
||||||
|
vivino_id: str = None,
|
||||||
|
cellartracker_id: str = None,
|
||||||
|
user_id: int = None,
|
||||||
|
source: str = "Vivino",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)
|
||||||
|
|
||||||
|
if not wine:
|
||||||
|
logger.error(
|
||||||
|
f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[vrobbler-scrobble] wine scrobble request received",
|
||||||
|
extra={
|
||||||
|
"wine_id": wine.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.WINE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(wine, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_coffee(
|
||||||
|
roastdb_id: str = None,
|
||||||
|
roastdb_url: str = None,
|
||||||
|
user_id: int = None,
|
||||||
|
source: str = "RoastDB",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
slug = None
|
||||||
|
if roastdb_url and not roastdb_id:
|
||||||
|
slug = roastdb_url.rstrip("/").split("/")[-1]
|
||||||
|
|
||||||
|
coffee = Coffee.find_or_create(roastdb_id=roastdb_id, slug=slug)
|
||||||
|
|
||||||
|
if not coffee:
|
||||||
|
logger.error(f"No coffee found for RoastDB ID {roastdb_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[vrobbler-scrobble] coffee scrobble request received",
|
||||||
|
extra={
|
||||||
|
"coffee_id": coffee.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.COFFEE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(coffee, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_water(
|
||||||
|
user_id: int = None,
|
||||||
|
source: str = "Vrobbler",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
water = Drink.find_or_create_water()
|
||||||
|
|
||||||
|
if not water:
|
||||||
|
logger.error("Could not find or create Water instance")
|
||||||
|
return
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
"log": {"format": DrinkFormat.BY_THE_GLASS, "size_ml": 250},
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[vrobbler-scrobble] water scrobble request received",
|
||||||
|
extra={
|
||||||
|
"water_id": water.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.DRINK,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(water, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_puzzle(
|
def manual_scrobble_puzzle(
|
||||||
ipdb_id: str,
|
ipdb_id: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -1364,6 +1492,50 @@ def manual_scrobble_food(
|
|||||||
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
|
def manual_scrobble_drink(
|
||||||
|
item_id: str,
|
||||||
|
user_id: int,
|
||||||
|
source: str = "Vrobbler",
|
||||||
|
action: Optional[str] = None,
|
||||||
|
):
|
||||||
|
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
|
||||||
|
if not match:
|
||||||
|
logger.info(
|
||||||
|
"[manual_scrobble_drink] invalid format, expected 'Drink Name - calories'",
|
||||||
|
extra={"item_id": item_id, "user_id": user_id},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
drink_name = match.group(1).strip()
|
||||||
|
calories = int(match.group(2))
|
||||||
|
|
||||||
|
drink = Drink.objects.filter(title=drink_name).first()
|
||||||
|
if not drink:
|
||||||
|
drink = Drink.objects.create(
|
||||||
|
title=drink_name,
|
||||||
|
calories=calories,
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": source,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[scrobblers] manual drink scrobble request received",
|
||||||
|
extra={
|
||||||
|
"drink_id": drink.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scrobble_dict": scrobble_dict,
|
||||||
|
"media_type": Scrobble.MediaType.DRINK,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return Scrobble.create_or_update(drink, user_id, scrobble_dict)
|
||||||
|
|
||||||
|
|
||||||
def manual_scrobble_discgolf(
|
def manual_scrobble_discgolf(
|
||||||
item_id: str,
|
item_id: str,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@ -4,10 +4,9 @@ import logging
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
import pendulum
|
import pendulum
|
||||||
import pytz
|
import pytz
|
||||||
|
import requests
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
@ -26,6 +25,7 @@ class BearerTokenAuthentication(TokenAuthentication):
|
|||||||
keyword = "Bearer"
|
keyword = "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
from boardgames.models import BoardGame, BoardGameVariant
|
||||||
from django.http import (
|
from django.http import (
|
||||||
FileResponse,
|
FileResponse,
|
||||||
Http404,
|
Http404,
|
||||||
@ -33,7 +33,7 @@ from django.http import (
|
|||||||
JsonResponse,
|
JsonResponse,
|
||||||
)
|
)
|
||||||
from django.shortcuts import get_object_or_404, redirect
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.urls import reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.dateformat import DateFormat
|
from django.utils.dateformat import DateFormat
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
@ -66,14 +66,14 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from scrobbles.api import serializers
|
from scrobbles.api import serializers
|
||||||
from scrobbles.constants import (
|
from scrobbles.constants import (
|
||||||
|
DRINK_MODELS,
|
||||||
LONG_PLAY_MEDIA,
|
LONG_PLAY_MEDIA,
|
||||||
MANUAL_SCROBBLE_FNS,
|
MANUAL_SCROBBLE_FNS,
|
||||||
PLAY_AGAIN_MEDIA,
|
PLAY_AGAIN_MEDIA,
|
||||||
|
Visibility,
|
||||||
)
|
)
|
||||||
from scrobbles.export import export_scrobbles
|
from scrobbles.export import export_scrobbles
|
||||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||||
from scrobbles.constants import Visibility
|
|
||||||
from scrobbles.sqids import decode_scrobble_share
|
|
||||||
from scrobbles.models import (
|
from scrobbles.models import (
|
||||||
AudioScrobblerTSVImport,
|
AudioScrobblerTSVImport,
|
||||||
BGStatsImport,
|
BGStatsImport,
|
||||||
@ -90,6 +90,7 @@ from scrobbles.models import (
|
|||||||
UDiscCSVImport,
|
UDiscCSVImport,
|
||||||
)
|
)
|
||||||
from scrobbles.scrobblers import *
|
from scrobbles.scrobblers import *
|
||||||
|
from scrobbles.sqids import decode_scrobble_share
|
||||||
from scrobbles.tasks import (
|
from scrobbles.tasks import (
|
||||||
process_koreader_import,
|
process_koreader_import,
|
||||||
process_lastfm_import,
|
process_lastfm_import,
|
||||||
@ -210,14 +211,20 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
|||||||
media = self.object
|
media = self.object
|
||||||
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
|
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
|
||||||
qs = media.scrobble_set.filter(user=self.request.user)
|
qs = media.scrobble_set.filter(user=self.request.user)
|
||||||
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
completed = (
|
||||||
|
qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
||||||
|
)
|
||||||
if completed and completed.long_play_seconds:
|
if completed and completed.long_play_seconds:
|
||||||
context_data["long_play_total_seconds"] = completed.long_play_seconds
|
context_data["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
context_data["long_play_finished_date"] = completed.timestamp
|
context_data["long_play_finished_date"] = completed.timestamp
|
||||||
else:
|
else:
|
||||||
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
latest_finished = (
|
||||||
|
qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
||||||
|
)
|
||||||
if latest_finished and latest_finished.long_play_seconds:
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds
|
context_data["long_play_total_seconds"] = (
|
||||||
|
latest_finished.long_play_seconds
|
||||||
|
)
|
||||||
context_data["long_play_finished_date"] = None
|
context_data["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context_data
|
return context_data
|
||||||
@ -235,7 +242,8 @@ class RecentScrobbleList(ListView):
|
|||||||
scrobble = manual_scrobble_from_url(
|
scrobble = manual_scrobble_from_url(
|
||||||
scrobble_url, self.request.user.id, source, action
|
scrobble_url, self.request.user.id, source, action
|
||||||
)
|
)
|
||||||
return HttpResponseRedirect(scrobble.redirect_url(user.id))
|
if scrobble:
|
||||||
|
return HttpResponseRedirect(scrobble.redirect_url(user.id))
|
||||||
return super().get(*args, **kwargs)
|
return super().get(*args, **kwargs)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
@ -610,6 +618,18 @@ class ManualScrobbleView(FormView):
|
|||||||
item_str = form.cleaned_data.get("item_id")
|
item_str = form.cleaned_data.get("item_id")
|
||||||
logger.debug(f"Looking for scrobblable media with input {item_str}")
|
logger.debug(f"Looking for scrobblable media with input {item_str}")
|
||||||
|
|
||||||
|
if item_str.startswith("http://") or item_str.startswith("https://"):
|
||||||
|
scrobble = manual_scrobble_from_url(
|
||||||
|
item_str, self.request.user.id, source="Vrobbler"
|
||||||
|
)
|
||||||
|
if scrobble:
|
||||||
|
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
"Could not scrobble from URL. The source may be unavailable.",
|
||||||
|
)
|
||||||
|
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
|
||||||
|
|
||||||
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
|
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
|
||||||
key = item_str[:3]
|
key = item_str[:3]
|
||||||
item_id = item_str[4:]
|
item_id = item_str[4:]
|
||||||
@ -888,8 +908,15 @@ def scrobble_start(request, media_uuid):
|
|||||||
|
|
||||||
media_obj = None
|
media_obj = None
|
||||||
for app, model in PLAY_AGAIN_MEDIA.items():
|
for app, model in PLAY_AGAIN_MEDIA.items():
|
||||||
media_model = apps.get_model(app_label=app, model_name=model)
|
if app == "drinks":
|
||||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
for drink_model in DRINK_MODELS:
|
||||||
|
media_model = apps.get_model(app_label=app, model_name=drink_model)
|
||||||
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
|
if media_obj:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
media_model = apps.get_model(app_label=app, model_name=model)
|
||||||
|
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||||
if media_obj:
|
if media_obj:
|
||||||
break
|
break
|
||||||
|
|
||||||
@ -1134,7 +1161,10 @@ def toggle_favorite(request, media_type, object_id):
|
|||||||
"VideoGame": ("videogames", "VideoGame"),
|
"VideoGame": ("videogames", "VideoGame"),
|
||||||
"BoardGame": ("boardgames", "BoardGame"),
|
"BoardGame": ("boardgames", "BoardGame"),
|
||||||
"GeoLocation": ("locations", "GeoLocation"),
|
"GeoLocation": ("locations", "GeoLocation"),
|
||||||
"Beer": ("beers", "Beer"),
|
"Beer": ("drinks", "Beer"),
|
||||||
|
"Wine": ("drinks", "Wine"),
|
||||||
|
"Coffee": ("drinks", "Coffee"),
|
||||||
|
"Water": ("drinks", "Water"),
|
||||||
"Puzzle": ("puzzles", "Puzzle"),
|
"Puzzle": ("puzzles", "Puzzle"),
|
||||||
"Food": ("foods", "Food"),
|
"Food": ("foods", "Food"),
|
||||||
"Trail": ("trails", "Trail"),
|
"Trail": ("trails", "Trail"),
|
||||||
@ -1235,6 +1265,29 @@ class ScrobbleDetailView(DetailView):
|
|||||||
def get_form_class(self):
|
def get_form_class(self):
|
||||||
return self.object.media_obj.logdata_cls().form()
|
return self.object.media_obj.logdata_cls().form()
|
||||||
|
|
||||||
|
def _update_board_game_widgets(self, form):
|
||||||
|
if not isinstance(self.object.media_obj, BoardGame):
|
||||||
|
return
|
||||||
|
|
||||||
|
if "expansion_ids" in form.fields:
|
||||||
|
expansions = BoardGame.objects.filter(
|
||||||
|
expansion_for_boardgame=self.object.media_obj
|
||||||
|
)
|
||||||
|
form.fields["expansion_ids"].queryset = expansions
|
||||||
|
|
||||||
|
if "variant_ids" in form.fields:
|
||||||
|
form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(
|
||||||
|
board_game=self.object.media_obj
|
||||||
|
)
|
||||||
|
form.fields["variant_ids"].widget.attrs[
|
||||||
|
"data-board-game-id"
|
||||||
|
] = self.object.media_obj.id
|
||||||
|
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
|
||||||
|
self.request.build_absolute_uri(
|
||||||
|
reverse("boardgames:ajax-create-variant")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def get_form(self):
|
def get_form(self):
|
||||||
FormClass = self.get_form_class()
|
FormClass = self.get_form_class()
|
||||||
|
|
||||||
@ -1245,12 +1298,42 @@ class ScrobbleDetailView(DetailView):
|
|||||||
else:
|
else:
|
||||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||||
|
|
||||||
return FormClass(initial=log)
|
form = FormClass(initial=log)
|
||||||
|
self._update_board_game_widgets(form)
|
||||||
|
|
||||||
|
drink_types = ("Beer", "Wine", "Coffee")
|
||||||
|
if self.object.media_type in drink_types and "size_ml" in form.fields:
|
||||||
|
user = self.request.user
|
||||||
|
use_oz = hasattr(user, "profile") and user.profile.volume_unit == "imperial"
|
||||||
|
if use_oz:
|
||||||
|
form.fields["size_ml"].label = "Size (oz)"
|
||||||
|
original_clean = form.fields["size_ml"].clean
|
||||||
|
|
||||||
|
def clean_size_ml_oz(value):
|
||||||
|
val = original_clean(value)
|
||||||
|
if val is not None and val != "":
|
||||||
|
from drinks.models import ML_PER_OZ
|
||||||
|
|
||||||
|
return round(float(val) * ML_PER_OZ)
|
||||||
|
return val
|
||||||
|
|
||||||
|
form.fields["size_ml"].clean = clean_size_ml_oz
|
||||||
|
if log.get("size_ml"):
|
||||||
|
from drinks.models import ML_PER_OZ
|
||||||
|
|
||||||
|
form.fields["size_ml"].initial = round(
|
||||||
|
log["size_ml"] / ML_PER_OZ, 1
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form.fields["size_ml"].label = "Size (mL)"
|
||||||
|
|
||||||
|
return form
|
||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
self.object = self.get_object()
|
self.object = self.get_object()
|
||||||
FormClass = self.get_form_class()
|
FormClass = self.get_form_class()
|
||||||
form = FormClass(request.POST)
|
form = FormClass(request.POST)
|
||||||
|
self._update_board_game_widgets(form)
|
||||||
|
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
data = form.cleaned_data.copy()
|
data = form.cleaned_data.copy()
|
||||||
@ -1263,6 +1346,12 @@ class ScrobbleDetailView(DetailView):
|
|||||||
if data.get("with_people_ids") is not None:
|
if data.get("with_people_ids") is not None:
|
||||||
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
|
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
|
||||||
|
|
||||||
|
if data.get("expansion_ids") is not None:
|
||||||
|
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
|
||||||
|
|
||||||
|
if data.get("variant_ids") is not None:
|
||||||
|
data["variant_ids"] = [v.id for v in data["variant_ids"]]
|
||||||
|
|
||||||
if data.get("mood_reason_ids") is not None:
|
if data.get("mood_reason_ids") is not None:
|
||||||
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
|
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
|
||||||
|
|
||||||
@ -1346,21 +1435,25 @@ class ScrobbleDetailView(DetailView):
|
|||||||
context["has_mopidy_uri"] = False
|
context["has_mopidy_uri"] = False
|
||||||
|
|
||||||
if self.object.is_long_play and fk_field:
|
if self.object.is_long_play and fk_field:
|
||||||
all_scrobbles = Scrobble.objects.filter(
|
all_scrobbles = Scrobble.objects.filter(user=user, **{fk_field: media_obj})
|
||||||
user=user, **{fk_field: media_obj}
|
completed = (
|
||||||
|
all_scrobbles.filter(long_play_complete=True)
|
||||||
|
.order_by("-timestamp")
|
||||||
|
.first()
|
||||||
)
|
)
|
||||||
completed = all_scrobbles.filter(
|
|
||||||
long_play_complete=True
|
|
||||||
).order_by("-timestamp").first()
|
|
||||||
if completed and completed.long_play_seconds:
|
if completed and completed.long_play_seconds:
|
||||||
context["long_play_total_seconds"] = completed.long_play_seconds
|
context["long_play_total_seconds"] = completed.long_play_seconds
|
||||||
context["long_play_finished_date"] = completed.timestamp
|
context["long_play_finished_date"] = completed.timestamp
|
||||||
else:
|
else:
|
||||||
latest_finished = all_scrobbles.filter(
|
latest_finished = (
|
||||||
played_to_completion=True
|
all_scrobbles.filter(played_to_completion=True)
|
||||||
).order_by("-timestamp").first()
|
.order_by("-timestamp")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
if latest_finished and latest_finished.long_play_seconds:
|
if latest_finished and latest_finished.long_play_seconds:
|
||||||
context["long_play_total_seconds"] = latest_finished.long_play_seconds
|
context["long_play_total_seconds"] = (
|
||||||
|
latest_finished.long_play_seconds
|
||||||
|
)
|
||||||
context["long_play_finished_date"] = None
|
context["long_play_finished_date"] = None
|
||||||
|
|
||||||
return context
|
return context
|
||||||
@ -1926,8 +2019,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
|
|||||||
scrobble_filter = {"scrobble__played_to_completion": True}
|
scrobble_filter = {"scrobble__played_to_completion": True}
|
||||||
|
|
||||||
def get_items(self, user, start_date, end_date):
|
def get_items(self, user, start_date, end_date):
|
||||||
from boardgames.models import BoardGame
|
|
||||||
|
|
||||||
return super().get_items(user, start_date, end_date, BoardGame)
|
return super().get_items(user, start_date, end_date, BoardGame)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +1,78 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.distribution %}
|
{% if data.distribution %}
|
||||||
|
{{ data.distribution|json_script:"activity-distribution-data" }}
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<canvas id="activityDistChart" width="700" style="max-width:100%;"></canvas>
|
||||||
<table class="table table-striped table-sm">
|
<script>
|
||||||
<thead>
|
(function() {
|
||||||
<tr>
|
var el = document.getElementById('activity-distribution-data');
|
||||||
<th>Media Type</th>
|
if (!el) return;
|
||||||
<th class="text-end">Total</th>
|
var data = JSON.parse(el.textContent);
|
||||||
<th class="text-end">Completed</th>
|
if (!data.length) return;
|
||||||
<th class="text-end">%</th>
|
var canvas = document.getElementById('activityDistChart');
|
||||||
<th>Distribution</th>
|
var ctx = canvas.getContext('2d');
|
||||||
</tr>
|
var W = canvas.width;
|
||||||
</thead>
|
var rowH = 34;
|
||||||
<tbody>
|
var labelW = 140;
|
||||||
{% with max=data.distribution.0.count %}
|
var barLeft = labelW + 8;
|
||||||
{% for entry in data.distribution %}
|
var barRight = W - 80;
|
||||||
<tr>
|
var barMaxW = barRight - barLeft;
|
||||||
<td>{{ entry.media_type }}</td>
|
var padTop = 8;
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
|
||||||
<td class="text-end">{{ entry.completed }}</td>
|
var maxCount = data[0].count;
|
||||||
<td class="text-end">{{ entry.pct }}%</td>
|
|
||||||
<td style="width: 30%;">
|
// Set canvas height based on data
|
||||||
{% if max > 0 %}
|
canvas.height = data.length * rowH + padTop;
|
||||||
<div class="progress" style="height: 12px;">
|
|
||||||
<div class="progress-bar" role="progressbar"
|
function roundRect(x, y, w, h, r) {
|
||||||
style="width: {{ entry.pct }}%;"
|
ctx.beginPath();
|
||||||
aria-valuenow="{{ entry.count }}"
|
ctx.moveTo(x + r, y);
|
||||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
ctx.lineTo(x + w - r, y);
|
||||||
</div>
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
</div>
|
ctx.lineTo(x + w, y + h - r);
|
||||||
{% endif %}
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
</td>
|
ctx.lineTo(x + r, y + h);
|
||||||
</tr>
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
{% endfor %}
|
ctx.lineTo(x, y + r);
|
||||||
{% endwith %}
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
</tbody>
|
ctx.closePath();
|
||||||
</table>
|
}
|
||||||
</div>
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var y = padTop + i * rowH;
|
||||||
|
var pct = entry.count / maxCount;
|
||||||
|
var barW = pct * barMaxW;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.media_type, labelW - 6, y + rowH / 2);
|
||||||
|
|
||||||
|
// Bar background
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Bar fill — green (top) to red (bottom)
|
||||||
|
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||||
|
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Count + percentage label on the right
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.count + ' (' + entry.pct + '%)', barRight + 6, y + rowH / 2);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No activity data found.</p>
|
<p class="text-muted">No activity data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,43 +1,74 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.moods %}
|
{% if data.moods %}
|
||||||
|
{{ data.moods|json_script:"mood-distribution-data" }}
|
||||||
<p class="text-muted mb-3">
|
<p class="text-muted mb-3">
|
||||||
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
Total mood check-ins{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total }}</strong>
|
||||||
· Positive: <strong>{{ data.positive_count }}</strong>
|
· Positive: <strong>{{ data.positive_count }}</strong>
|
||||||
· Negative: <strong>{{ data.negative_count }}</strong>
|
· Negative: <strong>{{ data.negative_count }}</strong>
|
||||||
</p>
|
</p>
|
||||||
<div class="table-responsive">
|
<canvas id="moodDistChart" width="700" style="max-width:100%;"></canvas>
|
||||||
<table class="table table-striped table-sm">
|
<script>
|
||||||
<thead>
|
(function() {
|
||||||
<tr>
|
var el = document.getElementById('mood-distribution-data');
|
||||||
<th>Mood</th>
|
if (!el) return;
|
||||||
<th class="text-end">Count</th>
|
var data = JSON.parse(el.textContent);
|
||||||
<th>Distribution</th>
|
if (!data.length) return;
|
||||||
</tr>
|
var canvas = document.getElementById('moodDistChart');
|
||||||
</thead>
|
var ctx = canvas.getContext('2d');
|
||||||
<tbody>
|
var rowH = 34;
|
||||||
{% with max=data.moods.0.count %}
|
var labelW = 140;
|
||||||
{% for entry in data.moods %}
|
var barLeft = labelW + 8;
|
||||||
<tr>
|
var barRight = canvas.width - 80;
|
||||||
<td>{{ entry.mood }}</td>
|
var barMaxW = barRight - barLeft;
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
var padTop = 8;
|
||||||
<td style="width: 40%;">
|
|
||||||
{% if max > 0 %}
|
canvas.height = data.length * rowH + padTop;
|
||||||
<div class="progress" style="height: 12px;">
|
|
||||||
<div class="progress-bar" role="progressbar"
|
var maxCount = data[0].count;
|
||||||
style="width: {% widthratio entry.count max 100 %}%;"
|
|
||||||
aria-valuenow="{{ entry.count }}"
|
function roundRect(x, y, w, h, r) {
|
||||||
aria-valuemin="0" aria-valuemax="{{ max }}">
|
ctx.beginPath();
|
||||||
</div>
|
ctx.moveTo(x + r, y);
|
||||||
</div>
|
ctx.lineTo(x + w - r, y);
|
||||||
{% endif %}
|
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||||||
</td>
|
ctx.lineTo(x + w, y + h - r);
|
||||||
</tr>
|
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||||||
{% endfor %}
|
ctx.lineTo(x + r, y + h);
|
||||||
{% endwith %}
|
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||||||
</tbody>
|
ctx.lineTo(x, y + r);
|
||||||
</table>
|
ctx.quadraticCurveTo(x, y, x + r, y);
|
||||||
</div>
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var y = padTop + i * rowH;
|
||||||
|
var pct = entry.count / maxCount;
|
||||||
|
var barW = pct * barMaxW;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = '13px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(entry.mood, labelW - 6, y + rowH / 2);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#f3f4f6';
|
||||||
|
roundRect(barLeft, y + 4, barMaxW, rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
var hue = 120 * (1 - i / (data.length - 1 || 1));
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 65%, 50%)';
|
||||||
|
roundRect(barLeft, y + 4, Math.max(barW, 2), rowH - 8, 4);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#374151';
|
||||||
|
ctx.font = 'bold 13px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText('' + entry.count, barRight + 6, y + rowH / 2);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No mood distribution data found.</p>
|
<p class="text-muted">No mood distribution data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,37 +1,105 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.trajectory %}
|
{% if data.trajectory %}
|
||||||
<div class="table-responsive">
|
{{ data.trajectory|json_script:"mood-trajectory-data" }}
|
||||||
<table class="table table-striped table-sm">
|
<div class="d-flex flex-column align-items-center">
|
||||||
<thead>
|
<canvas id="moodTrajectoryChart" width="700" height="300" style="max-width:100%;"></canvas>
|
||||||
<tr>
|
<div class="d-flex gap-4 mt-2 small text-muted">
|
||||||
<th>Date</th>
|
<span>← Earlier</span>
|
||||||
<th class="text-end">Avg Quality</th>
|
<span>Later →</span>
|
||||||
<th class="text-end">Check-ins</th>
|
</div>
|
||||||
<th>Mood Bar</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for entry in data.trajectory %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ entry.date }}</td>
|
|
||||||
<td class="text-end">{{ entry.avg_quality }}</td>
|
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
|
||||||
<td style="width: 40%;">
|
|
||||||
<div class="progress" style="height: 16px;">
|
|
||||||
<div class="progress-bar {% if entry.avg_quality >= 5 %}bg-success{% elif entry.avg_quality >= 4 %}bg-info{% elif entry.avg_quality >= 3 %}bg-warning{% else %}bg-danger{% endif %}"
|
|
||||||
role="progressbar"
|
|
||||||
style="width: {% widthratio entry.avg_quality 7 100 %}%;"
|
|
||||||
aria-valuenow="{{ entry.avg_quality }}"
|
|
||||||
aria-valuemin="1" aria-valuemax="7">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.getElementById('mood-trajectory-data');
|
||||||
|
if (!el) return;
|
||||||
|
var data = JSON.parse(el.textContent);
|
||||||
|
if (!data.length) return;
|
||||||
|
var canvas = document.getElementById('moodTrajectoryChart');
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var W = canvas.width, H = canvas.height;
|
||||||
|
var pad = { top: 20, right: 20, bottom: 30, left: 40 };
|
||||||
|
var plotW = W - pad.left - pad.right;
|
||||||
|
var plotH = H - pad.top - pad.bottom;
|
||||||
|
|
||||||
|
var yMin = 1, yMax = 7;
|
||||||
|
var yRange = yMax - yMin;
|
||||||
|
var maxCount = data.reduce(function(m, d) { return Math.max(m, d.count); }, 0);
|
||||||
|
|
||||||
|
function xPos(i) {
|
||||||
|
return pad.left + (i / (data.length - 1 || 1)) * plotW;
|
||||||
|
}
|
||||||
|
function yPos(val) {
|
||||||
|
return pad.top + (1 - (val - yMin) / yRange) * plotH;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background grid
|
||||||
|
ctx.strokeStyle = '#e5e7eb';
|
||||||
|
ctx.lineWidth = 0.5;
|
||||||
|
for (var q = 1; q <= 7; q++) {
|
||||||
|
var y = yPos(q);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, y);
|
||||||
|
ctx.lineTo(W - pad.right, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'right';
|
||||||
|
ctx.fillText(q.toFixed(1), pad.left - 5, y + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference line at neutral (4)
|
||||||
|
var neutralY = yPos(4);
|
||||||
|
ctx.strokeStyle = '#d1d5db';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pad.left, neutralY);
|
||||||
|
ctx.lineTo(W - pad.right, neutralY);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
ctx.fillStyle = '#9ca3af';
|
||||||
|
ctx.font = '11px sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.fillText('neutral', W - pad.right + 4, neutralY + 4);
|
||||||
|
|
||||||
|
// Line chart
|
||||||
|
ctx.beginPath();
|
||||||
|
data.forEach(function(d, i) {
|
||||||
|
var x = xPos(i);
|
||||||
|
var y = yPos(d.avg_quality);
|
||||||
|
if (i === 0) ctx.moveTo(x, y);
|
||||||
|
else ctx.lineTo(x, y);
|
||||||
|
});
|
||||||
|
ctx.strokeStyle = '#6366f1';
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Gradient fill below the line
|
||||||
|
var gradient = ctx.createLinearGradient(0, pad.top, 0, H - pad.bottom);
|
||||||
|
gradient.addColorStop(0, 'rgba(99, 102, 241, 0.25)');
|
||||||
|
gradient.addColorStop(1, 'rgba(99, 102, 241, 0.02)');
|
||||||
|
ctx.lineTo(xPos(data.length - 1), yPos(yMin));
|
||||||
|
ctx.lineTo(xPos(0), yPos(yMin));
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Dots
|
||||||
|
data.forEach(function(d, i) {
|
||||||
|
var x = xPos(i);
|
||||||
|
var y = yPos(d.avg_quality);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, 3.5, 0, 2 * Math.PI);
|
||||||
|
ctx.fillStyle = '#6366f1';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No mood check-in data found.</p>
|
<p class="text-muted">No mood check-in data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,50 +1,90 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.hours %}
|
{% if data.hours %}
|
||||||
<div class="table-responsive">
|
{{ data.hours|json_script:"peak-hours-data" }}
|
||||||
<table class="table table-striped table-sm">
|
<div class="d-flex flex-wrap align-items-start justify-content-center gap-4">
|
||||||
<thead>
|
<div>
|
||||||
<tr>
|
<canvas id="peakHoursChart" width="300" height="300" style="max-width:100%;"></canvas>
|
||||||
<th>Hour</th>
|
</div>
|
||||||
<th class="text-end">Scrobbles</th>
|
<div class="table-responsive" style="max-height:360px; overflow-y:auto;">
|
||||||
<th>Distribution</th>
|
<table class="table table-sm table-borderless mb-0" id="peakHoursLegend">
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th style="width:14px; padding-right:0;"></th>
|
||||||
{% with total=data.hours|dictsortreversed:"count"|first %}
|
<th>Hour</th>
|
||||||
{% with max_count=total.count %}
|
<th class="text-end">Scrobbles</th>
|
||||||
{% for entry in data.hours %}
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td>
|
<tbody>
|
||||||
{% if entry.hour == 0 %}
|
{% for entry in data.hours %}
|
||||||
12 AM
|
<tr>
|
||||||
{% elif entry.hour < 12 %}
|
<td class="p-1 text-center">
|
||||||
{{ entry.hour }} AM
|
<span class="legend-swatch" data-idx="{{ forloop.counter0 }}" style="display:inline-block; width:12px; height:12px; border-radius:2px;"></span>
|
||||||
{% elif entry.hour == 12 %}
|
</td>
|
||||||
12 PM
|
<td>
|
||||||
{% else %}
|
{% if entry.hour == 0 %}
|
||||||
{{ entry.hour|add:"-12" }} PM
|
12 AM
|
||||||
{% endif %}
|
{% elif entry.hour < 12 %}
|
||||||
</td>
|
{{ entry.hour }} AM
|
||||||
<td class="text-end">{{ entry.count }}</td>
|
{% elif entry.hour == 12 %}
|
||||||
<td style="width: 40%;">
|
12 PM
|
||||||
{% if max_count > 0 %}
|
{% else %}
|
||||||
<div class="progress" style="height: 12px;">
|
{{ entry.hour|add:"-12" }} PM
|
||||||
<div class="progress-bar" role="progressbar"
|
{% endif %}
|
||||||
style="width: {{ entry.count|floatformat:0 }}%;"
|
</td>
|
||||||
aria-valuenow="{{ entry.count }}"
|
<td class="text-end">{{ entry.count }}</td>
|
||||||
aria-valuemin="0" aria-valuemax="{{ max_count }}">
|
</tr>
|
||||||
</div>
|
{% endfor %}
|
||||||
</div>
|
</tbody>
|
||||||
{% endif %}
|
</table>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endwith %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var dataEl = document.getElementById('peak-hours-data');
|
||||||
|
if (!dataEl) return;
|
||||||
|
var data = JSON.parse(dataEl.textContent);
|
||||||
|
var total = data.reduce(function(s, h) { return s + h.count; }, 0) || 1;
|
||||||
|
var canvas = document.getElementById('peakHoursChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
var ctx = canvas.getContext('2d');
|
||||||
|
var cx = canvas.width / 2;
|
||||||
|
var cy = canvas.height / 2;
|
||||||
|
var radius = Math.min(cx, cy) - 10;
|
||||||
|
var startAngle = -Math.PI / 2;
|
||||||
|
|
||||||
|
var minCount = data.reduce(function(m, h) { return Math.min(m, h.count); }, Infinity);
|
||||||
|
var maxCount = data.reduce(function(m, h) { return Math.max(m, h.count); }, 0);
|
||||||
|
var range = maxCount - minCount || 1;
|
||||||
|
|
||||||
|
function countToHue(count) {
|
||||||
|
var t = (count - minCount) / range;
|
||||||
|
return 120 * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(function(entry, i) {
|
||||||
|
var sliceAngle = (entry.count / total) * 2 * Math.PI;
|
||||||
|
var hue = countToHue(entry.count);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy);
|
||||||
|
ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = 'hsl(' + hue + ', 70%, 50%)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
startAngle += sliceAngle;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.legend-swatch').forEach(function(el) {
|
||||||
|
var idx = parseInt(el.getAttribute('data-idx'), 10);
|
||||||
|
var entry = data[idx];
|
||||||
|
var hue = countToHue(entry.count);
|
||||||
|
el.style.backgroundColor = 'hsl(' + hue + ', 70%, 50%)';
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-muted">No activity data found.</p>
|
<p class="text-muted">No activity data found.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@ -1,31 +1,45 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
{% if data.total and data.total > 0 %}
|
{% if data.total and data.total > 0 %}
|
||||||
<h5>Overall</h5>
|
<div class="mb-4">
|
||||||
<div class="table-responsive mb-4">
|
{% for slug, info in data.categories.items %}
|
||||||
<table class="table table-striped table-sm">
|
{% if forloop.first %}
|
||||||
<thead>
|
<div class="card text-center border-0 bg-light mb-3">
|
||||||
<tr>
|
<div class="card-body py-4">
|
||||||
<th>Category</th>
|
{% if slug == "early_bird" %}
|
||||||
<th class="text-end">Scrobbles</th>
|
<div class="display-1">🌅</div>
|
||||||
<th class="text-end">%</th>
|
{% elif slug == "day_jay" %}
|
||||||
</tr>
|
<div class="display-1">☀️</div>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
<div class="display-1">🌙</div>
|
||||||
{% for slug, info in data.categories.items %}
|
{% endif %}
|
||||||
<tr>
|
<h3 class="mt-2">{{ info.label }}</h3>
|
||||||
<td>{{ info.label }}</td>
|
<div class="display-4 fw-bold">{{ info.pct }}%</div>
|
||||||
<td class="text-end">{{ info.count }}</td>
|
<div class="text-muted">{{ info.count }} scrobbles</div>
|
||||||
<td class="text-end">{{ info.pct }}%</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
<tr class="table-secondary">
|
<div class="card text-center border-0 bg-light mb-2">
|
||||||
<td><strong>Total</strong></td>
|
<div class="card-body py-2 d-flex align-items-center justify-content-between px-4">
|
||||||
<td class="text-end"><strong>{{ data.total }}</strong></td>
|
<div class="d-flex align-items-center gap-3">
|
||||||
<td class="text-end"></td>
|
{% if slug == "early_bird" %}
|
||||||
</tr>
|
<span style="font-size:1.5rem;">🌅</span>
|
||||||
</tbody>
|
{% elif slug == "day_jay" %}
|
||||||
</table>
|
<span style="font-size:1.5rem;">☀️</span>
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:1.5rem;">🌙</span>
|
||||||
|
{% endif %}
|
||||||
|
<h5 class="mb-0">{{ info.label }}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<span class="fs-4 fw-bold">{{ info.pct }}%</span>
|
||||||
|
<br><small class="text-muted">{{ info.count }} scrobbles</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="text-center text-muted small mt-2">Total: {{ data.total }} scrobbles across Books, Trails, Birding Locations, and Board Games</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5>By Media Type</h5>
|
<h5>By Media Type</h5>
|
||||||
|
|||||||
@ -1,38 +1,27 @@
|
|||||||
<div class="row">
|
<div class="row g-3">
|
||||||
<div class="col-12">
|
{% if data %}
|
||||||
{% if data %}
|
{% for mt, info in data.items %}
|
||||||
<div class="table-responsive">
|
<div class="col-6 col-md-4 col-lg-3 col-xl-2">
|
||||||
<table class="table table-striped table-sm">
|
<div class="card text-center h-100">
|
||||||
<thead>
|
<div class="card-body d-flex flex-column align-items-center justify-content-center" style="aspect-ratio: 1;">
|
||||||
<tr>
|
{% if info.change_pct > 0 %}
|
||||||
<th>Media Type</th>
|
<div class="display-3 lh-1 text-success">↑</div>
|
||||||
<th class="text-end">Recent ({{ current_period_label }})</th>
|
<div class="fs-6 text-success mt-1">+{{ info.change_pct }}%</div>
|
||||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
{% elif info.change_pct < 0 %}
|
||||||
<th class="text-end">Change</th>
|
<div class="display-3 lh-1 text-danger">↓</div>
|
||||||
</tr>
|
<div class="fs-6 text-danger mt-1">{{ info.change_pct }}%</div>
|
||||||
</thead>
|
{% else %}
|
||||||
<tbody>
|
<div class="display-3 lh-1 text-muted">→</div>
|
||||||
{% for mt, info in data.items %}
|
<div class="fs-6 text-muted mt-1">0%</div>
|
||||||
<tr>
|
{% endif %}
|
||||||
<td>{{ mt }}</td>
|
<div class="fw-bold mt-2">{{ mt }}</div>
|
||||||
<td class="text-end">{{ info.recent }}</td>
|
</div>
|
||||||
<td class="text-end">{{ info.previous }}</td>
|
</div>
|
||||||
<td class="text-end">
|
|
||||||
{% if info.change_pct > 0 %}
|
|
||||||
<span class="text-success">+{{ info.change_pct }}%</span>
|
|
||||||
{% elif info.change_pct < 0 %}
|
|
||||||
<span class="text-danger">{{ info.change_pct }}%</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-muted">0%</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% endfor %}
|
||||||
<p class="text-muted">No trending data found.</p>
|
{% else %}
|
||||||
{% endif %}
|
<div class="col-12">
|
||||||
</div>
|
<p class="text-muted">No trending data found.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -59,18 +59,21 @@ def compute_time_of_day_categories(user, period="last_30"):
|
|||||||
if slug:
|
if slug:
|
||||||
cat_counts[slug] += count
|
cat_counts[slug] += count
|
||||||
mt_total += count
|
mt_total += count
|
||||||
by_media_type[mt] = {
|
mt_categories = {}
|
||||||
"total": mt_total,
|
|
||||||
"categories": {},
|
|
||||||
}
|
|
||||||
for slug in CATEGORIES:
|
for slug in CATEGORIES:
|
||||||
c = cat_counts[slug]
|
c = cat_counts[slug]
|
||||||
by_media_type[mt]["categories"][slug] = {
|
mt_categories[slug] = {
|
||||||
"count": c,
|
"count": c,
|
||||||
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
|
||||||
"label": CATEGORIES[slug]["label"],
|
"label": CATEGORIES[slug]["label"],
|
||||||
}
|
}
|
||||||
grand_totals[slug] += c
|
grand_totals[slug] += c
|
||||||
|
by_media_type[mt] = {
|
||||||
|
"total": mt_total,
|
||||||
|
"categories": dict(
|
||||||
|
sorted(mt_categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
|
),
|
||||||
|
}
|
||||||
grand_total += mt_total
|
grand_total += mt_total
|
||||||
|
|
||||||
categories = {}
|
categories = {}
|
||||||
@ -81,6 +84,9 @@ def compute_time_of_day_categories(user, period="last_30"):
|
|||||||
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
|
||||||
"label": CATEGORIES[slug]["label"],
|
"label": CATEGORIES[slug]["label"],
|
||||||
}
|
}
|
||||||
|
categories = dict(
|
||||||
|
sorted(categories.items(), key=lambda x: x[1]["count"], reverse=True)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
|
|||||||
@ -17,8 +17,8 @@ MISSING_ALL = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
MISSING_GROUPS = {
|
MISSING_GROUPS = {
|
||||||
"cover": lambda g: not bool(g.cover),
|
"cover": lambda g: _image_missing_or_broken(g, "cover"),
|
||||||
"screenshot": lambda g: not bool(g.screenshot),
|
"screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
|
||||||
"summary": lambda g: not g.summary,
|
"summary": lambda g: not g.summary,
|
||||||
"rating": lambda g: g.rating is None,
|
"rating": lambda g: g.rating is None,
|
||||||
"release_date": lambda g: g.release_date is None,
|
"release_date": lambda g: g.release_date is None,
|
||||||
@ -28,6 +28,16 @@ MISSING_GROUPS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _image_missing_or_broken(game, field_name) -> bool:
|
||||||
|
field = getattr(game, field_name)
|
||||||
|
if not bool(field):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
return not field.storage.exists(field.name)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _game_matches(game, flags):
|
def _game_matches(game, flags):
|
||||||
if not flags:
|
if not flags:
|
||||||
return False
|
return False
|
||||||
@ -103,6 +113,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
if all_missing:
|
if all_missing:
|
||||||
flags = MISSING_ALL
|
flags = MISSING_ALL
|
||||||
|
fix_broken_images = True
|
||||||
|
|
||||||
if not flags and not game_id and not force and not fix_broken_images:
|
if not flags and not game_id and not force and not fix_broken_images:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
|
|||||||
@ -30,6 +30,19 @@ class Command(BaseCommand):
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Only process channels with a twitch_id",
|
help="Only process channels with a twitch_id",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--needs-metadata",
|
||||||
|
action="store_true",
|
||||||
|
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _has_broken_image(self, channel) -> bool:
|
||||||
|
if not channel.cover_image or not channel.cover_image.name:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return not channel.cover_image.storage.exists(channel.cover_image.name)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from videos.models import Channel
|
from videos.models import Channel
|
||||||
@ -38,6 +51,7 @@ class Command(BaseCommand):
|
|||||||
dry_run = options["dry_run"]
|
dry_run = options["dry_run"]
|
||||||
youtube_only = options["youtube_only"]
|
youtube_only = options["youtube_only"]
|
||||||
twitch_only = options["twitch_only"]
|
twitch_only = options["twitch_only"]
|
||||||
|
needs_metadata = options["needs_metadata"]
|
||||||
|
|
||||||
qs = Channel.objects.all()
|
qs = Channel.objects.all()
|
||||||
|
|
||||||
@ -45,29 +59,61 @@ class Command(BaseCommand):
|
|||||||
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||||
elif twitch_only:
|
elif twitch_only:
|
||||||
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
||||||
|
elif needs_metadata:
|
||||||
|
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
|
||||||
|
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
|
||||||
|
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
|
||||||
|
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
|
||||||
|
qs = qs.filter(
|
||||||
|
no_id
|
||||||
|
| models.Q(cover_image__isnull=True)
|
||||||
|
| models.Q(cover_image="")
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
qs = qs.filter(
|
qs = qs.filter(
|
||||||
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
|
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
|
||||||
).exclude(youtube_id="", twitch_id="")
|
).exclude(youtube_id="", twitch_id="")
|
||||||
|
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
self.stdout.write(f"Processing {total} channels")
|
self.stdout.write(f"Processing {total} channels from DB filter")
|
||||||
|
|
||||||
|
broken_channels = []
|
||||||
|
if needs_metadata:
|
||||||
|
broken_qs = Channel.objects.filter(
|
||||||
|
cover_image__isnull=False,
|
||||||
|
).exclude(
|
||||||
|
cover_image="",
|
||||||
|
)
|
||||||
|
if youtube_only:
|
||||||
|
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
|
||||||
|
elif twitch_only:
|
||||||
|
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
|
||||||
|
for channel in broken_qs.iterator():
|
||||||
|
if self._has_broken_image(channel):
|
||||||
|
broken_channels.append(channel)
|
||||||
|
|
||||||
|
all_channels = list(qs) + broken_channels
|
||||||
|
total = len(all_channels)
|
||||||
|
self.stdout.write(f"Total channels to process: {total}")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for channel in qs.iterator():
|
for channel in all_channels:
|
||||||
source = "youtube" if channel.youtube_id else "twitch"
|
source = "youtube" if channel.youtube_id else "twitch"
|
||||||
identifier = channel.youtube_id or channel.twitch_id
|
identifier = channel.youtube_id or channel.twitch_id
|
||||||
|
status = f"({source}: {identifier})"
|
||||||
|
if self._has_broken_image(channel):
|
||||||
|
status += " [image BROKEN]"
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
|
f" [DRY RUN] Would fix {channel.name} {status}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
for channel in qs.iterator():
|
for channel in all_channels:
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
channel.fix_metadata(force=force)
|
channel.fix_metadata(force=force or self._has_broken_image(channel))
|
||||||
updated += 1
|
updated += 1
|
||||||
source = "youtube" if channel.youtube_id else "twitch"
|
source = "youtube" if channel.youtube_id else "twitch"
|
||||||
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
|
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
@ -28,9 +29,18 @@ class Command(BaseCommand):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--needs-metadata",
|
"--needs-metadata",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Only process series missing imdb_id or cover image",
|
help="Only process series missing imdb_id or with broken cover image",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _has_broken_image(self, series) -> bool:
|
||||||
|
"""Check if a series has a cover_image set but the file is missing."""
|
||||||
|
if not series.cover_image:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return not os.path.exists(series.cover_image.path)
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
from videos.models import Series
|
from videos.models import Series
|
||||||
|
|
||||||
@ -51,25 +61,50 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
|
|
||||||
total = qs.count()
|
total = qs.count()
|
||||||
self.stdout.write(f"Processing {total} series")
|
self.stdout.write(f"Processing {total} series from DB filter")
|
||||||
|
|
||||||
|
# Also find series with broken cover images
|
||||||
|
broken_image_series = []
|
||||||
|
if needs_metadata:
|
||||||
|
broken_qs = Series.objects.filter(
|
||||||
|
cover_image__isnull=False,
|
||||||
|
).exclude(
|
||||||
|
models.Q(imdb_id__isnull=True)
|
||||||
|
| models.Q(imdb_id="")
|
||||||
|
| models.Q(cover_image__isnull=True)
|
||||||
|
| models.Q(cover_image=""),
|
||||||
|
)
|
||||||
|
if imdb_id:
|
||||||
|
broken_qs = broken_qs.filter(imdb_id=imdb_id)
|
||||||
|
for series in broken_qs.iterator():
|
||||||
|
if self._has_broken_image(series):
|
||||||
|
broken_image_series.append(series)
|
||||||
|
|
||||||
|
all_series = list(qs) + broken_image_series
|
||||||
|
total = len(all_series)
|
||||||
|
self.stdout.write(f"Total series to process: {total}")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
for series in qs.iterator():
|
for series in all_series:
|
||||||
has_imdb = bool(series.imdb_id)
|
has_imdb = bool(series.imdb_id)
|
||||||
has_image = bool(series.cover_image)
|
has_image = bool(series.cover_image)
|
||||||
self.stdout.write(
|
image_broken = self._has_broken_image(series)
|
||||||
f" [DRY RUN] Would fix {series.name}"
|
status = f"imdb_id={'✓' if has_imdb else '✗'}"
|
||||||
f" (imdb_id={'✓' if has_imdb else '✗'}"
|
if image_broken:
|
||||||
f", image={'✓' if has_image else '✗'})"
|
status += ", image=BROKEN"
|
||||||
)
|
elif has_image:
|
||||||
|
status += ", image=✓"
|
||||||
|
else:
|
||||||
|
status += ", image=✗"
|
||||||
|
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
|
||||||
return
|
return
|
||||||
|
|
||||||
updated = 0
|
updated = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
for series in qs.iterator():
|
for series in all_series:
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
series.fix_metadata(force_update=force)
|
series.fix_metadata(force_update=force or self._has_broken_image(series))
|
||||||
updated += 1
|
updated += 1
|
||||||
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
self.stdout.write(f" [{updated}/{total}] {series.name}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
|
TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")
|
||||||
|
|
||||||
ALLOWED_HOSTS = ["*"]
|
ALLOWED_HOSTS = ["*"]
|
||||||
CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")]
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
|
os.getenv(
|
||||||
|
"VROBBLER_TRUSTED_ORIGINS",
|
||||||
|
"http://localhost:8000",
|
||||||
|
),
|
||||||
|
"https://dev-vrobbler.lab.unbl.ink",
|
||||||
|
]
|
||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||||
|
|
||||||
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)
|
||||||
@ -223,7 +229,7 @@ INSTALLED_APPS = [
|
|||||||
"webpages",
|
"webpages",
|
||||||
"tasks",
|
"tasks",
|
||||||
"trails",
|
"trails",
|
||||||
"beers",
|
"drinks",
|
||||||
"puzzles",
|
"puzzles",
|
||||||
"foods",
|
"foods",
|
||||||
"lifeevents",
|
"lifeevents",
|
||||||
@ -399,6 +405,12 @@ else:
|
|||||||
|
|
||||||
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
|
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
|
||||||
|
|
||||||
|
SKIP_AUTO_EXPANSION_DOWNLOAD = [
|
||||||
|
int(x.strip())
|
||||||
|
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
|
||||||
|
if x.strip().isdigit()
|
||||||
|
]
|
||||||
|
|
||||||
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
|
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
|
||||||
LOG_TYPE = "json" if JSON_LOGGING else "log"
|
LOG_TYPE = "json" if JSON_LOGGING else "log"
|
||||||
|
|
||||||
|
|||||||
@ -280,6 +280,12 @@
|
|||||||
<div class="nav-item text-nowrap">
|
<div class="nav-item text-nowrap">
|
||||||
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
|
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item text-nowrap">
|
||||||
|
<form method="post" action="{% url "drinks:quick_water_scrobble" %}" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="nav-link px-3 btn btn-link" style="text-decoration:none;">Drink water</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<div class="nav-item text-nowrap">
|
<div class="nav-item text-nowrap">
|
||||||
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
|
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -46,6 +46,20 @@
|
|||||||
<p>
|
<p>
|
||||||
<a href="{{object.start_url}}">Play again</a>
|
<a href="{{object.start_url}}">Play again</a>
|
||||||
</p>
|
</p>
|
||||||
|
{% if object.genre.all %}
|
||||||
|
<p>Genres:
|
||||||
|
{% for tag in object.genre.all %}
|
||||||
|
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.tags.all %}
|
||||||
|
<p>Tags:
|
||||||
|
{% for tag in object.tags.all %}
|
||||||
|
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if charts %}
|
{% if charts %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|||||||
@ -120,9 +120,67 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include "scrobbles/_top_charts.html" %}
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'charts:maloja-charts' %}" class="btn btn-sm btn-outline-secondary">🎨 Maloja Widgets</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
{% if charts.artist %}
|
||||||
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
|
<h3>🎤 Top Artists</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for chart in charts.artist|slice:":20" %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||||
|
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
|
||||||
|
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="{% url 'charts:chart-detail' 'artist' %}">View all »</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if charts.album %}
|
||||||
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
|
<h3>💿 Top Albums</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for chart in charts.album|slice:":20" %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||||
|
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
|
||||||
|
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="{% url 'charts:chart-detail' 'album' %}">View all »</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if charts.tv_series %}
|
||||||
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
|
<h3>📺 Top TV Series</h3>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for chart in charts.tv_series|slice:":20" %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
||||||
|
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
|
||||||
|
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
<li class="list-group-item">
|
||||||
|
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all »</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
{% if charts.track %}
|
{% if charts.track %}
|
||||||
<div class="col-md-6 col-lg-4 chart-section">
|
<div class="col-md-6 col-lg-4 chart-section">
|
||||||
<h3>🎵 Top Tracks</h3>
|
<h3>🎵 Top Tracks</h3>
|
||||||
|
|||||||
45
vrobbler/templates/charts/maloja_charts.html
Normal file
45
vrobbler/templates/charts/maloja_charts.html
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Maloja Widgets{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.container { margin-bottom: 100px; }
|
||||||
|
h2 { padding-top: 20px; }
|
||||||
|
.nav-tabs { cursor: pointer; }
|
||||||
|
.image-wrapper { contain: content; }
|
||||||
|
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
|
||||||
|
.caption {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 90%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.caption-medium {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 75%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.caption-small {
|
||||||
|
position: fixed; top: 5px; left: 5px;
|
||||||
|
padding: 3px; font-size: 60%;
|
||||||
|
color: white; background: rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
{% block grid_view_button %}{% endblock %}
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">← Full Charts</a>
|
||||||
|
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||||
|
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "scrobbles/_top_charts.html" %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@ -52,12 +52,24 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Date</th>
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Size</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{ scrobble.logdata.format_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.logdata.size_ml %}
|
||||||
|
{% if request.user.profile.volume_unit == "imperial" %}
|
||||||
|
{{ scrobble.logdata.size_oz }} oz
|
||||||
|
{% else %}
|
||||||
|
{{ scrobble.logdata.size_ml }} mL
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
81
vrobbler/templates/drinks/coffee_detail.html
Normal file
81
vrobbler/templates/drinks/coffee_detail.html
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
{% load static %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.cover img {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
float: left;
|
||||||
|
width: 252px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
float: left;
|
||||||
|
width: 600px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="summary">
|
||||||
|
{% if object.description%}
|
||||||
|
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||||
|
<hr />
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{scrobbles.count}} scrobbles</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{object.start_url}}">Drink again</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Size</th>
|
||||||
|
<th scope="col">Roast</th>
|
||||||
|
<th scope="col">Method</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{ scrobble.logdata.format_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.logdata.size_ml %}
|
||||||
|
{% if request.user.profile.volume_unit == "imperial" %}
|
||||||
|
{{ scrobble.logdata.size_oz }} oz
|
||||||
|
{% else %}
|
||||||
|
{{ scrobble.logdata.size_ml }} mL
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ scrobble.logdata.roast_level_display }}</td>
|
||||||
|
<td>{{ scrobble.logdata.brewing_method_display }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
24
vrobbler/templates/drinks/coffee_list.html
Normal file
24
vrobbler/templates/drinks/coffee_list.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Coffee{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
dl { width: 210px; float:left; margin-right: 10px; }
|
||||||
|
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||||
|
img { height:200px; width: 200px; object-fit: cover; }
|
||||||
|
dd .right { float:right; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
80
vrobbler/templates/drinks/drink_detail.html
Normal file
80
vrobbler/templates/drinks/drink_detail.html
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
{% load static %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.cover img {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
float: left;
|
||||||
|
width: 252px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
float: left;
|
||||||
|
width: 600px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="summary">
|
||||||
|
{% if object.description%}
|
||||||
|
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||||
|
<hr />
|
||||||
|
{% endif %}
|
||||||
|
<p style="float:right;">
|
||||||
|
<a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{scrobbles.count}} scrobbles</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{object.start_url}}">Drink again</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{ scrobble.logdata.format_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.logdata.size_ml %}
|
||||||
|
{% if request.user.profile.volume_unit == "imperial" %}
|
||||||
|
{{ scrobble.logdata.size_oz }} oz
|
||||||
|
{% else %}
|
||||||
|
{{ scrobble.logdata.size_ml }} mL
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
23
vrobbler/templates/drinks/drink_list.html
Normal file
23
vrobbler/templates/drinks/drink_list.html
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
|
||||||
|
{% block title %}Drinks{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
dl { width: 210px; float:left; margin-right: 10px; }
|
||||||
|
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||||
|
img { height:200px; width: 200px; object-fit: cover; }
|
||||||
|
dd .right { float:right; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
79
vrobbler/templates/drinks/water_detail.html
Normal file
79
vrobbler/templates/drinks/water_detail.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.cover img {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
float: left;
|
||||||
|
width: 252px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
float: left;
|
||||||
|
width: 600px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="summary">
|
||||||
|
{% if object.description%}
|
||||||
|
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||||
|
<hr />
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{scrobbles.count}} scrobbles</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{object.start_url}}">Drink again</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Size</th>
|
||||||
|
<th scope="col">Temperature</th>
|
||||||
|
<th scope="col">Sparkling</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{ scrobble.logdata.format_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.logdata.size_ml %}
|
||||||
|
{% if request.user.profile.volume_unit == "imperial" %}
|
||||||
|
{{ scrobble.logdata.size_oz }} oz
|
||||||
|
{% else %}
|
||||||
|
{{ scrobble.logdata.size_ml }} mL
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ scrobble.logdata.temperature|title }}</td>
|
||||||
|
<td>{% if scrobble.logdata.carbonated %}Yes{% elif scrobble.logdata.carbonated == False %}No{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
85
vrobbler/templates/drinks/wine_detail.html
Normal file
85
vrobbler/templates/drinks/wine_detail.html
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load mathfilters %}
|
||||||
|
{% load static %}
|
||||||
|
{% load naturalduration %}
|
||||||
|
|
||||||
|
{% block title %}{{object.title}}{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.cover img {
|
||||||
|
width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
float: left;
|
||||||
|
width: 252px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
float: left;
|
||||||
|
width: 600px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="summary">
|
||||||
|
{% if object.description%}
|
||||||
|
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||||
|
<hr />
|
||||||
|
{% endif %}
|
||||||
|
<p style="float:right;">
|
||||||
|
{% if object.vivino_link %}
|
||||||
|
<a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>
|
||||||
|
{% endif %}
|
||||||
|
{% if object.cellartracker_link %}
|
||||||
|
<a href="{{object.cellartracker_link}}">CellarTracker</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<p>{{scrobbles.count}} scrobbles</p>
|
||||||
|
<p>
|
||||||
|
<a href="{{object.start_url}}">Drink again</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h3>Last scrobbles</h3>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Date</th>
|
||||||
|
<th scope="col">Format</th>
|
||||||
|
<th scope="col">Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||||
|
<td>{{ scrobble.logdata.format_display }}</td>
|
||||||
|
<td>
|
||||||
|
{% if scrobble.logdata.size_ml %}
|
||||||
|
{% if request.user.profile.volume_unit == "imperial" %}
|
||||||
|
{{ scrobble.logdata.size_oz }} oz
|
||||||
|
{% else %}
|
||||||
|
{{ scrobble.logdata.size_ml }} mL
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
24
vrobbler/templates/drinks/wine_list.html
Normal file
24
vrobbler/templates/drinks/wine_list.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base_list.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Wines{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
dl { width: 210px; float:left; margin-right: 10px; }
|
||||||
|
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||||
|
img { height:200px; width: 200px; object-fit: cover; }
|
||||||
|
dd .right { float:right; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block lists %}
|
||||||
|
<div class="row">
|
||||||
|
|
||||||
|
<div class="col-md">
|
||||||
|
<div class="table-responsive">
|
||||||
|
{% include "_scrobblable_list.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user