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