Browse Source

[scrobbles] Add dynamic forms for LogData classes

Colin Powell 6 days ago
parent
commit
52494651bf

+ 32 - 3
vrobbler/apps/boardgames/models.py

@@ -5,6 +5,7 @@ from datetime import datetime
 from typing import Optional
 from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
+from django import forms
 import requests
 import requests
 from boardgames.bgg import lookup_boardgame_from_bgg
 from boardgames.bgg import lookup_boardgame_from_bgg
 from django.conf import settings
 from django.conf import settings
@@ -79,9 +80,20 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
     board: Optional[str] = None
     board: Optional[str] = None
     rounds: Optional[int] = None
     rounds: Optional[int] = None
     details: Optional[str] = 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
     @cached_property
     def player_log(self) -> str:
     def player_log(self) -> str:
@@ -94,6 +106,23 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
             )
             )
         return ""
         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):
 class BoardGamePublisher(TimeStampedModel):
     name = models.CharField(max_length=255)
     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):
                 ) or stop_timestamp.dst() == timedelta(0):
                     timestamp = timestamp - timedelta(hours=1)
                     timestamp = timestamp - timedelta(hours=1)
                     stop_timestamp = stop_timestamp - timedelta(hours=1)
                     stop_timestamp = stop_timestamp - timedelta(hours=1)
-                else:
-                    print("In DST! ", timestamp)
 
 
                 scrobble = Scrobble.objects.filter(
                 scrobble = Scrobble.objects.filter(
                     timestamp=timestamp,
                     timestamp=timestamp,

+ 11 - 2
vrobbler/apps/books/models.py

@@ -1,7 +1,7 @@
 from collections import OrderedDict
 from collections import OrderedDict
 from dataclasses import dataclass
 from dataclasses import dataclass
 import logging
 import logging
-from datetime import timedelta, datetime
+from datetime import datetime
 from typing import Optional
 from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
@@ -22,7 +22,6 @@ from scrobbles.mixins import (
     LongPlayScrobblableMixin,
     LongPlayScrobblableMixin,
     ObjectWithGenres,
     ObjectWithGenres,
     ScrobblableConstants,
     ScrobblableConstants,
-    ScrobblableMixin,
 )
 )
 from scrobbles.utils import get_scrobbles_for_media
 from scrobbles.utils import get_scrobbles_for_media
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
@@ -64,6 +63,16 @@ class BookLogData(BaseLogData, LongPlayLogData):
     page_start: Optional[int] = None
     page_start: Optional[int] = None
     page_end: 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):
 class Author(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)

+ 2 - 0
vrobbler/apps/bricksets/models.py

@@ -1,9 +1,11 @@
 from dataclasses import dataclass
 from dataclasses import dataclass
+
 from django.db import models
 from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from imagekit.models import ImageSpecField
 from imagekit.models import ImageSpecField
 from imagekit.processors import ResizeToFit
 from imagekit.processors import ResizeToFit
 from scrobbles.mixins import LongPlayScrobblableMixin
 from scrobbles.mixins import LongPlayScrobblableMixin
