[puzzles] Add puzzle model and hooks
This commit is contained in:
28
vrobbler/apps/puzzles/admin.py
Normal file
28
vrobbler/apps/puzzles/admin.py
Normal 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,
|
||||
]
|
||||
5
vrobbler/apps/puzzles/apps.py
Normal file
5
vrobbler/apps/puzzles/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PuzzlesConfig(AppConfig):
|
||||
name = "puzzles"
|
||||
163
vrobbler/apps/puzzles/migrations/0001_initial.py
Normal file
163
vrobbler/apps/puzzles/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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
vrobbler/apps/puzzles/migrations/__init__.py
Normal file
0
vrobbler/apps/puzzles/migrations/__init__.py
Normal file
108
vrobbler/apps/puzzles/models.py
Normal file
108
vrobbler/apps/puzzles/models.py
Normal 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"
|
||||
)
|
||||
5
vrobbler/apps/puzzles/sources/ipdb.py
Normal file
5
vrobbler/apps/puzzles/sources/ipdb.py
Normal file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
|
||||
def get_puzzle_from_ipdb_id(ipdb_id: str) -> dict:
|
||||
return {}
|
||||
14
vrobbler/apps/puzzles/urls.py
Normal file
14
vrobbler/apps/puzzles/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
11
vrobbler/apps/puzzles/views.py
Normal file
11
vrobbler/apps/puzzles/views.py
Normal 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
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -147,6 +147,7 @@ INSTALLED_APPS = [
|
||||
"tasks",
|
||||
"trails",
|
||||
"beers",
|
||||
"puzzles",
|
||||
"foods",
|
||||
"lifeevents",
|
||||
"moods",
|
||||
|
||||
Reference in New Issue
Block a user