[puzzles] Add puzzle model and hooks

This commit is contained in:
2025-05-10 23:36:03 -04:00
parent d066a98282
commit dd71bdd38c
16 changed files with 453 additions and 8 deletions

View File

@ -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,
]

View File

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

View File

@ -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,
},
),
]

View File

@ -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),
),
]

View File

@ -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"
)

View File

@ -0,0 +1,5 @@
#!/usr/bin/env python3
def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict:
return {}

View File

@ -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",
),
]

View File

@ -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

View File

@ -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",
}

View File

@ -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

View File

@ -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,
),
),
]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

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