+
 from vrobbler.apps.scrobbles.dataclasses import (
 from vrobbler.apps.scrobbles.dataclasses import (
     BaseLogData,
     BaseLogData,
     LongPlayLogData,
     LongPlayLogData,

+ 29 - 1
vrobbler/apps/scrobbles/dataclasses.py

@@ -3,9 +3,10 @@ from dataclasses import asdict, dataclass
 from typing import Optional
 from typing import Optional
 
 
 from dataclass_wizard import JSONWizard
 from dataclass_wizard import JSONWizard
+from django import forms
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
-from locations.models import GeoLocation
 from people.models import Person
 from people.models import Person
+from scrobbles.forms import form_from_dataclass
 
 
 User = get_user_model()
 User = get_user_model()
 
 
@@ -35,6 +36,16 @@ class BaseLogData(JSONDataclass):
     description: Optional[str] = None
     description: Optional[str] = None
     notes: Optional[list[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
 @dataclass
 class LongPlayLogData(JSONDataclass):
 class LongPlayLogData(JSONDataclass):
@@ -55,3 +66,20 @@ class WithPeopleLogData(JSONDataclass):
             Person.objects.filter(id=pid).first()
             Person.objects.filter(id=pid).first()
             for pid in self.with_people_ids
             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 django import forms
 
 
+from people.models import Person
+
 
 
 class ExportScrobbleForm(forms.Form):
 class ExportScrobbleForm(forms.Form):
     """Provide options for downloading scrobbles"""
     """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)
         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):
     def push_to_archivebox(self):
         pushable_media = hasattr(
         pushable_media = hasattr(
             self.media_obj, "push_to_archivebox"
             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(),
         views.ScrobbleLongPlaysView.as_view(),
         name="long-plays",
         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>/start/", views.scrobble_start, name="start"),
     path("scrobble/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
     path("scrobble/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
     path("scrobble/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
     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 datetime import datetime, timedelta
 from dateutil.relativedelta import relativedelta
 from dateutil.relativedelta import relativedelta
 
 
+from django.shortcuts import redirect
 import pendulum
 import pendulum
 import pytz
 import pytz
 from django.apps import apps
 from django.apps import apps
 from django.contrib import messages
 from django.contrib import messages
 from django.contrib.auth.mixins import LoginRequiredMixin
 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.db.models.query import QuerySet
 from django.http import FileResponse, HttpResponseRedirect, JsonResponse
 from django.http import FileResponse, HttpResponseRedirect, JsonResponse
 from django.urls import reverse_lazy
 from django.urls import reverse_lazy
@@ -75,12 +76,17 @@ class ScrobbleableListView(ListView):
             user_filter = Q(scrobble__user=self.request.user)
             user_filter = Q(scrobble__user=self.request.user)
 
 
         queryset = (
         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
         return queryset
 
 
+
 class ScrobbleableDetailView(DetailView):
 class ScrobbleableDetailView(DetailView):
     model = None
     model = None
     slug_field = "uuid"
     slug_field = "uuid"
@@ -926,3 +932,57 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
         ).first()
         ).first()
 
 
         return data
         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_id: Optional[str] = None
     todoist_project_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:
     def notes_as_str(self) -> str:
         """Return formatted notes with line breaks and no keys"""
         """Return formatted notes with line breaks and no keys"""
         note_block = ""
         note_block = ""

+ 31 - 0
vrobbler/apps/videogames/models.py

@@ -3,6 +3,7 @@ import logging
 from typing import Optional
 from typing import Optional
 from uuid import uuid4
 from uuid import uuid4
 
 
+from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.db import models
 from django.db import models
@@ -24,6 +25,36 @@ BNULL = {"blank": True, "null": True}
 User = get_user_model()
 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):
 class VideoGamePlatform(TimeStampedModel):
     name = models.CharField(max_length=255)
     name = models.CharField(max_length=255)
     uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
     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 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
     model = VideoGame
-    paginate_by = 20
+    paginate_by = 40
 
 
 
 
 class VideoGameDetailView(ScrobbleableDetailView):
 class VideoGameDetailView(ScrobbleableDetailView):

+ 16 - 0
vrobbler/templates/base.html

@@ -329,5 +329,21 @@
         </div>
         </div>
 
 
         {% block extra_js %}{% endblock %}
         {% 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>
     </body>
 </html>
 </html>

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

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

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

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

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

@@ -27,14 +27,6 @@
 </div>
 </div>
 <div class="row">
 <div class="row">
     <p>{{scrobbles.count}} scrobbles</p>
     <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>
 <div class="row">
 <div class="row">
     <div class="col-md">
     <div class="col-md">
@@ -52,7 +44,7 @@
                 <tbody>
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
                     <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.long_play_complete == True %}Yes{% endif %}</td>
                         <td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% 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>
                         <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>
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
                     <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><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.description}}</a></td>
                         <td>{{scrobble.logdata.notes_as_str|safe}}</td>
                         <td>{{scrobble.logdata.notes_as_str|safe}}</td>
                         <td>{{scrobble.source}}</td>
                         <td>{{scrobble.source}}</td>

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

@@ -81,7 +81,8 @@
                 <tbody>
                 <tbody>
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     {% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
                     <tr>
                     <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.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>{% 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>
                         <td>{% for platform in scrobble.video_game.platforms.all %}<a href="{{platform.get_absolute_url}}">{{platform}}</a>{% if not forloop.last %}, {% endif %}{% endfor %}</td>