[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 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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
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(),
|
||||
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"),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
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>
|
||||
{% 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user