[puzzles] Add puzzle model and hooks

This commit is contained in:
2025-05-10 23:36:03 -04:00
committed by Gogs
parent 89b6d8de06
commit 159459e1b9
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/", "-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",
} }

View File

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

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_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 ( from scrobbles.utils import get_file_md5_hash, media_class_to_foreign_key
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

View File

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

View File

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

View File

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