Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 681252b845 | |||
| f6a20b67a6 | |||
| c9298fe387 | |||
| f7d99f8b55 | |||
| 6861e28ac6 | |||
| fdc18d5a5f | |||
| 77d92f6c96 | |||
| d5d0eb6cd8 | |||
| d6f71e0761 | |||
| b00ebf49dd | |||
| 2385e9c7bd | |||
| d78529efe2 | |||
| f373e98e3d | |||
| 7559ce7824 | |||
| 2c481bd53a | |||
| 0deb3ee634 |
93
PROJECT.org
93
PROJECT.org
@ -619,6 +619,99 @@ The Edit log form should have from top to bottom:
|
||||
- Expansion ids (which should a multi-select widget of expansions for this game)
|
||||
- Location (which should be a drop down of BoardGameLocations for this user)
|
||||
|
||||
* Version 60.1 [1/1]
|
||||
** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:
|
||||
:PROPERTIES:
|
||||
:ID: edaf1200-b58e-8512-b2a2-c8bc25bed7a0
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Food ID 10 is "French Press Coffee with Sugar" and we should convert all of
|
||||
these scrobbles to scrobbles of a Coffee, but to figure out which coffee, we can
|
||||
use scrobbles of Task "Chore" where the title is "Roast coffee" and the notes
|
||||
are not null. In this case, the first note on each one has a Coffee title like
|
||||
"Mexico - Veracruz Huatusco" where the Origin is Mexico and the title is
|
||||
"Veracruz Huatusco"
|
||||
|
||||
We should create a new Coffee for each Chore with the title of "Roast Coffee"
|
||||
and then for each "French Press Coffee with Sugar", swap the Food ID 10 for the
|
||||
Coffee ID of the latest Coffee created from a Chore. If a new Chore is found,
|
||||
start updating the French Press scrobbles with a newly created Coffee.
|
||||
|
||||
If we don't have a roast log in the Chore tasks before a French Press entry, we should use the "Unknown Roast" Coffee title and skip all the othe metadata.
|
||||
|
||||
|
||||
* Version 60.0 [2/2]
|
||||
** DONE [#B] Send water drinking notifications :drinks:notifications:
|
||||
:PROPERTIES:
|
||||
:ID: 6c0db717-a0c7-34a1-57e1-941db0188875
|
||||
:END:
|
||||
** DONE [#B] Add wine as a ScrobbleItem in a drinks app and move beers into drinks app :drinks:beer:
|
||||
:PROPERTIES:
|
||||
:ID: add1be0d-eb33-3273-37bf-0f2cab7e67c5
|
||||
:END:
|
||||
|
||||
* Version 59.5 [1/1]
|
||||
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
|
||||
:END:
|
||||
|
||||
* Version 59.4 [2/2]
|
||||
** DONE Fix bug in fetching expansions for board games :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
|
||||
:END:
|
||||
|
||||
#+begin_src python
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
|
||||
expansion = BoardGame.find_or_create(str(exp_id))
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
|
||||
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
|
||||
game = bgg.game(game_id=lookup_id)
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
|
||||
raise BGGApiError(msg)
|
||||
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
|
||||
#+end_src
|
||||
|
||||
** DONE Board games should have genres extracted from family data :boardgames:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
|
||||
:END:
|
||||
|
||||
|
||||
* Version 59.3 [2/2]
|
||||
** DONE Exclude some board games from auto-expansion imports :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Some board games, especially trading card games, have silly amounts of expansions.
|
||||
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
|
||||
games where expansions should not be download automatically. This exclusion should also auto-include
|
||||
any games with "Collectible Card Games" in it's family.
|
||||
|
||||
** DONE Should be able to add new variants to board games via the log data form :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
|
||||
:END:
|
||||
|
||||
* Version 59.2 [1/1]
|
||||
** DONE Fix test failure in discgolf app :discgolf:tests:
|
||||
:PROPERTIES:
|
||||
:ID: 813ae357-0568-5a4c-1a35-172e95d02740
|
||||
:END:
|
||||
|
||||
* Version 59.1 [1/1]
|
||||
** DONE Fix bug when expansions have no image :boardgames:bug:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "59.1"
|
||||
version = "60.1"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
from django.contrib import admin
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
class BeerInline(admin.TabularInline):
|
||||
model = Beer
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(BeerStyle)
|
||||
class BeerStyle(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(BeerProducer)
|
||||
class BeerProducer(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
search_fields = ("name",)
|
||||
|
||||
|
||||
@admin.register(Beer)
|
||||
class BeerAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = (
|
||||
"uuid",
|
||||
"title",
|
||||
)
|
||||
raw_id_fields = ("styles", "producer")
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
@ -1,20 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from beers.models import Beer, BeerProducer, BeerStyle
|
||||
|
||||
|
||||
class BeerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Beer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerProducer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerStyle
|
||||
fields = "__all__"
|
||||
@ -1,21 +0,0 @@
|
||||
from rest_framework import permissions, viewsets
|
||||
from beers.api import serializers
|
||||
from beers import models
|
||||
|
||||
|
||||
class BeerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Beer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerStyleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerStyle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerStyleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -1,143 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from beers.untappd import get_beer_from_untappd_id
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class BeerProducer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Beer(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
ibu = models.SmallIntegerField(**BNULL)
|
||||
abv = models.FloatField(**BNULL)
|
||||
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||
non_alcoholic = models.BooleanField(default=False)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
|
||||
untappd_image_small = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
untappd_image_medium = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.producer}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.producer.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||
|
||||
@property
|
||||
def beeradvocate_link(self) -> str:
|
||||
link = ""
|
||||
if self.producer and self.beeradvocate_id:
|
||||
if self.beeradvocate_id:
|
||||
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def untappd_link(self) -> str:
|
||||
link = ""
|
||||
if self.untappd_id:
|
||||
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.untappd_image:
|
||||
url = self.untappd_image.url
|
||||
return url
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BeerLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||
|
||||
if not beer:
|
||||
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||
producer_dict = {}
|
||||
style_ids = []
|
||||
for key in list(beer_dict.keys()):
|
||||
if "producer__" in key:
|
||||
pkey = key.replace("producer__", "")
|
||||
producer_dict[pkey] = beer_dict.pop(key)
|
||||
if "styles" in key:
|
||||
for style in beer_dict.pop("styles"):
|
||||
style_inst, created = BeerStyle.objects.get_or_create(
|
||||
name=style
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||
beer_dict["producer_id"] = producer.id
|
||||
beer = Beer.objects.create(**beer_dict)
|
||||
for style_id in style_ids:
|
||||
beer.styles.add(style_id)
|
||||
|
||||
return beer
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
@ -1,14 +0,0 @@
|
||||
from django.urls import path
|
||||
from beers import views
|
||||
|
||||
app_name = "beers"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||
path(
|
||||
"beers/<slug:slug>/",
|
||||
views.BeerDetailView.as_view(),
|
||||
name="beer_detail",
|
||||
),
|
||||
]
|
||||
@ -1,11 +0,0 @@
|
||||
from beers.models import Beer
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
|
||||
|
||||
class BeerListView(ScrobbleableListView):
|
||||
model = Beer
|
||||
|
||||
|
||||
class BeerDetailView(ScrobbleableDetailView):
|
||||
model = Beer
|
||||
@ -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}"
|
||||
)
|
||||
@ -28,8 +28,10 @@ class Command(BaseCommand):
|
||||
commit = options.get("commit", False)
|
||||
bggeek_id = options.get("bggeek_id")
|
||||
|
||||
games = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(
|
||||
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)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -93,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from boardgames.widgets import VariantSelectWidget
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
@ -110,7 +111,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGameVariant.objects.all(),
|
||||
required=False,
|
||||
widget=forms.SelectMultiple(attrs={"size": 5}),
|
||||
widget=VariantSelectWidget(attrs={"size": 5}),
|
||||
),
|
||||
"expansion_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGame.objects.filter(
|
||||
@ -299,6 +300,7 @@ class BoardGame(ScrobblableMixin):
|
||||
expansion_for_boardgame = models.ForeignKey(
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
skip_expansions = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
@ -349,8 +351,9 @@ class BoardGame(ScrobblableMixin):
|
||||
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("categories", None)
|
||||
data.pop("designers", None)
|
||||
data.pop("publishers", None)
|
||||
data.pop("publisher", None)
|
||||
@ -366,13 +369,19 @@ class BoardGame(ScrobblableMixin):
|
||||
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
|
||||
self.save()
|
||||
|
||||
for cat in categories:
|
||||
self.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
self.tags.add(fam.strip())
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not self.cover:
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(self, expansions)
|
||||
if not self.skip_expansions:
|
||||
fetch_and_link_expansions(self, expansions)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
@ -414,6 +423,7 @@ class BoardGame(ScrobblableMixin):
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
families = bgg_data.pop("families", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url") or ""
|
||||
@ -430,6 +440,18 @@ class BoardGame(ScrobblableMixin):
|
||||
if publisher:
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
|
||||
game.publisher = publisher
|
||||
|
||||
skip_expansions = (
|
||||
game.bggeek_id is not None
|
||||
and str(game.bggeek_id).isdigit()
|
||||
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
|
||||
) or any(
|
||||
c == "Collectible Card Games" for c in categories
|
||||
)
|
||||
|
||||
if skip_expansions:
|
||||
game.skip_expansions = True
|
||||
|
||||
game.save()
|
||||
|
||||
if designers:
|
||||
@ -444,14 +466,20 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
if defer_expansions:
|
||||
from boardgames.tasks import fetch_board_game_expansions
|
||||
for cat in categories:
|
||||
game.genre.add(cat.strip())
|
||||
for fam in families:
|
||||
game.tags.add(fam.strip())
|
||||
|
||||
fetch_board_game_expansions.delay(game.id, expansions)
|
||||
else:
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
if expansions and not game.skip_expansions:
|
||||
if defer_expansions:
|
||||
from boardgames.tasks import fetch_board_game_expansions
|
||||
|
||||
fetch_and_link_expansions(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
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ def lookup_boardgame_from_bgg(
|
||||
)
|
||||
game_dict["mechanics"] = game.mechanics
|
||||
game_dict["categories"] = game.categories
|
||||
game_dict["families"] = game.families
|
||||
game_dict["designers"] = game.designers
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
|
||||
@ -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>
|
||||
@ -83,6 +83,7 @@ def _mock_bgg_game(bggeek_id, title, expansions=None):
|
||||
playingtime = 60
|
||||
mechanics = []
|
||||
categories = []
|
||||
families = []
|
||||
designers = []
|
||||
publishers = []
|
||||
|
||||
|
||||
@ -20,4 +20,9 @@ urlpatterns = [
|
||||
views.BoardGamePublisherDetailView.as_view(),
|
||||
name="publisher_detail",
|
||||
),
|
||||
path(
|
||||
"variants/ajax-create/",
|
||||
views.ajax_create_variant,
|
||||
name="ajax-create-variant",
|
||||
),
|
||||
]
|
||||
|
||||
@ -44,6 +44,13 @@ def fetch_and_link_expansions(
|
||||
"""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:
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import datetime
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views import generic
|
||||
from django.views.decorators.http import require_POST
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import (
|
||||
@ -10,6 +13,38 @@ from scrobbles.views import (
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
def ajax_create_variant(request):
|
||||
name = request.POST.get("name", "").strip()
|
||||
board_game_id = request.POST.get("board_game_id")
|
||||
description = request.POST.get("description", "").strip()
|
||||
|
||||
if not name or not board_game_id:
|
||||
return JsonResponse({"error": "Name and board game are required"}, status=400)
|
||||
|
||||
try:
|
||||
board_game_id = int(board_game_id)
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"error": "Invalid board game"}, status=400)
|
||||
|
||||
from boardgames.models import BoardGameVariant
|
||||
|
||||
variant = BoardGameVariant.objects.filter(
|
||||
name__iexact=name,
|
||||
board_game_id=board_game_id,
|
||||
).first()
|
||||
if variant:
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
variant = BoardGameVariant.objects.create(
|
||||
name=name,
|
||||
board_game_id=board_game_id,
|
||||
description=description or None,
|
||||
)
|
||||
|
||||
return JsonResponse({"id": variant.id, "name": variant.name})
|
||||
|
||||
|
||||
class BoardGameListView(ScrobbleableListView):
|
||||
model = BoardGame
|
||||
|
||||
|
||||
9
vrobbler/apps/boardgames/widgets.py
Normal file
9
vrobbler/apps/boardgames/widgets.py
Normal file
@ -0,0 +1,9 @@
|
||||
from django import forms
|
||||
|
||||
|
||||
class VariantSelectWidget(forms.SelectMultiple):
|
||||
template_name = "boardgames/widgets/variant_select.html"
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
return context
|
||||
@ -12,7 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_udisc_datetime(raw: str) -> datetime:
|
||||
return parse_datetime(raw)
|
||||
dt = parse_datetime(raw)
|
||||
if timezone.is_naive(dt):
|
||||
return timezone.make_aware(dt)
|
||||
return dt
|
||||
|
||||
|
||||
def _resolve_player(name: str, user_id: int) -> Person:
|
||||
|
||||
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,
|
||||
]
|
||||
66
vrobbler/apps/drinks/api/serializers.py
Normal file
66
vrobbler/apps/drinks/api/serializers.py
Normal file
@ -0,0 +1,66 @@
|
||||
from drinks.models import (
|
||||
Beer,
|
||||
BeerProducer,
|
||||
BeerStyle,
|
||||
Coffee,
|
||||
CoffeeRoaster,
|
||||
Wine,
|
||||
WineGrape,
|
||||
WineProducer,
|
||||
WineRegion,
|
||||
)
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class BeerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Beer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerProducer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = BeerStyle
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WineSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Wine
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WineProducerSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = WineProducer
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WineRegionSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = WineRegion
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class WineGrapeSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = WineGrape
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class CoffeeSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = Coffee
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class CoffeeRoasterSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = CoffeeRoaster
|
||||
fields = "__all__"
|
||||
57
vrobbler/apps/drinks/api/views.py
Normal file
57
vrobbler/apps/drinks/api/views.py
Normal file
@ -0,0 +1,57 @@
|
||||
from drinks import models
|
||||
from drinks.api import serializers
|
||||
from rest_framework import permissions, viewsets
|
||||
|
||||
|
||||
class BeerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Beer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class BeerStyleViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.BeerStyle.objects.all().order_by("-created")
|
||||
serializer_class = serializers.BeerStyleSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class WineViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Wine.objects.all().order_by("-created")
|
||||
serializer_class = serializers.WineSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class WineProducerViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.WineProducer.objects.all().order_by("-created")
|
||||
serializer_class = serializers.WineProducerSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class WineRegionViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.WineRegion.objects.all().order_by("-created")
|
||||
serializer_class = serializers.WineRegionSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class WineGrapeViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.WineGrape.objects.all().order_by("-created")
|
||||
serializer_class = serializers.WineGrapeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class CoffeeViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Coffee.objects.all().order_by("-created")
|
||||
serializer_class = serializers.CoffeeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
|
||||
class CoffeeRoasterViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.CoffeeRoaster.objects.all().order_by("-created")
|
||||
serializer_class = serializers.CoffeeRoasterSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||
|
||||
|
||||
class BeersConfig(AppConfig):
|
||||
name = "beers"
|
||||
name = "drinks"
|
||||
130
vrobbler/apps/drinks/cellartracker.py
Normal file
130
vrobbler/apps/drinks/cellartracker.py
Normal file
@ -0,0 +1,130 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CELLARTRACKER_URL = "https://www.cellartracker.com/wine.asp?iWine={cellartracker_id}"
|
||||
|
||||
|
||||
def get_title_from_soup(soup) -> str:
|
||||
title = ""
|
||||
try:
|
||||
title = soup.find("h1").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return title
|
||||
|
||||
|
||||
def get_description_from_soup(soup) -> str:
|
||||
desc = ""
|
||||
try:
|
||||
desc = soup.find("div", class_="wine-notes").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return desc
|
||||
|
||||
|
||||
def get_wine_type_from_soup(soup) -> str:
|
||||
wine_type = ""
|
||||
try:
|
||||
wine_type_text = soup.find("td", string=lambda t: t and "Type" in t)
|
||||
if wine_type_text:
|
||||
wine_type = (
|
||||
wine_type_text.find_next_sibling("td").get_text(strip=True).lower()
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return wine_type
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> float:
|
||||
rating = None
|
||||
try:
|
||||
rating_text = soup.find("td", string=lambda t: t and "Score" in t)
|
||||
if rating_text:
|
||||
rating = float(rating_text.find_next_sibling("td").get_text(strip=True))
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
return rating
|
||||
|
||||
|
||||
def get_grapes_from_soup(soup) -> list[str]:
|
||||
grapes = []
|
||||
try:
|
||||
grape_text = soup.find("td", string=lambda t: t and "Varietal" in t)
|
||||
if grape_text:
|
||||
grape_str = grape_text.find_next_sibling("td").get_text(strip=True)
|
||||
grapes = [g.strip() for g in grape_str.split("/") if g.strip()]
|
||||
except AttributeError:
|
||||
pass
|
||||
return grapes
|
||||
|
||||
|
||||
def get_region_from_soup(soup) -> str:
|
||||
region = ""
|
||||
try:
|
||||
region_text = soup.find("td", string=lambda t: t and "Region" in t)
|
||||
if region_text:
|
||||
region = region_text.find_next_sibling("td").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return region
|
||||
|
||||
|
||||
def get_producer_from_soup(soup) -> str:
|
||||
producer = ""
|
||||
try:
|
||||
producer_text = soup.find("td", string=lambda t: t and "Producer" in t)
|
||||
if producer_text:
|
||||
producer = producer_text.find_next_sibling("td").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return producer
|
||||
|
||||
|
||||
def get_vintage_from_soup(soup) -> str:
|
||||
vintage = ""
|
||||
try:
|
||||
vintage_text = soup.find("td", string=lambda t: t and "Vintage" in t)
|
||||
if vintage_text:
|
||||
vintage = vintage_text.find_next_sibling("td").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return vintage
|
||||
|
||||
|
||||
def get_image_url_from_soup(soup) -> str:
|
||||
image_url = ""
|
||||
try:
|
||||
image_url = soup.find("img", class_="wine-image")["src"]
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
pass
|
||||
return image_url
|
||||
|
||||
|
||||
def get_wine_from_cellartracker_id(cellartracker_id: str) -> dict:
|
||||
wine_url = CELLARTRACKER_URL.format(cellartracker_id=cellartracker_id)
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
response = requests.get(wine_url, headers=headers)
|
||||
wine_dict = {"cellartracker_id": cellartracker_id}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from cellartracker.com", extra={"response": response}
|
||||
)
|
||||
return wine_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
wine_dict["title"] = get_title_from_soup(soup)
|
||||
wine_dict["description"] = get_description_from_soup(soup)
|
||||
wine_dict["wine_type"] = get_wine_type_from_soup(soup)
|
||||
wine_dict["cellartracker_rating"] = get_rating_from_soup(soup)
|
||||
wine_dict["grapes"] = get_grapes_from_soup(soup)
|
||||
wine_dict["region"] = get_region_from_soup(soup)
|
||||
wine_dict["producer__name"] = get_producer_from_soup(soup)
|
||||
wine_dict["vintage"] = get_vintage_from_soup(soup)
|
||||
wine_dict["image_url"] = get_image_url_from_soup(soup)
|
||||
|
||||
return wine_dict
|
||||
0
vrobbler/apps/drinks/management/__init__.py
Normal file
0
vrobbler/apps/drinks/management/__init__.py
Normal file
@ -0,0 +1,474 @@
|
||||
from bisect import bisect_right
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from drinks.models import BrewingMethod, Coffee, CoffeeRoaster, RoastLevel
|
||||
from scrobbles.models import FavoriteMedia, Scrobble
|
||||
|
||||
BASE_RUN_TIME_SECONDS = 2400
|
||||
|
||||
# French Press (Food ID 10)
|
||||
FOOD_ID_FRENCH_PRESS = 10
|
||||
UNKNOWN_ROAST_TITLE = "Unknown Roast"
|
||||
TIMBERWYCK_ROASTER_ID = 1
|
||||
FRENCH_PRESS_CALORIES = 50
|
||||
FRENCH_PRESS_BASE_TAGS = {"sweet maria", "green coffee"}
|
||||
|
||||
# Medium Roast (Food ID 27)
|
||||
FOOD_ID_MEDIUM_ROAST = 27
|
||||
MEDIUM_ROAST_TITLE = "Medium Roast"
|
||||
MEDIUM_ROAST_ROASTER_NAME = "Dunkin' Donuts"
|
||||
MEDIUM_ROAST_CALORIES = 150
|
||||
MEDIUM_ROAST_BASE_TAGS = {"dunkin"}
|
||||
|
||||
# Lavazza (Food ID 12)
|
||||
FOOD_ID_LAVAZZA = 12
|
||||
LAVAZZA_TITLE = "Qualità Oro"
|
||||
LAVAZZA_ROASTER_NAME = "Lavazza"
|
||||
LAVAZZA_ORIGIN = "Blended"
|
||||
LAVAZZA_CALORIES = 50
|
||||
LAVAZZA_BASE_TAGS = {"lavazza", "espresso"}
|
||||
|
||||
# Drip Coffee (Food ID 61)
|
||||
FOOD_ID_DRIP_COFFEE = 61
|
||||
DRIP_COFFEE_CALORIES = 50
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Migrate French Press, Medium Roast, and Lavazza food scrobbles "
|
||||
"to Coffee scrobbles."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Show what would be changed without making changes",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options["dry_run"]
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("DRY RUN - no changes will be saved\n")
|
||||
)
|
||||
|
||||
# French Press migration
|
||||
self.stdout.write(self.style.NOTICE("=== French Press Coffee ==="))
|
||||
french_migrated, french_skipped = self._migrate_french_press(dry_run)
|
||||
|
||||
# Medium Roast migration
|
||||
self.stdout.write(self.style.NOTICE("\n=== Medium Roast ==="))
|
||||
medium_migrated, medium_skipped = self._migrate_medium_roast(dry_run)
|
||||
|
||||
# Lavazza migration
|
||||
self.stdout.write(self.style.NOTICE("\n=== Lavazza ==="))
|
||||
lavazza_migrated, lavazza_skipped = self._migrate_lavazza(dry_run)
|
||||
|
||||
# Drip Coffee migration
|
||||
self.stdout.write(self.style.NOTICE("\n=== Drip Coffee ==="))
|
||||
drip_migrated, drip_skipped = self._migrate_drip_coffee(dry_run)
|
||||
|
||||
self.stdout.write("")
|
||||
total_migrated = (
|
||||
french_migrated + medium_migrated + lavazza_migrated + drip_migrated
|
||||
)
|
||||
total_skipped = french_skipped + medium_skipped + lavazza_skipped + drip_skipped
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Dry run: would migrate {total_migrated} scrobbles, "
|
||||
f"skip {total_skipped} already migrated. "
|
||||
f"Use without --dry-run to apply."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Migrated {total_migrated} scrobbles, skipped {total_skipped} "
|
||||
f"already migrated."
|
||||
)
|
||||
)
|
||||
|
||||
def _migrate_french_press(self, dry_run):
|
||||
roast_coffees = self._build_roast_coffee_timeline()
|
||||
if not roast_coffees:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
|
||||
)
|
||||
return 0, 0
|
||||
|
||||
unknown_roast = self._get_or_create_unknown_roast(dry_run)
|
||||
food_scrobbles = self._get_food_scrobbles(FOOD_ID_FRENCH_PRESS)
|
||||
favorite_foods = self._get_favorite_foods(FOOD_ID_FRENCH_PRESS)
|
||||
|
||||
timestamps = [ts for ts, _ in roast_coffees]
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
for scrobble in food_scrobbles.iterator():
|
||||
if scrobble.coffee_id is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
idx = bisect_right(timestamps, scrobble.timestamp) - 1
|
||||
if idx >= 0:
|
||||
_, coffee = roast_coffees[idx]
|
||||
else:
|
||||
coffee = unknown_roast
|
||||
|
||||
log = scrobble.log or {}
|
||||
log["brewing_method"] = BrewingMethod.FRENCH_PRESS
|
||||
log["roast_level"] = RoastLevel.FULL_CITY_PLUS
|
||||
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.coffee = coffee
|
||||
scrobble.food = None
|
||||
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||
migrated += 1
|
||||
|
||||
self._migrate_favorites(
|
||||
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
|
||||
)
|
||||
return migrated, skipped
|
||||
|
||||
def _migrate_medium_roast(self, dry_run):
|
||||
coffee = self._get_or_create_medium_roast(dry_run)
|
||||
food_scrobbles = self._get_food_scrobbles(FOOD_ID_MEDIUM_ROAST)
|
||||
favorite_foods = self._get_favorite_foods(FOOD_ID_MEDIUM_ROAST)
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
for scrobble in food_scrobbles.iterator():
|
||||
if scrobble.coffee_id is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
log = scrobble.log or {}
|
||||
log["brewing_method"] = BrewingMethod.DRIP
|
||||
log["roast_level"] = RoastLevel.FULL_CITY
|
||||
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.coffee = coffee
|
||||
scrobble.food = None
|
||||
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||
migrated += 1
|
||||
|
||||
self._migrate_medium_roast_favorites(favorite_foods, coffee, dry_run)
|
||||
return migrated, skipped
|
||||
|
||||
def _migrate_lavazza(self, dry_run):
|
||||
coffee = self._get_or_create_lavazza(dry_run)
|
||||
food_scrobbles = self._get_food_scrobbles(FOOD_ID_LAVAZZA)
|
||||
favorite_foods = self._get_favorite_foods(FOOD_ID_LAVAZZA)
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
for scrobble in food_scrobbles.iterator():
|
||||
if scrobble.coffee_id is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
log = scrobble.log or {}
|
||||
log["brewing_method"] = BrewingMethod.MOKA_POT
|
||||
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.coffee = coffee
|
||||
scrobble.food = None
|
||||
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||
migrated += 1
|
||||
|
||||
self._migrate_lavazza_favorites(favorite_foods, coffee, dry_run)
|
||||
return migrated, skipped
|
||||
|
||||
def _migrate_lavazza_favorites(self, favorites, coffee, dry_run):
|
||||
updated = 0
|
||||
for fav in favorites:
|
||||
self.stdout.write(
|
||||
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||
)
|
||||
if not dry_run:
|
||||
fav.coffee = coffee
|
||||
fav.food = None
|
||||
fav.media_type = Scrobble.MediaType.COFFEE
|
||||
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
self.stdout.write(
|
||||
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||
)
|
||||
|
||||
def _migrate_drip_coffee(self, dry_run):
|
||||
roast_coffees = self._build_roast_coffee_timeline()
|
||||
if not roast_coffees:
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")
|
||||
)
|
||||
return 0, 0
|
||||
|
||||
unknown_roast = self._get_or_create_unknown_roast(dry_run)
|
||||
food_scrobbles = self._get_food_scrobbles(FOOD_ID_DRIP_COFFEE)
|
||||
favorite_foods = self._get_favorite_foods(FOOD_ID_DRIP_COFFEE)
|
||||
|
||||
timestamps = [ts for ts, _ in roast_coffees]
|
||||
|
||||
migrated = 0
|
||||
skipped = 0
|
||||
for scrobble in food_scrobbles.iterator():
|
||||
if scrobble.coffee_id is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
idx = bisect_right(timestamps, scrobble.timestamp) - 1
|
||||
if idx >= 0:
|
||||
_, coffee = roast_coffees[idx]
|
||||
else:
|
||||
coffee = unknown_roast
|
||||
|
||||
log = scrobble.log or {}
|
||||
log["brewing_method"] = BrewingMethod.DRIP
|
||||
log["roast_level"] = RoastLevel.CITY
|
||||
|
||||
self.stdout.write(
|
||||
f" Scrobble {scrobble.id}: Food '{scrobble.food}' -> "
|
||||
f"Coffee '{coffee}' ({scrobble.timestamp})"
|
||||
)
|
||||
if not dry_run:
|
||||
scrobble.coffee = coffee
|
||||
scrobble.food = None
|
||||
scrobble.media_type = Scrobble.MediaType.COFFEE
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["coffee", "food", "media_type", "log"])
|
||||
migrated += 1
|
||||
|
||||
self._migrate_drip_coffee_favorites(
|
||||
favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run
|
||||
)
|
||||
return migrated, skipped
|
||||
|
||||
def _migrate_drip_coffee_favorites(
|
||||
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
|
||||
):
|
||||
updated = 0
|
||||
for fav in favorites:
|
||||
idx = bisect_right(timestamps, fav.created) - 1
|
||||
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
|
||||
|
||||
self.stdout.write(
|
||||
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||
)
|
||||
if not dry_run:
|
||||
fav.coffee = coffee
|
||||
fav.food = None
|
||||
fav.media_type = Scrobble.MediaType.COFFEE
|
||||
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
self.stdout.write(
|
||||
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||
)
|
||||
|
||||
def _get_or_create_lavazza(self, dry_run):
|
||||
roaster, _ = CoffeeRoaster.objects.get_or_create(name=LAVAZZA_ROASTER_NAME)
|
||||
defaults = {
|
||||
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||
"roaster": roaster,
|
||||
"calories": LAVAZZA_CALORIES,
|
||||
"origin": LAVAZZA_ORIGIN,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
try:
|
||||
return Coffee.objects.get(title=LAVAZZA_TITLE, roaster=roaster)
|
||||
except Coffee.DoesNotExist:
|
||||
return Coffee(title=LAVAZZA_TITLE, **defaults)
|
||||
|
||||
coffee, created = Coffee.objects.get_or_create(
|
||||
title=LAVAZZA_TITLE,
|
||||
roaster=roaster,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
tags = set(LAVAZZA_TITLE.lower().split()) | LAVAZZA_BASE_TAGS
|
||||
coffee.tags.add(*tags)
|
||||
self.stdout.write(f" Created Coffee: '{LAVAZZA_TITLE}' (tags={tags})")
|
||||
return coffee
|
||||
|
||||
def _migrate_medium_roast_favorites(self, favorites, coffee, dry_run):
|
||||
updated = 0
|
||||
for fav in favorites:
|
||||
self.stdout.write(
|
||||
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||
)
|
||||
if not dry_run:
|
||||
fav.coffee = coffee
|
||||
fav.food = None
|
||||
fav.media_type = Scrobble.MediaType.COFFEE
|
||||
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
self.stdout.write(
|
||||
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||
)
|
||||
|
||||
def _build_roast_coffee_timeline(self):
|
||||
"""Find Task scrobbles where task title='Chore' and log title='Roast coffee'.
|
||||
|
||||
Returns a list of (timestamp, Coffee) sorted by timestamp.
|
||||
"""
|
||||
task_scrobbles = (
|
||||
Scrobble.objects.filter(
|
||||
media_type=Scrobble.MediaType.TASK,
|
||||
task__title="Chore",
|
||||
log__title="Roast coffee",
|
||||
)
|
||||
.exclude(log__notes=None)
|
||||
.exclude(log__notes=[])
|
||||
.order_by("timestamp")
|
||||
)
|
||||
|
||||
coffees = []
|
||||
for scrobble in task_scrobbles:
|
||||
notes = scrobble.log.get("notes") or []
|
||||
if not notes:
|
||||
continue
|
||||
|
||||
first_note = notes[0] if isinstance(notes, list) else str(notes)
|
||||
origin, title = self._parse_coffee_name(first_note)
|
||||
coffee = self._get_or_create_coffee(title, origin)
|
||||
coffees.append((scrobble.timestamp, coffee))
|
||||
|
||||
return coffees
|
||||
|
||||
def _parse_coffee_name(self, note):
|
||||
"""Parse 'Mexico - Veracruz Huatusco' into (origin, title)."""
|
||||
if " - " in note:
|
||||
parts = note.split(" - ", 1)
|
||||
return parts[0].strip(), parts[1].strip()
|
||||
return None, note.strip()
|
||||
|
||||
def _get_or_create_coffee(self, title, origin):
|
||||
coffee, created = Coffee.objects.get_or_create(
|
||||
title=title,
|
||||
origin=origin,
|
||||
defaults={
|
||||
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||
"roaster_id": TIMBERWYCK_ROASTER_ID,
|
||||
"calories": FRENCH_PRESS_CALORIES,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
tags = set(title.lower().split()) | FRENCH_PRESS_BASE_TAGS
|
||||
coffee.tags.add(*tags)
|
||||
self.stdout.write(
|
||||
f" Created Coffee: '{title}' (origin={origin}, tags={tags})"
|
||||
)
|
||||
return coffee
|
||||
|
||||
def _get_or_create_unknown_roast(self, dry_run):
|
||||
defaults = {
|
||||
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||
"roaster_id": TIMBERWYCK_ROASTER_ID,
|
||||
"calories": FRENCH_PRESS_CALORIES,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
try:
|
||||
return Coffee.objects.get(title=UNKNOWN_ROAST_TITLE)
|
||||
except Coffee.DoesNotExist:
|
||||
return Coffee(title=UNKNOWN_ROAST_TITLE, **defaults)
|
||||
|
||||
coffee, created = Coffee.objects.get_or_create(
|
||||
title=UNKNOWN_ROAST_TITLE,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
tags = {"unknown", "roast"} | FRENCH_PRESS_BASE_TAGS
|
||||
coffee.tags.add(*tags)
|
||||
self.stdout.write(
|
||||
f" Created Coffee: '{UNKNOWN_ROAST_TITLE}' (tags={tags})"
|
||||
)
|
||||
return coffee
|
||||
|
||||
def _get_or_create_medium_roast(self, dry_run):
|
||||
roaster, _ = CoffeeRoaster.objects.get_or_create(name=MEDIUM_ROAST_ROASTER_NAME)
|
||||
defaults = {
|
||||
"base_run_time_seconds": BASE_RUN_TIME_SECONDS,
|
||||
"roaster": roaster,
|
||||
"calories": MEDIUM_ROAST_CALORIES,
|
||||
"origin": None,
|
||||
}
|
||||
|
||||
if dry_run:
|
||||
try:
|
||||
return Coffee.objects.get(title=MEDIUM_ROAST_TITLE, roaster=roaster)
|
||||
except Coffee.DoesNotExist:
|
||||
return Coffee(title=MEDIUM_ROAST_TITLE, **defaults)
|
||||
|
||||
coffee, created = Coffee.objects.get_or_create(
|
||||
title=MEDIUM_ROAST_TITLE,
|
||||
roaster=roaster,
|
||||
defaults=defaults,
|
||||
)
|
||||
if created:
|
||||
tags = set(MEDIUM_ROAST_TITLE.lower().split()) | MEDIUM_ROAST_BASE_TAGS
|
||||
coffee.tags.add(*tags)
|
||||
self.stdout.write(f" Created Coffee: '{MEDIUM_ROAST_TITLE}' (tags={tags})")
|
||||
return coffee
|
||||
|
||||
def _get_food_scrobbles(self, food_id):
|
||||
return Scrobble.objects.filter(
|
||||
food_id=food_id,
|
||||
media_type=Scrobble.MediaType.FOOD,
|
||||
).order_by("timestamp")
|
||||
|
||||
def _get_favorite_foods(self, food_id):
|
||||
return FavoriteMedia.objects.filter(
|
||||
food_id=food_id,
|
||||
media_type=Scrobble.MediaType.FOOD,
|
||||
)
|
||||
|
||||
def _migrate_favorites(
|
||||
self, favorites, roast_coffees, timestamps, unknown_roast, dry_run
|
||||
):
|
||||
updated = 0
|
||||
for fav in favorites:
|
||||
idx = bisect_right(timestamps, fav.created) - 1
|
||||
coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast
|
||||
|
||||
self.stdout.write(
|
||||
f" FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"
|
||||
)
|
||||
if not dry_run:
|
||||
fav.coffee = coffee
|
||||
fav.food = None
|
||||
fav.media_type = Scrobble.MediaType.COFFEE
|
||||
fav.save(update_fields=["coffee", "food", "media_type"])
|
||||
updated += 1
|
||||
|
||||
if updated:
|
||||
self.stdout.write(
|
||||
f" {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"
|
||||
)
|
||||
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal file
507
vrobbler/apps/drinks/management/commands/populate_demo_data.py
Normal file
@ -0,0 +1,507 @@
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from drinks.models import (
|
||||
Beer,
|
||||
BeerProducer,
|
||||
BeerStyle,
|
||||
BrewingMethod,
|
||||
Coffee,
|
||||
CoffeeRoaster,
|
||||
DrinkFormat,
|
||||
RoastLevel,
|
||||
Wine,
|
||||
WineGrape,
|
||||
WineProducer,
|
||||
WineRegion,
|
||||
WineType,
|
||||
)
|
||||
from scrobbles.models import FavoriteMedia, Scrobble
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Realistic demo data
|
||||
|
||||
BEER_PRODUCERS = [
|
||||
("Trillium Brewing", "Canton, MA"),
|
||||
("Tree House Brewing", "Charlton, MA"),
|
||||
("The Alchemist", "Stowe, VT"),
|
||||
("Hill Farmstead", "Greensboro, VT"),
|
||||
("Other Half Brewing", "Brooklyn, NY"),
|
||||
("Equilibrium Brewery", "Middletown, NY"),
|
||||
("Monkish Brewing", "Torrance, CA"),
|
||||
("Russian River Brewing", "Santa Rosa, CA"),
|
||||
("Cellar Maker", "San Francisco, CA"),
|
||||
("Definitive Brewing", "Portland, ME"),
|
||||
]
|
||||
|
||||
BEER_STYLES = [
|
||||
"New England IPA",
|
||||
"Hazy Double IPA",
|
||||
"West Coast IPA",
|
||||
"Pale Ale",
|
||||
"Stout",
|
||||
"Imperial Stout",
|
||||
"Porter",
|
||||
"Saison",
|
||||
"Pilsner",
|
||||
"Lager",
|
||||
"Gose",
|
||||
"Sour Ale",
|
||||
"Brown Ale",
|
||||
"Barleywine",
|
||||
"Wheat Ale",
|
||||
]
|
||||
|
||||
BEERS = [
|
||||
("Congress Street", "Trillium Brewing", ["New England IPA"], 6.8, 45),
|
||||
("Julius", "Tree House Brewing", ["New England IPA"], 6.8, 50),
|
||||
("Heady Topper", "The Alchemist", ["New England IPA"], 8.0, 70),
|
||||
("Sip of Sunshine", "Hill Farmstead", ["New England IPA"], 8.0, 60),
|
||||
("All Green Everything", "Other Half Brewing", ["Hazy Double IPA"], 10.0, 100),
|
||||
("Moment of Clarity", "Equilibrium Brewery", ["New England IPA"], 8.5, 55),
|
||||
("Tired Hands Collab", "Monkish Brewing", ["New England IPA"], 7.2, 40),
|
||||
("Pliny the Elder", "Russian River Brewing", ["West Coast IPA"], 8.0, 100),
|
||||
("Density", "Cellar Maker", ["Hazy Double IPA"], 8.2, 65),
|
||||
("Bright Days Ahead", "Definitive Brewing", ["Pale Ale"], 5.5, 35),
|
||||
("Bright", "Tree House Brewing", ["Pale Ale"], 5.2, 30),
|
||||
("King Julius", "Tree House Brewing", ["Hazy Double IPA"], 9.0, 80),
|
||||
("Very Hazy", "Other Half Brewing", ["New England IPA"], 7.5, 45),
|
||||
("Galaxy Dry Hop", "Equilibrium Brewery", ["New England IPA"], 7.0, 50),
|
||||
("DDH Foggier Window", "Monkish Brewing", ["Hazy Double IPA"], 8.0, 70),
|
||||
]
|
||||
|
||||
WINE_PRODUCERS = [
|
||||
("Domaine de la Romanée-Conti", "Burgundy, France"),
|
||||
("Opus One", "Napa Valley, CA"),
|
||||
("Penfolds", "South Australia"),
|
||||
("Cloudy Bay", "Marlborough, New Zealand"),
|
||||
("Antinori", "Tuscany, Italy"),
|
||||
("Vega Sicilia", "Ribera del Duero, Spain"),
|
||||
("Catena Zapata", "Mendoza, Argentina"),
|
||||
("Concha y Toro", "Casablanca Valley, Chile"),
|
||||
("Kim Crawford", "Hawke's Bay, New Zealand"),
|
||||
("Meiomi", "Monterey County, CA"),
|
||||
("Château d'Esclans", "Provence, France"),
|
||||
("Veuve Clicquot", "Reims, France"),
|
||||
("Giacomo Conterno", "Piemonte, Italy"),
|
||||
("Château de Beaucastel", "Rhône, France"),
|
||||
("Dr. Loosen", "Mosel, Germany"),
|
||||
]
|
||||
|
||||
WINE_REGIONS = [
|
||||
("Burgundy", "France"),
|
||||
("Napa Valley", "USA"),
|
||||
("Barossa Valley", "Australia"),
|
||||
("Marlborough", "New Zealand"),
|
||||
("Tuscany", "Italy"),
|
||||
("Ribera del Duero", "Spain"),
|
||||
("Mendoza", "Argentina"),
|
||||
("Casablanca Valley", "Chile"),
|
||||
("Hawke's Bay", "New Zealand"),
|
||||
("Monterey County", "USA"),
|
||||
("Willamette Valley", "USA"),
|
||||
("Rioja", "Spain"),
|
||||
("Piemonte", "Italy"),
|
||||
("Champagne", "France"),
|
||||
("Mosel", "Germany"),
|
||||
("Provence", "France"),
|
||||
("Rhône", "France"),
|
||||
("Reims", "France"),
|
||||
]
|
||||
|
||||
WINE_GRAPES = [
|
||||
"Pinot Noir",
|
||||
"Cabernet Sauvignon",
|
||||
"Merlot",
|
||||
"Syrah",
|
||||
"Grenache",
|
||||
"Chardonnay",
|
||||
"Sauvignon Blanc",
|
||||
"Riesling",
|
||||
"Tempranillo",
|
||||
"Malbec",
|
||||
"Nebbiolo",
|
||||
"Sangiovese",
|
||||
"Zinfandel",
|
||||
"Viognier",
|
||||
"Mourvèdre",
|
||||
]
|
||||
|
||||
WINES = [
|
||||
(
|
||||
"2021 Pinot Noir",
|
||||
"Domaine de la Romanée-Conti",
|
||||
"Burgundy",
|
||||
"Pinot Noir",
|
||||
"red",
|
||||
"2021",
|
||||
4.5,
|
||||
),
|
||||
(
|
||||
"2019 Cabernet Sauvignon",
|
||||
"Opus One",
|
||||
"Napa Valley",
|
||||
"Cabernet Sauvignon",
|
||||
"red",
|
||||
"2019",
|
||||
4.8,
|
||||
),
|
||||
("2020 Shiraz", "Penfolds", "Barossa Valley", "Syrah", "red", "2020", 4.6),
|
||||
(
|
||||
"2022 Sauvignon Blanc",
|
||||
"Cloudy Bay",
|
||||
"Marlborough",
|
||||
"Sauvignon Blanc",
|
||||
"white",
|
||||
"2022",
|
||||
4.2,
|
||||
),
|
||||
("2020 Tignanello", "Antinori", "Tuscany", "Sangiovese", "red", "2020", 4.7),
|
||||
(
|
||||
"2018 Unico",
|
||||
"Vega Sicilia",
|
||||
"Ribera del Duero",
|
||||
"Tempranillo",
|
||||
"red",
|
||||
"2018",
|
||||
4.9,
|
||||
),
|
||||
("2021 Malbec", "Catena Zapata", "Mendoza", "Malbec", "red", "2021", 4.4),
|
||||
(
|
||||
"2022 Chardonnay",
|
||||
"Concha y Toro",
|
||||
"Casablanca Valley",
|
||||
"Chardonnay",
|
||||
"white",
|
||||
"2022",
|
||||
4.1,
|
||||
),
|
||||
(
|
||||
"2021 Sauvignon Blanc",
|
||||
"Kim Crawford",
|
||||
"Hawke's Bay",
|
||||
"Sauvignon Blanc",
|
||||
"white",
|
||||
"2021",
|
||||
4.0,
|
||||
),
|
||||
("2020 Pinot Noir", "Meiomi", "Monterey County", "Pinot Noir", "red", "2020", 4.3),
|
||||
("2021 Rosé", "Château d'Esclans", "Provence", "Grenache", "rosé", "2021", 4.2),
|
||||
(
|
||||
"2020 Champagne Brut",
|
||||
"Veuve Clicquot",
|
||||
"Champagne",
|
||||
"Chardonnay",
|
||||
"sparkling",
|
||||
"NV",
|
||||
4.5,
|
||||
),
|
||||
("2019 Barolo", "Giacomo Conterno", "Piemonte", "Nebbiolo", "red", "2019", 4.8),
|
||||
(
|
||||
"2020 Châteauneuf-du-Pape",
|
||||
"Château de Beaucastel",
|
||||
"Rhône",
|
||||
"Grenache",
|
||||
"red",
|
||||
"2020",
|
||||
4.6,
|
||||
),
|
||||
("2021 Riesling Spätlese", "Dr. Loosen", "Mosel", "Riesling", "white", "2021", 4.3),
|
||||
]
|
||||
|
||||
COFFEE_ROASTERS = [
|
||||
("Counter Culture", "Durham, NC"),
|
||||
("Intelligentsia", "Chicago, IL"),
|
||||
("Stumptown", "Portland, OR"),
|
||||
("Blue Bottle", "Oakland, CA"),
|
||||
(
|
||||
"Onyx Coffee Lab",
|
||||
"Rogers, AR",
|
||||
),
|
||||
("Verve", "Santa Cruz, CA"),
|
||||
("Heart", "Portland, OR"),
|
||||
("George Howell", "Boston, MA"),
|
||||
("SEY", "Brooklyn, NY"),
|
||||
("Black & White", "Raleigh, NC"),
|
||||
]
|
||||
|
||||
COFFEES = [
|
||||
("Hologram", "Counter Culture", "Ethiopia", "medium"),
|
||||
("Big Trouble", "Counter Culture", "Colombia", "medium"),
|
||||
("Black Cat Espresso", "Intelligentsia", "Brazil", "medium"),
|
||||
("Hair Bender", "Intelligentsia", "Latin America", "dark"),
|
||||
("Stumptown Hair Bender", "Stumptown", "Latin America", "medium"),
|
||||
("Holler Mountain", "Stumptown", "Ethiopia", "medium"),
|
||||
("Bella Donovan", "Blue Bottle", "Latin America", "medium"),
|
||||
("Three Africas", "Blue Bottle", "Uganda", "light"),
|
||||
("Monarch", "Onyx Coffee Lab", "Ethiopia", "light"),
|
||||
("Geometry", "Onyx Coffee Lab", "Colombia", "medium"),
|
||||
("Buena Vista", "Verve", "Guatemala", "medium"),
|
||||
("Farmhouse", "Verve", "Costa Rica", "medium"),
|
||||
("Decaf", "Heart", "Colombia", "medium"),
|
||||
("Los Altos", "Heart", "Guatemala", "medium"),
|
||||
("Hayes Valley Espresso", "George Howell", "Brazil", "medium"),
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Populate demo data for the drinks app with realistic scrobbles"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user",
|
||||
type=str,
|
||||
help="Username to create scrobbles for (default: creates 'demo')",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--days",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Number of days of history to generate (default: 30)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clear",
|
||||
action="store_true",
|
||||
help="Clear existing demo data before populating",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get("user") or "demo"
|
||||
days = options["days"]
|
||||
clear = options["clear"]
|
||||
|
||||
user, _created = User.objects.get_or_create(
|
||||
username=username,
|
||||
defaults={"email": f"{username}@demo.local"},
|
||||
)
|
||||
if _created:
|
||||
user.set_password("demo")
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Created user '{username}'"))
|
||||
else:
|
||||
self.stdout.write(f"Using existing user '{username}'")
|
||||
|
||||
if clear:
|
||||
self.stdout.write("Clearing existing demo drink scrobbles...")
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type__in=["Beer", "Wine", "Coffee"],
|
||||
).delete()
|
||||
FavoriteMedia.objects.filter(user=user).delete()
|
||||
self.stdout.write(self.style.SUCCESS("Cleared demo data"))
|
||||
|
||||
self.stdout.write("Creating beer styles...")
|
||||
style_objects = {}
|
||||
for style_name in BEER_STYLES:
|
||||
style, _ = BeerStyle.objects.get_or_create(name=style_name)
|
||||
style_objects[style_name] = style
|
||||
|
||||
self.stdout.write("Creating beer producers...")
|
||||
producer_objects = {}
|
||||
for name, location in BEER_PRODUCERS:
|
||||
producer, _ = BeerProducer.objects.get_or_create(
|
||||
name=name, defaults={"location": location}
|
||||
)
|
||||
producer_objects[name] = producer
|
||||
|
||||
self.stdout.write("Creating beers...")
|
||||
beer_objects = []
|
||||
for title, producer_name, styles, abv, ibu in BEERS:
|
||||
beer, _ = Beer.objects.get_or_create(
|
||||
untappd_id=title.lower().replace(" ", "-"),
|
||||
defaults={
|
||||
"title": title,
|
||||
"abv": abv,
|
||||
"ibu": ibu,
|
||||
"producer_id": producer_objects[producer_name].id,
|
||||
"description": f"A delicious {styles[0]} from {producer_name}.",
|
||||
"base_run_time_seconds": 900,
|
||||
"calories": int(abv * 30),
|
||||
},
|
||||
)
|
||||
for style_name in styles:
|
||||
beer.styles.add(style_objects[style_name])
|
||||
beer_objects.append(beer)
|
||||
|
||||
self.stdout.write("Creating wine regions...")
|
||||
region_objects = {}
|
||||
for name, country in WINE_REGIONS:
|
||||
region, _ = WineRegion.objects.get_or_create(
|
||||
name=name, defaults={"country": country}
|
||||
)
|
||||
region_objects[name] = region
|
||||
|
||||
self.stdout.write("Creating wine producers...")
|
||||
wine_producer_objects = {}
|
||||
for name, location in WINE_PRODUCERS:
|
||||
producer, _ = WineProducer.objects.get_or_create(
|
||||
name=name, defaults={"location": location}
|
||||
)
|
||||
wine_producer_objects[name] = producer
|
||||
|
||||
self.stdout.write("Creating wine grapes...")
|
||||
grape_objects = {}
|
||||
for name in WINE_GRAPES:
|
||||
grape, _ = WineGrape.objects.get_or_create(name=name)
|
||||
grape_objects[name] = grape
|
||||
|
||||
self.stdout.write("Creating wines...")
|
||||
wine_objects = []
|
||||
for (
|
||||
title,
|
||||
producer_name,
|
||||
region_name,
|
||||
grape_name,
|
||||
wine_type,
|
||||
vintage,
|
||||
rating,
|
||||
) in WINES:
|
||||
wine, _ = Wine.objects.get_or_create(
|
||||
vivino_id=title.lower().replace(" ", "-"),
|
||||
defaults={
|
||||
"title": title,
|
||||
"wine_type": wine_type,
|
||||
"vintage": vintage,
|
||||
"vivino_rating": rating,
|
||||
"producer_id": wine_producer_objects[producer_name].id,
|
||||
"region_id": region_objects[region_name].id,
|
||||
"description": f"A fine {wine_type} from {producer_name}.",
|
||||
"base_run_time_seconds": 3600,
|
||||
"calories": 125,
|
||||
},
|
||||
)
|
||||
wine.grapes.add(grape_objects[grape_name])
|
||||
wine_objects.append(wine)
|
||||
|
||||
self.stdout.write("Creating coffee roasters...")
|
||||
roaster_objects = {}
|
||||
for name, location in COFFEE_ROASTERS:
|
||||
roaster, _ = CoffeeRoaster.objects.get_or_create(
|
||||
name=name, defaults={"location": location}
|
||||
)
|
||||
roaster_objects[name] = roaster
|
||||
|
||||
self.stdout.write("Creating coffees...")
|
||||
coffee_objects = []
|
||||
for title, roaster_name, origin, _roast_level in COFFEES:
|
||||
coffee, _ = Coffee.objects.get_or_create(
|
||||
roastdb_id=title.lower().replace(" ", "-"),
|
||||
defaults={
|
||||
"title": title,
|
||||
"origin": origin,
|
||||
"roaster_id": roaster_objects[roaster_name].id,
|
||||
"description": f"Specialty coffee from {roaster_name}.",
|
||||
"base_run_time_seconds": 1200,
|
||||
"calories": 5,
|
||||
},
|
||||
)
|
||||
coffee_objects.append(coffee)
|
||||
|
||||
self.stdout.write("Creating scrobbles...")
|
||||
now = timezone.now()
|
||||
scrobbles_created = 0
|
||||
|
||||
for day_offset in range(days):
|
||||
day = now - timedelta(days=day_offset)
|
||||
|
||||
# Random number of drinks per day (1-5)
|
||||
num_drinks = random.randint(1, 5)
|
||||
for _ in range(num_drinks):
|
||||
# Pick a random drink type weighted toward beers
|
||||
drink_type = random.choices(
|
||||
["beer", "wine", "coffee"], weights=[50, 25, 25]
|
||||
)[0]
|
||||
|
||||
if drink_type == "beer":
|
||||
media = random.choice(beer_objects)
|
||||
media_type = Scrobble.MediaType.BEER
|
||||
source = random.choice(["Untappd", "Vrobbler"])
|
||||
log_data = {
|
||||
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||
"format": random.choice(
|
||||
[
|
||||
DrinkFormat.ALUMINUM_CAN,
|
||||
DrinkFormat.GLASS_BOTTLE,
|
||||
DrinkFormat.DRAFT,
|
||||
]
|
||||
),
|
||||
"size_ml": random.choice([330, 355, 473, 500]),
|
||||
}
|
||||
elif drink_type == "wine":
|
||||
media = random.choice(wine_objects)
|
||||
media_type = Scrobble.MediaType.WINE
|
||||
source = random.choice(["Vivino", "Vrobbler"])
|
||||
log_data = {
|
||||
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||
"format": random.choice(
|
||||
[
|
||||
DrinkFormat.GLASS_BOTTLE,
|
||||
DrinkFormat.BY_THE_GLASS,
|
||||
DrinkFormat.BOX,
|
||||
]
|
||||
),
|
||||
"size_ml": random.choice([150, 375, 750]),
|
||||
}
|
||||
else:
|
||||
media = random.choice(coffee_objects)
|
||||
media_type = Scrobble.MediaType.COFFEE
|
||||
source = random.choice(["RoastDB", "Vrobbler"])
|
||||
log_data = {
|
||||
"rating": str(random.choice([3.5, 4.0, 4.5, 5.0])),
|
||||
"format": random.choice(
|
||||
[
|
||||
DrinkFormat.GLASS_BOTTLE,
|
||||
DrinkFormat.ALUMINUM_CAN,
|
||||
DrinkFormat.BY_THE_GLASS,
|
||||
]
|
||||
),
|
||||
"size_ml": random.choice([240, 355, 473]),
|
||||
"roast_level": random.choice(RoastLevel.values),
|
||||
"brewing_method": random.choice(BrewingMethod.values),
|
||||
"single_origin_or_blend": random.choice(
|
||||
["single origin", "blend"]
|
||||
),
|
||||
}
|
||||
|
||||
# Random time during the day
|
||||
hour = random.randint(7, 22)
|
||||
minute = random.randint(0, 59)
|
||||
timestamp = day.replace(
|
||||
hour=hour, minute=minute, second=0, microsecond=0
|
||||
)
|
||||
|
||||
# Use the FK field name from the media class name
|
||||
fk_field = f"{media.__class__.__name__[0].lower()}{media.__class__.__name__[1:]}"
|
||||
# Simpler: just lowercase the class name
|
||||
fk_field = media.__class__.__name__.lower()
|
||||
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=user,
|
||||
media_type=media_type,
|
||||
timestamp=timestamp,
|
||||
playback_position_seconds=media.base_run_time_seconds or 900,
|
||||
played_to_completion=True,
|
||||
source=source,
|
||||
log=log_data,
|
||||
**{fk_field: media},
|
||||
)
|
||||
scrobbles_created += 1
|
||||
|
||||
# Some scrobbles get favorited
|
||||
if random.random() < 0.15:
|
||||
FavoriteMedia.toggle(media, user)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\nDone! Created:\n"
|
||||
f" {len(beer_objects)} beers\n"
|
||||
f" {len(wine_objects)} wines\n"
|
||||
f" {len(coffee_objects)} coffees\n"
|
||||
f" {scrobbles_created} scrobbles over {days} days\n"
|
||||
f" User: {username} / demo"
|
||||
)
|
||||
)
|
||||
@ -1,9 +1,10 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@ -46,6 +47,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "beers_beerproducer",
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
@ -127,6 +129,7 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "beers_beer",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
@ -1,13 +1,13 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0001_initial"),
|
||||
("drinks", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -25,7 +25,7 @@ class Migration(migrations.Migration):
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="beers.beerproducer",
|
||||
to="drinks.beerproducer",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
@ -1,13 +1,13 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:47
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||
("drinks", "0002_beer_beeradvocate_image_beer_producer_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -38,6 +38,7 @@ class Migration(migrations.Migration):
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
"db_table": "beers_beerstyle",
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
@ -70,6 +71,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="styles",
|
||||
field=models.ManyToManyField(to="beers.beerstyle"),
|
||||
field=models.ManyToManyField(to="drinks.beerstyle"),
|
||||
),
|
||||
]
|
||||
@ -1,13 +1,14 @@
|
||||
# Generated by Django 4.2.16 on 2024-10-22 21:52
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||
("drinks", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -41,7 +42,7 @@ class Migration(migrations.Migration):
|
||||
model_name="beer",
|
||||
name="styles",
|
||||
field=models.ManyToManyField(
|
||||
related_name="styles", to="beers.beerstyle"
|
||||
related_name="styles", to="drinks.beerstyle"
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -7,7 +7,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"beers",
|
||||
"drinks",
|
||||
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
|
||||
),
|
||||
]
|
||||
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('beers', '0005_alter_beer_run_time_seconds'),
|
||||
('drinks', '0005_alter_beer_run_time_seconds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -1,14 +1,14 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-26 21:25
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("beers", "0006_remove_beer_run_time_seconds_and_more"),
|
||||
("drinks", "0006_remove_beer_run_time_seconds_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -1,14 +1,14 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-01 15:49
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0075_add_channel_scrobble"),
|
||||
("beers", "0007_beer_tags"),
|
||||
("drinks", "0007_beer_tags"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
@ -0,0 +1,400 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-13 14:33
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("drinks", "0008_alter_beer_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CoffeeRoaster",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("location", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WineGrape",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WineProducer",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("location", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("vivino_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||
(
|
||||
"cellartracker_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WineRegion",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
("country", models.CharField(blank=True, max_length=255, null=True)),
|
||||
],
|
||||
options={
|
||||
"get_latest_by": "modified",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="beer",
|
||||
name="calories",
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="producer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.beerproducer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="styles",
|
||||
field=models.ManyToManyField(related_name="styles", to="drinks.beerstyle"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="beer",
|
||||
name="untappd_image",
|
||||
field=models.ImageField(blank=True, null=True, upload_to="drinks/untappd/"),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Wine",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||
("calories", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
(
|
||||
"wine_type",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("red", "Red"),
|
||||
("white", "White"),
|
||||
("rosé", "Rosé"),
|
||||
("sparkling", "Sparkling"),
|
||||
("dessert", "Dessert"),
|
||||
("fortified", "Fortified"),
|
||||
("orange", "Orange"),
|
||||
],
|
||||
max_length=20,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
("vintage", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("vivino_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("vivino_rating", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"vivino_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="drinks/vivino/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"cellartracker_id",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
("cellartracker_rating", models.FloatField(blank=True, null=True)),
|
||||
(
|
||||
"cellartracker_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="drinks/cellartracker/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
(
|
||||
"grapes",
|
||||
models.ManyToManyField(
|
||||
blank=True, related_name="wines", to="drinks.winegrape"
|
||||
),
|
||||
),
|
||||
(
|
||||
"producer",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.wineproducer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"region",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.wineregion",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Coffee",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||
("calories", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("description", models.TextField(blank=True, null=True)),
|
||||
("origin", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("roastdb_id", models.CharField(blank=True, max_length=255, null=True)),
|
||||
(
|
||||
"roastdb_image",
|
||||
models.ImageField(
|
||||
blank=True, null=True, upload_to="drinks/roastdb/"
|
||||
),
|
||||
),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
(
|
||||
"roaster",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.coffeeroaster",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,90 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-13 18:26
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_extensions.db.fields
|
||||
import taggit.managers
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0101_favoritemedia_coffee_favoritemedia_wine_and_more"),
|
||||
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
|
||||
("drinks", "0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="beerproducer",
|
||||
options={},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="beerstyle",
|
||||
options={},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="wine",
|
||||
name="non_alcoholic",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Drink",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=255, null=True)),
|
||||
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
|
||||
("calories", models.PositiveIntegerField(blank=True, null=True)),
|
||||
("is_alcoholic", models.BooleanField(default=False)),
|
||||
(
|
||||
"genre",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="scrobbles.ObjectWithGenres",
|
||||
to="scrobbles.Genre",
|
||||
verbose_name="Genre",
|
||||
),
|
||||
),
|
||||
(
|
||||
"tags",
|
||||
taggit.managers.TaggableManager(
|
||||
blank=True,
|
||||
help_text="A comma-separated list of tags.",
|
||||
through="taggit.TaggedItem",
|
||||
to="taggit.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"db_table": "drinks_drink",
|
||||
},
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
0
vrobbler/apps/drinks/migrations/__init__.py
Normal file
596
vrobbler/apps/drinks/models.py
Normal file
596
vrobbler/apps/drinks/models.py
Normal file
@ -0,0 +1,596 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
from django.apps import apps
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from drinks.cellartracker import get_wine_from_cellartracker_id
|
||||
from drinks.roastdb import get_coffee_from_slug, search_coffee_on_roastdb
|
||||
from drinks.untappd import get_beer_from_untappd_id
|
||||
from drinks.vivino import get_wine_from_vivino_id
|
||||
from imagekit.models import ImageSpecField
|
||||
from imagekit.processors import ResizeToFit
|
||||
from scrobbles.dataclasses import BaseLogData
|
||||
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
||||
|
||||
BNULL = {"blank": True, "null": True}
|
||||
|
||||
|
||||
class WineType(models.TextChoices):
|
||||
RED = "red", "Red"
|
||||
WHITE = "white", "White"
|
||||
ROSE = "rosé", "Rosé"
|
||||
SPARKLING = "sparkling", "Sparkling"
|
||||
DESSERT = "dessert", "Dessert"
|
||||
FORTIFIED = "fortified", "Fortified"
|
||||
ORANGE = "orange", "Orange"
|
||||
|
||||
|
||||
class DrinkFormat(models.TextChoices):
|
||||
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
||||
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
||||
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
||||
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
||||
KEG = "keg", "Keg"
|
||||
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
||||
DRAFT = "draft", "Draft"
|
||||
GROWLER = "growler", "Growler"
|
||||
BOX = "box", "Box"
|
||||
MUG = "mug", "Mug"
|
||||
|
||||
|
||||
class RoastLevel(models.TextChoices):
|
||||
CINNAMON = "cinnamon", "Cinnamon"
|
||||
CITY = "city", "City"
|
||||
CITY_PLUS = "city_plus", "City+"
|
||||
FULL_CITY = "full_city", "Full City"
|
||||
FULL_CITY_PLUS = "full_city_plus", "Full City+"
|
||||
VIENNA = "vienna", "Vienna"
|
||||
FRENCH = "french", "French"
|
||||
SPANISH = "spanish", "Spanish"
|
||||
|
||||
|
||||
class BrewingMethod(models.TextChoices):
|
||||
POUROVER = "pourover", "Pour-over"
|
||||
DRIP = "drip", "Drip"
|
||||
FRENCH_PRESS = "french_press", "French Press"
|
||||
ESPRESSO = "espresso", "Espresso"
|
||||
AEROPRESS = "aeropress", "AeroPress"
|
||||
KEURIG = "keurig", "Keurig"
|
||||
NESCAPRESSO = "nespresso", "Nespresso"
|
||||
COLD_BREW = "cold_brew", "Cold Brew"
|
||||
MOKA_POT = "moka_pot", "Moka Pot"
|
||||
SIPHON = "siphon", "Siphon"
|
||||
TURKISH = "turkish", "Turkish"
|
||||
CHEMEX = "chemex", "Chemex"
|
||||
|
||||
|
||||
ML_PER_OZ = 29.5735
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrinkLogData(BaseLogData):
|
||||
rating: Optional[str] = None
|
||||
format: Optional[str] = None
|
||||
size_ml: Optional[int] = None
|
||||
|
||||
@property
|
||||
def size_oz(self) -> Optional[float]:
|
||||
if self.size_ml:
|
||||
return round(float(self.size_ml) / ML_PER_OZ, 1)
|
||||
return None
|
||||
|
||||
@property
|
||||
def format_display(self) -> str:
|
||||
if self.format:
|
||||
return DrinkFormat(self.format).label
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from django import forms
|
||||
|
||||
return {
|
||||
"format": forms.ChoiceField(
|
||||
choices=[("", "---------")] + list(DrinkFormat.choices),
|
||||
required=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeerLogData(DrinkLogData):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class WineLogData(DrinkLogData):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoffeeLogData(DrinkLogData):
|
||||
roast_level: Optional[str] = None
|
||||
brewing_method: Optional[str] = None
|
||||
single_origin_or_blend: Optional[str] = None
|
||||
|
||||
@property
|
||||
def roast_level_display(self) -> str:
|
||||
if self.roast_level:
|
||||
return RoastLevel(self.roast_level).label
|
||||
return ""
|
||||
|
||||
@property
|
||||
def brewing_method_display(self) -> str:
|
||||
if self.brewing_method:
|
||||
return BrewingMethod(self.brewing_method).label
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from django import forms
|
||||
|
||||
fields = super().override_fields()
|
||||
fields.update(
|
||||
{
|
||||
"roast_level": forms.ChoiceField(
|
||||
choices=[("", "---------")] + list(RoastLevel.choices),
|
||||
required=False,
|
||||
),
|
||||
"brewing_method": forms.ChoiceField(
|
||||
choices=[("", "---------")] + list(BrewingMethod.choices),
|
||||
required=False,
|
||||
),
|
||||
}
|
||||
)
|
||||
return fields
|
||||
|
||||
|
||||
class Drink(ScrobblableMixin):
|
||||
calories = models.PositiveIntegerField(**BNULL)
|
||||
is_alcoholic = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
db_table = "drinks_drink"
|
||||
|
||||
def __str__(self):
|
||||
return self.title or "Drink"
|
||||
|
||||
@classmethod
|
||||
def find_or_create_water(cls) -> "Drink":
|
||||
water, _ = cls.objects.get_or_create(
|
||||
title="Water", defaults={"calories": 0, "base_run_time_seconds": 120}
|
||||
)
|
||||
return water
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("drinks:drink_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return DrinkLogData
|
||||
|
||||
|
||||
class BeerStyle(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
db_table = "beers_beerstyle"
|
||||
|
||||
|
||||
class BeerProducer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def find_or_create(cls, title: str) -> "BeerProducer":
|
||||
return cls.objects.filter(title=title).first()
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
class Meta:
|
||||
db_table = "beers_beerproducer"
|
||||
|
||||
|
||||
class Beer(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
ibu = models.SmallIntegerField(**BNULL)
|
||||
abv = models.FloatField(**BNULL)
|
||||
styles = models.ManyToManyField(BeerStyle, related_name="styles")
|
||||
calories = models.PositiveIntegerField(**BNULL)
|
||||
non_alcoholic = models.BooleanField(default=False)
|
||||
beeradvocate_id = models.CharField(max_length=255, **BNULL)
|
||||
beeradvocate_score = models.SmallIntegerField(**BNULL)
|
||||
untappd_image = models.ImageField(upload_to="drinks/untappd/", **BNULL)
|
||||
untappd_image_small = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
untappd_image_medium = ImageSpecField(
|
||||
source="untappd_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
untappd_id = models.CharField(max_length=255, **BNULL)
|
||||
untappd_rating = models.FloatField(**BNULL)
|
||||
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("drinks:beer_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.producer}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.producer.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="beer")
|
||||
|
||||
@property
|
||||
def beeradvocate_link(self) -> str:
|
||||
link = ""
|
||||
if self.producer and self.beeradvocate_id:
|
||||
if self.beeradvocate_id:
|
||||
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def untappd_link(self) -> str:
|
||||
link = ""
|
||||
if self.untappd_id:
|
||||
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
|
||||
return link
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.untappd_image:
|
||||
url = self.untappd_image.url
|
||||
return url
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return BeerLogData
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, untappd_id: str) -> "Beer":
|
||||
beer = cls.objects.filter(untappd_id=untappd_id).first()
|
||||
|
||||
if not beer:
|
||||
beer_dict = get_beer_from_untappd_id(untappd_id)
|
||||
producer_dict = {}
|
||||
style_ids = []
|
||||
for key in list(beer_dict.keys()):
|
||||
if "producer__" in key:
|
||||
pkey = key.replace("producer__", "")
|
||||
producer_dict[pkey] = beer_dict.pop(key)
|
||||
if "styles" in key:
|
||||
for style in beer_dict.pop("styles"):
|
||||
style_inst, created = BeerStyle.objects.get_or_create(
|
||||
name=style
|
||||
)
|
||||
style_ids.append(style_inst.id)
|
||||
|
||||
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
|
||||
beer_dict["producer_id"] = producer.id
|
||||
beer = Beer.objects.create(**beer_dict)
|
||||
for style_id in style_ids:
|
||||
beer.styles.add(style_id)
|
||||
|
||||
return beer
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "beers_beer"
|
||||
|
||||
|
||||
# --- Wine models ---
|
||||
|
||||
|
||||
class WineGrape(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class WineRegion(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
country = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class WineProducer(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Wine(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
calories = models.PositiveIntegerField(**BNULL)
|
||||
non_alcoholic = models.BooleanField(default=False)
|
||||
wine_type = models.CharField(max_length=20, choices=WineType.choices, **BNULL)
|
||||
grapes = models.ManyToManyField(WineGrape, related_name="wines", blank=True)
|
||||
region = models.ForeignKey(WineRegion, on_delete=models.DO_NOTHING, **BNULL)
|
||||
producer = models.ForeignKey(WineProducer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
vintage = models.CharField(max_length=255, **BNULL)
|
||||
vivino_id = models.CharField(max_length=255, **BNULL)
|
||||
vivino_rating = models.FloatField(**BNULL)
|
||||
vivino_image = models.ImageField(upload_to="drinks/vivino/", **BNULL)
|
||||
vivino_image_small = ImageSpecField(
|
||||
source="vivino_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
vivino_image_medium = ImageSpecField(
|
||||
source="vivino_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
cellartracker_id = models.CharField(max_length=255, **BNULL)
|
||||
cellartracker_rating = models.FloatField(**BNULL)
|
||||
cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.producer}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.producer.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="wine")
|
||||
|
||||
@property
|
||||
def vivino_link(self) -> str:
|
||||
if self.vivino_id:
|
||||
return f"https://www.vivino.com/{self.vivino_id}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def cellartracker_link(self) -> str:
|
||||
if self.cellartracker_id:
|
||||
return (
|
||||
f"https://www.cellartracker.com/wine.asp?iWine={self.cellartracker_id}"
|
||||
)
|
||||
return ""
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.vivino_image:
|
||||
return self.vivino_image.url
|
||||
if self.cellartracker_image:
|
||||
return self.cellartracker_image.url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return WineLogData
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.vivino_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(
|
||||
cls, vivino_id: str = None, cellartracker_id: str = None
|
||||
) -> "Wine":
|
||||
wine = None
|
||||
if vivino_id:
|
||||
wine = cls.objects.filter(vivino_id=vivino_id).first()
|
||||
if not wine and cellartracker_id:
|
||||
wine = cls.objects.filter(cellartracker_id=cellartracker_id).first()
|
||||
|
||||
if wine:
|
||||
return wine
|
||||
|
||||
wine_dict = None
|
||||
if vivino_id:
|
||||
wine_dict = get_wine_from_vivino_id(vivino_id)
|
||||
elif cellartracker_id:
|
||||
wine_dict = get_wine_from_cellartracker_id(cellartracker_id)
|
||||
|
||||
if not wine_dict or not wine_dict.get("title"):
|
||||
return None
|
||||
|
||||
producer_name = wine_dict.pop("producer__name", "")
|
||||
region_name = wine_dict.pop("region", "")
|
||||
grape_names = wine_dict.pop("grapes", [])
|
||||
image_url = wine_dict.pop("image_url", "")
|
||||
|
||||
producer = None
|
||||
if producer_name:
|
||||
producer, _ = WineProducer.objects.get_or_create(name=producer_name)
|
||||
|
||||
region = None
|
||||
if region_name:
|
||||
region, _ = WineRegion.objects.get_or_create(name=region_name)
|
||||
|
||||
wine = cls.objects.create(
|
||||
title=wine_dict.get("title", ""),
|
||||
description=wine_dict.get("description", ""),
|
||||
wine_type=wine_dict.get("wine_type", ""),
|
||||
vintage=wine_dict.get("vintage", ""),
|
||||
vivino_id=wine_dict.get("vivino_id", vivino_id),
|
||||
vivino_rating=wine_dict.get("vivino_rating"),
|
||||
cellartracker_id=wine_dict.get("cellartracker_id", cellartracker_id),
|
||||
cellartracker_rating=wine_dict.get("cellartracker_rating"),
|
||||
producer=producer,
|
||||
region=region,
|
||||
)
|
||||
|
||||
for grape_name in grape_names:
|
||||
grape, _ = WineGrape.objects.get_or_create(name=grape_name)
|
||||
wine.grapes.add(grape)
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
wine.save_image_from_url(image_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return wine
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
|
||||
|
||||
# --- Coffee models ---
|
||||
|
||||
|
||||
class CoffeeRoaster(TimeStampedModel):
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.TextField(**BNULL)
|
||||
location = models.CharField(max_length=255, **BNULL)
|
||||
roastdb_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Coffee(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
calories = models.PositiveIntegerField(**BNULL)
|
||||
origin = models.CharField(max_length=255, **BNULL)
|
||||
roaster = models.ForeignKey(CoffeeRoaster, on_delete=models.DO_NOTHING, **BNULL)
|
||||
roastdb_id = models.CharField(max_length=255, **BNULL)
|
||||
roastdb_image = models.ImageField(upload_to="drinks/roastdb/", **BNULL)
|
||||
roastdb_image_small = ImageSpecField(
|
||||
source="roastdb_image",
|
||||
processors=[ResizeToFit(100, 100)],
|
||||
format="JPEG",
|
||||
options={"quality": 60},
|
||||
)
|
||||
roastdb_image_medium = ImageSpecField(
|
||||
source="roastdb_image",
|
||||
processors=[ResizeToFit(300, 300)],
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
return reverse("drinks:coffee_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.roaster}"
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return self.roaster.name
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Drinking", tags="coffee")
|
||||
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
if self.roastdb_image:
|
||||
return self.roastdb_image.url
|
||||
return ""
|
||||
|
||||
@property
|
||||
def logdata_cls(self):
|
||||
return CoffeeLogData
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.roastdb_image.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, roastdb_id: str = None, slug: str = None) -> "Coffee":
|
||||
coffee = None
|
||||
if roastdb_id:
|
||||
coffee = cls.objects.filter(roastdb_id=roastdb_id).first()
|
||||
if not coffee and slug:
|
||||
coffee = cls.objects.filter(roastdb_id=slug).first()
|
||||
if coffee:
|
||||
return coffee
|
||||
|
||||
coffee_dict = None
|
||||
if slug:
|
||||
coffee_dict = get_coffee_from_slug(slug)
|
||||
elif roastdb_id:
|
||||
coffee_dict = get_coffee_from_slug(roastdb_id)
|
||||
|
||||
if not coffee_dict or not coffee_dict.get("title"):
|
||||
return None
|
||||
|
||||
roaster_name = coffee_dict.pop("roaster__name", "")
|
||||
image_url = coffee_dict.pop("image_url", "")
|
||||
|
||||
roaster = None
|
||||
if roaster_name:
|
||||
roaster, _ = CoffeeRoaster.objects.get_or_create(name=roaster_name)
|
||||
|
||||
coffee = cls.objects.create(
|
||||
title=coffee_dict.get("title", ""),
|
||||
description=coffee_dict.get("description", ""),
|
||||
origin=coffee_dict.get("origin", ""),
|
||||
roastdb_id=slug or roastdb_id,
|
||||
roaster=roaster,
|
||||
)
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
coffee.save_image_from_url(image_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return coffee
|
||||
|
||||
def scrobbles(self, user_id):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
return Scrobble.objects.filter(user_id=user_id, coffee=self).order_by(
|
||||
"-timestamp"
|
||||
)
|
||||
89
vrobbler/apps/drinks/roastdb.py
Normal file
89
vrobbler/apps/drinks/roastdb.py
Normal file
@ -0,0 +1,89 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROASTDB_URL = "https://www.roastdb.com/beans/{slug}"
|
||||
ROASTDB_SEARCH_URL = "https://www.roastdb.com/explore?q={query}"
|
||||
|
||||
|
||||
def get_coffee_from_slug(slug: str) -> dict:
|
||||
coffee_url = ROASTDB_URL.format(slug=slug)
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
response = requests.get(coffee_url, headers=headers)
|
||||
coffee_dict = {"slug": slug}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning("Bad response from roastdb.com", extra={"response": response})
|
||||
return coffee_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
for script in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(script.string)
|
||||
if data.get("@type") == "Product":
|
||||
coffee_dict["title"] = data.get("name", "")
|
||||
coffee_dict["description"] = data.get("description", "")
|
||||
coffee_dict["image_url"] = data.get("image", "")
|
||||
brand = data.get("brand", {})
|
||||
coffee_dict["roaster__name"] = brand.get("name", "")
|
||||
origin = data.get("countryOfOrigin", {})
|
||||
coffee_dict["origin"] = origin.get("name", "")
|
||||
break
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
continue
|
||||
|
||||
if not coffee_dict.get("title"):
|
||||
bean_data = _extract_initial_bean(soup)
|
||||
if bean_data:
|
||||
coffee_dict["title"] = bean_data.get("name", "")
|
||||
coffee_dict["description"] = bean_data.get("description", "")
|
||||
coffee_dict["origin"] = bean_data.get("origin_country", "")
|
||||
coffee_dict["roaster__name"] = bean_data.get("roaster_name", "")
|
||||
image_urls = bean_data.get("image_urls", [])
|
||||
if image_urls:
|
||||
coffee_dict["image_url"] = image_urls[0]
|
||||
|
||||
return coffee_dict
|
||||
|
||||
|
||||
def _extract_initial_bean(soup) -> dict | None:
|
||||
for script in soup.find_all("script"):
|
||||
if script.string and "initialBean" in (script.string or ""):
|
||||
match = re.search(r'"initialBean"\s*:\s*(\{.*?\})\s*\}', script.string)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group(1))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def search_coffee_on_roastdb(query: str) -> list[dict]:
|
||||
search_url = ROASTDB_SEARCH_URL.format(query=query)
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
response = requests.get(search_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from roastdb.com search", extra={"response": response}
|
||||
)
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
results = []
|
||||
|
||||
for link in soup.find_all("a", href=True):
|
||||
href = link["href"]
|
||||
if href.startswith("/beans/"):
|
||||
slug = href.split("/beans/", 1)[1].rstrip("/")
|
||||
title = link.get_text(strip=True)
|
||||
if slug and title:
|
||||
results.append({"slug": slug, "title": title})
|
||||
|
||||
return results
|
||||
37
vrobbler/apps/drinks/urls.py
Normal file
37
vrobbler/apps/drinks/urls.py
Normal file
@ -0,0 +1,37 @@
|
||||
from django.urls import path
|
||||
from drinks import views
|
||||
|
||||
app_name = "drinks"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("beers/", views.BeerListView.as_view(), name="beer_list"),
|
||||
path(
|
||||
"beers/<slug:slug>/",
|
||||
views.BeerDetailView.as_view(),
|
||||
name="beer_detail",
|
||||
),
|
||||
path("wines/", views.WineListView.as_view(), name="wine_list"),
|
||||
path(
|
||||
"wines/<slug:slug>/",
|
||||
views.WineDetailView.as_view(),
|
||||
name="wine_detail",
|
||||
),
|
||||
path("coffees/", views.CoffeeListView.as_view(), name="coffee_list"),
|
||||
path(
|
||||
"coffees/<slug:slug>/",
|
||||
views.CoffeeDetailView.as_view(),
|
||||
name="coffee_detail",
|
||||
),
|
||||
path("drinks/", views.DrinkListView.as_view(), name="drink_list"),
|
||||
path(
|
||||
"drinks/<slug:slug>/",
|
||||
views.DrinkDetailView.as_view(),
|
||||
name="drink_detail",
|
||||
),
|
||||
path(
|
||||
"quick-water/",
|
||||
views.QuickWaterScrobbleView.as_view(),
|
||||
name="quick_water_scrobble",
|
||||
),
|
||||
]
|
||||
58
vrobbler/apps/drinks/views.py
Normal file
58
vrobbler/apps/drinks/views.py
Normal file
@ -0,0 +1,58 @@
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.views import View
|
||||
from drinks.models import Beer, Coffee, Drink, Wine
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
|
||||
|
||||
class BeerListView(ScrobbleableListView):
|
||||
model = Beer
|
||||
|
||||
|
||||
class BeerDetailView(ScrobbleableDetailView):
|
||||
model = Beer
|
||||
|
||||
|
||||
class WineListView(ScrobbleableListView):
|
||||
model = Wine
|
||||
|
||||
|
||||
class WineDetailView(ScrobbleableDetailView):
|
||||
model = Wine
|
||||
|
||||
|
||||
class CoffeeListView(ScrobbleableListView):
|
||||
model = Coffee
|
||||
|
||||
|
||||
class CoffeeDetailView(ScrobbleableDetailView):
|
||||
model = Coffee
|
||||
|
||||
|
||||
class DrinkListView(ScrobbleableListView):
|
||||
model = Drink
|
||||
|
||||
|
||||
class DrinkDetailView(ScrobbleableDetailView):
|
||||
model = Drink
|
||||
|
||||
|
||||
class QuickWaterScrobbleView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if not request.user.is_authenticated:
|
||||
messages.error(request, "You must be logged in to scrobble water.")
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
from scrobbles.scrobblers import manual_scrobble_water
|
||||
|
||||
scrobble = manual_scrobble_water(user_id=request.user.id)
|
||||
|
||||
if scrobble:
|
||||
messages.success(request, "Water scrobbled!")
|
||||
return HttpResponseRedirect(scrobble.redirect_url(request.user.id))
|
||||
|
||||
messages.error(request, "Failed to scrobble water.")
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
return self.post(request, *args, **kwargs)
|
||||
131
vrobbler/apps/drinks/vivino.py
Normal file
131
vrobbler/apps/drinks/vivino.py
Normal file
@ -0,0 +1,131 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VIVINO_URL = "https://www.vivino.com/{vivino_id}"
|
||||
|
||||
|
||||
def get_title_from_soup(soup) -> str:
|
||||
title = ""
|
||||
try:
|
||||
title = soup.find("span", class_="winePageHeader__title").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return title
|
||||
|
||||
|
||||
def get_description_from_soup(soup) -> str:
|
||||
desc = ""
|
||||
try:
|
||||
desc = soup.find("div", class_="winePageHeader__description").get_text(
|
||||
strip=True
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return desc
|
||||
|
||||
|
||||
def get_wine_type_from_soup(soup) -> str:
|
||||
wine_type = ""
|
||||
try:
|
||||
wine_type = (
|
||||
soup.find("span", class_="winePageHeader__wine-type")
|
||||
.get_text(strip=True)
|
||||
.lower()
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return wine_type
|
||||
|
||||
|
||||
def get_rating_from_soup(soup) -> float:
|
||||
rating = None
|
||||
try:
|
||||
rating = float(
|
||||
soup.find("div", class_="winePageHeader__ratingValue").get_text(strip=True)
|
||||
)
|
||||
except (AttributeError, ValueError):
|
||||
pass
|
||||
return rating
|
||||
|
||||
|
||||
def get_grapes_from_soup(soup) -> list[str]:
|
||||
grapes = []
|
||||
try:
|
||||
grape_elements = soup.find_all("a", class_="wineLink")
|
||||
grapes = [
|
||||
g.get_text(strip=True) for g in grape_elements if g.get_text(strip=True)
|
||||
]
|
||||
except AttributeError:
|
||||
pass
|
||||
return grapes
|
||||
|
||||
|
||||
def get_region_from_soup(soup) -> str:
|
||||
region = ""
|
||||
try:
|
||||
region = soup.find("span", class_="winePageHeader__region").get_text(strip=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
return region
|
||||
|
||||
|
||||
def get_producer_from_soup(soup) -> str:
|
||||
producer = ""
|
||||
try:
|
||||
producer = soup.find("span", class_="winePageHeader__producer").get_text(
|
||||
strip=True
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return producer
|
||||
|
||||
|
||||
def get_vintage_from_soup(soup) -> str:
|
||||
vintage = ""
|
||||
try:
|
||||
vintage = soup.find("span", class_="winePageHeader__vintage").get_text(
|
||||
strip=True
|
||||
)
|
||||
except AttributeError:
|
||||
pass
|
||||
return vintage
|
||||
|
||||
|
||||
def get_image_url_from_soup(soup) -> str:
|
||||
image_url = ""
|
||||
try:
|
||||
image_url = soup.find("img", class_="winePageHeader__image")["src"]
|
||||
except (AttributeError, KeyError, TypeError):
|
||||
pass
|
||||
return image_url
|
||||
|
||||
|
||||
def get_wine_from_vivino_id(vivino_id: str) -> dict:
|
||||
wine_url = VIVINO_URL.format(vivino_id=vivino_id)
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
response = requests.get(wine_url, headers=headers)
|
||||
wine_dict = {"vivino_id": vivino_id}
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Bad response from vivino.com",
|
||||
extra={"status_code": response.status_code, "vivino_id": vivino_id},
|
||||
)
|
||||
return wine_dict
|
||||
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
wine_dict["title"] = get_title_from_soup(soup)
|
||||
wine_dict["description"] = get_description_from_soup(soup)
|
||||
wine_dict["wine_type"] = get_wine_type_from_soup(soup)
|
||||
wine_dict["vivino_rating"] = get_rating_from_soup(soup)
|
||||
wine_dict["grapes"] = get_grapes_from_soup(soup)
|
||||
wine_dict["region"] = get_region_from_soup(soup)
|
||||
wine_dict["producer__name"] = get_producer_from_soup(soup)
|
||||
wine_dict["vintage"] = get_vintage_from_soup(soup)
|
||||
wine_dict["image_url"] = get_image_url_from_soup(soup)
|
||||
|
||||
return wine_dict
|
||||
@ -1,5 +1,4 @@
|
||||
from django import forms
|
||||
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import Scrobble
|
||||
@ -42,6 +41,10 @@ class UserProfileForm(forms.ModelForm):
|
||||
"home_scrobble_limit",
|
||||
"live_now_playing",
|
||||
"weigh_in_units",
|
||||
"volume_unit",
|
||||
"default_water_format",
|
||||
"default_water_size",
|
||||
"water_reminder_enabled",
|
||||
"trends_disabled",
|
||||
]
|
||||
widgets = {
|
||||
@ -51,9 +54,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
}
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
mt.value: mt.label for mt in Scrobble.MediaType
|
||||
}
|
||||
MEDIA_TYPE_LABELS = {mt.value: mt.label for mt in Scrobble.MediaType}
|
||||
|
||||
INHERIT = ""
|
||||
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-13 16:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0040_userprofile_trends_disabled"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="volume_unit",
|
||||
field=models.CharField(
|
||||
choices=[("metric", "Metric (mL, L)"), ("imperial", "Imperial (oz)")],
|
||||
default="metric",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
39
vrobbler/apps/profiles/migrations/0042_add_water_defaults.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-14 00:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0041_userprofile_volume_unit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_water_format",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("aluminum_can", "Aluminum Can"),
|
||||
("glass_bottle", "Glass Bottle"),
|
||||
("plastic_bottle", "Plastic Bottle"),
|
||||
("metal_bottle", "Metal Bottle"),
|
||||
("keg", "Keg"),
|
||||
("by_the_glass", "By the Glass"),
|
||||
("draft", "Draft"),
|
||||
("growler", "Growler"),
|
||||
("box", "Box"),
|
||||
],
|
||||
default="by_the_glass",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_water_size",
|
||||
field=models.PositiveIntegerField(
|
||||
default=250, help_text="Default water scrobble size in mL"
|
||||
),
|
||||
),
|
||||
]
|
||||
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
18
vrobbler/apps/profiles/migrations/0043_add_water_reminder.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-14 03:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0042_add_water_defaults"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="water_reminder_enabled",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -1,14 +1,16 @@
|
||||
from zoneinfo import ZoneInfo
|
||||
import pendulum
|
||||
from django.utils import timezone
|
||||
import logging
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pendulum
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from encrypted_field import EncryptedField
|
||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||
|
||||
VISIBILITY_CHOICES = (
|
||||
("public", "Public"),
|
||||
("shared", "Shared"),
|
||||
@ -26,6 +28,23 @@ class WeighUnit(models.TextChoices):
|
||||
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
||||
|
||||
|
||||
class VolumeUnit(models.TextChoices):
|
||||
METRIC = "metric", "Metric (mL, L)"
|
||||
IMPERIAL = "imperial", "Imperial (oz)"
|
||||
|
||||
|
||||
class WaterFormat(models.TextChoices):
|
||||
ALUMINUM_CAN = "aluminum_can", "Aluminum Can"
|
||||
GLASS_BOTTLE = "glass_bottle", "Glass Bottle"
|
||||
PLASTIC_BOTTLE = "plastic_bottle", "Plastic Bottle"
|
||||
METAL_BOTTLE = "metal_bottle", "Metal Bottle"
|
||||
KEG = "keg", "Keg"
|
||||
BY_THE_GLASS = "by_the_glass", "By the Glass"
|
||||
DRAFT = "draft", "Draft"
|
||||
GROWLER = "growler", "Growler"
|
||||
BOX = "box", "Box"
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
timezone = models.CharField(
|
||||
@ -71,11 +90,13 @@ class UserProfile(TimeStampedModel):
|
||||
|
||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||
favorites_mopidy_playlist = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
max_length=255,
|
||||
**BNULL,
|
||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||
)
|
||||
monthly_mopidy_playlist_pattern = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
max_length=255,
|
||||
**BNULL,
|
||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||
)
|
||||
|
||||
@ -93,7 +114,7 @@ class UserProfile(TimeStampedModel):
|
||||
media_type_visibility = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
|
||||
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
|
||||
)
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
@ -106,6 +127,25 @@ class UserProfile(TimeStampedModel):
|
||||
default=WeighUnit.METRIC,
|
||||
)
|
||||
|
||||
volume_unit = models.CharField(
|
||||
max_length=16,
|
||||
choices=VolumeUnit.choices,
|
||||
default=VolumeUnit.METRIC,
|
||||
)
|
||||
|
||||
default_water_format = models.CharField(
|
||||
max_length=20,
|
||||
choices=WaterFormat.choices,
|
||||
default=WaterFormat.BY_THE_GLASS,
|
||||
)
|
||||
|
||||
default_water_size = models.PositiveIntegerField(
|
||||
default=250,
|
||||
help_text="Default water scrobble size in mL",
|
||||
)
|
||||
|
||||
water_reminder_enabled = models.BooleanField(default=False)
|
||||
|
||||
trends_disabled = models.BooleanField(default=False)
|
||||
|
||||
disabled_trends = models.JSONField(
|
||||
@ -153,7 +193,11 @@ class UserProfile(TimeStampedModel):
|
||||
from django.conf import settings
|
||||
|
||||
server_tz = ZoneInfo(settings.TIME_ZONE)
|
||||
ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz)
|
||||
ref_dt = (
|
||||
timestamp
|
||||
if timestamp.tzinfo is not None
|
||||
else timestamp.replace(tzinfo=server_tz)
|
||||
)
|
||||
|
||||
timezone = self.tzinfo
|
||||
if self.timezone_change_log:
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from scrobbles.mixins import Genre
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
BGStatsImport,
|
||||
@ -14,7 +14,6 @@ from scrobbles.models import (
|
||||
TrailGPXImport,
|
||||
UDiscCSVImport,
|
||||
)
|
||||
from scrobbles.mixins import Genre
|
||||
|
||||
|
||||
class ScrobbleInline(admin.TabularInline):
|
||||
@ -182,6 +181,8 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||
"brick_set",
|
||||
"trail",
|
||||
"beer",
|
||||
"wine",
|
||||
"coffee",
|
||||
"web_page",
|
||||
"life_event",
|
||||
"birding_location",
|
||||
|
||||
@ -1,12 +1,16 @@
|
||||
from django.db import models
|
||||
from enum import Enum
|
||||
|
||||
from django.db import models
|
||||
|
||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PUBLIC = "public", "Public"
|
||||
SHARED = "shared", "Shared"
|
||||
PRIVATE = "private", "Private"
|
||||
|
||||
|
||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||
|
||||
LONG_PLAY_MEDIA = {
|
||||
@ -23,6 +27,7 @@ AUTO_FINISH_MEDIA = {
|
||||
"tracks": "Track",
|
||||
"videos": "Video",
|
||||
"moods": "Mood",
|
||||
"drinks": "Drink",
|
||||
}
|
||||
|
||||
PLAY_AGAIN_MEDIA = {
|
||||
@ -35,7 +40,10 @@ PLAY_AGAIN_MEDIA = {
|
||||
"boardgames": "BoardGame",
|
||||
"locations": "GeoLocation",
|
||||
"trails": "Trail",
|
||||
"beers": "Beer",
|
||||
"drinks": "Beer",
|
||||
"drinks": "Wine",
|
||||
"drinks": "Coffee",
|
||||
"drinks": "Drink",
|
||||
"puzzles": "Puzzle",
|
||||
"foods": "Food",
|
||||
"tasks": "Task",
|
||||
@ -48,6 +56,8 @@ PLAY_AGAIN_MEDIA = {
|
||||
"discgolf": "DiscGolfCourse",
|
||||
}
|
||||
|
||||
DRINK_MODELS = ["Beer", "Wine", "Coffee", "Drink"]
|
||||
|
||||
MEDIA_END_PADDING_SECONDS = {
|
||||
"Video": 3600, # 60 min
|
||||
}
|
||||
@ -59,6 +69,8 @@ SCROBBLE_CONTENT_URLS = {
|
||||
"-s": ["https://www.thesportsdb.com/event/"],
|
||||
"-g": ["https://boardgamegeek.com/boardgame/"],
|
||||
"-u": ["https://untappd.com/"],
|
||||
"-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],
|
||||
"-co": ["https://roastdb.com/"],
|
||||
"-b": ["https://www.amazon.com/"],
|
||||
"-t": ["https://app.todoist.com/app/task/{id}"],
|
||||
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
|
||||
@ -77,12 +89,15 @@ MANUAL_SCROBBLE_FNS = {
|
||||
"-i": "manual_scrobble_video",
|
||||
"-g": "manual_scrobble_board_game",
|
||||
"-u": "manual_scrobble_beer",
|
||||
"-wi": "manual_scrobble_wine",
|
||||
"-co": "manual_scrobble_coffee",
|
||||
"-w": "manual_scrobble_webpage",
|
||||
"-t": "manual_scrobble_task",
|
||||
"-p": "manual_scrobble_puzzle",
|
||||
"-l": "manual_scrobble_brickset",
|
||||
"-c": "manual_scrobble_book",
|
||||
"-f": "manual_scrobble_food",
|
||||
"-d": "manual_scrobble_drink",
|
||||
"-h": "manual_scrobble_twitch_channel",
|
||||
"-dg": "manual_scrobble_discgolf",
|
||||
"-pp": "manual_scrobble_paper",
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
sent_count = send_water_reminder_notifications()
|
||||
print(f"Sent {sent_count} water reminder notifications")
|
||||
@ -18,16 +18,32 @@ class ScrobbleToolset(MCPToolset):
|
||||
qs = (
|
||||
Scrobble.objects.filter(user=self.request.user)
|
||||
.select_related(
|
||||
"video", "track", "book", "video_game", "board_game",
|
||||
"beer", "puzzle", "food", "trail", "task", "web_page",
|
||||
"life_event", "mood", "brick_set", "podcast_episode",
|
||||
"sport_event", "geo_location", "birding_location",
|
||||
"disc_golf_course", "channel",
|
||||
"video",
|
||||
"track",
|
||||
"book",
|
||||
"video_game",
|
||||
"board_game",
|
||||
"beer",
|
||||
"puzzle",
|
||||
"food",
|
||||
"trail",
|
||||
"task",
|
||||
"web_page",
|
||||
"life_event",
|
||||
"mood",
|
||||
"brick_set",
|
||||
"podcast_episode",
|
||||
"sport_event",
|
||||
"geo_location",
|
||||
"birding_location",
|
||||
"disc_golf_course",
|
||||
"channel",
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
|
||||
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
@ -47,6 +63,7 @@ class ScrobbleToolset(MCPToolset):
|
||||
) -> list[dict]:
|
||||
"""Search scrobbles by text in their log data or related media titles."""
|
||||
from django.db.models import Q
|
||||
|
||||
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
|
||||
if media_type:
|
||||
qs = qs.filter(media_type=media_type)
|
||||
@ -74,6 +91,7 @@ class ScrobbleToolset(MCPToolset):
|
||||
) -> list[dict]:
|
||||
"""Get scrobbles for a specific date (YYYY-MM-DD format)."""
|
||||
import datetime
|
||||
|
||||
try:
|
||||
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
@ -86,9 +104,7 @@ class ScrobbleToolset(MCPToolset):
|
||||
qs = qs.filter(media_type=media_type)
|
||||
return [_scrobble_to_dict(s) for s in qs]
|
||||
|
||||
def get_in_progress_scrobbles(
|
||||
self, media_type: str | None = None
|
||||
) -> list[dict]:
|
||||
def get_in_progress_scrobbles(self, media_type: str | None = None) -> list[dict]:
|
||||
"""Get scrobbles currently in progress (started but not finished).
|
||||
These are long-play items like books, video games, brick sets, or tasks."""
|
||||
qs = Scrobble.objects.filter(
|
||||
@ -122,70 +138,124 @@ class MediaToolset(MCPToolset):
|
||||
def get_book(self, uuid: str) -> dict | None:
|
||||
"""Get a book by UUID."""
|
||||
from books.models import Book
|
||||
|
||||
try:
|
||||
b = Book.objects.get(uuid=uuid)
|
||||
except Book.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(b, fields=["title", "pages", "language",
|
||||
"first_publish_year", "isbn_13",
|
||||
"publisher", "summary"])
|
||||
return _media_to_dict(
|
||||
b,
|
||||
fields=[
|
||||
"title",
|
||||
"pages",
|
||||
"language",
|
||||
"first_publish_year",
|
||||
"isbn_13",
|
||||
"publisher",
|
||||
"summary",
|
||||
],
|
||||
)
|
||||
|
||||
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List books, optionally filtered by author name."""
|
||||
from books.models import Book
|
||||
|
||||
qs = Book.objects.all().order_by("title")
|
||||
if author:
|
||||
qs = qs.filter(authors__name__icontains=author)
|
||||
return [_media_to_dict(b, fields=["title", "pages", "language",
|
||||
"first_publish_year", "isbn_13",
|
||||
"publisher"]) for b in qs[:limit]]
|
||||
return [
|
||||
_media_to_dict(
|
||||
b,
|
||||
fields=[
|
||||
"title",
|
||||
"pages",
|
||||
"language",
|
||||
"first_publish_year",
|
||||
"isbn_13",
|
||||
"publisher",
|
||||
],
|
||||
)
|
||||
for b in qs[:limit]
|
||||
]
|
||||
|
||||
def get_track(self, uuid: str) -> dict | None:
|
||||
"""Get a music track by UUID."""
|
||||
from music.models import Track
|
||||
|
||||
try:
|
||||
t = Track.objects.select_related("artist_fk").get(uuid=uuid)
|
||||
except Track.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(t, fields=["title", "base_run_time_seconds",
|
||||
"artist_fk__name", "genre"])
|
||||
return _media_to_dict(
|
||||
t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
|
||||
)
|
||||
|
||||
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List music tracks, optionally filtered by artist name."""
|
||||
from music.models import Track
|
||||
|
||||
qs = Track.objects.select_related("artist_fk").all().order_by("title")
|
||||
if artist:
|
||||
qs = qs.filter(artist_fk__name__icontains=artist)
|
||||
return [_media_to_dict(t, fields=["title", "base_run_time_seconds",
|
||||
"artist_fk__name", "genre"])
|
||||
for t in qs[:limit]]
|
||||
return [
|
||||
_media_to_dict(
|
||||
t, fields=["title", "base_run_time_seconds", "artist_fk__name", "genre"]
|
||||
)
|
||||
for t in qs[:limit]
|
||||
]
|
||||
|
||||
def get_video(self, uuid: str) -> dict | None:
|
||||
"""Get a video by UUID."""
|
||||
from videos.models import Video
|
||||
|
||||
try:
|
||||
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
|
||||
except Video.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(v, fields=["title", "year", "overview",
|
||||
"imdb_id", "imdb_rating",
|
||||
"tv_series__name", "channel__title",
|
||||
"season_number", "episode_number"])
|
||||
return _media_to_dict(
|
||||
v,
|
||||
fields=[
|
||||
"title",
|
||||
"year",
|
||||
"overview",
|
||||
"imdb_id",
|
||||
"imdb_rating",
|
||||
"tv_series__name",
|
||||
"channel__title",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
],
|
||||
)
|
||||
|
||||
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
|
||||
"""List videos, optionally filtered by series name."""
|
||||
from videos.models import Video
|
||||
qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")
|
||||
|
||||
qs = (
|
||||
Video.objects.select_related("tv_series", "channel").all().order_by("title")
|
||||
)
|
||||
if series:
|
||||
qs = qs.filter(tv_series__name__icontains=series)
|
||||
return [_media_to_dict(v, fields=["title", "year", "overview",
|
||||
"tv_series__name", "channel__title",
|
||||
"season_number", "episode_number"])
|
||||
for v in qs[:limit]]
|
||||
return [
|
||||
_media_to_dict(
|
||||
v,
|
||||
fields=[
|
||||
"title",
|
||||
"year",
|
||||
"overview",
|
||||
"tv_series__name",
|
||||
"channel__title",
|
||||
"season_number",
|
||||
"episode_number",
|
||||
],
|
||||
)
|
||||
for v in qs[:limit]
|
||||
]
|
||||
|
||||
def get_board_game(self, uuid: str) -> dict | None:
|
||||
"""Get a board game by UUID."""
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
try:
|
||||
bg = BoardGame.objects.get(uuid=uuid)
|
||||
except BoardGame.DoesNotExist:
|
||||
@ -195,34 +265,69 @@ class MediaToolset(MCPToolset):
|
||||
def list_board_games(self, limit: int = 20) -> list[dict]:
|
||||
"""List board games."""
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
qs = BoardGame.objects.all().order_by("title")[:limit]
|
||||
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
|
||||
|
||||
def get_podcast_episode(self, uuid: str) -> dict | None:
|
||||
"""Get a podcast episode by UUID."""
|
||||
from podcasts.models import PodcastEpisode
|
||||
|
||||
try:
|
||||
pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
|
||||
uuid=uuid
|
||||
)
|
||||
except PodcastEpisode.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(pe, fields=["title", "podcast__title",
|
||||
"producer__name", "base_run_time_seconds"])
|
||||
return _media_to_dict(
|
||||
pe,
|
||||
fields=[
|
||||
"title",
|
||||
"podcast__title",
|
||||
"producer__name",
|
||||
"base_run_time_seconds",
|
||||
],
|
||||
)
|
||||
|
||||
def get_beer(self, uuid: str) -> dict | None:
|
||||
"""Get a beer by UUID."""
|
||||
from beers.models import Beer
|
||||
from drinks.models import Beer
|
||||
|
||||
try:
|
||||
b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
|
||||
except Beer.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(b, fields=["title", "style__name",
|
||||
"producer__name", "abv"])
|
||||
return _media_to_dict(
|
||||
b, fields=["title", "style__name", "producer__name", "abv"]
|
||||
)
|
||||
|
||||
def get_wine(self, uuid: str) -> dict | None:
|
||||
"""Get a wine by UUID."""
|
||||
from drinks.models import Wine
|
||||
|
||||
try:
|
||||
w = Wine.objects.select_related("region", "producer").get(uuid=uuid)
|
||||
except Wine.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(
|
||||
w,
|
||||
fields=["title", "wine_type", "vintage", "producer__name", "region__name"],
|
||||
)
|
||||
|
||||
def get_coffee(self, uuid: str) -> dict | None:
|
||||
"""Get a coffee by UUID."""
|
||||
from drinks.models import Coffee
|
||||
|
||||
try:
|
||||
c = Coffee.objects.select_related("roaster").get(uuid=uuid)
|
||||
except Coffee.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(c, fields=["title", "origin", "roaster__name"])
|
||||
|
||||
def get_brick_set(self, uuid: str) -> dict | None:
|
||||
"""Get a brick set (LEGO) by UUID."""
|
||||
from bricksets.models import BrickSet
|
||||
|
||||
try:
|
||||
bs = BrickSet.objects.get(uuid=uuid)
|
||||
except BrickSet.DoesNotExist:
|
||||
@ -232,26 +337,27 @@ class MediaToolset(MCPToolset):
|
||||
def get_video_game(self, uuid: str) -> dict | None:
|
||||
"""Get a video game by UUID."""
|
||||
from videogames.models import VideoGame
|
||||
|
||||
try:
|
||||
vg = VideoGame.objects.get(uuid=uuid)
|
||||
except VideoGame.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(vg, fields=["title", "genre",
|
||||
"base_run_time_seconds"])
|
||||
return _media_to_dict(vg, fields=["title", "genre", "base_run_time_seconds"])
|
||||
|
||||
def get_puzzle(self, uuid: str) -> dict | None:
|
||||
"""Get a puzzle by UUID."""
|
||||
from puzzles.models import Puzzle
|
||||
|
||||
try:
|
||||
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
|
||||
except Puzzle.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(p, fields=["title", "piece_count",
|
||||
"manufacturer__name"])
|
||||
return _media_to_dict(p, fields=["title", "piece_count", "manufacturer__name"])
|
||||
|
||||
def get_web_page(self, uuid: str) -> dict | None:
|
||||
"""Get a web page by UUID."""
|
||||
from webpages.models import WebPage
|
||||
|
||||
try:
|
||||
wp = WebPage.objects.select_related("domain").get(uuid=uuid)
|
||||
except WebPage.DoesNotExist:
|
||||
@ -261,6 +367,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_task(self, uuid: str) -> dict | None:
|
||||
"""Get a task by UUID."""
|
||||
from tasks.models import Task
|
||||
|
||||
try:
|
||||
t = Task.objects.get(uuid=uuid)
|
||||
except Task.DoesNotExist:
|
||||
@ -270,6 +377,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_trail(self, uuid: str) -> dict | None:
|
||||
"""Get a trail by UUID."""
|
||||
from trails.models import Trail
|
||||
|
||||
try:
|
||||
t = Trail.objects.get(uuid=uuid)
|
||||
except Trail.DoesNotExist:
|
||||
@ -279,6 +387,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_geo_location(self, uuid: str) -> dict | None:
|
||||
"""Get a geo location by UUID."""
|
||||
from locations.models import GeoLocation
|
||||
|
||||
try:
|
||||
gl = GeoLocation.objects.get(uuid=uuid)
|
||||
except GeoLocation.DoesNotExist:
|
||||
@ -288,6 +397,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_life_event(self, uuid: str) -> dict | None:
|
||||
"""Get a life event by UUID."""
|
||||
from lifeevents.models import LifeEvent
|
||||
|
||||
try:
|
||||
le = LifeEvent.objects.get(uuid=uuid)
|
||||
except LifeEvent.DoesNotExist:
|
||||
@ -297,6 +407,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_mood(self, uuid: str) -> dict | None:
|
||||
"""Get a mood entry by UUID."""
|
||||
from moods.models import Mood
|
||||
|
||||
try:
|
||||
m = Mood.objects.get(uuid=uuid)
|
||||
except Mood.DoesNotExist:
|
||||
@ -306,6 +417,7 @@ class MediaToolset(MCPToolset):
|
||||
def get_food(self, uuid: str) -> dict | None:
|
||||
"""Get a food entry by UUID."""
|
||||
from foods.models import Food
|
||||
|
||||
try:
|
||||
f = Food.objects.select_related("category").get(uuid=uuid)
|
||||
except Food.DoesNotExist:
|
||||
@ -315,16 +427,20 @@ class MediaToolset(MCPToolset):
|
||||
def get_bird_sighting(self, uuid: str) -> dict | None:
|
||||
"""Get a bird sighting by UUID."""
|
||||
from birds.models import BirdSighting
|
||||
|
||||
try:
|
||||
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
|
||||
except BirdSighting.DoesNotExist:
|
||||
return None
|
||||
return _media_to_dict(bs, fields=["title", "bird__common_name",
|
||||
"bird__scientific_name", "location"])
|
||||
return _media_to_dict(
|
||||
bs,
|
||||
fields=["title", "bird__common_name", "bird__scientific_name", "location"],
|
||||
)
|
||||
|
||||
def get_disc_golf_course(self, uuid: str) -> dict | None:
|
||||
"""Get a disc golf course by UUID."""
|
||||
from discgolf.models import DiscGolfCourse
|
||||
|
||||
try:
|
||||
dg = DiscGolfCourse.objects.get(uuid=uuid)
|
||||
except DiscGolfCourse.DoesNotExist:
|
||||
@ -434,16 +550,15 @@ def _scrobble_to_dict(s: Scrobble) -> dict:
|
||||
|
||||
def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
||||
if s.video:
|
||||
return _media_to_dict(s.video, fields=["title", "year", "imdb_id",
|
||||
"imdb_rating"])
|
||||
return _media_to_dict(
|
||||
s.video, fields=["title", "year", "imdb_id", "imdb_rating"]
|
||||
)
|
||||
if s.track:
|
||||
return _media_to_dict(s.track, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
return _media_to_dict(s.track, fields=["title", "base_run_time_seconds"])
|
||||
if s.book:
|
||||
return _media_to_dict(s.book, fields=["title", "pages"])
|
||||
if s.video_game:
|
||||
return _media_to_dict(s.video_game, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
return _media_to_dict(s.video_game, fields=["title", "base_run_time_seconds"])
|
||||
if s.board_game:
|
||||
return _media_to_dict(s.board_game, fields=["title"])
|
||||
if s.beer:
|
||||
@ -453,8 +568,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
||||
if s.food:
|
||||
return _media_to_dict(s.food, fields=["title"])
|
||||
if s.trail:
|
||||
return _media_to_dict(s.trail, fields=["title",
|
||||
"base_run_time_seconds"])
|
||||
return _media_to_dict(s.trail, fields=["title", "base_run_time_seconds"])
|
||||
if s.task:
|
||||
return _media_to_dict(s.task, fields=["title"])
|
||||
if s.web_page:
|
||||
@ -470,8 +584,7 @@ def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
|
||||
if s.sport_event:
|
||||
return {"title": str(s.sport_event)}
|
||||
if s.geo_location:
|
||||
return _media_to_dict(s.geo_location, fields=["title", "latitude",
|
||||
"longitude"])
|
||||
return _media_to_dict(s.geo_location, fields=["title", "latitude", "longitude"])
|
||||
if s.birding_location:
|
||||
return _media_to_dict(s.birding_location, fields=["title"])
|
||||
if s.disc_golf_course:
|
||||
|
||||
@ -7,7 +7,7 @@ import django.db.models.deletion
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("beers", "0001_initial"),
|
||||
("drinks", "0001_initial"),
|
||||
("scrobbles", "0065_alter_scrobble_log"),
|
||||
]
|
||||
|
||||
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="beers.beer",
|
||||
to="drinks.beer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
|
||||
@ -19,7 +19,7 @@ class Migration(migrations.Migration):
|
||||
("puzzles", "0006_alter_puzzle_genre"),
|
||||
("videogames", "0015_alter_videogame_genre"),
|
||||
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||
("beers", "0008_alter_beer_genre"),
|
||||
("drinks", "0008_alter_beer_genre"),
|
||||
("foods", "0007_alter_food_genre"),
|
||||
("tasks", "0007_alter_task_genre"),
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
@ -98,7 +98,7 @@ class Migration(migrations.Migration):
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="beers.beer",
|
||||
to="drinks.beer",
|
||||
),
|
||||
),
|
||||
(
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-13 14:33
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("drinks", "0009_coffeeroaster_winegrape_wineproducer_wineregion_and_more"),
|
||||
("scrobbles", "0100_rename_disc_golf_favoritemedia_disc_golf_course_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="favoritemedia",
|
||||
name="coffee",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="drinks.coffee",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="favoritemedia",
|
||||
name="wine",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="drinks.wine",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="coffee",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.coffee",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="wine",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.wine",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="favoritemedia",
|
||||
name="beer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="drinks.beer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="favoritemedia",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scrobble",
|
||||
name="beer",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.beer",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scrobble",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
],
|
||||
default="Video",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,102 @@
|
||||
# Generated by Django 4.2.29 on 2026-07-13 18:31
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("drinks", "0010_alter_beerproducer_options_alter_beerstyle_options_and_more"),
|
||||
("scrobbles", "0101_favoritemedia_coffee_favoritemedia_wine_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="favoritemedia",
|
||||
name="drink",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="drinks.drink",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="drink",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to="drinks.drink",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="favoritemedia",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Drink", "Drink"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="scrobble",
|
||||
name="media_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Wine", "Wine"),
|
||||
("Coffee", "Coffee"),
|
||||
("Drink", "Drink"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
("DiscGolfCourse", "Disc golf"),
|
||||
],
|
||||
default="Video",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -9,7 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
from beers.models import Beer
|
||||
from drinks.models import Beer, Wine, Coffee, Drink
|
||||
from birds.models import BirdingLocation
|
||||
from discgolf.models import DiscGolfCourse
|
||||
from boardgames.models import BoardGame
|
||||
@ -684,6 +684,9 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
|
||||
"GeoLocation": ("geo_location",),
|
||||
"Trail": ("trail",),
|
||||
"Beer": ("beer",),
|
||||
"Wine": ("wine",),
|
||||
"Coffee": ("coffee",),
|
||||
"Drink": ("drink",),
|
||||
"Puzzle": ("puzzle",),
|
||||
"Food": ("food",),
|
||||
"Task": ("task",),
|
||||
@ -712,6 +715,9 @@ class ScrobbleQuerySet(models.QuerySet):
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"beer",
|
||||
"wine",
|
||||
"coffee",
|
||||
"drink",
|
||||
"puzzle",
|
||||
"food",
|
||||
"trail",
|
||||
@ -763,6 +769,9 @@ class Scrobble(TimeStampedModel):
|
||||
GEO_LOCATION = "GeoLocation", "GeoLocation"
|
||||
TRAIL = "Trail", "Trail"
|
||||
BEER = "Beer", "Beer"
|
||||
WINE = "Wine", "Wine"
|
||||
COFFEE = "Coffee", "Coffee"
|
||||
DRINK = "Drink", "Drink"
|
||||
PUZZLE = "Puzzle", "Puzzle"
|
||||
FOOD = "Food", "Food"
|
||||
TASK = "Task", "Task"
|
||||
@ -792,6 +801,9 @@ class Scrobble(TimeStampedModel):
|
||||
board_game = models.ForeignKey(BoardGame, on_delete=models.DO_NOTHING, **BNULL)
|
||||
geo_location = models.ForeignKey(GeoLocation, on_delete=models.DO_NOTHING, **BNULL)
|
||||
beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL)
|
||||
wine = models.ForeignKey(Wine, on_delete=models.DO_NOTHING, **BNULL)
|
||||
coffee = models.ForeignKey(Coffee, on_delete=models.DO_NOTHING, **BNULL)
|
||||
drink = models.ForeignKey(Drink, on_delete=models.DO_NOTHING, **BNULL)
|
||||
puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL)
|
||||
food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL)
|
||||
trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL)
|
||||
@ -1220,7 +1232,10 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
if self.is_long_play:
|
||||
long_play_secs = 0
|
||||
if self.long_play_last_scrobble and not self.long_play_last_scrobble.long_play_complete:
|
||||
if (
|
||||
self.long_play_last_scrobble
|
||||
and not self.long_play_last_scrobble.long_play_complete
|
||||
):
|
||||
long_play_secs = self.long_play_last_scrobble.long_play_seconds or 0
|
||||
percent = int(((playback_seconds + long_play_secs) / run_time_secs) * 100)
|
||||
|
||||
@ -1367,6 +1382,12 @@ class Scrobble(TimeStampedModel):
|
||||
media_obj = self.trail
|
||||
if self.beer:
|
||||
media_obj = self.beer
|
||||
if self.wine:
|
||||
media_obj = self.wine
|
||||
if self.coffee:
|
||||
media_obj = self.coffee
|
||||
if self.drink:
|
||||
media_obj = self.drink
|
||||
if self.puzzle:
|
||||
media_obj = self.puzzle
|
||||
if self.task:
|
||||
@ -1928,6 +1949,9 @@ class FavoriteMedia(TimeStampedModel):
|
||||
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
||||
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
||||
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
||||
wine = models.ForeignKey(Wine, on_delete=models.CASCADE, **BNULL)
|
||||
coffee = models.ForeignKey(Coffee, on_delete=models.CASCADE, **BNULL)
|
||||
drink = models.ForeignKey(Drink, on_delete=models.CASCADE, **BNULL)
|
||||
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
||||
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
||||
@ -1982,6 +2006,12 @@ class FavoriteMedia(TimeStampedModel):
|
||||
media_obj = self.trail
|
||||
if self.beer:
|
||||
media_obj = self.beer
|
||||
if self.wine:
|
||||
media_obj = self.wine
|
||||
if self.coffee:
|
||||
media_obj = self.coffee
|
||||
if self.drink:
|
||||
media_obj = self.drink
|
||||
if self.puzzle:
|
||||
media_obj = self.puzzle
|
||||
if self.task:
|
||||
@ -2014,6 +2044,9 @@ class FavoriteMedia(TimeStampedModel):
|
||||
"BoardGame": "board_game",
|
||||
"GeoLocation": "geo_location",
|
||||
"Beer": "beer",
|
||||
"Wine": "wine",
|
||||
"Coffee": "coffee",
|
||||
"Drink": "drink",
|
||||
"Puzzle": "puzzle",
|
||||
"Food": "food",
|
||||
"Trail": "trail",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from abc import ABC, abstractmethod
|
||||
import requests
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.urls import reverse
|
||||
@ -119,3 +119,26 @@ class MoodNtfyNotification(BasicNtfyNotification):
|
||||
"Click": self.click_url,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class WaterReminderNtfyNotification(BasicNtfyNotification):
|
||||
def __init__(self, profile, **kwargs):
|
||||
super().__init__(profile)
|
||||
self.ntfy_str: str = "Time to drink some water!"
|
||||
self.click_url = self.url_tmpl.format(
|
||||
path=reverse("drinks:quick_water_scrobble")
|
||||
)
|
||||
self.title = "Water Reminder"
|
||||
|
||||
def send(self):
|
||||
if self.profile and self.profile.ntfy_enabled and self.profile.ntfy_url:
|
||||
requests.post(
|
||||
self.profile.ntfy_url,
|
||||
data=self.ntfy_str.encode(encoding="utf-8"),
|
||||
headers={
|
||||
"Title": self.title,
|
||||
"Priority": "high",
|
||||
"Tags": "droplet, check",
|
||||
"Click": self.click_url,
|
||||
},
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ from typing import Any, Optional
|
||||
import pendulum
|
||||
import pytz
|
||||
import requests
|
||||
from beers.models import Beer
|
||||
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
|
||||
from boardgames.utils import board_names_to_variants
|
||||
from books.constants import READCOMICSONLINE_URL
|
||||
@ -16,6 +15,7 @@ from bricksets.models import BrickSet
|
||||
from dateutil.parser import parse
|
||||
from discgolf.models import DiscGolfCourse
|
||||
from django.utils import timezone
|
||||
from drinks.models import Beer, Coffee, Drink, DrinkFormat, Wine
|
||||
from foods.models import Food
|
||||
from foods.sources.rscraper import RecipeScraperService
|
||||
from locations.constants import LOCATION_PROVIDERS
|
||||
@ -648,6 +648,30 @@ def manual_scrobble_from_url(
|
||||
item_id = url
|
||||
|
||||
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
|
||||
|
||||
if content_key == "-wi":
|
||||
if "cellartracker.com" in url:
|
||||
return manual_scrobble_wine(
|
||||
cellartracker_id=item_id,
|
||||
user_id=user_id,
|
||||
source="CellarTracker",
|
||||
action=action,
|
||||
)
|
||||
else:
|
||||
return manual_scrobble_wine(
|
||||
vivino_id=item_id,
|
||||
user_id=user_id,
|
||||
source="Vivino",
|
||||
action=action,
|
||||
)
|
||||
elif content_key == "-co":
|
||||
return manual_scrobble_coffee(
|
||||
roastdb_url=url,
|
||||
user_id=user_id,
|
||||
source="RoastDB",
|
||||
action=action,
|
||||
)
|
||||
|
||||
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
|
||||
|
||||
|
||||
@ -766,9 +790,7 @@ def todoist_scrobble_task(
|
||||
todoist_task["title"] = todoist_task.pop("description")
|
||||
todoist_task["description"] = todoist_task.pop("details")
|
||||
labels = todoist_task.pop("todoist_label_list", [])
|
||||
todoist_task["labels"] = [
|
||||
l for l in labels if l.lower() != "inprogress"
|
||||
]
|
||||
todoist_task["labels"] = [l for l in labels if l.lower() != "inprogress"]
|
||||
todoist_task.pop("todoist_type")
|
||||
todoist_task.pop("todoist_event")
|
||||
|
||||
@ -933,7 +955,9 @@ def emacs_scrobble_task(
|
||||
|
||||
task_data.pop("notes", None)
|
||||
task_data["title"] = task_data.pop("description")
|
||||
task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description")
|
||||
task_data["description"] = _extract_org_section(
|
||||
task_data.pop("body"), "*** Description"
|
||||
)
|
||||
task_data["labels"] = task_data.pop("labels")
|
||||
|
||||
task_data["orgmode_id"] = task_data.pop("source_id")
|
||||
@ -1256,6 +1280,115 @@ def manual_scrobble_beer(
|
||||
return Scrobble.create_or_update(beer, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_wine(
|
||||
vivino_id: str = None,
|
||||
cellartracker_id: str = None,
|
||||
user_id: int = None,
|
||||
source: str = "Vivino",
|
||||
action: Optional[str] = None,
|
||||
):
|
||||
wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)
|
||||
|
||||
if not wine:
|
||||
logger.error(
|
||||
f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"
|
||||
)
|
||||
return
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
}
|
||||
logger.info(
|
||||
"[vrobbler-scrobble] wine scrobble request received",
|
||||
extra={
|
||||
"wine_id": wine.id,
|
||||
"user_id": user_id,
|
||||
"scrobble_dict": scrobble_dict,
|
||||
"media_type": Scrobble.MediaType.WINE,
|
||||
},
|
||||
)
|
||||
|
||||
return Scrobble.create_or_update(wine, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_coffee(
|
||||
roastdb_id: str = None,
|
||||
roastdb_url: str = None,
|
||||
user_id: int = None,
|
||||
source: str = "RoastDB",
|
||||
action: Optional[str] = None,
|
||||
):
|
||||
slug = None
|
||||
if roastdb_url and not roastdb_id:
|
||||
slug = roastdb_url.rstrip("/").split("/")[-1]
|
||||
|
||||
coffee = Coffee.find_or_create(roastdb_id=roastdb_id, slug=slug)
|
||||
|
||||
if not coffee:
|
||||
logger.error(f"No coffee found for RoastDB ID {roastdb_id}")
|
||||
return
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
}
|
||||
logger.info(
|
||||
"[vrobbler-scrobble] coffee scrobble request received",
|
||||
extra={
|
||||
"coffee_id": coffee.id,
|
||||
"user_id": user_id,
|
||||
"scrobble_dict": scrobble_dict,
|
||||
"media_type": Scrobble.MediaType.COFFEE,
|
||||
},
|
||||
)
|
||||
|
||||
return Scrobble.create_or_update(coffee, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_water(
|
||||
user_id: int = None,
|
||||
source: str = "Vrobbler",
|
||||
action: Optional[str] = None,
|
||||
):
|
||||
water = Drink.find_or_create_water()
|
||||
|
||||
if not water:
|
||||
logger.error("Could not find or create Water instance")
|
||||
return
|
||||
|
||||
water_format = DrinkFormat.BY_THE_GLASS
|
||||
water_size_ml = 250
|
||||
|
||||
profile = UserProfile.objects.filter(user_id=user_id).first()
|
||||
if profile:
|
||||
water_format = profile.default_water_format or water_format
|
||||
water_size_ml = profile.default_water_size or water_size_ml
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
"log": {"format": water_format, "size_ml": water_size_ml},
|
||||
}
|
||||
logger.info(
|
||||
"[vrobbler-scrobble] water scrobble request received",
|
||||
extra={
|
||||
"water_id": water.id,
|
||||
"user_id": user_id,
|
||||
"scrobble_dict": scrobble_dict,
|
||||
"media_type": Scrobble.MediaType.DRINK,
|
||||
},
|
||||
)
|
||||
|
||||
return Scrobble.create_or_update(water, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_puzzle(
|
||||
ipdb_id: str,
|
||||
user_id: int,
|
||||
@ -1367,6 +1500,50 @@ def manual_scrobble_food(
|
||||
return Scrobble.create_or_update(food, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_drink(
|
||||
item_id: str,
|
||||
user_id: int,
|
||||
source: str = "Vrobbler",
|
||||
action: Optional[str] = None,
|
||||
):
|
||||
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
|
||||
if not match:
|
||||
logger.info(
|
||||
"[manual_scrobble_drink] invalid format, expected 'Drink Name - calories'",
|
||||
extra={"item_id": item_id, "user_id": user_id},
|
||||
)
|
||||
return
|
||||
|
||||
drink_name = match.group(1).strip()
|
||||
calories = int(match.group(2))
|
||||
|
||||
drink = Drink.objects.filter(title=drink_name).first()
|
||||
if not drink:
|
||||
drink = Drink.objects.create(
|
||||
title=drink_name,
|
||||
calories=calories,
|
||||
)
|
||||
|
||||
scrobble_dict = {
|
||||
"user_id": user_id,
|
||||
"timestamp": timezone.now(),
|
||||
"playback_position_seconds": 0,
|
||||
"source": source,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"[scrobblers] manual drink scrobble request received",
|
||||
extra={
|
||||
"drink_id": drink.id,
|
||||
"user_id": user_id,
|
||||
"scrobble_dict": scrobble_dict,
|
||||
"media_type": Scrobble.MediaType.DRINK,
|
||||
},
|
||||
)
|
||||
|
||||
return Scrobble.create_or_update(drink, user_id, scrobble_dict)
|
||||
|
||||
|
||||
def manual_scrobble_discgolf(
|
||||
item_id: str,
|
||||
user_id: int,
|
||||
|
||||
@ -276,15 +276,11 @@ def push_scrobble_to_archivebox(scrobble_id):
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
"Scrobble %s not found for archivebox push", scrobble_id
|
||||
)
|
||||
logger.warning("Scrobble %s not found for archivebox push", scrobble_id)
|
||||
return
|
||||
webpage = scrobble.web_page
|
||||
if not webpage:
|
||||
logger.warning(
|
||||
"Scrobble %s has no web_page for archivebox push", scrobble_id
|
||||
)
|
||||
logger.warning("Scrobble %s has no web_page for archivebox push", scrobble_id)
|
||||
return
|
||||
webpage.push_to_archivebox(scrobble.user)
|
||||
|
||||
@ -582,6 +578,14 @@ def send_mood_checkin():
|
||||
send_mood_checkin_reminders()
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_water_reminder():
|
||||
"""Send water reminder notifications during daylight hours."""
|
||||
from vrobbler.apps.scrobbles.utils import send_water_reminder_notifications
|
||||
|
||||
send_water_reminder_notifications()
|
||||
|
||||
|
||||
@shared_task
|
||||
def backfill_scrobble_sentiment():
|
||||
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
@ -10,6 +9,7 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
import requests
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
@ -23,6 +23,7 @@ from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
from scrobbles.notifications import (
|
||||
MoodNtfyNotification,
|
||||
ScrobbleNtfyNotification,
|
||||
WaterReminderNtfyNotification,
|
||||
)
|
||||
from scrobbles.tasks import (
|
||||
process_koreader_import,
|
||||
@ -154,8 +155,7 @@ def import_lastfm_for_all_users(restart=False):
|
||||
last_processed = lfm_import.processed_finished
|
||||
else:
|
||||
logger.info(
|
||||
"No existing LastFM import for user %s, "
|
||||
"starting a full parse",
|
||||
"No existing LastFM import for user %s, " "starting a full parse",
|
||||
user_id,
|
||||
)
|
||||
last_processed = None
|
||||
@ -350,6 +350,20 @@ def send_mood_checkin_reminders() -> int:
|
||||
return notifications_sent
|
||||
|
||||
|
||||
def send_water_reminder_notifications() -> int:
|
||||
"""Send water reminders to users with the setting enabled, during daylight hours.
|
||||
|
||||
Only sends if the user's local time is between 6 AM and 11 PM."""
|
||||
notifications_sent = 0
|
||||
for profile in UserProfile.objects.filter(water_reminder_enabled=True):
|
||||
local_hour = timezone.localtime(timezone.now(), profile.tzinfo).hour
|
||||
if 6 <= local_hour <= 22:
|
||||
WaterReminderNtfyNotification(profile).send()
|
||||
notifications_sent += 1
|
||||
|
||||
return notifications_sent
|
||||
|
||||
|
||||
def extract_domain(url):
|
||||
parsed_url = urlparse(url)
|
||||
domain = parsed_url.netloc.split(".")[-2] + "." + parsed_url.netloc.split(".")[-1]
|
||||
@ -460,7 +474,8 @@ def _ensure_mopidy_playlist(profile):
|
||||
pass
|
||||
|
||||
result = _mopidy_rpc(
|
||||
profile, "core.playlists.create",
|
||||
profile,
|
||||
"core.playlists.create",
|
||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||
)
|
||||
logger.info(
|
||||
@ -588,7 +603,8 @@ def remove_track_from_mopidy_favorites_playlist(favorite):
|
||||
if playlist and playlist.get("uri"):
|
||||
existing_tracks = playlist.get("tracks") or []
|
||||
filtered = [
|
||||
t for t in existing_tracks
|
||||
t
|
||||
for t in existing_tracks
|
||||
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
|
||||
]
|
||||
if len(filtered) == len(existing_tracks):
|
||||
@ -639,7 +655,8 @@ def _ensure_mopidy_playlist_by_name(profile, playlist_name):
|
||||
pass
|
||||
|
||||
result = _mopidy_rpc(
|
||||
profile, "core.playlists.create",
|
||||
profile,
|
||||
"core.playlists.create",
|
||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||
)
|
||||
return result
|
||||
|
||||
@ -4,10 +4,9 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
import requests
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.apps import apps
|
||||
from django.contrib import messages
|
||||
@ -26,6 +25,7 @@ class BearerTokenAuthentication(TokenAuthentication):
|
||||
keyword = "Bearer"
|
||||
|
||||
|
||||
from boardgames.models import BoardGame, BoardGameVariant
|
||||
from django.http import (
|
||||
FileResponse,
|
||||
Http404,
|
||||
@ -33,7 +33,7 @@ from django.http import (
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.dateformat import DateFormat
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
@ -66,14 +66,14 @@ from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from scrobbles.api import serializers
|
||||
from scrobbles.constants import (
|
||||
DRINK_MODELS,
|
||||
LONG_PLAY_MEDIA,
|
||||
MANUAL_SCROBBLE_FNS,
|
||||
PLAY_AGAIN_MEDIA,
|
||||
Visibility,
|
||||
)
|
||||
from scrobbles.export import export_scrobbles
|
||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.sqids import decode_scrobble_share
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
BGStatsImport,
|
||||
@ -90,6 +90,7 @@ from scrobbles.models import (
|
||||
UDiscCSVImport,
|
||||
)
|
||||
from scrobbles.scrobblers import *
|
||||
from scrobbles.sqids import decode_scrobble_share
|
||||
from scrobbles.tasks import (
|
||||
process_koreader_import,
|
||||
process_lastfm_import,
|
||||
@ -210,14 +211,20 @@ class ScrobbleableDetailView(ChartContextMixin, DetailView):
|
||||
media = self.object
|
||||
if hasattr(media, "is_long_play_media") and media.is_long_play_media():
|
||||
qs = media.scrobble_set.filter(user=self.request.user)
|
||||
completed = qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
||||
completed = (
|
||||
qs.filter(long_play_complete=True).order_by("-timestamp").first()
|
||||
)
|
||||
if completed and completed.long_play_seconds:
|
||||
context_data["long_play_total_seconds"] = completed.long_play_seconds
|
||||
context_data["long_play_finished_date"] = completed.timestamp
|
||||
else:
|
||||
latest_finished = qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
||||
latest_finished = (
|
||||
qs.filter(played_to_completion=True).order_by("-timestamp").first()
|
||||
)
|
||||
if latest_finished and latest_finished.long_play_seconds:
|
||||
context_data["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||
context_data["long_play_total_seconds"] = (
|
||||
latest_finished.long_play_seconds
|
||||
)
|
||||
context_data["long_play_finished_date"] = None
|
||||
|
||||
return context_data
|
||||
@ -235,7 +242,8 @@ class RecentScrobbleList(ListView):
|
||||
scrobble = manual_scrobble_from_url(
|
||||
scrobble_url, self.request.user.id, source, action
|
||||
)
|
||||
return HttpResponseRedirect(scrobble.redirect_url(user.id))
|
||||
if scrobble:
|
||||
return HttpResponseRedirect(scrobble.redirect_url(user.id))
|
||||
return super().get(*args, **kwargs)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
@ -610,6 +618,18 @@ class ManualScrobbleView(FormView):
|
||||
item_str = form.cleaned_data.get("item_id")
|
||||
logger.debug(f"Looking for scrobblable media with input {item_str}")
|
||||
|
||||
if item_str.startswith("http://") or item_str.startswith("https://"):
|
||||
scrobble = manual_scrobble_from_url(
|
||||
item_str, self.request.user.id, source="Vrobbler"
|
||||
)
|
||||
if scrobble:
|
||||
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
|
||||
messages.error(
|
||||
self.request,
|
||||
"Could not scrobble from URL. The source may be unavailable.",
|
||||
)
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
|
||||
key = item_str[:3]
|
||||
item_id = item_str[4:]
|
||||
@ -888,8 +908,15 @@ def scrobble_start(request, media_uuid):
|
||||
|
||||
media_obj = None
|
||||
for app, model in PLAY_AGAIN_MEDIA.items():
|
||||
media_model = apps.get_model(app_label=app, model_name=model)
|
||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||
if app == "drinks":
|
||||
for drink_model in DRINK_MODELS:
|
||||
media_model = apps.get_model(app_label=app, model_name=drink_model)
|
||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||
if media_obj:
|
||||
break
|
||||
else:
|
||||
media_model = apps.get_model(app_label=app, model_name=model)
|
||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||
if media_obj:
|
||||
break
|
||||
|
||||
@ -1134,7 +1161,10 @@ def toggle_favorite(request, media_type, object_id):
|
||||
"VideoGame": ("videogames", "VideoGame"),
|
||||
"BoardGame": ("boardgames", "BoardGame"),
|
||||
"GeoLocation": ("locations", "GeoLocation"),
|
||||
"Beer": ("beers", "Beer"),
|
||||
"Beer": ("drinks", "Beer"),
|
||||
"Wine": ("drinks", "Wine"),
|
||||
"Coffee": ("drinks", "Coffee"),
|
||||
"Water": ("drinks", "Water"),
|
||||
"Puzzle": ("puzzles", "Puzzle"),
|
||||
"Food": ("foods", "Food"),
|
||||
"Trail": ("trails", "Trail"),
|
||||
@ -1235,15 +1265,29 @@ class ScrobbleDetailView(DetailView):
|
||||
def get_form_class(self):
|
||||
return self.object.media_obj.logdata_cls().form()
|
||||
|
||||
def _update_expansion_ids_queryset(self, form):
|
||||
from boardgames.models import BoardGame
|
||||
def _update_board_game_widgets(self, form):
|
||||
if not isinstance(self.object.media_obj, BoardGame):
|
||||
return
|
||||
|
||||
if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields:
|
||||
if "expansion_ids" in form.fields:
|
||||
expansions = BoardGame.objects.filter(
|
||||
expansion_for_boardgame=self.object.media_obj
|
||||
)
|
||||
form.fields["expansion_ids"].queryset = expansions
|
||||
|
||||
if "variant_ids" in form.fields:
|
||||
form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(
|
||||
board_game=self.object.media_obj
|
||||
)
|
||||
form.fields["variant_ids"].widget.attrs[
|
||||
"data-board-game-id"
|
||||
] = self.object.media_obj.id
|
||||
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
|
||||
self.request.build_absolute_uri(
|
||||
reverse("boardgames:ajax-create-variant")
|
||||
)
|
||||
)
|
||||
|
||||
def get_form(self):
|
||||
FormClass = self.get_form_class()
|
||||
|
||||
@ -1255,14 +1299,41 @@ class ScrobbleDetailView(DetailView):
|
||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||
|
||||
form = FormClass(initial=log)
|
||||
self._update_expansion_ids_queryset(form)
|
||||
self._update_board_game_widgets(form)
|
||||
|
||||
drink_types = ("Beer", "Wine", "Coffee")
|
||||
if self.object.media_type in drink_types and "size_ml" in form.fields:
|
||||
user = self.request.user
|
||||
use_oz = hasattr(user, "profile") and user.profile.volume_unit == "imperial"
|
||||
if use_oz:
|
||||
form.fields["size_ml"].label = "Size (oz)"
|
||||
original_clean = form.fields["size_ml"].clean
|
||||
|
||||
def clean_size_ml_oz(value):
|
||||
val = original_clean(value)
|
||||
if val is not None and val != "":
|
||||
from drinks.models import ML_PER_OZ
|
||||
|
||||
return round(float(val) * ML_PER_OZ)
|
||||
return val
|
||||
|
||||
form.fields["size_ml"].clean = clean_size_ml_oz
|
||||
if log.get("size_ml"):
|
||||
from drinks.models import ML_PER_OZ
|
||||
|
||||
form.fields["size_ml"].initial = round(
|
||||
log["size_ml"] / ML_PER_OZ, 1
|
||||
)
|
||||
else:
|
||||
form.fields["size_ml"].label = "Size (mL)"
|
||||
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
FormClass = self.get_form_class()
|
||||
form = FormClass(request.POST)
|
||||
self._update_expansion_ids_queryset(form)
|
||||
self._update_board_game_widgets(form)
|
||||
|
||||
if form.is_valid():
|
||||
data = form.cleaned_data.copy()
|
||||
@ -1364,21 +1435,25 @@ class ScrobbleDetailView(DetailView):
|
||||
context["has_mopidy_uri"] = False
|
||||
|
||||
if self.object.is_long_play and fk_field:
|
||||
all_scrobbles = Scrobble.objects.filter(
|
||||
user=user, **{fk_field: media_obj}
|
||||
all_scrobbles = Scrobble.objects.filter(user=user, **{fk_field: media_obj})
|
||||
completed = (
|
||||
all_scrobbles.filter(long_play_complete=True)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
completed = all_scrobbles.filter(
|
||||
long_play_complete=True
|
||||
).order_by("-timestamp").first()
|
||||
if completed and completed.long_play_seconds:
|
||||
context["long_play_total_seconds"] = completed.long_play_seconds
|
||||
context["long_play_finished_date"] = completed.timestamp
|
||||
else:
|
||||
latest_finished = all_scrobbles.filter(
|
||||
played_to_completion=True
|
||||
).order_by("-timestamp").first()
|
||||
latest_finished = (
|
||||
all_scrobbles.filter(played_to_completion=True)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if latest_finished and latest_finished.long_play_seconds:
|
||||
context["long_play_total_seconds"] = latest_finished.long_play_seconds
|
||||
context["long_play_total_seconds"] = (
|
||||
latest_finished.long_play_seconds
|
||||
)
|
||||
context["long_play_finished_date"] = None
|
||||
|
||||
return context
|
||||
@ -1944,8 +2019,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
|
||||
scrobble_filter = {"scrobble__played_to_completion": True}
|
||||
|
||||
def get_items(self, user, start_date, end_date):
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
return super().get_items(user, start_date, end_date, BoardGame)
|
||||
|
||||
|
||||
|
||||
@ -188,6 +188,10 @@ CELERY_BEAT_SCHEDULE = {
|
||||
"task": "scrobbles.tasks.send_mood_checkin",
|
||||
"schedule": crontab(hour="*/4", minute=0),
|
||||
},
|
||||
"send-water-reminder": {
|
||||
"task": "scrobbles.tasks.send_water_reminder",
|
||||
"schedule": crontab(hour="*/3", minute=0),
|
||||
},
|
||||
"backfill-scrobble-sentiment": {
|
||||
"task": "scrobbles.tasks.backfill_scrobble_sentiment",
|
||||
"schedule": crontab(minute="0"),
|
||||
@ -229,7 +233,7 @@ INSTALLED_APPS = [
|
||||
"webpages",
|
||||
"tasks",
|
||||
"trails",
|
||||
"beers",
|
||||
"drinks",
|
||||
"puzzles",
|
||||
"foods",
|
||||
"lifeevents",
|
||||
@ -405,6 +409,12 @@ else:
|
||||
|
||||
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
|
||||
|
||||
SKIP_AUTO_EXPANSION_DOWNLOAD = [
|
||||
int(x.strip())
|
||||
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
|
||||
if x.strip().isdigit()
|
||||
]
|
||||
|
||||
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
|
||||
LOG_TYPE = "json" if JSON_LOGGING else "log"
|
||||
|
||||
|
||||
@ -280,6 +280,9 @@
|
||||
<div class="nav-item text-nowrap">
|
||||
<a class="nav-link px-3" href="{% url "moods:checkin" %}">Check-in</a>
|
||||
</div>
|
||||
<div class="nav-item text-nowrap">
|
||||
<a class="nav-link px-3" href="{% url "drinks:quick_water_scrobble" %}">Drink water</a>
|
||||
</div>
|
||||
<div class="nav-item text-nowrap">
|
||||
<a class="nav-link px-3" href="{% url "profiles:profile_settings" %}">Settings</a>
|
||||
</div>
|
||||
|
||||
@ -46,6 +46,20 @@
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Play again</a>
|
||||
</p>
|
||||
{% if object.genre.all %}
|
||||
<p>Genres:
|
||||
{% for tag in object.genre.all %}
|
||||
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if object.tags.all %}
|
||||
<p>Tags:
|
||||
{% for tag in object.tags.all %}
|
||||
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if charts %}
|
||||
<div class="row">
|
||||
|
||||
@ -52,12 +52,24 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{{ scrobble.logdata.format_display }}</td>
|
||||
<td>
|
||||
{% if scrobble.logdata.size_ml %}
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ scrobble.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ scrobble.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
81
vrobbler/templates/drinks/coffee_detail.html
Normal file
81
vrobbler/templates/drinks/coffee_detail.html
Normal file
@ -0,0 +1,81 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load mathfilters %}
|
||||
{% load static %}
|
||||
{% load naturalduration %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.cover img {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
float: left;
|
||||
width: 252px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
float: left;
|
||||
width: 600px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
<div class="row">
|
||||
<div class="summary">
|
||||
{% if object.description%}
|
||||
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||
<hr />
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>{{scrobbles.count}} scrobbles</p>
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Drink again</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
<th scope="col">Roast</th>
|
||||
<th scope="col">Method</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{{ scrobble.logdata.format_display }}</td>
|
||||
<td>
|
||||
{% if scrobble.logdata.size_ml %}
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ scrobble.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ scrobble.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ scrobble.logdata.roast_level_display }}</td>
|
||||
<td>{{ scrobble.logdata.brewing_method_display }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
vrobbler/templates/drinks/coffee_list.html
Normal file
24
vrobbler/templates/drinks/coffee_list.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Coffee{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
dl { width: 210px; float:left; margin-right: 10px; }
|
||||
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||
img { height:200px; width: 200px; object-fit: cover; }
|
||||
dd .right { float:right; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
80
vrobbler/templates/drinks/drink_detail.html
Normal file
80
vrobbler/templates/drinks/drink_detail.html
Normal file
@ -0,0 +1,80 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load mathfilters %}
|
||||
{% load static %}
|
||||
{% load naturalduration %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.cover img {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
float: left;
|
||||
width: 252px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
float: left;
|
||||
width: 600px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
<div class="row">
|
||||
<div class="summary">
|
||||
{% if object.description%}
|
||||
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||
<hr />
|
||||
{% endif %}
|
||||
<p style="float:right;">
|
||||
<a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>{{scrobbles.count}} scrobbles</p>
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Drink again</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{{ scrobble.logdata.format_display }}</td>
|
||||
<td>
|
||||
{% if scrobble.logdata.size_ml %}
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ scrobble.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ scrobble.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
23
vrobbler/templates/drinks/drink_list.html
Normal file
23
vrobbler/templates/drinks/drink_list.html
Normal file
@ -0,0 +1,23 @@
|
||||
{% extends "base_list.html" %}
|
||||
|
||||
{% block title %}Drinks{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
dl { width: 210px; float:left; margin-right: 10px; }
|
||||
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||
img { height:200px; width: 200px; object-fit: cover; }
|
||||
dd .right { float:right; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
79
vrobbler/templates/drinks/water_detail.html
Normal file
79
vrobbler/templates/drinks/water_detail.html
Normal file
@ -0,0 +1,79 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.cover img {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
float: left;
|
||||
width: 252px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
float: left;
|
||||
width: 600px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
<div class="row">
|
||||
<div class="summary">
|
||||
{% if object.description%}
|
||||
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||
<hr />
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>{{scrobbles.count}} scrobbles</p>
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Drink again</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
<th scope="col">Temperature</th>
|
||||
<th scope="col">Sparkling</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{{ scrobble.logdata.format_display }}</td>
|
||||
<td>
|
||||
{% if scrobble.logdata.size_ml %}
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ scrobble.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ scrobble.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ scrobble.logdata.temperature|title }}</td>
|
||||
<td>{% if scrobble.logdata.carbonated %}Yes{% elif scrobble.logdata.carbonated == False %}No{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
85
vrobbler/templates/drinks/wine_detail.html
Normal file
85
vrobbler/templates/drinks/wine_detail.html
Normal file
@ -0,0 +1,85 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load mathfilters %}
|
||||
{% load static %}
|
||||
{% load naturalduration %}
|
||||
|
||||
{% block title %}{{object.title}}{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.cover img {
|
||||
width: 250px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
float: left;
|
||||
width: 252px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.summary {
|
||||
float: left;
|
||||
width: 600px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
|
||||
<div class="row">
|
||||
<div class="summary">
|
||||
{% if object.description%}
|
||||
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
|
||||
<hr />
|
||||
{% endif %}
|
||||
<p style="float:right;">
|
||||
{% if object.vivino_link %}
|
||||
<a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>
|
||||
{% endif %}
|
||||
{% if object.cellartracker_link %}
|
||||
<a href="{{object.cellartracker_link}}">CellarTracker</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<p>{{scrobbles.count}} scrobbles</p>
|
||||
<p>
|
||||
<a href="{{object.start_url}}">Drink again</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md">
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Date</th>
|
||||
<th scope="col">Format</th>
|
||||
<th scope="col">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{{ scrobble.logdata.format_display }}</td>
|
||||
<td>
|
||||
{% if scrobble.logdata.size_ml %}
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ scrobble.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ scrobble.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
24
vrobbler/templates/drinks/wine_list.html
Normal file
24
vrobbler/templates/drinks/wine_list.html
Normal file
@ -0,0 +1,24 @@
|
||||
{% extends "base_list.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Wines{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
dl { width: 210px; float:left; margin-right: 10px; }
|
||||
dt a { color:white; text-decoration: none; font-size:smaller; }
|
||||
img { height:200px; width: 200px; object-fit: cover; }
|
||||
dd .right { float:right; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block lists %}
|
||||
<div class="row">
|
||||
|
||||
<div class="col-md">
|
||||
<div class="table-responsive">
|
||||
{% include "_scrobblable_list.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -156,7 +156,7 @@
|
||||
<p>No board games today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'beers:beer_list' %}">Beers</a></h3>
|
||||
<h3><a href="{% url 'drinks:beer_list' %}">Beers</a></h3>
|
||||
{% if Beer %}
|
||||
{% with scrobbles=Beer count=Beer_count time=Beer_time %}
|
||||
{% include "scrobbles/_scrobble_table.html" %}
|
||||
@ -165,6 +165,24 @@
|
||||
<p>No beer today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'drinks:wine_list' %}">Wines</a></h3>
|
||||
{% if Wine %}
|
||||
{% with scrobbles=Wine count=Wine_count time=Wine_time %}
|
||||
{% include "scrobbles/_scrobble_table.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p>No wine today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'drinks:coffee_list' %}">Coffee</a></h3>
|
||||
{% if Coffee %}
|
||||
{% with scrobbles=Coffee count=Coffee_count time=Coffee_time %}
|
||||
{% include "scrobbles/_scrobble_table.html" %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p>No coffee today</p>
|
||||
{% endif %}
|
||||
|
||||
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
|
||||
{% if BrickSet %}
|
||||
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}
|
||||
|
||||
@ -88,7 +88,13 @@
|
||||
{% elif scrobble.food %}
|
||||
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
|
||||
{% elif scrobble.beer %}
|
||||
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
<a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
{% elif scrobble.wine %}
|
||||
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
|
||||
{% elif scrobble.coffee %}
|
||||
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
|
||||
{% elif scrobble.water %}
|
||||
<a href="{% url 'drinks:water_detail' scrobble.water.uuid %}">{{ scrobble.water.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||
{% elif scrobble.podcast_episode %}
|
||||
|
||||
@ -175,6 +175,36 @@
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}
|
||||
{% if object.logdata.format_display or object.logdata.size_ml %}
|
||||
<div class="mb-3">
|
||||
<dl class="row" style="max-width: 400px;">
|
||||
{% if object.logdata.format_display %}
|
||||
<dt class="col-sm-5">Format</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.format_display }}</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.size_ml %}
|
||||
<dt class="col-sm-5">Size</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if request.user.profile.volume_unit == "imperial" %}
|
||||
{{ object.logdata.size_oz }} oz
|
||||
{% else %}
|
||||
{{ object.logdata.size_ml }} mL
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.roast_level_display %}
|
||||
<dt class="col-sm-5">Roast</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.brewing_method_display %}
|
||||
<dt class="col-sm-5">Method</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if object.media_type == "Task" and object.logdata.description %}
|
||||
<p>{{ object.logdata.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
@ -82,7 +82,11 @@
|
||||
{% elif scrobble.food %}
|
||||
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
|
||||
{% elif scrobble.beer %}
|
||||
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
<a href="{% url 'drinks:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
|
||||
{% elif scrobble.wine %}
|
||||
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
|
||||
{% elif scrobble.coffee %}
|
||||
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
|
||||
{% elif scrobble.web_page %}
|
||||
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
|
||||
{% elif scrobble.podcast_episode %}
|
||||
|
||||
@ -107,6 +107,32 @@
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if object.media_type == "Beer" or object.media_type == "Wine" or object.media_type == "Coffee" %}
|
||||
{% if object.logdata.format_display or object.logdata.size_ml %}
|
||||
<div class="mb-3">
|
||||
<dl class="row" style="max-width: 400px;">
|
||||
{% if object.logdata.format_display %}
|
||||
<dt class="col-sm-5">Format</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.format_display }}</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.size_ml %}
|
||||
<dt class="col-sm-5">Size</dt>
|
||||
<dd class="col-sm-7">
|
||||
{{ object.logdata.size_oz }} oz
|
||||
</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.roast_level_display %}
|
||||
<dt class="col-sm-5">Roast</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.roast_level_display }}</dd>
|
||||
{% endif %}
|
||||
{% if object.logdata.brewing_method_display %}
|
||||
<dt class="col-sm-5">Method</dt>
|
||||
<dd class="col-sm-7">{{ object.logdata.brewing_method_display }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if object.media_type == "Task" and object.logdata.description %}
|
||||
<p>{{ object.logdata.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
@ -6,48 +6,63 @@ from oauth2_provider import urls as oauth2_urls
|
||||
from rest_framework import routers
|
||||
|
||||
import vrobbler.apps.scrobbles.views as scrobbles_views
|
||||
|
||||
from vrobbler.apps.discgolf import urls as discgolf_urls
|
||||
from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet
|
||||
|
||||
from vrobbler.apps.birds import urls as birds_urls
|
||||
from vrobbler.apps.birds.api.views import BirdingLocationViewSet, BirdViewSet
|
||||
from vrobbler.apps.boardgames import urls as boardgame_urls
|
||||
from vrobbler.apps.boardgames.api.views import (
|
||||
BoardGameViewSet,
|
||||
BoardGameDesignerViewSet,
|
||||
BoardGamePublisherViewSet,
|
||||
BoardGameLocationViewSet,
|
||||
BoardGamePublisherViewSet,
|
||||
BoardGameVariantViewSet,
|
||||
BoardGameViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.books import urls as book_urls
|
||||
from vrobbler.apps.books.api.views import AuthorViewSet, BookViewSet
|
||||
|
||||
from vrobbler.apps.bricksets import urls as bricksets_urls
|
||||
from vrobbler.apps.bricksets.api.views import BrickSetViewSet
|
||||
from vrobbler.apps.charts import urls as charts_urls
|
||||
from vrobbler.apps.discgolf import urls as discgolf_urls
|
||||
from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet
|
||||
from vrobbler.apps.drinks import urls as drinks_urls
|
||||
from vrobbler.apps.drinks.api.views import (
|
||||
BeerProducerViewSet,
|
||||
BeerStyleViewSet,
|
||||
BeerViewSet,
|
||||
CoffeeRoasterViewSet,
|
||||
CoffeeViewSet,
|
||||
WineGrapeViewSet,
|
||||
WineProducerViewSet,
|
||||
WineRegionViewSet,
|
||||
WineViewSet,
|
||||
)
|
||||
from vrobbler.apps.foods import urls as foods_urls
|
||||
from vrobbler.apps.foods.api.views import FoodCategoryViewSet, FoodViewSet
|
||||
from vrobbler.apps.lifeevents import urls as lifeevents_urls
|
||||
from vrobbler.apps.lifeevents.api.views import LifeEventViewSet
|
||||
|
||||
from vrobbler.apps.locations import urls as locations_urls
|
||||
from vrobbler.apps.locations.api.views import GeoLocationViewSet
|
||||
|
||||
from vrobbler.apps.moods import urls as moods_urls
|
||||
from vrobbler.apps.moods.api.views import MoodViewSet
|
||||
|
||||
from vrobbler.apps.foods import urls as foods_urls
|
||||
from vrobbler.apps.foods.api.views import FoodViewSet, FoodCategoryViewSet
|
||||
|
||||
from vrobbler.apps.music import urls as music_urls
|
||||
from vrobbler.apps.music.api.views import (
|
||||
AlbumViewSet,
|
||||
ArtistViewSet,
|
||||
TrackViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.people import urls as people_urls
|
||||
from vrobbler.apps.podcasts import urls as podcast_urls
|
||||
from vrobbler.apps.podcasts.api.views import (
|
||||
ProducerViewSet,
|
||||
PodcastViewSet,
|
||||
PodcastEpisodeViewSet,
|
||||
PodcastViewSet,
|
||||
ProducerViewSet,
|
||||
)
|
||||
from vrobbler.apps.profiles import urls as profiles_urls
|
||||
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet
|
||||
from vrobbler.apps.puzzles import urls as puzzles_urls
|
||||
from vrobbler.apps.puzzles.api.views import (
|
||||
PuzzleManufacturerViewSet,
|
||||
PuzzleViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.scrobbles import urls as scrobble_urls
|
||||
from vrobbler.apps.scrobbles.api.views import (
|
||||
AudioScrobblerTSVImportViewSet,
|
||||
@ -55,7 +70,6 @@ from vrobbler.apps.scrobbles.api.views import (
|
||||
LastFmImportViewSet,
|
||||
ScrobbleViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.sports import urls as sports_urls
|
||||
from vrobbler.apps.sports.api.views import (
|
||||
LeagueViewSet,
|
||||
@ -67,50 +81,19 @@ from vrobbler.apps.sports.api.views import (
|
||||
)
|
||||
from vrobbler.apps.tasks import urls as tasks_urls
|
||||
from vrobbler.apps.tasks.api.views import TaskViewSet
|
||||
|
||||
from vrobbler.apps.profiles import urls as profiles_urls
|
||||
from vrobbler.apps.profiles.api.views import UserProfileViewSet, UserViewSet
|
||||
|
||||
from vrobbler.apps.trails import urls as trails_urls
|
||||
from vrobbler.apps.trails.api.views import TrailViewSet
|
||||
|
||||
from vrobbler.apps.beers import urls as beers_urls
|
||||
from vrobbler.apps.beers.api.views import (
|
||||
BeerViewSet,
|
||||
BeerProducerViewSet,
|
||||
BeerStyleViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.birds import urls as birds_urls
|
||||
from vrobbler.apps.birds.api.views import (
|
||||
BirdViewSet,
|
||||
BirdingLocationViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.puzzles import urls as puzzles_urls
|
||||
from vrobbler.apps.puzzles.api.views import (
|
||||
PuzzleViewSet,
|
||||
PuzzleManufacturerViewSet,
|
||||
)
|
||||
from vrobbler.apps.trends import urls as trends_urls
|
||||
from vrobbler.apps.videogames import urls as videogame_urls
|
||||
|
||||
from vrobbler.apps.videos import urls as video_urls
|
||||
from vrobbler.apps.videos.api.views import (
|
||||
ChannelViewSet,
|
||||
SeriesViewSet,
|
||||
VideoViewSet,
|
||||
)
|
||||
|
||||
from vrobbler.apps.bricksets import urls as bricksets_urls
|
||||
from vrobbler.apps.bricksets.api.views import BrickSetViewSet
|
||||
|
||||
from vrobbler.apps.webpages import urls as webpages_urls
|
||||
from vrobbler.apps.webpages.api.views import DomainViewSet, WebPageViewSet
|
||||
|
||||
from vrobbler.apps.people import urls as people_urls
|
||||
from vrobbler.apps.charts import urls as charts_urls
|
||||
from vrobbler.apps.trends import urls as trends_urls
|
||||
|
||||
# from vrobbler.apps.modern_ui import urls as modern_ui_urls
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
@ -143,6 +126,12 @@ router.register(r"locations", GeoLocationViewSet)
|
||||
router.register(r"beers", BeerViewSet)
|
||||
router.register(r"beer-producers", BeerProducerViewSet)
|
||||
router.register(r"beer-styles", BeerStyleViewSet)
|
||||
router.register(r"wines", WineViewSet)
|
||||
router.register(r"wine-producers", WineProducerViewSet)
|
||||
router.register(r"wine-regions", WineRegionViewSet)
|
||||
router.register(r"wine-grapes", WineGrapeViewSet)
|
||||
router.register(r"coffees", CoffeeViewSet)
|
||||
router.register(r"coffee-roasters", CoffeeRoasterViewSet)
|
||||
router.register(r"boardgames", BoardGameViewSet)
|
||||
router.register(r"boardgame-designers", BoardGameDesignerViewSet)
|
||||
router.register(r"boardgame-publishers", BoardGamePublisherViewSet)
|
||||
@ -175,7 +164,7 @@ urlpatterns = [
|
||||
path("", include(sports_urls, namespace="sports")),
|
||||
path("", include(locations_urls, namespace="locations")),
|
||||
path("", include(trails_urls, namespace="trails")),
|
||||
path("", include(beers_urls, namespace="beers")),
|
||||
path("", include(drinks_urls, namespace="drinks")),
|
||||
path("", include(discgolf_urls, namespace="discgolf")),
|
||||
path("", include(foods_urls, namespace="foods")),
|
||||
path("", include(puzzles_urls, namespace="puzzles")),
|
||||
|
||||
Reference in New Issue
Block a user