From 52494651bf58f050201caf8fab352dbc5d601d07 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sun, 17 Aug 2025 12:37:06 -0400 Subject: [PATCH] [scrobbles] Add dynamic forms for LogData classes --- vrobbler/apps/boardgames/models.py | 35 +++++++- vrobbler/apps/books/koreader.py | 2 - vrobbler/apps/books/models.py | 13 ++- vrobbler/apps/bricksets/models.py | 2 + vrobbler/apps/scrobbles/dataclasses.py | 30 ++++++- vrobbler/apps/scrobbles/forms.py | 83 +++++++++++++++++++ vrobbler/apps/scrobbles/models.py | 6 ++ .../apps/scrobbles/templatetags/form_tags.py | 11 +++ vrobbler/apps/scrobbles/urls.py | 5 ++ vrobbler/apps/scrobbles/views.py | 68 ++++++++++++++- vrobbler/apps/tasks/models.py | 11 +++ vrobbler/apps/videogames/models.py | 31 +++++++ vrobbler/apps/videogames/views.py | 10 ++- vrobbler/templates/base.html | 16 ++++ vrobbler/templates/beers/beer_detail.html | 2 +- .../boardgames/boardgame_detail.html | 2 +- vrobbler/templates/books/book_detail.html | 10 +-- .../templates/scrobbles/scrobble_detail.html | 38 +++++++++ vrobbler/templates/tasks/task_detail.html | 2 +- .../videogames/videogame_detail.html | 3 +- 20 files changed, 352 insertions(+), 28 deletions(-) create mode 100644 vrobbler/apps/scrobbles/templatetags/form_tags.py create mode 100644 vrobbler/templates/scrobbles/scrobble_detail.html diff --git a/vrobbler/apps/boardgames/models.py b/vrobbler/apps/boardgames/models.py index ffb071d..bbda1af 100644 --- a/vrobbler/apps/boardgames/models.py +++ b/vrobbler/apps/boardgames/models.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Optional from uuid import uuid4 +from django import forms import requests from boardgames.bgg import lookup_boardgame_from_bgg from django.conf import settings @@ -79,9 +80,20 @@ class BoardGameLogData(BaseLogData, LongPlayLogData): board: Optional[str] = None rounds: Optional[int] = None details: Optional[str] = None - # Legacy - learning: Optional[bool] = None - scenario: Optional[str] = None + + _excluded_fields = { + "lichess_id", + "speed", + "rated", + "moves", + "variant", + } + + @cached_property + def location(self): + if not self.location_id: + return + return BoardGameLocation.objects.filter(id=self.location_id).first() @cached_property def player_log(self) -> str: @@ -94,6 +106,23 @@ class BoardGameLogData(BaseLogData, LongPlayLogData): ) return "" + @classmethod + def override_fields(cls) -> dict: + fields = {} + for base in cls.mro()[1:]: + if hasattr(base, "override_fields"): + base_fields = base.override_fields() + fields.update(base_fields) + custom_fields = { + "location_id": forms.ModelChoiceField( + queryset=BoardGameLocation.objects.all(), + required=False, + widget=forms.Select(), + ) + } + fields.update(custom_fields) + return fields + class BoardGamePublisher(TimeStampedModel): name = models.CharField(max_length=255) diff --git a/vrobbler/apps/books/koreader.py b/vrobbler/apps/books/koreader.py index be7de80..cbd456e 100644 --- a/vrobbler/apps/books/koreader.py +++ b/vrobbler/apps/books/koreader.py @@ -291,8 +291,6 @@ def build_scrobbles_from_book_map( ) or stop_timestamp.dst() == timedelta(0): timestamp = timestamp - timedelta(hours=1) stop_timestamp = stop_timestamp - timedelta(hours=1) - else: - print("In DST! ", timestamp) scrobble = Scrobble.objects.filter( timestamp=timestamp, diff --git a/vrobbler/apps/books/models.py b/vrobbler/apps/books/models.py index 7202ed7..3d53ec2 100644 --- a/vrobbler/apps/books/models.py +++ b/vrobbler/apps/books/models.py @@ -1,7 +1,7 @@ from collections import OrderedDict from dataclasses import dataclass import logging -from datetime import timedelta, datetime +from datetime import datetime from typing import Optional from uuid import uuid4 @@ -22,7 +22,6 @@ from scrobbles.mixins import ( LongPlayScrobblableMixin, ObjectWithGenres, ScrobblableConstants, - ScrobblableMixin, ) from scrobbles.utils import get_scrobbles_for_media from taggit.managers import TaggableManager @@ -64,6 +63,16 @@ class BookLogData(BaseLogData, LongPlayLogData): page_start: Optional[int] = None page_end: Optional[int] = None + _excluded_fields = {"koreader_hash", "page_data"} + + def avg_seconds_per_page(self): + if self.page_data: + total_duration = 0 + for page_num, stats in self.page_data.items(): + total_duration += stats.get("duration", 0) + if total_duration: + return int(total_duration / len(self.page_data)) + class Author(TimeStampedModel): name = models.CharField(max_length=255) diff --git a/vrobbler/apps/bricksets/models.py b/vrobbler/apps/bricksets/models.py index 91d0eb3..1728f10 100644 --- a/vrobbler/apps/bricksets/models.py +++ b/vrobbler/apps/bricksets/models.py @@ -1,9 +1,11 @@ from dataclasses import dataclass + from django.db import models from django.urls import reverse from imagekit.models import ImageSpecField from imagekit.processors import ResizeToFit from scrobbles.mixins import LongPlayScrobblableMixin + from vrobbler.apps.scrobbles.dataclasses import ( BaseLogData, LongPlayLogData, diff --git a/vrobbler/apps/scrobbles/dataclasses.py b/vrobbler/apps/scrobbles/dataclasses.py index 56020bb..0a2b4b3 100644 --- a/vrobbler/apps/scrobbles/dataclasses.py +++ b/vrobbler/apps/scrobbles/dataclasses.py @@ -3,9 +3,10 @@ from dataclasses import asdict, dataclass from typing import Optional from dataclass_wizard import JSONWizard +from django import forms from django.contrib.auth import get_user_model -from locations.models import GeoLocation from people.models import Person +from scrobbles.forms import form_from_dataclass User = get_user_model() @@ -35,6 +36,16 @@ class BaseLogData(JSONDataclass): description: Optional[str] = None notes: Optional[list[str]] = None + _excluded_fields = {} + + @classmethod + def form(cls): + return form_from_dataclass(cls) + + @classmethod + def override_fields(cls) -> dict: + return {} + @dataclass class LongPlayLogData(JSONDataclass): @@ -55,3 +66,20 @@ class WithPeopleLogData(JSONDataclass): Person.objects.filter(id=pid).first() for pid in self.with_people_ids ] + + @classmethod + def override_fields(cls) -> dict: + fields = {} + for base in cls.mro()[1:]: + if hasattr(base, "override_fields"): + base_fields = base.override_fields() + fields.update(base_fields) + custom_fields = { + "with_people_ids": forms.ModelMultipleChoiceField( + queryset=Person.objects.all(), + required=False, + widget=forms.SelectMultiple(attrs={"size": 10}), + ) + } + fields.update(custom_fields) + return fields diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py index 17e3be9..6fd56e9 100644 --- a/vrobbler/apps/scrobbles/forms.py +++ b/vrobbler/apps/scrobbles/forms.py @@ -1,5 +1,10 @@ +from dataclasses import fields +from typing import Union, get_args, get_origin + from django import forms +from people.models import Person + class ExportScrobbleForm(forms.Form): """Provide options for downloading scrobbles""" @@ -23,3 +28,81 @@ class ScrobbleForm(forms.Form): } ), ) + + +# Mapping of types to Django form field classes +TYPE_FIELD_MAP = { + int: forms.IntegerField, + float: forms.FloatField, + bool: forms.BooleanField, + str: forms.CharField, + dict: forms.JSONField, + list: forms.JSONField, +} + +# Optional: type-to-widget mapping +TYPE_WIDGET_MAP = { + str: forms.TextInput(attrs={"size": 80}), + dict: forms.Textarea(attrs={"rows": 10, "cols": 80}), + list: forms.Textarea(attrs={"rows": 6, "cols": 80}), + bool: forms.CheckboxInput(), +} + + +def django_form_field_from_type(field_type, required=True): + origin = get_origin(field_type) + + # Handle Optional / Union + if origin is Union: + args = get_args(field_type) + if type(None) in args: + required = False + non_none_type = [arg for arg in args if arg is not type(None)][0] + return django_form_field_from_type( + non_none_type, required=required + ) + + # Determine actual type + base_type = origin if origin else field_type + field_class = TYPE_FIELD_MAP.get(base_type, forms.CharField) + widget = TYPE_WIDGET_MAP.get(base_type) + + return ( + field_class(required=required, widget=widget) + if widget + else field_class(required=required) + ) + + +def form_from_dataclass(dataclass): + form_fields = {} + # Override notes field + for f in fields(dataclass): + if f.name in dataclass.override_fields(): + form_fields[f.name] = dataclass.override_fields()[f.name] + continue + + required = f.default is None and f.default_factory is None + form_fields[f.name] = django_form_field_from_type( + f.type, required=required + ) + + if f.name in dataclass._excluded_fields: + form_fields[f.name].disabled = True + + form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields) + + if "notes" in form_cls.base_fields: + form_cls.base_fields["notes"] = forms.CharField( + required=False, + widget=forms.Textarea(attrs={"rows": 4}), + ) + + def clean_notes(self): + notes_str = self.cleaned_data.get("notes", "") + return [ + line.strip() for line in notes_str.splitlines() if line.strip() + ] + + form_cls.clean_notes = clean_notes + return form_cls diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 1e82254..e573dcc 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -697,6 +697,12 @@ class Scrobble(TimeStampedModel): return super(Scrobble, self).save(*args, **kwargs) + def get_absolute_url(self): + if not self.uuid: + self.uuid = uuid4() + self.save() + return reverse("scrobbles:detail", kwargs={"uuid": self.uuid}) + def push_to_archivebox(self): pushable_media = hasattr( self.media_obj, "push_to_archivebox" diff --git a/vrobbler/apps/scrobbles/templatetags/form_tags.py b/vrobbler/apps/scrobbles/templatetags/form_tags.py new file mode 100644 index 0000000..1203d9c --- /dev/null +++ b/vrobbler/apps/scrobbles/templatetags/form_tags.py @@ -0,0 +1,11 @@ +from django import template + +register = template.Library() + + +@register.filter(name="add_class") +def add_class(field, css_class): + # If the widget is CheckboxInput, skip adding 'form-control' + if field.field.widget.__class__.__name__ == "CheckboxInput": + return field.as_widget() + return field.as_widget(attrs={"class": css_class}) diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 742deff..9505f7b 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -95,6 +95,11 @@ urlpatterns = [ views.ScrobbleLongPlaysView.as_view(), name="long-plays", ), + path( + "scrobble//", + views.ScrobbleDetailView.as_view(), + name="detail", + ), path("scrobble//start/", views.scrobble_start, name="start"), path("scrobble//finish/", views.scrobble_finish, name="finish"), path("scrobble//cancel/", views.scrobble_cancel, name="cancel"), diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 5599ef9..63998cf 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -4,12 +4,13 @@ import logging from datetime import datetime, timedelta from dateutil.relativedelta import relativedelta +from django.shortcuts import redirect import pendulum import pytz from django.apps import apps from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Count, Q +from django.db.models import Count, Q, Max from django.db.models.query import QuerySet from django.http import FileResponse, HttpResponseRedirect, JsonResponse from django.urls import reverse_lazy @@ -75,12 +76,17 @@ class ScrobbleableListView(ListView): user_filter = Q(scrobble__user=self.request.user) queryset = ( - queryset.filter(user_filter).annotate( - scrobble_count=Count("scrobble") - ).filter(scrobble_count__gt=0).order_by("-scrobble_count") + queryset.filter(user_filter) + .annotate( + scrobble_count=Count("scrobble", distinct=True), + last_scrobble=Max("scrobble__timestamp"), + ) + .filter(scrobble_count__gt=0) + .order_by("-last_scrobble") ) return queryset + class ScrobbleableDetailView(DetailView): model = None slug_field = "uuid" @@ -926,3 +932,57 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView): ).first() return data + + +class ScrobbleDetailView(DetailView): + model = Scrobble + slug_field = "uuid" + slug_url_kwarg = "uuid" + + def get_form_class(self): + return self.object.media_obj.logdata_cls.form() + + def get_form(self): + FormClass = self.get_form_class() + + log = self.object.log or {} + initial_notes = log.get("notes", []) + if isinstance(initial_notes, list): + notes_str = "\n".join(initial_notes) + notes_str_fixed = notes_str.encode("utf-8").decode( + "unicode_escape" + ) + log["notes"] = notes_str_fixed + + return FormClass(initial=log) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + FormClass = self.get_form_class() + form = FormClass(request.POST) + + if form.is_valid(): + data = form.cleaned_data.copy() + + for field_name, field in form.fields.items(): + if field.disabled: + original_value = (self.object.log or {}).get(field_name) + data[field_name] = original_value + + if "with_people_ids" in data: + data["with_people_ids"] = [ + p.id for p in data["with_people_ids"] + ] + + self.object.log = data + self.object.save(update_fields=["log"]) + return redirect(self.object.get_absolute_url()) + + context = self.get_context_data(log_form=form) + return self.render_to_response(context) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if "log_form" not in context: + context["log_form"] = self.get_form() + return context diff --git a/vrobbler/apps/tasks/models.py b/vrobbler/apps/tasks/models.py index 44355ea..ec29326 100644 --- a/vrobbler/apps/tasks/models.py +++ b/vrobbler/apps/tasks/models.py @@ -26,6 +26,17 @@ class TaskLogData(BaseLogData): todoist_id: Optional[str] = None todoist_project_id: Optional[str] = None + _excluded_fields = { + "labels", + "orgmode_id", + "orgmode_state", + "orgmode_properties", + "orgmode_drawers", + "orgmode_timestamps", + "todoist_id", + "todoist_project_id", + } + def notes_as_str(self) -> str: """Return formatted notes with line breaks and no keys""" note_block = "" diff --git a/vrobbler/apps/videogames/models.py b/vrobbler/apps/videogames/models.py index 4e63503..8eea4d0 100644 --- a/vrobbler/apps/videogames/models.py +++ b/vrobbler/apps/videogames/models.py @@ -3,6 +3,7 @@ import logging from typing import Optional from uuid import uuid4 +from django import forms from django.conf import settings from django.contrib.auth import get_user_model from django.db import models @@ -24,6 +25,36 @@ BNULL = {"blank": True, "null": True} User = get_user_model() +@dataclass +class VideoGameLogData(BaseLogData, LongPlayLogData, WithPeopleLogData): + platform_id: Optional[int] = None + emulated: Optional[bool] = False + emulator: Optional[str] = None + + @property + def platform(self): + if not self.platform_id: + return + return VideoGamePlatform.objects.filter(id=self.platform_id).first() + + @classmethod + def override_fields(cls) -> dict: + fields = {} + for base in cls.mro()[1:]: + if hasattr(base, "override_fields"): + base_fields = base.override_fields() + fields.update(base_fields) + custom_fields = { + "platform_id": forms.ModelChoiceField( + queryset=VideoGamePlatform.objects.all(), + required=False, + widget=forms.Select(), + ) + } + fields.update(custom_fields) + return fields + + class VideoGamePlatform(TimeStampedModel): name = models.CharField(max_length=255) uuid = models.UUIDField(default=uuid4, editable=False, **BNULL) diff --git a/vrobbler/apps/videogames/views.py b/vrobbler/apps/videogames/views.py index 7c8a661..551ca3a 100644 --- a/vrobbler/apps/videogames/views.py +++ b/vrobbler/apps/videogames/views.py @@ -1,10 +1,14 @@ from videogames.models import VideoGame, VideoGamePlatform -from scrobbles.views import ScrobbleImportListView, ScrobbleableDetailView +from scrobbles.views import ( + ScrobbleImportListView, + ScrobbleableDetailView, + ScrobbleableListView, +) -class VideoGameListView(ScrobbleImportListView): +class VideoGameListView(ScrobbleableListView): model = VideoGame - paginate_by = 20 + paginate_by = 40 class VideoGameDetailView(ScrobbleableDetailView): diff --git a/vrobbler/templates/base.html b/vrobbler/templates/base.html index 850ba16..b1bd2cf 100644 --- a/vrobbler/templates/base.html +++ b/vrobbler/templates/base.html @@ -329,5 +329,21 @@ {% block extra_js %}{% endblock %} + + diff --git a/vrobbler/templates/beers/beer_detail.html b/vrobbler/templates/beers/beer_detail.html index 7bde048..907fecf 100644 --- a/vrobbler/templates/beers/beer_detail.html +++ b/vrobbler/templates/beers/beer_detail.html @@ -57,7 +57,7 @@ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} - {{scrobble.local_timestamp}} + {{scrobble.local_timestamp}} {% endfor %} diff --git a/vrobbler/templates/boardgames/boardgame_detail.html b/vrobbler/templates/boardgames/boardgame_detail.html index fd07cb2..1286247 100644 --- a/vrobbler/templates/boardgames/boardgame_detail.html +++ b/vrobbler/templates/boardgames/boardgame_detail.html @@ -62,7 +62,7 @@ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} - {{scrobble.local_timestamp}} + {{scrobble.local_timestamp}} {{scrobble.media_obj.publisher}} {% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %} diff --git a/vrobbler/templates/books/book_detail.html b/vrobbler/templates/books/book_detail.html index 0fbc114..dd82988 100644 --- a/vrobbler/templates/books/book_detail.html +++ b/vrobbler/templates/books/book_detail.html @@ -27,14 +27,6 @@

