From dd71bdd38cf121469fec8bc4e960de2ebbe104d8 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 10 May 2025 23:36:03 -0400 Subject: [PATCH] [puzzles] Add puzzle model and hooks --- vrobbler/apps/puzzles/admin.py | 28 +++ vrobbler/apps/puzzles/apps.py | 5 + .../apps/puzzles/migrations/0001_initial.py | 163 ++++++++++++++++++ .../0002_alter_puzzle_dimensions_in_inches.py | 18 ++ vrobbler/apps/puzzles/migrations/__init__.py | 0 vrobbler/apps/puzzles/models.py | 108 ++++++++++++ vrobbler/apps/puzzles/sources/ipdb.py | 5 + vrobbler/apps/puzzles/urls.py | 14 ++ vrobbler/apps/puzzles/views.py | 11 ++ vrobbler/apps/scrobbles/constants.py | 2 + vrobbler/apps/scrobbles/dataclasses.py | 7 + ...robble_puzzle_alter_scrobble_media_type.py | 53 ++++++ vrobbler/apps/scrobbles/models.py | 12 +- vrobbler/apps/scrobbles/scrobblers.py | 31 +++- vrobbler/apps/scrobbles/views.py | 3 +- vrobbler/settings.py | 1 + 16 files changed, 453 insertions(+), 8 deletions(-) create mode 100644 vrobbler/apps/puzzles/admin.py create mode 100644 vrobbler/apps/puzzles/apps.py create mode 100644 vrobbler/apps/puzzles/migrations/0001_initial.py create mode 100644 vrobbler/apps/puzzles/migrations/0002_alter_puzzle_dimensions_in_inches.py create mode 100644 vrobbler/apps/puzzles/migrations/__init__.py create mode 100644 vrobbler/apps/puzzles/models.py create mode 100644 vrobbler/apps/puzzles/sources/ipdb.py create mode 100644 vrobbler/apps/puzzles/urls.py create mode 100644 vrobbler/apps/puzzles/views.py create mode 100644 vrobbler/apps/scrobbles/migrations/0069_scrobble_puzzle_alter_scrobble_media_type.py diff --git a/vrobbler/apps/puzzles/admin.py b/vrobbler/apps/puzzles/admin.py new file mode 100644 index 0000000..44f1043 --- /dev/null +++ b/vrobbler/apps/puzzles/admin.py @@ -0,0 +1,28 @@ +from puzzles.models import Puzzle, PuzzleManufacturer +from django.contrib import admin +from scrobbles.admin import ScrobbleInline + + +class PuzzleInline(admin.TabularInline): + model = Puzzle + extra = 0 + + +@admin.register(PuzzleManufacturer) +class PuzzleManufacturerAdmin(admin.ModelAdmin): + date_hierarchy = "created" + search_fields = ("name",) + + +@admin.register(Puzzle) +class PuzzleAdmin(admin.ModelAdmin): + date_hierarchy = "created" + list_display = ( + "uuid", + "title", + ) + ordering = ("-created",) + search_fields = ("title",) + inlines = [ + ScrobbleInline, + ] diff --git a/vrobbler/apps/puzzles/apps.py b/vrobbler/apps/puzzles/apps.py new file mode 100644 index 0000000..80f73c2 --- /dev/null +++ b/vrobbler/apps/puzzles/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PuzzlesConfig(AppConfig): + name = "puzzles" diff --git a/vrobbler/apps/puzzles/migrations/0001_initial.py b/vrobbler/apps/puzzles/migrations/0001_initial.py new file mode 100644 index 0000000..9ec8a8b --- /dev/null +++ b/vrobbler/apps/puzzles/migrations/0001_initial.py @@ -0,0 +1,163 @@ +# Generated by Django 4.2.19 on 2025-05-11 03:21 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import taggit.managers +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"), + ] + + operations = [ + migrations.CreateModel( + name="PuzzleManufacturer", + 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)), + ( + "ipdb_id", + models.CharField(blank=True, max_length=200, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + migrations.CreateModel( + name="Puzzle", + 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), + ), + ("run_time_seconds", models.IntegerField(default=900)), + ( + "run_time_ticks", + models.PositiveBigIntegerField(blank=True, null=True), + ), + ("description", models.TextField(blank=True, null=True)), + ( + "orientation", + models.CharField(blank=True, max_length=50, null=True), + ), + ( + "dimensions_in_inches", + models.CharField(blank=True, max_length=10, null=True), + ), + ("publish_year", models.IntegerField(blank=True, null=True)), + ( + "material", + models.CharField(blank=True, max_length=50, null=True), + ), + ( + "cut_style", + models.CharField(blank=True, max_length=100, null=True), + ), + ("pieces_count", models.IntegerField(blank=True, null=True)), + ( + "igdb_id", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "barcode", + models.CharField(blank=True, max_length=13, null=True), + ), + ( + "igdb_image", + models.ImageField( + blank=True, null=True, upload_to="puzzles/igdb/" + ), + ), + ( + "genre", + taggit.managers.TaggableManager( + blank=True, + help_text="A comma-separated list of tags.", + through="scrobbles.ObjectWithGenres", + to="scrobbles.Genre", + verbose_name="Tags", + ), + ), + ( + "manufacturer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="puzzles.puzzlemanufacturer", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/vrobbler/apps/puzzles/migrations/0002_alter_puzzle_dimensions_in_inches.py b/vrobbler/apps/puzzles/migrations/0002_alter_puzzle_dimensions_in_inches.py new file mode 100644 index 0000000..029858c --- /dev/null +++ b/vrobbler/apps/puzzles/migrations/0002_alter_puzzle_dimensions_in_inches.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.19 on 2025-05-11 03:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("puzzles", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="puzzle", + name="dimensions_in_inches", + field=models.CharField(blank=True, max_length=30, null=True), + ), + ] diff --git a/vrobbler/apps/puzzles/migrations/__init__.py b/vrobbler/apps/puzzles/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/puzzles/models.py b/vrobbler/apps/puzzles/models.py new file mode 100644 index 0000000..24bc30f --- /dev/null +++ b/vrobbler/apps/puzzles/models.py @@ -0,0 +1,108 @@ +from uuid import uuid4 + +from puzzles.sources import ipdb +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 PuzzleLogData +from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin + +BNULL = {"blank": True, "null": True} + + +class PuzzleManufacturer(TimeStampedModel): + uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) + name = models.CharField(max_length=255) + ipdb_id = models.CharField(max_length=200, **BNULL) + description = models.TextField(**BNULL) + + def __str__(self) -> str: + return str(self.name) + + +class Puzzle(ScrobblableMixin): + description = models.TextField(**BNULL) + orientation = models.CharField(max_length=50, **BNULL) + dimensions_in_inches = models.CharField(max_length=30, **BNULL) + publish_year = models.IntegerField(**BNULL) + material = models.CharField(max_length=50, **BNULL) + cut_style = models.CharField(max_length=100, **BNULL) + pieces_count = models.IntegerField(**BNULL) + igdb_id = models.CharField(max_length=255, **BNULL) + barcode = models.CharField(max_length=13, **BNULL) + igdb_image = models.ImageField(upload_to="puzzles/igdb/", **BNULL) + igdb_image_small = ImageSpecField( + source="igdb_image", + processors=[ResizeToFit(100, 100)], + format="JPEG", + options={"quality": 60}, + ) + igdb_image_medium = ImageSpecField( + source="igdb_image", + processors=[ResizeToFit(300, 300)], + format="JPEG", + options={"quality": 75}, + ) + manufacturer = models.ForeignKey( + PuzzleManufacturer, on_delete=models.DO_NOTHING, **BNULL + ) + + def get_absolute_url(self) -> str: + return reverse("puzzles:puzzle_detail", kwargs={"slug": self.uuid}) + + def __str__(self): + return f"{self.title} ({self.pieces_count}) by {self.manufacturer}" + + @property + def subtitle(self): + return self.manufacturer.name + + @property + def strings(self) -> ScrobblableConstants: + return ScrobblableConstants(verb="Solving", tags="puzzle") + + @property + def igdb_link(self) -> str: + link = "" + if self.igdb_id: + link = f"https://www.ipdb.plus/IPDb/puzzle.php?id={self.igdb_id}" + return link + + @property + def primary_image_url(self) -> str: + url = "" + if self.ipdb_image: + url = self.ipdb_image.url + return url + + @property + def logdata_cls(self): + return PuzzleLogData + + @classmethod + def find_or_create(cls, ipdb_id: str) -> "Puzzle": + puzzle = cls.objects.filter(ipdb_id=ipdb_id).first() + + if not puzzle: + puzzle_dict = ipdb.get_puzzle_from_ipdb_id(ipdb_id) + manufacturer_name = puzzle_dict.pop("manufacturer") + manufacturer, _created = PuzzleManufacturer.objects.get_or_create( + name=manufacturer_name + ) + ipdb_dict["manufacturer_id"] = manufacturer.id + + genres = puzzle_dict.pop("genres") + puzzle = Puzzle.objects.create(**puzzle_dict) + if genres: + puzzle.genre.add(*genres) + + return puzzle + + def scrobbles(self, user_id): + Scrobble = apps.get_model("scrobbles", "Scrobble") + return Scrobble.objects.filter(user_id=user_id, puzzle=self).order_by( + "-timestamp" + ) diff --git a/vrobbler/apps/puzzles/sources/ipdb.py b/vrobbler/apps/puzzles/sources/ipdb.py new file mode 100644 index 0000000..ce3ceec --- /dev/null +++ b/vrobbler/apps/puzzles/sources/ipdb.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + + +def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict: + return {} diff --git a/vrobbler/apps/puzzles/urls.py b/vrobbler/apps/puzzles/urls.py new file mode 100644 index 0000000..7da77af --- /dev/null +++ b/vrobbler/apps/puzzles/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from puzzles import views + +app_name = "puzzles" + + +urlpatterns = [ + path("puzzles/", views.PuzzleListView.as_view(), name="puzzle_list"), + path( + "puzzles//", + views.PuzzleDetailView.as_view(), + name="puzzle_detail", + ), +] diff --git a/vrobbler/apps/puzzles/views.py b/vrobbler/apps/puzzles/views.py new file mode 100644 index 0000000..6d1e7f0 --- /dev/null +++ b/vrobbler/apps/puzzles/views.py @@ -0,0 +1,11 @@ +from puzzles.models import Puzzle + +from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView + + +class PuzzleListView(ScrobbleableListView): + model = Puzzle + + +class PuzzleDetailView(ScrobbleableDetailView): + model = Puzzle diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.py index 29705e8..e978917 100644 --- a/vrobbler/apps/scrobbles/constants.py +++ b/vrobbler/apps/scrobbles/constants.py @@ -34,6 +34,7 @@ SCROBBLE_CONTENT_URLS = { "-b": "https://www.amazon.com/", "-t": "https://app.todoist.com/app/task/{id}", "-i": "https://www.youtube.com/watch?v=", + "-p": "https://www.ipdb.plus/IPDb/puzzle.php?id=", } EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",) @@ -47,6 +48,7 @@ MANUAL_SCROBBLE_FNS = { "-u": "manual_scrobble_beer", "-w": "manual_scrobble_webpage", "-t": "manual_scrobble_task", + "-p": "manual_scrobble_puzzle", } diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.py index ba3b47a..aa2dcbf 100644 --- a/vrobbler/apps/scrobbles/dataclasses.py +++ b/vrobbler/apps/scrobbles/dataclasses.py @@ -211,3 +211,10 @@ class FoodLogData(JSONDataclass): details: Optional[str] = None rating: Optional[str] = None notes: Optional[str] = None + + +@dataclass +class PuzzleLogData(JSONDataclass): + with_others: Optional[str] = None + rating: Optional[str] = None + notes: Optional[str] = None diff --git a/vrobbler/apps/scrobbles/migrations/0069_scrobble_puzzle_alter_scrobble_media_type.py b/vrobbler/apps/scrobbles/migrations/0069_scrobble_puzzle_alter_scrobble_media_type.py new file mode 100644 index 0000000..651ec5f --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0069_scrobble_puzzle_alter_scrobble_media_type.py @@ -0,0 +1,53 @@ +# Generated by Django 4.2.19 on 2025-05-11 03:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("puzzles", "0001_initial"), + ("scrobbles", "0068_scrobble_paper_alter_scrobble_media_type"), + ] + + operations = [ + migrations.AddField( + model_name="scrobble", + name="puzzle", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to="puzzles.puzzle", + ), + ), + 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"), + ("Puzzle", "Puzzle"), + ("Food", "Food"), + ("Task", "Task"), + ("WebPage", "Web Page"), + ("LifeEvent", "Life event"), + ("Mood", "Mood"), + ("BrickSet", "Brick set"), + ], + default="Video", + max_length=14, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 87f14b6..d206900 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -36,20 +36,18 @@ from profiles.utils import ( start_of_month, start_of_week, ) +from puzzles.models import Puzzle from scrobbles import dataclasses as logdata from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS +from scrobbles.notifications import NtfyNotification from scrobbles.stats import build_charts -from scrobbles.utils import ( - get_file_md5_hash, - media_class_to_foreign_key, -) +from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key from sports.models import SportEvent from tasks.models import Task from trails.models import Trail from videogames import retroarch from videogames.models import VideoGame from videos.models import Series, Video -from scrobbles.notifications import NtfyNotification from webpages.models import WebPage logger = logging.getLogger(__name__) @@ -508,6 +506,7 @@ class Scrobble(TimeStampedModel): GEO_LOCATION = "GeoLocation", "GeoLocation" TRAIL = "Trail", "Trail" BEER = "Beer", "Beer" + PUZZLE = "Puzzle", "Puzzle" FOOD = "Food", "Food" TASK = "Task", "Task" WEBPAGE = "WebPage", "Web Page" @@ -536,6 +535,7 @@ class Scrobble(TimeStampedModel): GeoLocation, on_delete=models.DO_NOTHING, **BNULL ) beer = models.ForeignKey(Beer, on_delete=models.DO_NOTHING, **BNULL) + puzzle = models.ForeignKey(Puzzle, on_delete=models.DO_NOTHING, **BNULL) food = models.ForeignKey(Food, on_delete=models.DO_NOTHING, **BNULL) trail = models.ForeignKey(Trail, on_delete=models.DO_NOTHING, **BNULL) task = models.ForeignKey(Task, on_delete=models.DO_NOTHING, **BNULL) @@ -948,6 +948,8 @@ class Scrobble(TimeStampedModel): media_obj = self.trail if self.beer: media_obj = self.beer + if self.puzzle: + media_obj = self.puzzle if self.task: media_obj = self.task return media_obj diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 2ba6dec..bc079c7 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -14,9 +14,9 @@ from locations.constants import LOCATION_PROVIDERS from locations.models import GeoLocation from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS from music.models import Track -from music.utils import get_or_create_track from podcasts.models import PodcastEpisode from podcasts.utils import parse_mopidy_uri +from puzzles.models import Puzzle from scrobbles.constants import ( JELLYFIN_AUDIO_ITEM_TYPES, MANUAL_SCROBBLE_FNS, @@ -679,3 +679,32 @@ def manual_scrobble_beer( # TODO Kick out a process to enrich the media here, and in every scrobble event return Scrobble.create_or_update(beer, user_id, scrobble_dict) + + +def manual_scrobble_puzzle( + ipdb_id: str, user_id: int, action: Optional[str] = None +): + puzzle = Puzzle.find_or_create(ipdb_id) + + if not puzzle: + logger.error(f"No puzzle found for IPDB ID {ipdb_id}") + return + + scrobble_dict = { + "user_id": user_id, + "timestamp": timezone.now(), + "playback_position_seconds": 0, + "source": "Vrobbler", + } + logger.info( + "[vrobbler-scrobble] puzzle scrobble request received", + extra={ + "puzzle_id": puzzle.id, + "user_id": user_id, + "scrobble_dict": scrobble_dict, + "media_type": Scrobble.MediaType.PUZZLE, + }, + ) + + # TODO Kick out a process to enrich the media here, and in every scrobble event + return Scrobble.create_or_update(puzzle, user_id, scrobble_dict) diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 30eb077..3c6434b 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -25,14 +25,13 @@ from rest_framework.decorators import ( permission_classes, ) from rest_framework.parsers import MultiPartParser -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from scrobbles.api import serializers from scrobbles.constants import ( LONG_PLAY_MEDIA, MANUAL_SCROBBLE_FNS, PLAY_AGAIN_MEDIA, - SCROBBLE_CONTENT_URLS, ) from scrobbles.export import export_scrobbles from scrobbles.forms import ExportScrobbleForm, ScrobbleForm diff --git a/vrobbler/settings.py b/vrobbler/settings.py index 74996ae..94de984 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -147,6 +147,7 @@ INSTALLED_APPS = [ "tasks", "trails", "beers", + "puzzles", "foods", "lifeevents", "moods",