Ver Fonte

[puzzles] Add puzzle model and hooks

Colin Powell há 3 dias atrás
pai
commit
dd71bdd38c

+ 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/",
     "-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",
 }
 
 

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

+ 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_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

+ 30 - 1
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)

+ 1 - 2
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

+ 1 - 0
vrobbler/settings.py

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