{{scrobbles.count}} scrobbles

-

Read {{scrobbles.last.book_pages_read}} pages{% if scrobbles.last.long_play_complete %} and completed{% else %}{% endif %}

-

- {% if scrobbles.last.long_play_complete == True %} - Read again - {% else %} - Resume reading - {% endif %} -

@@ -52,7 +44,7 @@ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} - {{scrobble.local_timestamp}} + {{scrobble.local_timestamp}} {% if scrobble.long_play_complete == True %}Yes{% endif %} {% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %} {% for author in scrobble.book.authors.all %}{{author}}{% if not forloop.last %}, {% endif %}{% endfor %} diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html new file mode 100644 index 0000000..213606e --- /dev/null +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -0,0 +1,38 @@ +{% extends "base_list.html" %} +{% load form_tags %} +{% load mathfilters %} +{% load static %} + +{% block title %}{{object.name}}{% endblock %} + +{% block lists %} + +
+ +

{{ object.media_obj }} - {{object.media_type}}

+ + +

Rate: {{object.logdata.avg_seconds_per_page}}s per page

+ +

Edit Log

+
+ {% csrf_token %} + {% for field in log_form %} +
+ + {{ field|add_class:"form-control" }} + {% if field.help_text %} +
{{ field.help_text }}
+ {% endif %} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + + +
+ +
+ +{% endblock %} diff --git a/vrobbler/templates/tasks/task_detail.html b/vrobbler/templates/tasks/task_detail.html index 0bbc829..ea29461 100644 --- a/vrobbler/templates/tasks/task_detail.html +++ b/vrobbler/templates/tasks/task_detail.html @@ -62,7 +62,7 @@ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} - {{scrobble.local_timestamp}} + {{scrobble.local_timestamp}} {{scrobble.logdata.description}} {{scrobble.logdata.notes_as_str|safe}} {{scrobble.source}} diff --git a/vrobbler/templates/videogames/videogame_detail.html b/vrobbler/templates/videogames/videogame_detail.html index f4ab042..182cbee 100644 --- a/vrobbler/templates/videogames/videogame_detail.html +++ b/vrobbler/templates/videogames/videogame_detail.html @@ -81,7 +81,8 @@ {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %} - {{scrobble.local_timestamp}} + + {{scrobble.local_timestamp}} {% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %} {% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %} {% for platform in scrobble.video_game.platforms.all %}{{platform}}{% if not forloop.last %}, {% endif %}{% endfor %}