Browse Source

[puzzles] Add puzzle model and hooks

Colin Powell 4 days ago
parent
commit
159459e1b9

+ 28 - 0
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,
+    ]

+ 5 - 0
vrobbler/apps/puzzles/apps.py

@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class PuzzlesConfig(AppConfig):
+    name = "puzzles"

+ 163 - 0
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,
+            },
+        ),
+    ]

+ 18 - 0
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),
+        ),
+    ]

+ 0 - 0
vrobbler/apps/puzzles/migrations/__init__.py


+ 108 - 0
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"
+        )

+ 5 - 0
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 {}

+ 14 - 0
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/<slug:slug>/",
+        views.PuzzleDetailView.as_view(),
+        name="puzzle_detail",
+    ),
+]

+ 11 - 0
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

+ 2 - 0
vrobbler/apps/scrobbles/constants.py

@@ -34,6 +34,7 @@ SCROBBLE_CONTENT_URLS = {
     "-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}",
     "-i": "https://www.youtube.com/watch?v=",
     "-i": "https://www.youtube.com/watch?v=",
+    "-p": "https://www.ipdb.plus/IPDb/puzzle.php?id=",
 }
 }
 
 
 EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
 EXCLUDE_FROM_NOW_PLAYING = ("GeoLocation",)
@@ -47,6 +48,7 @@ MANUAL_SCROBBLE_FNS = {
     "-u": "manual_scrobble_beer",
     "-u": "manual_scrobble_beer",
     "-w": "manual_scrobble_webpage",
     "-w": "manual_scrobble_webpage",
     "-t": "manual_scrobble_task",
     "-t": "manual_scrobble_task",
+    "-p": "manual_scrobble_puzzle",
 }
 }
 
 
 
 

+ 7 - 0
vrobbler/apps/scrobbles/dataclasses.py

@@ -211,3 +211,10 @@ class FoodLogData(JSONDataclass):
     details: Optional[str] = None
     details: Optional[str] = None
     rating: Optional[str] = None
     rating: Optional[str] = None
     notes: Optional[str] = None
     notes: Optional[str] = None
+
+
+@dataclass
+class PuzzleLogData(JSONDataclass):
+    with_others: Optional[str] = None
+    rating: Optional[str] = None
+    notes: Optional[str] = None

+ 53 - 0
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,
+            ),
+        ),
+    ]

+ 7 - 5
vrobbler/apps/scrobbles/models.py

@@ -36,20 +36,18 @@ from profiles.utils import (
     start_of_month,
     start_of_month,
     start_of_week,
     start_of_week,
 )
 )
+from puzzles.models import Puzzle
 from scrobbles import dataclasses as logdata
 from scrobbles import dataclasses as logdata
 from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
 from scrobbles.constants import LONG_PLAY_MEDIA, MEDIA_END_PADDING_SECONDS
+from scrobbles.notifications import NtfyNotification
 from scrobbles.stats import build_charts
 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 sports.models import SportEvent
 from tasks.models import Task
 from tasks.models import Task
 from trails.models import Trail
 from trails.models import Trail
 from videogames import retroarch
 from videogames import retroarch
 from videogames.models import VideoGame
 from videogames.models import VideoGame
 from videos.models import Series, Video
 from videos.models import Series, Video
-from scrobbles.notifications import NtfyNotification
 from webpages.models import WebPage
 from webpages.models import WebPage
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -508,6 +506,7 @@ class Scrobble(TimeStampedModel):
         GEO_LOCATION = "GeoLocation", "GeoLocation"
         GEO_LOCATION = "GeoLocation", "GeoLocation"
         TRAIL = "Trail", "Trail"
         TRAIL = "Trail", "Trail"
         BEER = "Beer", "Beer"
         BEER = "Beer", "Beer"
+        PUZZLE = "Puzzle", "Puzzle"
         FOOD = "Food", "Food"
         FOOD = "Food", "Food"
         TASK = "Task", "Task"
         TASK = "Task", "Task"
         WEBPAGE = "WebPage", "Web Page"
         WEBPAGE = "WebPage", "Web Page"
@@ -536,6 +535,7 @@ class Scrobble(TimeStampedModel):
         GeoLocation, on_delete=models.DO_NOTHING, **BNULL
         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)
+    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)
     task = models.ForeignKey(Task, 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
             media_obj = self.trail
         if self.beer:
         if self.beer:
             media_obj = self.beer
             media_obj = self.beer
+        if self.puzzle:
+            media_obj = self.puzzle
         if self.task:
         if self.task:
             media_obj = self.task
             media_obj = self.task
         return media_obj
         return media_obj

+ 30 - 1
vrobbler/apps/scrobbles/scrobblers.py

@@ -14,9 +14,9 @@ from locations.constants import LOCATION_PROVIDERS
 from locations.models import GeoLocation
 from locations.models import GeoLocation
 from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
 from music.constants import JELLYFIN_POST_KEYS, MOPIDY_POST_KEYS
 from music.models import Track
 from music.models import Track
-from music.utils import get_or_create_track
 from podcasts.models import PodcastEpisode
 from podcasts.models import PodcastEpisode
 from podcasts.utils import parse_mopidy_uri
 from podcasts.utils import parse_mopidy_uri
+from puzzles.models import Puzzle
 from scrobbles.constants import (
 from scrobbles.constants import (
     JELLYFIN_AUDIO_ITEM_TYPES,
     JELLYFIN_AUDIO_ITEM_TYPES,
     MANUAL_SCROBBLE_FNS,
     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
     # 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)
     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)

+ 1 - 2
vrobbler/apps/scrobbles/views.py

@@ -25,14 +25,13 @@ from rest_framework.decorators import (
     permission_classes,
     permission_classes,
 )
 )
 from rest_framework.parsers import MultiPartParser
 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 rest_framework.response import Response
 from scrobbles.api import serializers
 from scrobbles.api import serializers
 from scrobbles.constants import (
 from scrobbles.constants import (
     LONG_PLAY_MEDIA,
     LONG_PLAY_MEDIA,
     MANUAL_SCROBBLE_FNS,
     MANUAL_SCROBBLE_FNS,
     PLAY_AGAIN_MEDIA,
     PLAY_AGAIN_MEDIA,
-    SCROBBLE_CONTENT_URLS,
 )
 )
 from scrobbles.export import export_scrobbles
 from scrobbles.export import export_scrobbles
 from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
 from scrobbles.forms import ExportScrobbleForm, ScrobbleForm

+ 1 - 0
vrobbler/settings.py

@@ -147,6 +147,7 @@ INSTALLED_APPS = [
     "tasks",
     "tasks",
     "trails",
     "trails",
     "beers",
     "beers",
+    "puzzles",
     "foods",
     "foods",
     "lifeevents",
     "lifeevents",
     "moods",
     "moods",