Browse Source

[scrobbles] Add dynamic forms for LogData classes

Colin Powell 6 ngày trước cách đây
mục cha
commit
52494651bf

+ 32 - 3
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)

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

+ 11 - 2
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)

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

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

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

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

+ 11 - 0
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})

+ 5 - 0
vrobbler/apps/scrobbles/urls.py

@@ -95,6 +95,11 @@ urlpatterns = [
         views.ScrobbleLongPlaysView.as_view(),
         name="long-plays",
     ),
+    path(
+        "scrobble/<slug:uuid>/",
+        views.ScrobbleDetailView.as_view(),
+        name="detail",
+    ),
     path("scrobble/<slug:uuid>/start/", views.scrobble_start, name="start"),
     path("scrobble/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
     path("scrobble/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),

+ 64 - 4
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

+ 11 - 0
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 = ""

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

+ 7 - 3
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):

+ 16 - 0
vrobbler/templates/base.html

@@ -329,5 +329,21 @@
         </div>
 
         {% block extra_js %}{% endblock %}
+        <script>
+        (() => {
+        'use strict'
+        const forms = document.querySelectorAll('.needs-validation')
+        Array.from(forms).forEach(form => {
+            form.addEventListener('submit', event => {
+            if (!form.checkValidity()) {
+                event.preventDefault()
+                event.stopPropagation()
+            }
+            form.classList.add('was-validated')
+            }, false)
+        })
+        })()
+        </script>
+
     </body>
 </html>

+ 1 - 1
vrobbler/templates/beers/beer_detail.html

@@ -57,7 +57,7 @@
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
-                        <td>{{scrobble.local_timestamp}}</td>
+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
                     </tr>
                     {% endfor %}
                 </tbody>

+ 1 - 1
vrobbler/templates/boardgames/boardgame_detail.html

@@ -62,7 +62,7 @@
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
-                        <td>{{scrobble.local_timestamp}}</td>
+                        <td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
                         <td>{{scrobble.media_obj.publisher}}</td>
                         <td>{% if scrobble.logdata.player_log %}{{scrobble.logdata.player_log}}{% else %}No data{% endif %}</td>
                     </tr>

+ 1 - 9
vrobbler/templates/books/book_detail.html

@@ -27,14 +27,6 @@
 </div>
 <div class="row">
     <p>{{scrobbles.count}} scrobbles</p>
-    <p>Read {{scrobbles.last.book_pages_read}} pages{% if scrobbles.last.long_play_complete %} and completed{% else %}{% endif %}</p>
-    <p>
-        {% if scrobbles.last.long_play_complete == True %}
-        <a href="">Read again</a>
-        {% else %}
-        <a href="">Resume reading</a>
-        {% endif %}
-    </p>
 </div>
 <div class="row">
     <div class="col-md">
@@ -52,7 +44,7 @@
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
-                        <td>{{scrobble.local_timestamp}}</td>
+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
                         <td>{% if scrobble.long_play_complete == True %}Yes{% endif %}</td>
                         <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
                         <td>{% for author in scrobble.book.authors.all %}<a href="{{author.get_absolute_url}}">{{author}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>

+ 38 - 0
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 %}
+
+<div class="row">
+
+<h1>{{ object.media_obj }} - {{object.media_type}}</h1>
+
+<!-- Your existing detail page content -->
+<p>Rate: {{object.logdata.avg_seconds_per_page}}s per page</p>
+
+<h2>Edit Log</h2>
+<form method="post" class="needs-validation" novalidate>
+  {% csrf_token %}
+  {% for field in log_form %}
+    <div class="mb-3">
+      <label for="{{ field.id_for_label }}" class="form-label">{{ field.label }}</label>
+      {{ field|add_class:"form-control" }}
+      {% if field.help_text %}
+        <div class="form-text">{{ field.help_text }}</div>
+      {% endif %}
+      {% for error in field.errors %}
+        <div class="invalid-feedback d-block">{{ error }}</div>
+      {% endfor %}
+    </div>
+  {% endfor %}
+
+  <button type="submit" class="btn btn-primary">Save</button>
+</form>
+
+</div>
+
+{% endblock %}

+ 1 - 1
vrobbler/templates/tasks/task_detail.html

@@ -62,7 +62,7 @@
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
-                        <td>{{scrobble.local_timestamp}}</td>
+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
                         <td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
                         <td>{{scrobble.logdata.notes_as_str|safe}}</td>
                         <td>{{scrobble.source}}</td>

+ 2 - 1
vrobbler/templates/videogames/videogame_detail.html

@@ -81,7 +81,8 @@
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
-                        <td>{{scrobble.local_timestamp}}</td>
+
+                        <td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
                         <td>{% if scrobble.long_play_complete == True %}Yes{% else %}Not yet{% endif %}</td>
                         <td>{% if scrobble.in_progress %}Now playing{% else %}{{scrobble.playback_position_seconds|natural_duration}}{% endif %}</td>
                         <td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>