Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab2e154ed3 | |||
| 46c5ff6993 | |||
| c3673e2679 | |||
| 00249e3d3b | |||
| 9523d631b0 | |||
| abf0eae493 | |||
| 740e5268dd | |||
| 681252b845 | |||
| f6a20b67a6 | |||
| c9298fe387 | |||
| f7d99f8b55 | |||
| 6861e28ac6 | |||
| fdc18d5a5f | |||
| 77d92f6c96 | |||
| d5d0eb6cd8 | |||
| d6f71e0761 | |||
| b00ebf49dd | |||
| 2385e9c7bd | |||
| d78529efe2 | |||
| f373e98e3d | |||
| 7559ce7824 | |||
| 2c481bd53a | |||
| 0deb3ee634 | |||
| 28a53d70eb | |||
| 98d4e8bacb | |||
| 8f97131b8d | |||
| 7c1f709f96 | |||
| 4b005e0e5b | |||
| d5dd63be0d | |||
| 5aa89b7e0a | |||
| cf444e8dd4 | |||
| a74a89c747 | |||
| 1695f7393e | |||
| 4468e68110 | |||
| da08eca4ab | |||
| 08752e30a4 | |||
| 619718c045 | |||
| cb23d5a5be | |||
| ec4c190e6c | |||
| 58126928c7 |
@ -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:
|
||||||
213
PROJECT.org
213
PROJECT.org
@ -580,19 +580,53 @@ named constants for maintainability.
|
|||||||
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
|
||||||
|
|
||||||
|
|
||||||
** TODO [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
|
** 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)
|
||||||
|
|
||||||
|
** TODO [#B] Auto sync board game scrobbles to BGG :boardgames:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
* Version 61.0 [3/3]
|
||||||
|
** DONE [#B] Try adding a nature app with Observations and Species
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
|
:ID: 9551cf06-02b7-1bc9-3600-b0407ecc5967
|
||||||
:END:
|
:END:
|
||||||
|
|
||||||
*** Description
|
*** Description
|
||||||
|
|
||||||
No check for existing BGG plays before posting, which can create duplicates.
|
iNaturalist has a feed in atom.
|
||||||
Should look up past plays by =bggeek_id= first.
|
|
||||||
|
|
||||||
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
|
** DONE [#A] Add ability to push one or sync all board game scrobbles to BGG :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1f306552-35e8-4a75-a661-b0956e8de967
|
||||||
|
:END:
|
||||||
|
|
||||||
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
*** Description
|
||||||
|
|
||||||
|
We have a half-baked BGG sync module. I don't even know it if works. Can we
|
||||||
|
clean it up, add some tests and then add UI features to either sync a single
|
||||||
|
board game scrobble (via the detail page) to BGG, along with a "sync all" button
|
||||||
|
on the board game list page. Additionally, the sync should merge data between
|
||||||
|
Vrobbler and BGG based on timestamp and bgg_id. Priority should be given to data
|
||||||
|
from Vrobbler.
|
||||||
|
|
||||||
|
** DONE [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
|
||||||
:END:
|
:END:
|
||||||
@ -603,7 +637,172 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just
|
|||||||
a helper method to create board game scrobbles given a json blob. It's
|
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:
|
|
||||||
|
* Version 60.2 [1/1]
|
||||||
|
** DONE Use FastCork to lookup wine data :drinks:wine:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: add9a951-d957-415d-b41e-feee40479774
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 60.1 [1/1]
|
||||||
|
** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: edaf1200-b58e-8512-b2a2-c8bc25bed7a0
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Food ID 10 is "French Press Coffee with Sugar" and we should convert all of
|
||||||
|
these scrobbles to scrobbles of a Coffee, but to figure out which coffee, we can
|
||||||
|
use scrobbles of Task "Chore" where the title is "Roast coffee" and the notes
|
||||||
|
are not null. In this case, the first note on each one has a Coffee title like
|
||||||
|
"Mexico - Veracruz Huatusco" where the Origin is Mexico and the title is
|
||||||
|
"Veracruz Huatusco"
|
||||||
|
|
||||||
|
We should create a new Coffee for each Chore with the title of "Roast Coffee"
|
||||||
|
and then for each "French Press Coffee with Sugar", swap the Food ID 10 for the
|
||||||
|
Coffee ID of the latest Coffee created from a Chore. If a new Chore is found,
|
||||||
|
start updating the French Press scrobbles with a newly created Coffee.
|
||||||
|
|
||||||
|
If we don't have a roast log in the Chore tasks before a French Press entry, we should use the "Unknown Roast" Coffee title and skip all the othe metadata.
|
||||||
|
|
||||||
|
|
||||||
|
* Version 60.0 [2/2]
|
||||||
|
** DONE [#B] Send water drinking notifications :drinks:notifications:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 6c0db717-a0c7-34a1-57e1-941db0188875
|
||||||
|
:END:
|
||||||
|
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.5 [1/1]
|
||||||
|
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.4 [2/2]
|
||||||
|
** DONE Fix bug in fetching expansions for board games :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
|
||||||
|
:END:
|
||||||
|
|
||||||
|
#+begin_src python
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
|
||||||
|
fetch_and_link_expansions(game, expansions)
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
|
||||||
|
expansion = BoardGame.find_or_create(str(exp_id))
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
|
||||||
|
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
|
||||||
|
game = bgg.game(game_id=lookup_id)
|
||||||
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
|
||||||
|
raise BGGApiError(msg)
|
||||||
|
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
|
||||||
|
#+end_src
|
||||||
|
|
||||||
|
** DONE Board games should have genres extracted from family data :boardgames:metadata:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
|
||||||
|
:END:
|
||||||
|
|
||||||
|
|
||||||
|
* Version 59.3 [2/2]
|
||||||
|
** DONE Exclude some board games from auto-expansion imports :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Some board games, especially trading card games, have silly amounts of expansions.
|
||||||
|
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
|
||||||
|
games where expansions should not be download automatically. This exclusion should also auto-include
|
||||||
|
any games with "Collectible Card Games" in it's family.
|
||||||
|
|
||||||
|
** DONE Should be able to add new variants to board games via the log data form :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.2 [1/1]
|
||||||
|
** DONE Fix test failure in discgolf app :discgolf:tests:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.1 [1/1]
|
||||||
|
** DONE Fix bug when expansions have no image :boardgames:bug:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 59.0 [3/3]
|
||||||
|
** DONE [#A] Add BoardGameVariant model :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 0ffb20d5-252f-b13d-473d-5529014602ff
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
Variants represent unique boards being used per scrobble or scenarios when
|
||||||
|
playing a game. Scrobbles of a board game may have one or more
|
||||||
|
boardgame_variant_ids assocaited with their log data, and a variant is created
|
||||||
|
for one specific board game.
|
||||||
|
|
||||||
|
|
||||||
|
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
|
||||||
|
:END:
|
||||||
|
|
||||||
|
*** Description
|
||||||
|
|
||||||
|
We don't want to blow up the BGG API, but if possible with not too
|
||||||
|
many calls, when we scrobble a board game, in order to allow
|
||||||
|
populating the "Expansions" multi select, we should fetch any
|
||||||
|
expansions for the board game when creating it for the first time.
|
||||||
|
|
||||||
|
We should also create a managemnt script to update existing board games.
|
||||||
|
|
||||||
|
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.8 [1/1]
|
||||||
|
** DONE [#B] Clean up trend templates :trends:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 83237e2c-857b-47c9-c86c-32a5e3f1359d
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.7 [2/2]
|
||||||
|
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
|
||||||
|
:END:
|
||||||
|
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.6 [1/1]
|
||||||
|
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
|
||||||
|
:END:
|
||||||
|
|
||||||
|
* Version 58.5 [1/1]
|
||||||
|
** DONE [#A] The maloja style charts are messed up :templates:charts:
|
||||||
|
:PROPERTIES:
|
||||||
|
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 58.4 [2/2]
|
* 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 = "61.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>"
|
|||||||
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
|
||||||
VROBBLER_GOOGLE_API_KEY="<key>"
|
VROBBLER_GOOGLE_API_KEY="<key>"
|
||||||
VROBBLER_LICHESS_API_KEY = "<key>"
|
VROBBLER_LICHESS_API_KEY = "<key>"
|
||||||
|
VROBBLER_FASTCORK_API_KEY="fc_<key>"
|
||||||
|
|
||||||
# Storages
|
# Storages
|
||||||
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import csv
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -101,48 +100,124 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
|
|||||||
return game_dict
|
return game_dict
|
||||||
|
|
||||||
|
|
||||||
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
def fetch_existing_bgg_plays(bgg_username: str) -> list[dict]:
|
||||||
bgg_username = "secstate" # user.profile.bgg_username
|
"""Fetch recent plays from BGG for a user to check for duplicates."""
|
||||||
bgg_password = "yYFCKnfo8AK89lc68q0S"
|
url = f"https://boardgamegeek.com/xmlapi2/plays?username={bgg_username}&page=1"
|
||||||
|
r = requests.get(url, headers=BASE_HEADERS)
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"Failed to fetch BGG plays",
|
||||||
|
extra={"status_code": r.status_code, "username": bgg_username},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
if not bgg_username or bgg_password:
|
soup = BeautifulSoup(r.text, "xml")
|
||||||
return
|
plays = []
|
||||||
|
for play_elem in soup.findAll("play"):
|
||||||
|
item = play_elem.find("item")
|
||||||
|
if item:
|
||||||
|
plays.append(
|
||||||
|
{
|
||||||
|
"objectid": item.get("objectid"),
|
||||||
|
"date": play_elem.get("date"),
|
||||||
|
"quantity": play_elem.get("quantity"),
|
||||||
|
"length": play_elem.get("length"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return plays
|
||||||
|
|
||||||
|
|
||||||
|
def is_duplicate_bgg_play(
|
||||||
|
existing_plays: list[dict], bgg_id: str, play_date: str
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a play already exists on BGG matching game ID and date."""
|
||||||
|
for play in existing_plays:
|
||||||
|
if play["objectid"] == str(bgg_id) and play["date"] == play_date:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
||||||
|
from boardgames.models import BoardGameLocation
|
||||||
|
from people.models import Person
|
||||||
|
|
||||||
|
bgg_username = user.profile.bgg_username if hasattr(user, "profile") else None
|
||||||
|
bgg_password = getattr(settings, "BGG_PASSWORD", "")
|
||||||
|
|
||||||
|
if not bgg_username or not bgg_password:
|
||||||
|
logger.warning(
|
||||||
|
"Cannot push to BGG: missing credentials",
|
||||||
|
extra={"user_id": user.id, "bgg_username": bgg_username},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
play_date = scrobble.timestamp.date().strftime("%Y-%m-%d")
|
||||||
|
bgg_id = scrobble.media_obj.bggeek_id
|
||||||
|
|
||||||
|
existing_plays = fetch_existing_bgg_plays(bgg_username)
|
||||||
|
if is_duplicate_bgg_play(existing_plays, bgg_id, play_date):
|
||||||
|
logger.info(
|
||||||
|
"Skipping duplicate BGG play",
|
||||||
|
extra={
|
||||||
|
"bgg_id": bgg_id,
|
||||||
|
"play_date": play_date,
|
||||||
|
"scrobble_id": scrobble.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
login_payload = {
|
login_payload = {
|
||||||
"credentials": {"username": bgg_username, "password": bgg_password}
|
"credentials": {"username": bgg_username, "password": bgg_password}
|
||||||
}
|
}
|
||||||
headers = BASE_HEADERS
|
headers = BASE_HEADERS.copy()
|
||||||
headers["content-type"] = "application/json"
|
headers["content-type"] = "application/json"
|
||||||
|
|
||||||
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
|
|
||||||
|
|
||||||
with requests.Session() as s:
|
with requests.Session() as s:
|
||||||
p = s.post(
|
p = s.post(
|
||||||
"https://boardgamegeek.com/login/api/v1",
|
"https://boardgamegeek.com/login/api/v1",
|
||||||
data=json.dumps(login_payload),
|
data=json.dumps(login_payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
if p.status_code != 200:
|
||||||
|
logger.error(
|
||||||
|
"BGG login failed",
|
||||||
|
extra={"status_code": p.status_code, "response": p.text},
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
if scrobble.log:
|
if scrobble.log:
|
||||||
for player in scrobble.log.get("players"):
|
for player in scrobble.log.get("players", []):
|
||||||
player_person = Person.objects.filter(
|
player_person = Person.objects.filter(
|
||||||
id=player.get("person_id")
|
id=player.get("person_id")
|
||||||
).first()
|
).first()
|
||||||
if player_person.get("bgg_username"):
|
if not player_person:
|
||||||
player["username"] = player_person.get("bgg_username")
|
continue
|
||||||
player["name"] = player_person.get("name")
|
|
||||||
player["win"] = player.get("win")
|
player_data = {
|
||||||
# player["role"] = player.get("role")
|
"name": player_person.name,
|
||||||
player["new"] = player.get("new")
|
"win": player.get("win", False),
|
||||||
player["score"] = player.get("score")
|
"new": player.get("new", False),
|
||||||
players.append(player)
|
"score": player.get("score"),
|
||||||
|
}
|
||||||
|
if player_person.bgg_username:
|
||||||
|
player_data["username"] = player_person.bgg_username
|
||||||
|
players.append(player_data)
|
||||||
|
|
||||||
|
location = None
|
||||||
|
if scrobble.log and scrobble.log.get("location_id"):
|
||||||
|
boardgame_location = BoardGameLocation.objects.filter(
|
||||||
|
id=scrobble.log["location_id"]
|
||||||
|
).first()
|
||||||
|
if boardgame_location:
|
||||||
|
location = boardgame_location.name
|
||||||
|
|
||||||
play_payload = {
|
play_payload = {
|
||||||
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
|
"playdate": play_date,
|
||||||
"length": scrobble.playback_position_seconds / 60,
|
"length": (scrobble.playback_position_seconds or 0) / 60,
|
||||||
"comments": "Uploaded from Vrobbler",
|
"comments": "Uploaded from Vrobbler",
|
||||||
"location": scrobble.log.location or None,
|
"location": location,
|
||||||
"objectid": scrobble.media_obj.bggeek_id,
|
"objectid": bgg_id,
|
||||||
"quantity": "1",
|
"quantity": "1",
|
||||||
"action": "save",
|
"action": "save",
|
||||||
"players": players,
|
"players": players,
|
||||||
@ -154,3 +229,4 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
|
|||||||
data=json.dumps(play_payload),
|
data=json.dumps(play_payload),
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
return r.status_code == 200
|
||||||
|
|||||||
@ -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
|
||||||
47
vrobbler/apps/drinks/fastcork.py
Normal file
47
vrobbler/apps/drinks/fastcork.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FASTCORK_BASE_URL = "https://fastcork.com"
|
||||||
|
FASTCORK_SEARCH_URL = f"{FASTCORK_BASE_URL}/v1/search"
|
||||||
|
|
||||||
|
|
||||||
|
def search_wines(query: str) -> list[dict]:
|
||||||
|
api_key = settings.FASTCORK_API_KEY
|
||||||
|
if not api_key:
|
||||||
|
logger.warning("FASTCORK_API_KEY is not configured")
|
||||||
|
return []
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {"query": query, "lang": "en"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
FASTCORK_SEARCH_URL, headers=headers, json=payload, timeout=10
|
||||||
|
)
|
||||||
|
except requests.RequestException:
|
||||||
|
logger.exception("FastCork search request failed")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
"Bad response from FastCork search",
|
||||||
|
extra={"status_code": response.status_code},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
if not data.get("success"):
|
||||||
|
logger.warning(
|
||||||
|
"FastCork search returned success=false",
|
||||||
|
extra={"response": data},
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
return data.get("results", {}).get("query", [])
|
||||||
0
vrobbler/apps/drinks/management/__init__.py
Normal file
0
vrobbler/apps/drinks/management/__init__.py
Normal file
@ -0,0 +1,474 @@
|
|||||||
|
from bisect import bisect_right
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from drinks.models import BrewingMethod, Coffee, CoffeeRoaster, RoastLevel
|
||||||
|
from scrobbles.models import FavoriteMedia, Scrobble
|
||||||
|
|
||||||
|
BASE_RUN_TIME_SECONDS = 2400
|
||||||
|
|
||||||
|
# French Press (Food ID 10)
|
||||||
|
FOOD_ID_FRENCH_PRESS = 10
|
||||||
|
UNKNOWN_ROAST_TITLE = "Unknown Roast"
|
||||||
|
TIMBERWYCK_ROASTER_ID = 1
|
||||||
|
FRENCH_PRESS_CALORIES = 50
|
||||||
|
FRENCH_PRESS_BASE_TAGS = {"sweet maria", "green coffee"}
|
||||||
|
|
||||||
|
# Medium Roast (Food ID 27)
|
||||||
|
FOOD_ID_MEDIUM_ROAST = 27
|
||||||
|
MEDIUM_ROAST_TITLE = "Medium Roast"
|
||||||
|
MEDIUM_ROAST_ROASTER_NAME = "Dunkin' Donuts"
|
||||||
|
MEDIUM_ROAST_CALORIES = 150
|
||||||
|
MEDIUM_ROAST_BASE_TAGS = {"dunkin"}
|
||||||
|
|
||||||
|
# Lavazza (Food ID 12)
|
||||||
|
FOOD_ID_LAVAZZA = 12
|
||||||
|
LAVAZZA_TITLE = "Qualità Oro"
|
||||||
|
LAVAZZA_ROASTER_NAME = "Lavazza"
|
||||||
|
LAVAZZA_ORIGIN = "Blended"
|
||||||
|
LAVAZZA_CALORIES = 50
|
||||||
|
LAVAZZA_BASE_TAGS = {"lavazza", "espresso"}
|
||||||
|
|
||||||
|
# Drip Coffee (Food ID 61)
|
||||||
|
FOOD_ID_DRIP_COFFEE = 61
|
||||||
|
DRIP_COFFEE_CALORIES = 50
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"Migrate French Press, Medium Roast, and Lavazza food scrobbles "
|
||||||
|
"to Coffee scrobbles."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Show what would be changed without making changes",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("DRY RUN - no changes will be saved\n")
|
||||||
|
)
|
||||||
|
|
||||||
|
# French Press migration
|
||||||
|
self.stdout.write(self.style.NOTICE("=== French Press Coffee ==="))
|
||||||
|
french_migrated, french_skipped = self._migrate_french_press(dry_run)
|
||||||
|
|
||||||
|
# Medium Roast migration
|
||||||
|
self.stdout.write(self.style.NOTICE("\n=== Medium Roast ==="))
|
||||||
|
medium_migrated, medium_skipped = self._migrate_medium_roast(dry_run)
|
||||||
|
|
||||||
|
# Lavazza migration
|
||||||
|
self.stdout.write(self.style.NOTICE("\n=== Lavazza ==="))
|
||||||
|
lavazza_migrated, lavazza_skipped = self._migrate_lavazza(dry_run)
|
||||||
|
|
||||||
|
# Drip Coffee migration
|
||||||
|
self.stdout.write(self.style.NOTICE("\n=== Drip Coffee ==="))
|
||||||
|
drip_migrated, drip_skipped = self._migrate_drip_coffee(dry_run)
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
|
total_migrated = (
|
||||||
|
french_migrated + medium_migrated + lavazza_migrated + drip_migrated
|
||||||
|
)
|
||||||
|
total_skipped = french_skipped + medium_skipped + lavazza_skipped + drip_skipped
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
f"Dry run: would migrate {total_migrated} scrobbles, "
|
||||||
|
f"skip {total_skipped} already migrated. "
|
||||||
|
f"Use without --dry-run to apply."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"Migrated {total_migrated} scrobbles, skipped {total_skipped} "
|
||||||
|
f"already migrated."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _migrate_french_press(self, dry_run):
|
||||||
|
roast_coffees = self._build_roast_coffee_timeline()
|
||||||
|
if not roast_coffees:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
|
||||||
|
)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
unknown_roast = self._get_or_create_unknown_roast(dry_run)
|
||||||
|
food_scrobbles = self._get_food_scrobbles(FOOD_ID_FRENCH_PRESS)
|
||||||
|
favorite_foods = self._get_favorite_foods(FOOD_ID_FRENCH_PRESS)
|
||||||
|
|
||||||
|
timestamps = [ts for ts, _ in roast_coffees]
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
for scrobble in food_scrobbles.iterator():
|
||||||
|
if scrobble.coffee_id is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = bisect_right(timestamps, scrobble.timestamp) - 1
|
||||||
|
if idx >= 0:
|
||||||
|
_, coffee = roast_coffees[idx]
|
||||||
|
else:
|
||||||
|
coffee = unknown_roast
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
log["brewing_method"] = BrewingMethod.FRENCH_PRESS
|
||||||
|
log["roast_level"] = RoastLevel.FULL_CITY_PLUS
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||||
|
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.coffee = coffee
|
||||||
|
scrobble.food = None
|
||||||
|
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
self._migrate_favorites(
|
||||||
|
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
|
||||||
|
)
|
||||||
|
return migrated, skipped
|
||||||
|
|
||||||
|
def _migrate_medium_roast(self, dry_run):
|
||||||
|
coffee = self._get_or_create_medium_roast(dry_run)
|
||||||
|
food_scrobbles = self._get_food_scrobbles(FOOD_ID_MEDIUM_ROAST)
|
||||||
|
favorite_foods = self._get_favorite_foods(FOOD_ID_MEDIUM_ROAST)
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
for scrobble in food_scrobbles.iterator():
|
||||||
|
if scrobble.coffee_id is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
log["brewing_method"] = BrewingMethod.DRIP
|
||||||
|
log["roast_level"] = RoastLevel.FULL_CITY
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||||
|
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.coffee = coffee
|
||||||
|
scrobble.food = None
|
||||||
|
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
self._migrate_medium_roast_favorites(favorite_foods, coffee, dry_run)
|
||||||
|
return migrated, skipped
|
||||||
|
|
||||||
|
def _migrate_lavazza(self, dry_run):
|
||||||
|
coffee = self._get_or_create_lavazza(dry_run)
|
||||||
|
food_scrobbles = self._get_food_scrobbles(FOOD_ID_LAVAZZA)
|
||||||
|
favorite_foods = self._get_favorite_foods(FOOD_ID_LAVAZZA)
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
for scrobble in food_scrobbles.iterator():
|
||||||
|
if scrobble.coffee_id is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
log["brewing_method"] = BrewingMethod.MOKA_POT
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||||
|
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.coffee = coffee
|
||||||
|
scrobble.food = None
|
||||||
|
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
self._migrate_lavazza_favorites(favorite_foods, coffee, dry_run)
|
||||||
|
return migrated, skipped
|
||||||
|
|
||||||
|
def _migrate_lavazza_favorites(self, favorites, coffee, dry_run):
|
||||||
|
updated = 0
|
||||||
|
for fav in favorites:
|
||||||
|
self.stdout.write(
|
||||||
|
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
fav.coffee = coffee
|
||||||
|
fav.food = None
|
||||||
|
fav.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _migrate_drip_coffee(self, dry_run):
|
||||||
|
roast_coffees = self._build_roast_coffee_timeline()
|
||||||
|
if not roast_coffees:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
|
||||||
|
)
|
||||||
|
return 0, 0
|
||||||
|
|
||||||
|
unknown_roast = self._get_or_create_unknown_roast(dry_run)
|
||||||
|
food_scrobbles = self._get_food_scrobbles(FOOD_ID_DRIP_COFFEE)
|
||||||
|
favorite_foods = self._get_favorite_foods(FOOD_ID_DRIP_COFFEE)
|
||||||
|
|
||||||
|
timestamps = [ts for ts, _ in roast_coffees]
|
||||||
|
|
||||||
|
migrated = 0
|
||||||
|
skipped = 0
|
||||||
|
for scrobble in food_scrobbles.iterator():
|
||||||
|
if scrobble.coffee_id is not None:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
idx = bisect_right(timestamps, scrobble.timestamp) - 1
|
||||||
|
if idx >= 0:
|
||||||
|
_, coffee = roast_coffees[idx]
|
||||||
|
else:
|
||||||
|
coffee = unknown_roast
|
||||||
|
|
||||||
|
log = scrobble.log or {}
|
||||||
|
log["brewing_method"] = BrewingMethod.DRIP
|
||||||
|
log["roast_level"] = RoastLevel.CITY
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||||
|
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
scrobble.coffee = coffee
|
||||||
|
scrobble.food = None
|
||||||
|
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
scrobble.log = log
|
||||||
|
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||||
|
migrated += 1
|
||||||
|
|
||||||
|
self._migrate_drip_coffee_favorites(
|
||||||
|
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
|
||||||
|
)
|
||||||
|
return migrated, skipped
|
||||||
|
|
||||||
|
def _migrate_drip_coffee_favorites(
|
||||||
|
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
|
||||||
|
):
|
||||||
|
updated = 0
|
||||||
|
for fav in favorites:
|
||||||
|
idx = bisect_right(timestamps, fav.created) - 1
|
||||||
|
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
fav.coffee = coffee
|
||||||
|
fav.food = None
|
||||||
|
fav.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_or_create_lavazza(self, dry_run):
|
||||||
|
roaster, _ = CoffeeRoaster.objects.get_or_create(name=LAVAZZA_ROASTER_NAME)
|
||||||
|
defaults = {
|
||||||
|
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||||
|
"roaster": roaster,
|
||||||
|
"calories": LAVAZZA_CALORIES,
|
||||||
|
"origin": LAVAZZA_ORIGIN,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
try:
|
||||||
|
return Coffee.objects.get(title=LAVAZZA_TITLE, roaster=roaster)
|
||||||
|
except Coffee.DoesNotExist:
|
||||||
|
return Coffee(title=LAVAZZA_TITLE, **defaults)
|
||||||
|
|
||||||
|
coffee, created = Coffee.objects.get_or_create(
|
||||||
|
title=LAVAZZA_TITLE,
|
||||||
|
roaster=roaster,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
tags = set(LAVAZZA_TITLE.lower().split()) | LAVAZZA_BASE_TAGS
|
||||||
|
coffee.tags.add(*tags)
|
||||||
|
self.stdout.write(f" Created Coffee: '{LAVAZZA_TITLE}' (tags={tags})")
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
def _migrate_medium_roast_favorites(self, favorites, coffee, dry_run):
|
||||||
|
updated = 0
|
||||||
|
for fav in favorites:
|
||||||
|
self.stdout.write(
|
||||||
|
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
fav.coffee = coffee
|
||||||
|
fav.food = None
|
||||||
|
fav.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_roast_coffee_timeline(self):
|
||||||
|
"""Find Task scrobbles where task title='Chore' and log title='Roast coffee'.
|
||||||
|
|
||||||
|
Returns a list of (timestamp, Coffee) sorted by timestamp.
|
||||||
|
"""
|
||||||
|
task_scrobbles = (
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
media_type=Scrobble.MediaType.TASK,
|
||||||
|
task__title="Chore",
|
||||||
|
log__title="Roast coffee",
|
||||||
|
)
|
||||||
|
.exclude(log__notes=None)
|
||||||
|
.exclude(log__notes=[])
|
||||||
|
.order_by("timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
coffees = []
|
||||||
|
for scrobble in task_scrobbles:
|
||||||
|
notes = scrobble.log.get("notes") or []
|
||||||
|
if not notes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_note = notes[0] if isinstance(notes, list) else str(notes)
|
||||||
|
origin, title = self._parse_coffee_name(first_note)
|
||||||
|
coffee = self._get_or_create_coffee(title, origin)
|
||||||
|
coffees.append((scrobble.timestamp, coffee))
|
||||||
|
|
||||||
|
return coffees
|
||||||
|
|
||||||
|
def _parse_coffee_name(self, note):
|
||||||
|
"""Parse 'Mexico - Veracruz Huatusco' into (origin, title)."""
|
||||||
|
if " - " in note:
|
||||||
|
parts = note.split(" - ", 1)
|
||||||
|
return parts[0].strip(), parts[1].strip()
|
||||||
|
return None, note.strip()
|
||||||
|
|
||||||
|
def _get_or_create_coffee(self, title, origin):
|
||||||
|
coffee, created = Coffee.objects.get_or_create(
|
||||||
|
title=title,
|
||||||
|
origin=origin,
|
||||||
|
defaults={
|
||||||
|
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||||
|
"roaster_id": TIMBERWYCK_ROASTER_ID,
|
||||||
|
"calories": FRENCH_PRESS_CALORIES,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
tags = set(title.lower().split()) | FRENCH_PRESS_BASE_TAGS
|
||||||
|
coffee.tags.add(*tags)
|
||||||
|
self.stdout.write(
|
||||||
|
f" Created Coffee: '{title}' (origin={origin}, tags={tags})"
|
||||||
|
)
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
def _get_or_create_unknown_roast(self, dry_run):
|
||||||
|
defaults = {
|
||||||
|
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||||
|
"roaster_id": TIMBERWYCK_ROASTER_ID,
|
||||||
|
"calories": FRENCH_PRESS_CALORIES,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
try:
|
||||||
|
return Coffee.objects.get(title=UNKNOWN_ROAST_TITLE)
|
||||||
|
except Coffee.DoesNotExist:
|
||||||
|
return Coffee(title=UNKNOWN_ROAST_TITLE, **defaults)
|
||||||
|
|
||||||
|
coffee, created = Coffee.objects.get_or_create(
|
||||||
|
title=UNKNOWN_ROAST_TITLE,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
tags = {"unknown", "roast"} | FRENCH_PRESS_BASE_TAGS
|
||||||
|
coffee.tags.add(*tags)
|
||||||
|
self.stdout.write(
|
||||||
|
f" Created Coffee: '{UNKNOWN_ROAST_TITLE}' (tags={tags})"
|
||||||
|
)
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
def _get_or_create_medium_roast(self, dry_run):
|
||||||
|
roaster, _ = CoffeeRoaster.objects.get_or_create(name=MEDIUM_ROAST_ROASTER_NAME)
|
||||||
|
defaults = {
|
||||||
|
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||||
|
"roaster": roaster,
|
||||||
|
"calories": MEDIUM_ROAST_CALORIES,
|
||||||
|
"origin": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
try:
|
||||||
|
return Coffee.objects.get(title=MEDIUM_ROAST_TITLE, roaster=roaster)
|
||||||
|
except Coffee.DoesNotExist:
|
||||||
|
return Coffee(title=MEDIUM_ROAST_TITLE, **defaults)
|
||||||
|
|
||||||
|
coffee, created = Coffee.objects.get_or_create(
|
||||||
|
title=MEDIUM_ROAST_TITLE,
|
||||||
|
roaster=roaster,
|
||||||
|
defaults=defaults,
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
tags = set(MEDIUM_ROAST_TITLE.lower().split()) | MEDIUM_ROAST_BASE_TAGS
|
||||||
|
coffee.tags.add(*tags)
|
||||||
|
self.stdout.write(f" Created Coffee: '{MEDIUM_ROAST_TITLE}' (tags={tags})")
|
||||||
|
return coffee
|
||||||
|
|
||||||
|
def _get_food_scrobbles(self, food_id):
|
||||||
|
return Scrobble.objects.filter(
|
||||||
|
food_id=food_id,
|
||||||
|
media_type=Scrobble.MediaType.FOOD,
|
||||||
|
).order_by("timestamp")
|
||||||
|
|
||||||
|
def _get_favorite_foods(self, food_id):
|
||||||
|
return FavoriteMedia.objects.filter(
|
||||||
|
food_id=food_id,
|
||||||
|
media_type=Scrobble.MediaType.FOOD,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _migrate_favorites(
|
||||||
|
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
|
||||||
|
):
|
||||||
|
updated = 0
|
||||||
|
for fav in favorites:
|
||||||
|
idx = bisect_right(timestamps, fav.created) - 1
|
||||||
|
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||||
|
)
|
||||||
|
if not dry_run:
|
||||||
|
fav.coffee = coffee
|
||||||
|
fav.food = None
|
||||||
|
fav.media_type = Scrobble.MediaType.COFFEE
|
||||||
|
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(
|
||||||
|
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||||
|
)
|
||||||
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal file
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import random
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.utils import timezone
|
||||||
|
from drinks.models import (
|
||||||
|
Beer,
|
||||||
|
BeerProducer,
|
||||||
|
BeerStyle,
|
||||||
|
BrewingMethod,
|
||||||
|
Coffee,
|
||||||
|
CoffeeRoaster,
|
||||||
|
DrinkFormat,
|
||||||
|
RoastLevel,
|
||||||
|
Wine,
|
||||||
|
WineGrape,
|
||||||
|
WineProducer,
|
||||||
|
WineRegion,
|
||||||
|
WineType,
|
||||||
|
)
|
||||||
|
from scrobbles.models import FavoriteMedia, Scrobble
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
# Realistic demo data
|
||||||
|
|
||||||
|
BEER_PRODUCERS = [
|
||||||
|
("Trillium Brewing", "Canton, MA"),
|
||||||
|
("Tree House Brewing", "Charlton, MA"),
|
||||||
|
("The Alchemist", "Stowe, VT"),
|
||||||
|
("Hill Farmstead", "Greensboro, VT"),
|
||||||
|
("Other Half Brewing", "Brooklyn, NY"),
|
||||||
|
("Equilibrium Brewery", "Middletown, NY"),
|
||||||
|
("Monkish Brewing", "Torrance, CA"),
|
||||||
|
("Russian River Brewing", "Santa Rosa, CA"),
|
||||||
|
("Cellar Maker", "San Francisco, CA"),
|
||||||
|
("Definitive Brewing", "Portland, ME"),
|
||||||
|
]
|
||||||
|
|
||||||
|
BEER_STYLES = [
|
||||||
|
"New England IPA",
|
||||||
|
"Hazy Double IPA",
|
||||||
|
"West Coast IPA",
|
||||||
|
"Pale Ale",
|
||||||
|
"Stout",
|
||||||
|
"Imperial Stout",
|
||||||
|
"Porter",
|
||||||
|
"Saison",
|
||||||
|
"Pilsner",
|
||||||
|
"Lager",
|
||||||
|
"Gose",
|
||||||
|
"Sour Ale",
|
||||||
|
"Brown Ale",
|
||||||
|
"Barleywine",
|
||||||
|
"Wheat Ale",
|
||||||
|
]
|
||||||
|
|
||||||
|
BEERS = [
|
||||||
|
("Congress Street", "Trillium Brewing", ["New England IPA"], 6.8, 45),
|
||||||
|
("Julius", "Tree House Brewing", ["New England IPA"], 6.8, 50),
|
||||||
|
("Heady Topper", "The Alchemist", ["New England IPA"], 8.0, 70),
|
||||||
|
("Sip of Sunshine", "Hill Farmstead", ["New England IPA"], 8.0, 60),
|
||||||
|
("All Green Everything", "Other Half Brewing", ["Hazy Double IPA"], 10.0, 100),
|
||||||
|
("Moment of Clarity", "Equilibrium Brewery", ["New England IPA"], 8.5, 55),
|
||||||
|
("Tired Hands Collab", "Monkish Brewing", ["New England IPA"], 7.2, 40),
|
||||||
|
("Pliny the Elder", "Russian River Brewing", ["West Coast IPA"], 8.0, 100),
|
||||||
|
("Density", "Cellar Maker", ["Hazy Double IPA"], 8.2, 65),
|
||||||
|
("Bright Days Ahead", "Definitive Brewing", ["Pale Ale"], 5.5, 35),
|
||||||
|
("Bright", "Tree House Brewing", ["Pale Ale"], 5.2, 30),
|
||||||
|
("King Julius", "Tree House Brewing", ["Hazy Double IPA"], 9.0, 80),
|
||||||
|
("Very Hazy", "Other Half Brewing", ["New England IPA"], 7.5, 45),
|
||||||
|
("Galaxy Dry Hop", "Equilibrium Brewery", ["New England IPA"], 7.0, 50),
|
||||||
|
("DDH Foggier Window", "Monkish Brewing", ["Hazy Double IPA"], 8.0, 70),
|
||||||
|
]
|
||||||
|
|
||||||
|
WINE_PRODUCERS = [
|
||||||
|
("Domaine de la Romanée-Conti", "Burgundy, France"),
|
||||||
|
("Opus One", "Napa Valley, CA"),
|
||||||
|
("Penfolds", "South Australia"),
|
||||||
|
("Cloudy Bay", "Marlborough, New Zealand"),
|
||||||
|
("Antinori", "Tuscany, Italy"),
|
||||||
|
("Vega Sicilia", "Ribera del Duero, Spain"),
|
||||||
|
("Catena Zapata", "Mendoza, Argentina"),
|
||||||
|
("Concha y Toro", "Casablanca Valley, Chile"),
|
||||||
|
("Kim Crawford", "Hawke's Bay, New Zealand"),
|
||||||
|
("Meiomi", "Monterey County, CA"),
|
||||||
|
("Château d'Esclans", "Provence, France"),
|
||||||
|
("Veuve Clicquot", "Reims, France"),
|
||||||
|
("Giacomo Conterno", "Piemonte, Italy"),
|
||||||
|
("Château de Beaucastel", "Rhône, France"),
|
||||||
|
("Dr. Loosen", "Mosel, Germany"),
|
||||||
|
]
|
||||||
|
|
||||||
|
WINE_REGIONS = [
|
||||||
|
("Burgundy", "France"),
|
||||||
|
("Napa Valley", "USA"),
|
||||||
|
("Barossa Valley", "Australia"),
|
||||||
|
("Marlborough", "New Zealand"),
|
||||||
|
("Tuscany", "Italy"),
|
||||||
|
("Ribera del Duero", "Spain"),
|
||||||
|
("Mendoza", "Argentina"),
|
||||||
|
("Casablanca Valley", "Chile"),
|
||||||
|
("Hawke's Bay", "New Zealand"),
|
||||||
|
("Monterey County", "USA"),
|
||||||
|
("Willamette Valley", "USA"),
|
||||||
|
("Rioja", "Spain"),
|
||||||
|
("Piemonte", "Italy"),
|
||||||
|
("Champagne", "France"),
|
||||||
|
("Mosel", "Germany"),
|
||||||
|
("Provence", "France"),
|
||||||
|
("Rhône", "France"),
|
||||||
|
("Reims", "France"),
|
||||||
|
]
|
||||||
|
|
||||||
|
WINE_GRAPES = [
|
||||||
|
"Pinot Noir",
|
||||||
|
"Cabernet Sauvignon",
|
||||||
|
"Merlot",
|
||||||
|
"Syrah",
|
||||||
|
"Grenache",
|
||||||
|
"Chardonnay",
|
||||||
|
"Sauvignon Blanc",
|
||||||
|
"Riesling",
|
||||||
|
"Tempranillo",
|
||||||
|
"Malbec",
|
||||||
|
"Nebbiolo",
|
||||||
|
"Sangiovese",
|
||||||
|
"Zinfandel",
|
||||||
|
"Viognier",
|
||||||
|
"Mourvèdre",
|
||||||
|
]
|
||||||
|
|
||||||
|
WINES = [
|
||||||
|
(
|
||||||
|
"2021 Pinot Noir",
|
||||||
|
"Domaine de la Romanée-Conti",
|
||||||
|
"Burgundy",
|
||||||
|
"Pinot Noir",
|
||||||
|
"red",
|
||||||
|
"2021",
|
||||||
|
4.5,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2019 Cabernet Sauvignon",
|
||||||
|
"Opus One",
|
||||||
|
"Napa Valley",
|
||||||
|
"Cabernet Sauvignon",
|
||||||
|
"red",
|
||||||
|
"2019",
|
||||||
|
4.8,
|
||||||
|
),
|
||||||
|
("2020 Shiraz", "Penfolds", "Barossa Valley", "Syrah", "red", "2020", 4.6),
|
||||||
|
(
|
||||||
|
"2022 Sauvignon Blanc",
|
||||||
|
"Cloudy Bay",
|
||||||
|
"Marlborough",
|
||||||
|
"Sauvignon Blanc",
|
||||||
|
"white",
|
||||||
|
"2022",
|
||||||
|
4.2,
|
||||||
|
),
|
||||||
|
("2020 Tignanello", "Antinori", "Tuscany", "Sangiovese", "red", "2020", 4.7),
|
||||||
|
(
|
||||||
|
"2018 Unico",
|
||||||
|
"Vega Sicilia",
|
||||||
|
"Ribera del Duero",
|
||||||
|
"Tempranillo",
|
||||||
|
"red",
|
||||||
|
"2018",
|
||||||
|
4.9,
|
||||||
|
),
|
||||||
|
("2021 Malbec", "Catena Zapata", "Mendoza", "Malbec", "red", "2021", 4.4),
|
||||||
|
(
|
||||||
|
"2022 Chardonnay",
|
||||||
|
"Concha y Toro",
|
||||||
|
"Casablanca Valley",
|
||||||
|
"Chardonnay",
|
||||||
|
"white",
|
||||||
|
"2022",
|
||||||
|
4.1,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2021 Sauvignon Blanc",
|
||||||
|
"Kim Crawford",
|
||||||
|
"Hawke's Bay",
|
||||||
|
"Sauvignon Blanc",
|
||||||
|
"white",
|
||||||
|
"2021",
|
||||||
|
4.0,
|
||||||
|
),
|
||||||
|
("2020 Pinot Noir", "Meiomi", "Monterey County", "Pinot Noir", "red", "2020", 4.3),
|
||||||
|
("2021 Rosé", "Château d'Esclans", "Provence", "Grenache", "rosé", "2021", 4.2),
|
||||||
|
(
|
||||||
|
"2020 Champagne Brut",
|
||||||
|
"Veuve Clicquot",
|
||||||
|
"Champagne",
|
||||||
|
"Chardonnay",
|
||||||
|
"sparkling",
|
||||||
|
"NV",
|
||||||
|
4.5,
|
||||||
|
),
|
||||||
|
("2019 Barolo", "Giacomo Conterno", "Piemonte", "Nebbiolo", "red", "2019", 4.8),
|
||||||
|
(
|
||||||
|
"2020 Châteauneuf-du-Pape",
|
||||||
|
"Château de Beaucastel",
|
||||||
|
"Rhône",
|
||||||
|
"Grenache",
|
||||||
|
"red",
|
||||||
|
"2020",
|
||||||
|
4.6,
|
||||||
|
),
|
||||||
|
("2021 Riesling Spätlese", "Dr. Loosen", "Mosel", "Riesling", "white", "2021", 4.3),
|
||||||
|
]
|
||||||
|
|
||||||
|
COFFEE_ROASTERS = [
|
||||||
|
("Counter Culture", "Durham, NC"),
|
||||||
|
("Intelligentsia", "Chicago, IL"),
|
||||||
|
("Stumptown", "Portland, OR"),
|
||||||
|
("Blue Bottle", "Oakland, CA"),
|
||||||
|
(
|
||||||
|
"Onyx Coffee Lab",
|
||||||
|
"Rogers, AR",
|
||||||
|
),
|
||||||
|
("Verve", "Santa Cruz, CA"),
|
||||||
|
("Heart", "Portland, OR"),
|
||||||
|
("George Howell", "Boston, MA"),
|
||||||
|
("SEY", "Brooklyn, NY"),
|
||||||
|
("Black & White", "Raleigh, NC"),
|
||||||
|
]
|
||||||
|
|
||||||
|
COFFEES = [
|
||||||
|
("Hologram", "Counter Culture", "Ethiopia", "medium"),
|
||||||
|
("Big Trouble", "Counter Culture", "Colombia", "medium"),
|
||||||
|
("Black Cat Espresso", "Intelligentsia", "Brazil", "medium"),
|
||||||
|
("Hair Bender", "Intelligentsia", "Latin America", "dark"),
|
||||||
|
("Stumptown Hair Bender", "Stumptown", "Latin America", "medium"),
|
||||||
|
("Holler Mountain", "Stumptown", "Ethiopia", "medium"),
|
||||||
|
("Bella Donovan", "Blue Bottle", "Latin America", "medium"),
|
||||||
|
("Three Africas", "Blue Bottle", "Uganda", "light"),
|
||||||
|
("Monarch", "Onyx Coffee Lab", "Ethiopia", "light"),
|
||||||
|
("Geometry", "Onyx Coffee Lab", "Colombia", "medium"),
|
||||||
|
("Buena Vista", "Verve", "Guatemala", "medium"),
|
||||||
|
("Farmhouse", "Verve", "Costa Rica", "medium"),
|
||||||
|
("Decaf", "Heart", "Colombia", "medium"),
|
||||||
|
("Los Altos", "Heart", "Guatemala", "medium"),
|
||||||
|
("Hayes Valley Espresso", "George Howell", "Brazil", "medium"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Populate demo data for the drinks app with realistic scrobbles"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--user",
|
||||||
|
type=str,
|
||||||
|
help="Username to create scrobbles for (default: creates 'demo')",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--days",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="Number of days of history to generate (default: 30)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--clear",
|
||||||
|
action="store_true",
|
||||||
|
help="Clear existing demo data before populating",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
username = options.get("user") or "demo"
|
||||||
|
days = options["days"]
|
||||||
|
clear = options["clear"]
|
||||||
|
|
||||||
|
user, _created = User.objects.get_or_create(
|
||||||
|
username=username,
|
||||||
|
defaults={"email": f"{username}@demo.local"},
|
||||||
|
)
|
||||||
|
if _created:
|
||||||
|
user.set_password("demo")
|
||||||
|
user.save()
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created user '{username}'"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f"Using existing user '{username}'")
|
||||||
|
|
||||||
|
if clear:
|
||||||
|
self.stdout.write("Clearing existing demo drink scrobbles...")
|
||||||
|
Scrobble.objects.filter(
|
||||||
|
user=user,
|
||||||
|
media_type__in=["Beer", "Wine", "Coffee"],
|
||||||
|
).delete()
|
||||||
|
FavoriteMedia.objects.filter(user=user).delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS("Cleared demo data"))
|
||||||
|
|
||||||
|
self.stdout.write("Creating beer styles...")
|
||||||
|
style_objects = {}
|
||||||
|
for style_name in BEER_STYLES:
|
||||||
|
style, _ = BeerStyle.objects.get_or_create(name=style_name)
|
||||||
|
style_objects[style_name] = style
|
||||||
|
|
||||||
|
self.stdout.write("Creating beer producers...")
|
||||||
|
producer_objects = {}
|
||||||
|
for name, location in BEER_PRODUCERS:
|
||||||
|
producer, _ = BeerProducer.objects.get_or_create(
|
||||||
|
name=name, defaults={"location": location}
|
||||||
|
)
|
||||||
|
producer_objects[name] = producer
|
||||||
|
|
||||||
|
self.stdout.write("Creating beers...")
|
||||||
|
beer_objects = []
|
||||||
|
for title, producer_name, styles, abv, ibu in BEERS:
|
||||||
|
beer, _ = Beer.objects.get_or_create(
|
||||||
|
untappd_id=title.lower().replace(" ", "-"),
|
||||||
|
defaults={
|
||||||
|
"title": title,
|
||||||
|
"abv": abv,
|
||||||
|
"ibu": ibu,
|
||||||
|
"producer_id": producer_objects[producer_name].id,
|
||||||
|
"description": f"A delicious {styles[0]} from {producer_name}.",
|
||||||
|
"base_run_time_seconds": 900,
|
||||||
|
"calories": int(abv * 30),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for style_name in styles:
|
||||||
|
beer.styles.add(style_objects[style_name])
|
||||||
|
beer_objects.append(beer)
|
||||||
|
|
||||||
|
self.stdout.write("Creating wine regions...")
|
||||||
|
region_objects = {}
|
||||||
|
for name, country in WINE_REGIONS:
|
||||||
|
region, _ = WineRegion.objects.get_or_create(
|
||||||
|
name=name, defaults={"country": country}
|
||||||
|
)
|
||||||
|
region_objects[name] = region
|
||||||
|
|
||||||
|
self.stdout.write("Creating wine producers...")
|
||||||
|
wine_producer_objects = {}
|
||||||
|
for name, location in WINE_PRODUCERS:
|
||||||
|
producer, _ = WineProducer.objects.get_or_create(
|
||||||
|
name=name, defaults={"location": location}
|
||||||
|
)
|
||||||
|
wine_producer_objects[name] = producer
|
||||||
|
|
||||||
|
self.stdout.write("Creating wine grapes...")
|
||||||
|
grape_objects = {}
|
||||||
|
for name in WINE_GRAPES:
|
||||||
|
grape, _ = WineGrape.objects.get_or_create(name=name)
|
||||||
|
grape_objects[name] = grape
|
||||||
|
|
||||||
|
self.stdout.write("Creating wines...")
|
||||||
|
wine_objects = []
|
||||||
|
for (
|
||||||
|
title,
|
||||||
|
producer_name,
|
||||||
|
region_name,
|
||||||
|
grape_name,
|
||||||
|
wine_type,
|
||||||
|
vintage,
|
||||||
|
rating,
|
||||||
|
) in WINES:
|
||||||
|
wine, _ = Wine.objects.get_or_create(
|
||||||
|
vivino_id=title.lower().replace(" ", "-"),
|
||||||
|
defaults={
|
||||||
|
"title": title,
|
||||||
|
"wine_type": wine_type,
|
||||||
|
"vintage": vintage,
|
||||||
|
"vivino_rating": rating,
|
||||||
|
"producer_id": wine_producer_objects[producer_name].id,
|
||||||
|
"region_id": region_objects[region_name].id,
|
||||||
|
"description": f"A fine {wine_type} from {producer_name}.",
|
||||||
|
"base_run_time_seconds": 3600,
|
||||||
|
"calories": 125,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
wine.grapes.add(grape_objects[grape_name])
|
||||||
|
wine_objects.append(wine)
|
||||||
|
|
||||||
|
self.stdout.write("Creating coffee roasters...")
|
||||||
|
roaster_objects = {}
|
||||||
|
for name, location in COFFEE_ROASTERS:
|
||||||
|
roaster, _ = CoffeeRoaster.objects.get_or_create(
|
||||||
|
name=name, defaults={"location": location}
|
||||||
|
)
|
||||||
|
roaster_objects[name] = roaster
|
||||||
|
|
||||||
|
self.stdout.write("Creating coffees...")
|
||||||
|
coffee_objects = []
|
||||||
|
for title, roaster_name, origin, _roast_level in COFFEES:
|
||||||
|
coffee, _ = Coffee.objects.get_or_create(
|
||||||
|
roastdb_id=title.lower().replace(" ", "-"),
|
||||||
|
defaults={
|
||||||
|
"title": title,
|
||||||
|
"origin": origin,
|
||||||
|
"roaster_id": roaster_objects[roaster_name].id,
|
||||||
|
"description": f"Specialty coffee from {roaster_name}.",
|
||||||
|
"base_run_time_seconds": 1200,
|
||||||
|
"calories": 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
coffee_objects.append(coffee)
|
||||||
|
|
||||||
|
self.stdout.write("Creating scrobbles...")
|
||||||
|
now = timezone.now()
|
||||||
|
scrobbles_created = 0
|
||||||
|
|
||||||
|
for day_offset in range(days):
|
||||||
|
day = now - timedelta(days=day_offset)
|
||||||
|
|
||||||
|
# Random number of drinks per day (1-5)
|
||||||
|
num_drinks = random.randint(1, 5)
|
||||||
|
for _ in range(num_drinks):
|
||||||
|
# Pick a random drink type weighted toward beers
|
||||||
|
drink_type = random.choices(
|
||||||
|
["beer", "wine", "coffee"], weights=[50, 25, 25]
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
if drink_type == "beer":
|
||||||
|
media = random.choice(beer_objects)
|
||||||
|
media_type = Scrobble.MediaType.BEER
|
||||||
|
source = random.choice(["Untappd", "Vrobbler"])
|
||||||
|
log_data = {
|
||||||
|
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||||
|
"format": random.choice(
|
||||||
|
[
|
||||||
|
DrinkFormat.ALUMINUM_CAN,
|
||||||
|
DrinkFormat.GLASS_BOTTLE,
|
||||||
|
DrinkFormat.DRAFT,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"size_ml": random.choice([330, 355, 473, 500]),
|
||||||
|
}
|
||||||
|
elif drink_type == "wine":
|
||||||
|
media = random.choice(wine_objects)
|
||||||
|
media_type = Scrobble.MediaType.WINE
|
||||||
|
source = random.choice(["Vivino", "Vrobbler"])
|
||||||
|
log_data = {
|
||||||
|
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||||
|
"format": random.choice(
|
||||||
|
[
|
||||||
|
DrinkFormat.GLASS_BOTTLE,
|
||||||
|
DrinkFormat.BY_THE_GLASS,
|
||||||
|
DrinkFormat.BOX,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"size_ml": random.choice([150, 375, 750]),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
media = random.choice(coffee_objects)
|
||||||
|
media_type = Scrobble.MediaType.COFFEE
|
||||||
|
source = random.choice(["RoastDB", "Vrobbler"])
|
||||||
|
log_data = {
|
||||||
|
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||||
|
"format": random.choice(
|
||||||
|
[
|
||||||
|
DrinkFormat.GLASS_BOTTLE,
|
||||||
|
DrinkFormat.ALUMINUM_CAN,
|
||||||
|
DrinkFormat.BY_THE_GLASS,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
"size_ml": random.choice([240, 355, 473]),
|
||||||
|
"roast_level": random.choice(RoastLevel.values),
|
||||||
|
"brewing_method": random.choice(BrewingMethod.values),
|
||||||
|
"single_origin_or_blend": random.choice(
|
||||||
|
["single origin", "blend"]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Random time during the day
|
||||||
|
hour = random.randint(7, 22)
|
||||||
|
minute = random.randint(0, 59)
|
||||||
|
timestamp = day.replace(
|
||||||
|
hour=hour, minute=minute, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the FK field name from the media class name
|
||||||
|
fk_field = f"{media.__class__.__name__[0].lower()}{media.__class__.__name__[1:]}"
|
||||||
|
# Simpler: just lowercase the class name
|
||||||
|
fk_field = media.__class__.__name__.lower()
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
user=user,
|
||||||
|
media_type=media_type,
|
||||||
|
timestamp=timestamp,
|
||||||
|
playback_position_seconds=media.base_run_time_seconds or 900,
|
||||||
|
played_to_completion=True,
|
||||||
|
source=source,
|
||||||
|
log=log_data,
|
||||||
|
**{fk_field: media},
|
||||||
|
)
|
||||||
|
scrobbles_created += 1
|
||||||
|
|
||||||
|
# Some scrobbles get favorited
|
||||||
|
if random.random() < 0.15:
|
||||||
|
FavoriteMedia.toggle(media, user)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f"\nDone! Created:\n"
|
||||||
|
f" {len(beer_objects)} beers\n"
|
||||||
|
f" {len(wine_objects)} wines\n"
|
||||||
|
f" {len(coffee_objects)} coffees\n"
|
||||||
|
f" {scrobbles_created} scrobbles over {days} days\n"
|
||||||
|
f" User: {username} / demo"
|
||||||
|
)
|
||||||
|
)
|
||||||
@ -1,9 +1,10 @@
|
|||||||
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
# 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,0 +1,43 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-15 00:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("drinks", "0010_alter_beerproducer_options_alter_beerstyle_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="abv",
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="aroma",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="decanting_time_minutes",
|
||||||
|
field=models.SmallIntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="food_pairing",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="serving_temperature_max_celsius",
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="wine",
|
||||||
|
name="serving_temperature_min_celsius",
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
662
vrobbler/apps/drinks/models.py
Normal file
662
vrobbler/apps/drinks/models.py
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.apps import apps
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django_extensions.db.models import TimeStampedModel
|
||||||
|
from drinks.cellartracker import get_wine_from_cellartracker_id
|
||||||
|
from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb
|
||||||
|
from drinks.untappd import get_beer_from_untappd_id
|
||||||
|
from drinks.vivino import get_wine_from_vivino_id
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import BaseLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
class WineType(models.TextChoices):
|
||||||
|
RED = "red", "Red"
|
||||||
|
WHITE = "white", "White"
|
||||||
|
ROSE = "rosé", "Rosé"
|
||||||
|
SPARKLING = "sparkling", "Sparkling"
|
||||||
|
DESSERT = "dessert", "Dessert"
|
||||||
|
FORTIFIED = "fortified", "Fortified"
|
||||||
|
ORANGE = "orange", "Orange"
|
||||||
|
|
||||||
|
|
||||||
|
class DrinkFormat(models.TextChoices):
|
||||||
|
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
||||||
|
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
||||||
|
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
||||||
|
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
||||||
|
KEG = "keg", "Keg"
|
||||||
|
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
GROWLER = "growler", "Growler"
|
||||||
|
BOX = "box", "Box"
|
||||||
|
MUG = "mug", "Mug"
|
||||||
|
|
||||||
|
|
||||||
|
class RoastLevel(models.TextChoices):
|
||||||
|
CINNAMON = "cinnamon", "Cinnamon"
|
||||||
|
CITY = "city", "City"
|
||||||
|
CITY_PLUS = "city_plus", "City+"
|
||||||
|
FULL_CITY = "full_city", "Full City"
|
||||||
|
FULL_CITY_PLUS = "full_city_plus", "Full City+"
|
||||||
|
VIENNA = "vienna", "Vienna"
|
||||||
|
FRENCH = "french", "French"
|
||||||
|
SPANISH = "spanish", "Spanish"
|
||||||
|
|
||||||
|
|
||||||
|
class BrewingMethod(models.TextChoices):
|
||||||
|
POUROVER = "pourover", "Pour-over"
|
||||||
|
DRIP = "drip", "Drip"
|
||||||
|
FRENCH_PRESS = "french_press", "French Press"
|
||||||
|
ESPRESSO = "espresso", "Espresso"
|
||||||
|
AEROPRESS = "aeropress", "AeroPress"
|
||||||
|
KEURIG = "keurig", "Keurig"
|
||||||
|
NESCAPRESSO = "nespresso", "Nespresso"
|
||||||
|
COLD_BREW = "cold_brew", "Cold Brew"
|
||||||
|
MOKA_POT = "moka_pot", "Moka Pot"
|
||||||
|
SIPHON = "siphon", "Siphon"
|
||||||
|
TURKISH = "turkish", "Turkish"
|
||||||
|
CHEMEX = "chemex", "Chemex"
|
||||||
|
|
||||||
|
|
||||||
|
ML_PER_OZ = 29.5735
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DrinkLogData(BaseLogData):
|
||||||
|
rating: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
size_ml: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size_oz(self) -> Optional[float]:
|
||||||
|
if self.size_ml:
|
||||||
|
return round(float(self.size_ml) / ML_PER_OZ, 1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format_display(self) -> str:
|
||||||
|
if self.format:
|
||||||
|
return DrinkFormat(self.format).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def override_fields(cls) -> dict:
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
return {
|
||||||
|
"format": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(DrinkFormat.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BeerLogData(DrinkLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WineLogData(DrinkLogData):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoffeeLogData(DrinkLogData):
|
||||||
|
roast_level: Optional[str] = None
|
||||||
|
brewing_method: Optional[str] = None
|
||||||
|
single_origin_or_blend: Optional[str] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def roast_level_display(self) -> str:
|
||||||
|
if self.roast_level:
|
||||||
|
return RoastLevel(self.roast_level).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brewing_method_display(self) -> str:
|
||||||
|
if self.brewing_method:
|
||||||
|
return BrewingMethod(self.brewing_method).label
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def override_fields(cls) -> dict:
|
||||||
|
from django import forms
|
||||||
|
|
||||||
|
fields = super().override_fields()
|
||||||
|
fields.update(
|
||||||
|
{
|
||||||
|
"roast_level": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(RoastLevel.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
"brewing_method": forms.ChoiceField(
|
||||||
|
choices=[("", "---------")] + list(BrewingMethod.choices),
|
||||||
|
required=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class Drink(ScrobblableMixin):
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
is_alcoholic = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "drinks_drink"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title or "Drink"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create_water(cls) -> "Drink":
|
||||||
|
water, _ = cls.objects.get_or_create(
|
||||||
|
title="Water", defaults={"calories": 0, "base_run_time_seconds": 120}
|
||||||
|
)
|
||||||
|
return water
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:drink_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return DrinkLogData
|
||||||
|
|
||||||
|
|
||||||
|
class BeerStyle(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beerstyle"
|
||||||
|
|
||||||
|
|
||||||
|
class BeerProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||||
|
return cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beerproducer"
|
||||||
|
|
||||||
|
|
||||||
|
class Beer(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
ibu = models.SmallIntegerField(**BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||||
|
untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)
|
||||||
|
untappd_image_small = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
untappd_image_medium = ImageSpecField(
|
||||||
|
source="untappd_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
untappd_rating = models.FloatField(**BNULL)
|
||||||
|
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.title} by {self.producer}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.producer.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def beeradvocate_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.producer and self.beeradvocate_id:
|
||||||
|
if self.beeradvocate_id:
|
||||||
|
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def untappd_link(self) -> str:
|
||||||
|
link = ""
|
||||||
|
if self.untappd_id:
|
||||||
|
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||||
|
return link
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
url = ""
|
||||||
|
if self.untappd_image:
|
||||||
|
url = self.untappd_image.url
|
||||||
|
return url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return BeerLogData
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||||
|
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||||
|
|
||||||
|
if not beer:
|
||||||
|
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||||
|
producer_dict = {}
|
||||||
|
style_ids = []
|
||||||
|
for key in list(beer_dict.keys()):
|
||||||
|
if "producer__" in key:
|
||||||
|
pkey = key.replace("producer__", "")
|
||||||
|
producer_dict[pkey] = beer_dict.pop(key)
|
||||||
|
if "styles" in key:
|
||||||
|
for style in beer_dict.pop("styles"):
|
||||||
|
style_inst, created = BeerStyle.objects.get_or_create(
|
||||||
|
name=style
|
||||||
|
)
|
||||||
|
style_ids.append(style_inst.id)
|
||||||
|
|
||||||
|
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||||
|
beer_dict["producer_id"] = producer.id
|
||||||
|
beer = Beer.objects.create(**beer_dict)
|
||||||
|
for style_id in style_ids:
|
||||||
|
beer.styles.add(style_id)
|
||||||
|
|
||||||
|
return beer
|
||||||
|
|
||||||
|
def scrobbles(self, user_id):
|
||||||
|
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||||
|
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||||
|
"-timestamp"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "beers_beer"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Wine models ---
|
||||||
|
|
||||||
|
|
||||||
|
class WineGrape(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class WineRegion(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
country = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class WineProducer(TimeStampedModel):
|
||||||
|
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
location = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Wine(ScrobblableMixin):
|
||||||
|
description = models.TextField(**BNULL)
|
||||||
|
calories = models.PositiveIntegerField(**BNULL)
|
||||||
|
non_alcoholic = models.BooleanField(default=False)
|
||||||
|
wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)
|
||||||
|
grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)
|
||||||
|
region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
|
vintage = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
vivino_rating = models.FloatField(**BNULL)
|
||||||
|
vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)
|
||||||
|
vivino_image_small = ImageSpecField(
|
||||||
|
source="vivino_image",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
vivino_image_medium = ImageSpecField(
|
||||||
|
source="vivino_image",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
cellartracker_rating = models.FloatField(**BNULL)
|
||||||
|
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)
|
||||||
|
abv = models.FloatField(**BNULL)
|
||||||
|
aroma = models.TextField(**BNULL)
|
||||||
|
food_pairing = models.TextField(**BNULL)
|
||||||
|
serving_temperature_min_celsius = models.FloatField(**BNULL)
|
||||||
|
serving_temperature_max_celsius = models.FloatField(**BNULL)
|
||||||
|
decanting_time_minutes = models.SmallIntegerField(**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
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create_from_search(cls, wine_data: dict) -> "Wine":
|
||||||
|
title = wine_data.get("full_wine_name", "")
|
||||||
|
producer_name = wine_data.get("winery", "")
|
||||||
|
|
||||||
|
wine = None
|
||||||
|
if title and producer_name:
|
||||||
|
wine = cls.objects.filter(title=title, producer__name=producer_name).first()
|
||||||
|
elif title:
|
||||||
|
wine = cls.objects.filter(title=title).first()
|
||||||
|
|
||||||
|
if wine:
|
||||||
|
return wine
|
||||||
|
|
||||||
|
producer = None
|
||||||
|
if producer_name:
|
||||||
|
producer, _ = WineProducer.objects.get_or_create(
|
||||||
|
name=producer_name,
|
||||||
|
defaults={
|
||||||
|
"description": wine_data.get("winery_description", ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not producer.description and wine_data.get("winery_description"):
|
||||||
|
producer.description = wine_data["winery_description"]
|
||||||
|
producer.save()
|
||||||
|
|
||||||
|
region = None
|
||||||
|
region_name = wine_data.get("region", "")
|
||||||
|
if region_name:
|
||||||
|
region, _ = WineRegion.objects.get_or_create(name=region_name)
|
||||||
|
|
||||||
|
wine = cls.objects.create(
|
||||||
|
title=title,
|
||||||
|
description=wine_data.get("tasting_notes", ""),
|
||||||
|
wine_type=wine_data.get("wine_type", "").lower(),
|
||||||
|
vintage=wine_data.get("vintage", ""),
|
||||||
|
producer=producer,
|
||||||
|
region=region,
|
||||||
|
abv=wine_data.get("alc_percentage"),
|
||||||
|
aroma=wine_data.get("aroma", ""),
|
||||||
|
food_pairing=wine_data.get("food_pairing", ""),
|
||||||
|
serving_temperature_min_celsius=wine_data.get(
|
||||||
|
"serving_temperature_celcius_range", {}
|
||||||
|
).get("min_temp"),
|
||||||
|
serving_temperature_max_celsius=wine_data.get(
|
||||||
|
"serving_temperature_celcius_range", {}
|
||||||
|
).get("max_temp"),
|
||||||
|
decanting_time_minutes=wine_data.get("decanting_time_minutes"),
|
||||||
|
)
|
||||||
|
|
||||||
|
grape_variety = wine_data.get("grape_variety", "")
|
||||||
|
if grape_variety:
|
||||||
|
for grape_name in grape_variety.split(","):
|
||||||
|
grape_name = grape_name.strip()
|
||||||
|
if grape_name:
|
||||||
|
grape, _ = WineGrape.objects.get_or_create(name=grape_name)
|
||||||
|
wine.grapes.add(grape)
|
||||||
|
|
||||||
|
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
|
||||||
47
vrobbler/apps/drinks/urls.py
Normal file
47
vrobbler/apps/drinks/urls.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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/search/",
|
||||||
|
views.WineSearchView.as_view(),
|
||||||
|
name="wine_search",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"wines/scrobble-from-search/",
|
||||||
|
views.WineScrobbleFromSearchView.as_view(),
|
||||||
|
name="wine_scrobble_from_search",
|
||||||
|
),
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
||||||
124
vrobbler/apps/drinks/views.py
Normal file
124
vrobbler/apps/drinks/views.py
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.views import View
|
||||||
|
from django.views.generic import TemplateView
|
||||||
|
from drinks.fastcork import search_wines
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class WineSearchView(TemplateView):
|
||||||
|
template_name = "drinks/wine_search.html"
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
query = self.request.GET.get("q", "").strip()
|
||||||
|
context["query"] = query
|
||||||
|
|
||||||
|
if query:
|
||||||
|
results = search_wines(query)
|
||||||
|
context["results"] = results
|
||||||
|
self.request.session["wine_search_results"] = results
|
||||||
|
self.request.session["wine_search_query"] = query
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class WineScrobbleFromSearchView(View):
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
messages.error(request, "You must be logged in to scrobble wine.")
|
||||||
|
return HttpResponseRedirect("/")
|
||||||
|
|
||||||
|
wine_index = request.POST.get("wine_index")
|
||||||
|
query = request.POST.get("query", "")
|
||||||
|
results = request.session.get("wine_search_results", [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
wine_index = int(wine_index)
|
||||||
|
wine_data = results[wine_index]
|
||||||
|
except (TypeError, IndexError, ValueError):
|
||||||
|
messages.error(request, "Invalid wine selection.")
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("drinks:wine_search") + f"?q={query}" if query else "/"
|
||||||
|
)
|
||||||
|
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
wine = Wine.find_or_create_from_search(wine_data)
|
||||||
|
|
||||||
|
if not wine:
|
||||||
|
messages.error(request, "Could not create wine from search result.")
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("drinks:wine_search") + f"?q={query}" if query else "/"
|
||||||
|
)
|
||||||
|
|
||||||
|
scrobble_dict = {
|
||||||
|
"user_id": request.user.id,
|
||||||
|
"timestamp": timezone.now(),
|
||||||
|
"playback_position_seconds": 0,
|
||||||
|
"source": "FastCork",
|
||||||
|
}
|
||||||
|
scrobble = Scrobble.create_or_update(wine, request.user.id, scrobble_dict)
|
||||||
|
|
||||||
|
if scrobble:
|
||||||
|
return HttpResponseRedirect(scrobble.redirect_url(request.user.id))
|
||||||
|
|
||||||
|
messages.error(request, "Failed to create scrobble.")
|
||||||
|
return HttpResponseRedirect(reverse("drinks:wine_list"))
|
||||||
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
|
||||||
0
vrobbler/apps/nature/__init__.py
Normal file
0
vrobbler/apps/nature/__init__.py
Normal file
14
vrobbler/apps/nature/admin.py
Normal file
14
vrobbler/apps/nature/admin.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from nature.models import SpeciesObservation
|
||||||
|
from scrobbles.admin import ScrobbleInline
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SpeciesObservation)
|
||||||
|
class SpeciesObservationAdmin(admin.ModelAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = ("uuid", "title", "scientific_name", "taxon_id")
|
||||||
|
ordering = ("-created",)
|
||||||
|
search_fields = ("title", "scientific_name")
|
||||||
|
inlines = [
|
||||||
|
ScrobbleInline,
|
||||||
|
]
|
||||||
0
vrobbler/apps/nature/api/__init__.py
Normal file
0
vrobbler/apps/nature/api/__init__.py
Normal file
8
vrobbler/apps/nature/api/serializers.py
Normal file
8
vrobbler/apps/nature/api/serializers.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from nature.models import SpeciesObservation
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
|
||||||
|
class SpeciesObservationSerializer(serializers.HyperlinkedModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SpeciesObservation
|
||||||
|
fields = "__all__"
|
||||||
9
vrobbler/apps/nature/api/views.py
Normal file
9
vrobbler/apps/nature/api/views.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
from nature import models
|
||||||
|
from nature.api import serializers
|
||||||
|
from rest_framework import permissions, viewsets
|
||||||
|
|
||||||
|
|
||||||
|
class SpeciesObservationViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = models.SpeciesObservation.objects.all().order_by("-created")
|
||||||
|
serializer_class = serializers.SpeciesObservationSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
5
vrobbler/apps/nature/apps.py
Normal file
5
vrobbler/apps/nature/apps.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class NatureConfig(AppConfig):
|
||||||
|
name = "nature"
|
||||||
312
vrobbler/apps/nature/importer.py
Normal file
312
vrobbler/apps/nature/importer.py
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.utils.dateparse import parse_datetime
|
||||||
|
from nature.models import SpeciesObservation, SpeciesObservationLogData
|
||||||
|
from scrobbles.models import Scrobble
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
INATURALIST_ATOM_URL = "https://www.inaturalist.org/observations.atom"
|
||||||
|
INATURALIST_API_URL = "https://api.inaturalist.org/v1/observations"
|
||||||
|
ATOM_NS = "http://www.w3.org/2005/Atom"
|
||||||
|
GEORSS_NS = "http://www.georss.org/georss"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_atom_feed(username: str) -> str:
|
||||||
|
"""Fetch the Atom feed for a given iNaturalist username."""
|
||||||
|
url = (
|
||||||
|
f"{INATURALIST_ATOM_URL}?verifiable=any&page=1&spam=&place_id=any"
|
||||||
|
f"&user_id={username}&project_id="
|
||||||
|
)
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
|
||||||
|
def parse_atom_entries(xml_text: str) -> list[dict]:
|
||||||
|
"""Parse Atom XML and extract observation entries."""
|
||||||
|
root = ET.fromstring(xml_text)
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
for entry in root.findall(f"{{{ATOM_NS}}}entry"):
|
||||||
|
entry_id = entry.findtext(f"{{{ATOM_NS}}}id", "")
|
||||||
|
observation_id = entry_id.split("/")[-1] if "/" in entry_id else entry_id
|
||||||
|
|
||||||
|
title = entry.findtext(f"{{{ATOM_NS}}}title", "")
|
||||||
|
published = entry.findtext(f"{{{ATOM_NS}}}published", "")
|
||||||
|
updated = entry.findtext(f"{{{ATOM_NS}}}updated", "")
|
||||||
|
|
||||||
|
content_el = entry.find(f"{{{ATOM_NS}}}content")
|
||||||
|
photo_urls = []
|
||||||
|
if content_el is not None and content_el.text:
|
||||||
|
photo_urls = re.findall(r'src="([^"]+)"', content_el.text)
|
||||||
|
|
||||||
|
georss_point = entry.findtext(f"{{{GEORSS_NS}}}point", "")
|
||||||
|
georss_feature = entry.findtext(f"{{{GEORSS_NS}}}featureName", "")
|
||||||
|
|
||||||
|
lat, lon = None, None
|
||||||
|
if georss_point:
|
||||||
|
parts = georss_point.strip().split()
|
||||||
|
if len(parts) == 2:
|
||||||
|
lat, lon = float(parts[0]), float(parts[1])
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
{
|
||||||
|
"observation_id": int(observation_id),
|
||||||
|
"title": title,
|
||||||
|
"published": published,
|
||||||
|
"updated": updated,
|
||||||
|
"photo_urls": photo_urls,
|
||||||
|
"lat": lat,
|
||||||
|
"lon": lon,
|
||||||
|
"place_guess": georss_feature,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_observation_detail(observation_id: int) -> dict:
|
||||||
|
"""Fetch detailed observation data from the iNaturalist REST API."""
|
||||||
|
url = f"{INATURALIST_API_URL}/{observation_id}"
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
if data.get("results"):
|
||||||
|
return data["results"][0]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def download_photo(url: str) -> tuple[str, bytes] | None:
|
||||||
|
"""Download a photo and return (filename, content)."""
|
||||||
|
try:
|
||||||
|
response = requests.get(url, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
filename = url.split("/")[-1].split("?")[0]
|
||||||
|
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
|
||||||
|
filename = "observation.jpg"
|
||||||
|
return filename, response.content
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to download photo: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _update_species_photo(species: SpeciesObservation, taxon: dict) -> None:
|
||||||
|
"""Download and save the default taxon photo for a species, if needed."""
|
||||||
|
if species.photo is not None:
|
||||||
|
return
|
||||||
|
default_photo = taxon.get("default_photo", {})
|
||||||
|
photo_url = default_photo.get("url")
|
||||||
|
if not photo_url:
|
||||||
|
return
|
||||||
|
photo_url = photo_url.replace("square", "medium")
|
||||||
|
downloaded = download_photo(photo_url)
|
||||||
|
if downloaded:
|
||||||
|
filename, content = downloaded
|
||||||
|
species.photo.save(filename, ContentFile(content), save=True)
|
||||||
|
|
||||||
|
|
||||||
|
def import_observations_for_user(user) -> list[int]:
|
||||||
|
"""Import iNaturalist observations for a single user.
|
||||||
|
|
||||||
|
Returns a list of created Scrobble IDs.
|
||||||
|
"""
|
||||||
|
profile = getattr(user, "profile", None)
|
||||||
|
if not profile or not profile.inaturalist_username:
|
||||||
|
logger.info(f"No iNaturalist username for user {user}, skipping")
|
||||||
|
return []
|
||||||
|
|
||||||
|
username = profile.inaturalist_username
|
||||||
|
logger.info(f"Importing iNaturalist observations for {username}")
|
||||||
|
|
||||||
|
created_scrobble_ids = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
xml_text = fetch_atom_feed(username)
|
||||||
|
entries = parse_atom_entries(xml_text)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(f"Failed to fetch/parse Atom feed for {username}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
for entry_data in entries:
|
||||||
|
try:
|
||||||
|
scrobble_id = _process_entry(user, entry_data)
|
||||||
|
if scrobble_id:
|
||||||
|
created_scrobble_ids.append(scrobble_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
f"Failed to process observation {entry_data['observation_id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Imported {len(created_scrobble_ids)} observations for {username}")
|
||||||
|
return created_scrobble_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _process_entry(user, entry_data: dict) -> int | None:
|
||||||
|
"""Process a single Atom entry into a SpeciesObservation and Scrobble.
|
||||||
|
|
||||||
|
Each iNaturalist observation becomes a new Scrobble. If a Scrobble
|
||||||
|
already exists for this observation, check whether the title or
|
||||||
|
species data has changed and update in place.
|
||||||
|
|
||||||
|
Returns the Scrobble ID.
|
||||||
|
"""
|
||||||
|
observation_id = entry_data["observation_id"]
|
||||||
|
entry_title = entry_data["title"]
|
||||||
|
|
||||||
|
existing_scrobble = Scrobble.objects.filter(
|
||||||
|
species_observation__isnull=False,
|
||||||
|
user=user,
|
||||||
|
source_id=str(observation_id),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
detail = fetch_observation_detail(observation_id)
|
||||||
|
|
||||||
|
species = None
|
||||||
|
taxon = detail.get("taxon", {})
|
||||||
|
if taxon:
|
||||||
|
common_name = taxon.get("preferred_common_name", entry_title)
|
||||||
|
scientific_name = taxon.get("name", "")
|
||||||
|
taxon_id = taxon.get("id")
|
||||||
|
iconic_taxon_name = taxon.get("iconic_taxon_name", "")
|
||||||
|
taxon_rank = taxon.get("rank", "")
|
||||||
|
wikipedia_url = taxon.get("wikipedia_url", "") or ""
|
||||||
|
if taxon_id and common_name:
|
||||||
|
species = SpeciesObservation.find_or_create(
|
||||||
|
common_name,
|
||||||
|
scientific_name,
|
||||||
|
taxon_id,
|
||||||
|
iconic_taxon_name=iconic_taxon_name,
|
||||||
|
rank=taxon_rank,
|
||||||
|
wikipedia_url=wikipedia_url,
|
||||||
|
)
|
||||||
|
_update_species_photo(species, taxon)
|
||||||
|
changed_fields = []
|
||||||
|
if species.title != common_name:
|
||||||
|
species.title = common_name
|
||||||
|
changed_fields.append("title")
|
||||||
|
if species.iconic_taxon_name != iconic_taxon_name:
|
||||||
|
species.iconic_taxon_name = iconic_taxon_name
|
||||||
|
changed_fields.append("iconic_taxon_name")
|
||||||
|
if species.rank != taxon_rank:
|
||||||
|
species.rank = taxon_rank
|
||||||
|
changed_fields.append("rank")
|
||||||
|
if species.wikipedia_url != wikipedia_url:
|
||||||
|
species.wikipedia_url = wikipedia_url
|
||||||
|
changed_fields.append("wikipedia_url")
|
||||||
|
if changed_fields:
|
||||||
|
species.save(update_fields=changed_fields)
|
||||||
|
|
||||||
|
quality_grade = detail.get("quality_grade", "")
|
||||||
|
place_guess = detail.get("place_guess", entry_data.get("place_guess", ""))
|
||||||
|
description = detail.get("description", "")
|
||||||
|
|
||||||
|
coords = detail.get("geojson", {}).get("coordinates")
|
||||||
|
if coords and len(coords) == 2:
|
||||||
|
lon, lat = coords[0], coords[1]
|
||||||
|
else:
|
||||||
|
lat = entry_data.get("lat")
|
||||||
|
lon = entry_data.get("lon")
|
||||||
|
|
||||||
|
observed_at = detail.get("time_observed_at") or detail.get("observed_on")
|
||||||
|
|
||||||
|
photo_url = entry_data["photo_urls"][0] if entry_data["photo_urls"] else None
|
||||||
|
|
||||||
|
all_photo_urls = entry_data.get("photo_urls", [])
|
||||||
|
if detail.get("photos"):
|
||||||
|
all_photo_urls = [
|
||||||
|
p.get("url", "").replace("square", "medium") for p in detail["photos"]
|
||||||
|
]
|
||||||
|
|
||||||
|
uri = detail.get("uri", "")
|
||||||
|
identifications_count = detail.get("identifications_count", 0)
|
||||||
|
|
||||||
|
log_data = SpeciesObservationLogData(
|
||||||
|
observation_id=observation_id,
|
||||||
|
photo_urls=all_photo_urls,
|
||||||
|
quality_grade=quality_grade,
|
||||||
|
place_guess=place_guess,
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
description=description or "",
|
||||||
|
uri=uri or "",
|
||||||
|
identifications_count=identifications_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_scrobble:
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if species and existing_scrobble.species_observation_id != species.id:
|
||||||
|
existing_scrobble.species_observation = species
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if existing_scrobble.log.get("place_guess") != place_guess:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if existing_scrobble.log.get("quality_grade") != quality_grade:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if existing_scrobble.log.get("photo_urls") != all_photo_urls:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if existing_scrobble.log.get("uri") != uri:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if existing_scrobble.log.get("identifications_count") != identifications_count:
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
logger.debug(f"Observation {observation_id} unchanged, skipping")
|
||||||
|
return existing_scrobble.id
|
||||||
|
|
||||||
|
logger.info(f"Observation {observation_id} updated, refreshing scrobble")
|
||||||
|
existing_scrobble.log = log_data.asdict
|
||||||
|
existing_scrobble.save(update_fields=["log", "species_observation"])
|
||||||
|
return existing_scrobble.id
|
||||||
|
|
||||||
|
timestamp = parse_datetime(observed_at) if observed_at else None
|
||||||
|
stop_timestamp = timestamp + timedelta(minutes=5) if timestamp else None
|
||||||
|
|
||||||
|
scrobble = Scrobble.objects.create(
|
||||||
|
user=user,
|
||||||
|
species_observation=species,
|
||||||
|
media_type=Scrobble.MediaType.SPECIES_OBSERVATION,
|
||||||
|
source="iNaturalist",
|
||||||
|
source_id=str(observation_id),
|
||||||
|
timestamp=timestamp,
|
||||||
|
stop_timestamp=stop_timestamp,
|
||||||
|
played_to_completion=True,
|
||||||
|
in_progress=False,
|
||||||
|
log=log_data.asdict,
|
||||||
|
)
|
||||||
|
|
||||||
|
if photo_url:
|
||||||
|
downloaded = download_photo(photo_url)
|
||||||
|
if downloaded:
|
||||||
|
filename, content = downloaded
|
||||||
|
scrobble.screenshot.save(filename, ContentFile(content), save=True)
|
||||||
|
|
||||||
|
return scrobble.id
|
||||||
|
|
||||||
|
|
||||||
|
def import_observations_for_all_users() -> list[int]:
|
||||||
|
"""Import iNaturalist observations for all users with auto-import enabled."""
|
||||||
|
users = User.objects.filter(
|
||||||
|
profile__inaturalist_auto_import=True,
|
||||||
|
profile__inaturalist_username__isnull=False,
|
||||||
|
).exclude(profile__inaturalist_username="")
|
||||||
|
|
||||||
|
all_scrobble_ids = []
|
||||||
|
for user in users:
|
||||||
|
scrobble_ids = import_observations_for_user(user)
|
||||||
|
all_scrobble_ids.extend(scrobble_ids)
|
||||||
|
|
||||||
|
return all_scrobble_ids
|
||||||
0
vrobbler/apps/nature/management/__init__.py
Normal file
0
vrobbler/apps/nature/management/__init__.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from nature.importer import import_observations_for_all_users
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import iNaturalist observations for all users with auto-import enabled"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
count = len(import_observations_for_all_users())
|
||||||
|
self.stdout.write(f"Imported {count} observations")
|
||||||
88
vrobbler/apps/nature/migrations/0001_initial.py
Normal file
88
vrobbler/apps/nature/migrations/0001_initial.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-17 02:42
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django_extensions.db.fields
|
||||||
|
import taggit.managers
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("scrobbles", "0102_favoritemedia_drink_scrobble_drink_and_more"),
|
||||||
|
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SpeciesObservation",
|
||||||
|
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)),
|
||||||
|
(
|
||||||
|
"scientific_name",
|
||||||
|
models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
("taxon_id", models.IntegerField(db_index=True, unique=True)),
|
||||||
|
(
|
||||||
|
"photo",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True, null=True, upload_to="nature/species/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"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={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-17 03:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("nature", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="speciesobservation",
|
||||||
|
name="iconic_taxon_name",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="speciesobservation",
|
||||||
|
name="rank",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="speciesobservation",
|
||||||
|
name="wikipedia_url",
|
||||||
|
field=models.URLField(blank=True, max_length=1024, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
vrobbler/apps/nature/migrations/__init__.py
Normal file
0
vrobbler/apps/nature/migrations/__init__.py
Normal file
101
vrobbler/apps/nature/models.py
Normal file
101
vrobbler/apps/nature/models.py
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from imagekit.models import ImageSpecField
|
||||||
|
from imagekit.processors import ResizeToFit
|
||||||
|
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
|
||||||
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
BNULL = {"blank": True, "null": True}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SpeciesObservationLogData(BaseLogData, WithPeopleLogData):
|
||||||
|
observation_id: Optional[int] = None
|
||||||
|
photo_urls: Optional[list[str]] = None
|
||||||
|
quality_grade: Optional[str] = None
|
||||||
|
place_guess: Optional[str] = None
|
||||||
|
lat: Optional[float] = None
|
||||||
|
lon: Optional[float] = None
|
||||||
|
uri: Optional[str] = None
|
||||||
|
identifications_count: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SpeciesObservation(ScrobblableMixin):
|
||||||
|
scientific_name = models.CharField(max_length=255, **BNULL)
|
||||||
|
taxon_id = models.IntegerField(unique=True, db_index=True)
|
||||||
|
iconic_taxon_name = models.CharField(max_length=255, **BNULL)
|
||||||
|
rank = models.CharField(max_length=255, **BNULL)
|
||||||
|
wikipedia_url = models.URLField(max_length=1024, **BNULL)
|
||||||
|
photo = models.ImageField(upload_to="nature/species/", **BNULL)
|
||||||
|
photo_small = ImageSpecField(
|
||||||
|
source="photo",
|
||||||
|
processors=[ResizeToFit(100, 100)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 60},
|
||||||
|
)
|
||||||
|
photo_medium = ImageSpecField(
|
||||||
|
source="photo",
|
||||||
|
processors=[ResizeToFit(300, 300)],
|
||||||
|
format="JPEG",
|
||||||
|
options={"quality": 75},
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.scientific_name:
|
||||||
|
return f"{self.title} ({self.scientific_name})"
|
||||||
|
return self.title or str(self.uuid)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("nature:species_observation_detail", kwargs={"slug": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subtitle(self):
|
||||||
|
return self.scientific_name or ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def run_time_seconds(self) -> int:
|
||||||
|
return self.base_run_time_seconds or 300
|
||||||
|
|
||||||
|
@property
|
||||||
|
def strings(self) -> ScrobblableConstants:
|
||||||
|
return ScrobblableConstants(verb="Observing", tags="leaf")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def logdata_cls(self):
|
||||||
|
return SpeciesObservationLogData
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_image_url(self) -> str:
|
||||||
|
if self.photo:
|
||||||
|
return self.photo.url
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def fix_metadata(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_or_create(
|
||||||
|
cls,
|
||||||
|
common_name: str,
|
||||||
|
scientific_name: str,
|
||||||
|
taxon_id: int,
|
||||||
|
iconic_taxon_name: str = "",
|
||||||
|
rank: str = "",
|
||||||
|
wikipedia_url: str = "",
|
||||||
|
) -> "SpeciesObservation":
|
||||||
|
species = cls.objects.filter(taxon_id=taxon_id).first()
|
||||||
|
if not species:
|
||||||
|
species = cls.objects.create(
|
||||||
|
title=common_name,
|
||||||
|
scientific_name=scientific_name,
|
||||||
|
taxon_id=taxon_id,
|
||||||
|
iconic_taxon_name=iconic_taxon_name,
|
||||||
|
rank=rank,
|
||||||
|
wikipedia_url=wikipedia_url or "",
|
||||||
|
)
|
||||||
|
return species
|
||||||
13
vrobbler/apps/nature/tasks.py
Normal file
13
vrobbler/apps/nature/tasks.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def import_from_inaturalist_all_users():
|
||||||
|
"""Import iNaturalist observations for all users."""
|
||||||
|
from nature.importer import import_observations_for_all_users
|
||||||
|
|
||||||
|
import_observations_for_all_users()
|
||||||
17
vrobbler/apps/nature/urls.py
Normal file
17
vrobbler/apps/nature/urls.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from nature import views
|
||||||
|
|
||||||
|
app_name = "nature"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path(
|
||||||
|
"observations/",
|
||||||
|
views.SpeciesObservationListView.as_view(),
|
||||||
|
name="species_observation_list",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"observations/<slug:slug>/",
|
||||||
|
views.SpeciesObservationDetailView.as_view(),
|
||||||
|
name="species_observation_detail",
|
||||||
|
),
|
||||||
|
]
|
||||||
11
vrobbler/apps/nature/views.py
Normal file
11
vrobbler/apps/nature/views.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from django.views import generic
|
||||||
|
from nature.models import SpeciesObservation
|
||||||
|
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||||
|
|
||||||
|
|
||||||
|
class SpeciesObservationListView(ScrobbleableListView):
|
||||||
|
model = SpeciesObservation
|
||||||
|
|
||||||
|
|
||||||
|
class SpeciesObservationDetailView(ScrobbleableDetailView):
|
||||||
|
model = SpeciesObservation
|
||||||
@ -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
|
||||||
@ -27,6 +26,9 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"archivebox_url",
|
"archivebox_url",
|
||||||
"bgg_username",
|
"bgg_username",
|
||||||
"lichess_username",
|
"lichess_username",
|
||||||
|
"inaturalist_username",
|
||||||
|
"inaturalist_user_id",
|
||||||
|
"inaturalist_auto_import",
|
||||||
"webdav_url",
|
"webdav_url",
|
||||||
"webdav_user",
|
"webdav_user",
|
||||||
"webdav_pass",
|
"webdav_pass",
|
||||||
@ -42,6 +44,10 @@ class UserProfileForm(forms.ModelForm):
|
|||||||
"home_scrobble_limit",
|
"home_scrobble_limit",
|
||||||
"live_now_playing",
|
"live_now_playing",
|
||||||
"weigh_in_units",
|
"weigh_in_units",
|
||||||
|
"volume_unit",
|
||||||
|
"default_water_format",
|
||||||
|
"default_water_size",
|
||||||
|
"water_reminder_enabled",
|
||||||
"trends_disabled",
|
"trends_disabled",
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
@ -51,9 +57,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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-14 00:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0041_userprofile_volume_unit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="default_water_format",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("aluminum_can", "Aluminum Can"),
|
||||||
|
("glass_bottle", "Glass Bottle"),
|
||||||
|
("plastic_bottle", "Plastic Bottle"),
|
||||||
|
("metal_bottle", "Metal Bottle"),
|
||||||
|
("keg", "Keg"),
|
||||||
|
("by_the_glass", "By the Glass"),
|
||||||
|
("draft", "Draft"),
|
||||||
|
("growler", "Growler"),
|
||||||
|
("box", "Box"),
|
||||||
|
],
|
||||||
|
default="by_the_glass",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="default_water_size",
|
||||||
|
field=models.PositiveIntegerField(
|
||||||
|
default=250, help_text="Default water scrobble size in mL"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-14 03:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0042_add_water_defaults"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="water_reminder_enabled",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 4.2.29 on 2026-07-17 02:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("profiles", "0043_add_water_reminder"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="inaturalist_auto_import",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="inaturalist_user_id",
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="inaturalist_username",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -1,14 +1,16 @@
|
|||||||
from zoneinfo import ZoneInfo
|
|
||||||
import pendulum
|
|
||||||
from django.utils import timezone
|
|
||||||
import logging
|
import logging
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pendulum
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django_extensions.db.models import TimeStampedModel
|
from django_extensions.db.models import TimeStampedModel
|
||||||
from encrypted_field import EncryptedField
|
from encrypted_field import EncryptedField
|
||||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||||
|
|
||||||
VISIBILITY_CHOICES = (
|
VISIBILITY_CHOICES = (
|
||||||
("public", "Public"),
|
("public", "Public"),
|
||||||
("shared", "Shared"),
|
("shared", "Shared"),
|
||||||
@ -26,6 +28,23 @@ class WeighUnit(models.TextChoices):
|
|||||||
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeUnit(models.TextChoices):
|
||||||
|
METRIC = "metric", "Metric (mL, L)"
|
||||||
|
IMPERIAL = "imperial", "Imperial (oz)"
|
||||||
|
|
||||||
|
|
||||||
|
class WaterFormat(models.TextChoices):
|
||||||
|
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
||||||
|
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
||||||
|
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
||||||
|
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
||||||
|
KEG = "keg", "Keg"
|
||||||
|
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
||||||
|
DRAFT = "draft", "Draft"
|
||||||
|
GROWLER = "growler", "Growler"
|
||||||
|
BOX = "box", "Box"
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(TimeStampedModel):
|
class UserProfile(TimeStampedModel):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
timezone = models.CharField(
|
timezone = models.CharField(
|
||||||
@ -71,11 +90,13 @@ class UserProfile(TimeStampedModel):
|
|||||||
|
|
||||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||||
favorites_mopidy_playlist = models.CharField(
|
favorites_mopidy_playlist = models.CharField(
|
||||||
max_length=255, **BNULL,
|
max_length=255,
|
||||||
|
**BNULL,
|
||||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||||
)
|
)
|
||||||
monthly_mopidy_playlist_pattern = models.CharField(
|
monthly_mopidy_playlist_pattern = models.CharField(
|
||||||
max_length=255, **BNULL,
|
max_length=255,
|
||||||
|
**BNULL,
|
||||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -93,7 +114,7 @@ class UserProfile(TimeStampedModel):
|
|||||||
media_type_visibility = models.JSONField(
|
media_type_visibility = models.JSONField(
|
||||||
default=dict,
|
default=dict,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
|
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
|
||||||
)
|
)
|
||||||
|
|
||||||
home_scrobble_limit = models.IntegerField(default=20)
|
home_scrobble_limit = models.IntegerField(default=20)
|
||||||
@ -106,6 +127,25 @@ class UserProfile(TimeStampedModel):
|
|||||||
default=WeighUnit.METRIC,
|
default=WeighUnit.METRIC,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
volume_unit = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
choices=VolumeUnit.choices,
|
||||||
|
default=VolumeUnit.METRIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
default_water_format = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=WaterFormat.choices,
|
||||||
|
default=WaterFormat.BY_THE_GLASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
default_water_size = models.PositiveIntegerField(
|
||||||
|
default=250,
|
||||||
|
help_text="Default water scrobble size in mL",
|
||||||
|
)
|
||||||
|
|
||||||
|
water_reminder_enabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
trends_disabled = models.BooleanField(default=False)
|
trends_disabled = models.BooleanField(default=False)
|
||||||
|
|
||||||
disabled_trends = models.JSONField(
|
disabled_trends = models.JSONField(
|
||||||
@ -114,6 +154,10 @@ class UserProfile(TimeStampedModel):
|
|||||||
help_text="List of trend slugs the user has disabled",
|
help_text="List of trend slugs the user has disabled",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
inaturalist_username = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
inaturalist_user_id = models.IntegerField(blank=True, null=True)
|
||||||
|
inaturalist_auto_import = models.BooleanField(default=False)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"User profile for {self.user}"
|
return f"User profile for {self.user}"
|
||||||
|
|
||||||
@ -153,7 +197,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",
|
||||||
@ -46,8 +54,11 @@ PLAY_AGAIN_MEDIA = {
|
|||||||
"channels": "Channel",
|
"channels": "Channel",
|
||||||
"birds": "BirdingLocation",
|
"birds": "BirdingLocation",
|
||||||
"discgolf": "DiscGolfCourse",
|
"discgolf": "DiscGolfCourse",
|
||||||
|
"nature": "SpeciesObservation",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
|
||||||
|
|
||||||
MEDIA_END_PADDING_SECONDS = {
|
MEDIA_END_PADDING_SECONDS = {
|
||||||
"Video": 3600, # 60 min
|
"Video": 3600, # 60 min
|
||||||
}
|
}
|
||||||
@ -59,6 +70,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": [],
|
||||||
|
"-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 +90,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",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from email.header import decode_header
|
|||||||
|
|
||||||
from profiles.models import UserProfile
|
from profiles.models import UserProfile
|
||||||
from scrobbles.models import Scrobble
|
from scrobbles.models import Scrobble
|
||||||
from scrobbles.scrobblers import email_scrobble_board_game
|
from scrobbles.scrobblers import scrobble_board_game_from_bgstats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -87,7 +87,7 @@ def import_scrobbles_from_imap() -> list[Scrobble]:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
scrobbles_created = email_scrobble_board_game(
|
scrobbles_created = scrobble_board_game_from_bgstats(
|
||||||
parsed_json, profile.user_id
|
parsed_json, profile.user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
sent_count = send_water_reminder_notifications()
|
||||||
|
print(f"Sent {sent_count} water reminder notifications")
|
||||||
@ -18,16 +18,32 @@ class ScrobbleToolset(MCPToolset):
|
|||||||
qs = (
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user