[scrobbles] Add dynamic forms for LogData classes
This commit is contained in:
@ -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
|
_excluded_fields = {
|
||||||
scenario: Optional[str] = None
|
"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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
vrobbler/apps/scrobbles/templatetags/form_tags.py
Normal file
11
vrobbler/apps/scrobbles/templatetags/form_tags.py
Normal file
@ -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})
|
||||||
@ -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"),
|
||||||
|
|||||||
@ -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(
|
queryset.filter(user_filter)
|
||||||
scrobble_count=Count("scrobble")
|
.annotate(
|
||||||
).filter(scrobble_count__gt=0).order_by("-scrobble_count")
|
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
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
vrobbler/templates/scrobbles/scrobble_detail.html
Normal file
38
vrobbler/templates/scrobbles/scrobble_detail.html
Normal file
@ -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 %}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user