Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec73e5151e | |||
| 2c90dd38b5 | |||
| c6b1e42d7a | |||
| fcf86d5b3f | |||
| 6fde9ec8d2 | |||
| 0f1882b21f | |||
| e819a2db0d |
29
PROJECT.org
29
PROJECT.org
@ -590,6 +590,35 @@ We should rename `email_scrobble_board_game` to reflect the fact that it's just
|
||||
a helper method to create board game scrobbles given a json blob. It's
|
||||
independent of the email flow it was originally creatdd for
|
||||
|
||||
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
|
||||
* Version 55.0 [3/3]
|
||||
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 9cc3b285-e478-041e-394b-3d550aefbe1d
|
||||
:END:
|
||||
** DONE [#B] Display videogame screenshots on scrobble detail if they exist :videogames:templates:
|
||||
:PROPERTIES:
|
||||
:ID: 0406d082-20f6-0d12-76e2-f281c4801468
|
||||
:END:
|
||||
** DONE [#B] Add autotagging to webpages based on domain, title :webpages:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: f658435b-f7a0-42e6-b9f6-226678a77a55
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
For easier filtering, like we do with tasks, we should auto tag WebPage instances
|
||||
based on the domain name split part by periods (so news.ycombinator.com tags: news, ycombinator, com)
|
||||
|
||||
And also based on the nouns in the title.
|
||||
|
||||
|
||||
* Version 54.5 [1/1]
|
||||
** DONE Fix bug in generating mood trends :trends:
|
||||
:PROPERTIES:
|
||||
:ID: 8e75abfa-8e70-d85b-00a4-a4813bbce879
|
||||
:END:
|
||||
|
||||
* Version 54.4 [2/2]
|
||||
** DONE [#A] Remove all-time trends :trends:
|
||||
:PROPERTIES:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "54.4"
|
||||
version = "55.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -519,7 +519,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
assert "First note" in response.content.decode()
|
||||
@ -545,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@ -574,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
"description": "Test description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@ -597,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
"description": "Original description",
|
||||
},
|
||||
)
|
||||
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
|
||||
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
@ -896,7 +896,7 @@ def test_change_visibility_owner_can_change(client):
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
|
||||
@ -919,7 +919,7 @@ def test_change_visibility_non_owner_gets_404(client):
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 404
|
||||
|
||||
@ -937,7 +937,7 @@ def test_change_visibility_anonymous_redirects_to_login(client):
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
assert "/login/" in response.url
|
||||
@ -956,7 +956,7 @@ def test_regenerate_share_token_invalidates_old_sqid(client):
|
||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
|
||||
response = client.post(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
@ -985,7 +985,7 @@ def test_share_analytics_owner_can_view(client):
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
@ -1005,7 +1005,7 @@ def test_share_analytics_non_owner_gets_404(client):
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
@ -1027,7 +1027,7 @@ def test_share_analytics_shows_view_logs(client):
|
||||
client.get(share_url)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
|
||||
@ -225,7 +225,7 @@ class Book(LongPlayScrobblableMixin):
|
||||
|
||||
@property
|
||||
def resume_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
||||
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(
|
||||
|
||||
@ -114,7 +114,7 @@ class ScrobblableMixin(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
|
||||
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid})
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -162,7 +162,7 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
|
||||
return False
|
||||
|
||||
def get_longplay_finish_url(self):
|
||||
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
|
||||
return reverse("scrobbles:longplay-finish", kwargs={"media_uuid": self.uuid})
|
||||
|
||||
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
|
||||
last = self.last_long_play_scrobble_for_user(user)
|
||||
|
||||
@ -798,6 +798,12 @@ class Scrobble(TimeStampedModel):
|
||||
format="JPEG",
|
||||
options={"quality": 75},
|
||||
)
|
||||
screenshot_large = ImageSpecField(
|
||||
source="screenshot",
|
||||
processors=[ResizeToFit(800, 800)],
|
||||
format="JPEG",
|
||||
options={"quality": 85},
|
||||
)
|
||||
long_play_seconds = models.BigIntegerField(**BNULL)
|
||||
long_play_complete = models.BooleanField(**BNULL)
|
||||
long_play_last_scrobble = models.ForeignKey(
|
||||
@ -900,7 +906,7 @@ class Scrobble(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def finish_url(self) -> str:
|
||||
return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
|
||||
return reverse("scrobbles:finish", kwargs={"pk": self.pk})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
class_name = self.media_obj.__class__.__name__
|
||||
@ -942,10 +948,7 @@ class Scrobble(TimeStampedModel):
|
||||
return super(Scrobble, self).save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.uuid:
|
||||
self.uuid = uuid4()
|
||||
self.save(update_fields=["uuid"])
|
||||
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
||||
return reverse("scrobbles:detail", kwargs={"pk": self.pk})
|
||||
|
||||
def get_share_url(self):
|
||||
if self.visibility == Visibility.PRIVATE:
|
||||
|
||||
@ -44,7 +44,7 @@ urlpatterns = [
|
||||
name="lookup-manual-scrobble",
|
||||
),
|
||||
path(
|
||||
"long-play-finish/<slug:uuid>/",
|
||||
"long-play-finish/<slug:media_uuid>/",
|
||||
views.scrobble_longplay_finish,
|
||||
name="longplay-finish",
|
||||
),
|
||||
@ -160,38 +160,38 @@ urlpatterns = [
|
||||
name="shared-detail",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/",
|
||||
"scrobbles/<int:pk>/",
|
||||
views.ScrobbleDetailView.as_view(),
|
||||
name="detail",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/regenerate-share-token/",
|
||||
"scrobbles/<int:pk>/regenerate-share-token/",
|
||||
views.RegenerateShareTokenView.as_view(),
|
||||
name="regenerate-share-token",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/change-visibility/",
|
||||
"scrobbles/<int:pk>/change-visibility/",
|
||||
views.ChangeVisibilityView.as_view(),
|
||||
name="change-visibility",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/share-analytics/",
|
||||
"scrobbles/<int:pk>/share-analytics/",
|
||||
views.ScrobbleShareAnalyticsView.as_view(),
|
||||
name="share-analytics",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||
"scrobbles/<int:pk>/add-to-mopidy-queue/",
|
||||
views.add_to_mopidy_queue,
|
||||
name="add-to-mopidy-queue",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
|
||||
"scrobbles/<int:pk>/add-to-mopidy-monthly-playlist/",
|
||||
views.add_to_mopidy_monthly_playlist,
|
||||
name="add-to-mopidy-monthly-playlist",
|
||||
),
|
||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||
path("scrobbles/<slug:media_uuid>/start/", views.scrobble_start, name="start"),
|
||||
path("scrobbles/<int:pk>/finish/", views.scrobble_finish, name="finish"),
|
||||
path("scrobbles/<int:pk>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||
path(
|
||||
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||
views.toggle_favorite,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import html
|
||||
import logging
|
||||
import requests
|
||||
import re
|
||||
@ -795,6 +796,7 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
||||
if not title:
|
||||
return []
|
||||
|
||||
title = html.unescape(title)
|
||||
cleaned = re.sub(r"[\(\)\[\]\{\}]", "", title)
|
||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||
|
||||
|
||||
@ -839,10 +839,10 @@ def import_audioscrobbler_file(request):
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def scrobble_start(request, uuid):
|
||||
def scrobble_start(request, media_uuid):
|
||||
logger.info(
|
||||
"[scrobble_start] called",
|
||||
extra={"request": request, "uuid": uuid},
|
||||
extra={"request": request, "media_uuid": media_uuid},
|
||||
)
|
||||
user = request.user
|
||||
success_url = request.META.get("HTTP_REFERER")
|
||||
@ -853,14 +853,14 @@ def scrobble_start(request, uuid):
|
||||
media_obj = None
|
||||
for app, model in PLAY_AGAIN_MEDIA.items():
|
||||
media_model = apps.get_model(app_label=app, model_name=model)
|
||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||
if media_obj:
|
||||
break
|
||||
|
||||
if not media_obj:
|
||||
logger.info(
|
||||
"[scrobble_start] media object not found",
|
||||
extra={"uuid": uuid, "user_id": user.id},
|
||||
extra={"media_uuid": media_uuid, "user_id": user.id},
|
||||
)
|
||||
raise Exception("No media object provided to scrobble")
|
||||
|
||||
@ -897,7 +897,7 @@ def scrobble_start(request, uuid):
|
||||
)
|
||||
else:
|
||||
messages.add_message(
|
||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
||||
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||
)
|
||||
|
||||
if (
|
||||
@ -915,7 +915,7 @@ def scrobble_start(request, uuid):
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
def scrobble_longplay_finish(request, uuid):
|
||||
def scrobble_longplay_finish(request, media_uuid):
|
||||
user = request.user
|
||||
success_url = request.META.get("HTTP_REFERER")
|
||||
|
||||
@ -923,7 +923,7 @@ def scrobble_longplay_finish(request, uuid):
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
# Try scrobble UUID first
|
||||
scrobble = Scrobble.objects.filter(uuid=uuid, user=user).first()
|
||||
scrobble = Scrobble.objects.filter(uuid=media_uuid, user=user).first()
|
||||
if scrobble:
|
||||
if scrobble.long_play_complete == True:
|
||||
scrobble.long_play_complete = None
|
||||
@ -947,13 +947,13 @@ def scrobble_longplay_finish(request, uuid):
|
||||
media_obj = None
|
||||
for app, model in LONG_PLAY_MEDIA.items():
|
||||
media_model = apps.get_model(app_label=app, model_name=model)
|
||||
media_obj = media_model.objects.filter(uuid=uuid).first()
|
||||
media_obj = media_model.objects.filter(uuid=media_uuid).first()
|
||||
if media_obj:
|
||||
break
|
||||
|
||||
if not media_obj:
|
||||
messages.add_message(
|
||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
||||
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||
)
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
@ -976,14 +976,14 @@ def scrobble_longplay_finish(request, uuid):
|
||||
)
|
||||
else:
|
||||
messages.add_message(
|
||||
request, messages.ERROR, f"Media with uuid {uuid} not found."
|
||||
request, messages.ERROR, f"Media with uuid {media_uuid} not found."
|
||||
)
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def scrobble_finish(request, uuid):
|
||||
def scrobble_finish(request, pk):
|
||||
user = request.user
|
||||
success_url = request.META.get("HTTP_REFERER")
|
||||
if not success_url:
|
||||
@ -992,7 +992,7 @@ def scrobble_finish(request, uuid):
|
||||
if not user.is_authenticated:
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||
if scrobble:
|
||||
scrobble.stop(force_finish=True)
|
||||
messages.add_message(
|
||||
@ -1007,14 +1007,14 @@ def scrobble_finish(request, uuid):
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def scrobble_cancel(request, uuid):
|
||||
def scrobble_cancel(request, pk):
|
||||
user = request.user
|
||||
success_url = reverse_lazy("vrobbler-home")
|
||||
|
||||
if not user.is_authenticated:
|
||||
return HttpResponseRedirect(success_url)
|
||||
|
||||
scrobble = Scrobble.objects.filter(user=user, uuid=uuid).first()
|
||||
scrobble = Scrobble.objects.filter(user=user, pk=pk).first()
|
||||
if scrobble:
|
||||
scrobble.cancel()
|
||||
messages.add_message(
|
||||
@ -1028,11 +1028,11 @@ def scrobble_cancel(request, uuid):
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_mopidy_queue(request, uuid):
|
||||
def add_to_mopidy_queue(request, pk):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||
mopidy_url = request.user.profile.mopidy_api_url
|
||||
|
||||
if not mopidy_url:
|
||||
@ -1041,22 +1041,22 @@ def add_to_mopidy_queue(request, uuid):
|
||||
messages.ERROR,
|
||||
"Mopidy API URL not configured in your profile settings.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
||||
|
||||
task.delay(scrobble.id)
|
||||
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
||||
messages.add_message(request, messages.SUCCESS, msg)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_mopidy_monthly_playlist(request, uuid):
|
||||
def add_to_mopidy_monthly_playlist(request, pk):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||
profile = request.user.profile
|
||||
pattern = profile.monthly_mopidy_playlist_pattern
|
||||
|
||||
@ -1066,7 +1066,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
||||
messages.ERROR,
|
||||
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
now = now_user_timezone(profile)
|
||||
playlist_name = DateFormat(now).format(pattern)
|
||||
@ -1079,7 +1079,7 @@ def add_to_mopidy_monthly_playlist(request, uuid):
|
||||
messages.SUCCESS,
|
||||
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
return redirect("scrobbles:detail", pk=pk)
|
||||
|
||||
|
||||
@require_POST
|
||||
@ -1184,8 +1184,6 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
class ScrobbleDetailView(DetailView):
|
||||
model = Scrobble
|
||||
slug_field = "uuid"
|
||||
slug_url_kwarg = "uuid"
|
||||
paginate_by = 100
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
@ -1385,15 +1383,15 @@ class ScrobbleExploreView(ListView):
|
||||
|
||||
|
||||
class RegenerateShareTokenView(LoginRequiredMixin, View):
|
||||
def post(self, request, uuid):
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
def post(self, request, pk):
|
||||
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||
scrobble.regenerate_share_token()
|
||||
return redirect(scrobble.get_absolute_url())
|
||||
|
||||
|
||||
class ChangeVisibilityView(LoginRequiredMixin, View):
|
||||
def post(self, request, uuid):
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
def post(self, request, pk):
|
||||
scrobble = get_object_or_404(Scrobble, pk=pk, user=request.user)
|
||||
visibility = request.POST.get("visibility")
|
||||
if visibility not in (Visibility.PUBLIC, Visibility.SHARED, Visibility.PRIVATE):
|
||||
return redirect(scrobble.get_absolute_url())
|
||||
@ -1404,8 +1402,6 @@ class ChangeVisibilityView(LoginRequiredMixin, View):
|
||||
|
||||
class ScrobbleShareAnalyticsView(LoginRequiredMixin, DetailView):
|
||||
model = Scrobble
|
||||
slug_field = "uuid"
|
||||
slug_url_kwarg = "uuid"
|
||||
template_name = "scrobbles/scrobble_share_analytics.html"
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@ -1,13 +1,10 @@
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def _mood_scrobbles(user, period="all_time"):
|
||||
def _mood_scrobbles(user, period="last_30"):
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
@ -19,17 +16,25 @@ def _mood_scrobbles(user, period="all_time"):
|
||||
return Scrobble.objects.filter(filters).select_related("mood")
|
||||
|
||||
|
||||
def _parse_quality(raw):
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _avg_quality(values):
|
||||
if not values:
|
||||
nums = [v for v in values if v is not None]
|
||||
if not nums:
|
||||
return 0.0
|
||||
return round(sum(values) / len(values), 2)
|
||||
return round(sum(nums) / len(nums), 2)
|
||||
|
||||
|
||||
def compute_mood_trajectory(user, period="all_time"):
|
||||
def compute_mood_trajectory(user, period="last_30"):
|
||||
scrobbles = _mood_scrobbles(user, period).order_by("timestamp")
|
||||
by_date = defaultdict(list)
|
||||
for s in scrobbles:
|
||||
quality = s.log.get("mood_quality")
|
||||
quality = _parse_quality(s.log.get("mood_quality"))
|
||||
if quality is not None:
|
||||
day_key = s.timestamp.strftime("%Y-%m-%d")
|
||||
by_date[day_key].append(quality)
|
||||
@ -48,13 +53,13 @@ def compute_mood_trajectory(user, period="all_time"):
|
||||
return {"trajectory": trajectory}
|
||||
|
||||
|
||||
def compute_mood_by_time(user, period="all_time"):
|
||||
def compute_mood_by_time(user, period="last_30"):
|
||||
scrobbles = _mood_scrobbles(user, period)
|
||||
by_hour = defaultdict(list)
|
||||
by_day = defaultdict(list)
|
||||
|
||||
for s in scrobbles:
|
||||
quality = s.log.get("mood_quality")
|
||||
quality = _parse_quality(s.log.get("mood_quality"))
|
||||
if quality is not None and s.timestamp:
|
||||
by_hour[s.timestamp.hour].append(quality)
|
||||
by_day[s.timestamp.isoweekday()].append(quality)
|
||||
@ -94,7 +99,7 @@ def compute_mood_by_time(user, period="all_time"):
|
||||
return {"hours": hours, "days": days}
|
||||
|
||||
|
||||
def compute_mood_distribution(user, period="all_time"):
|
||||
def compute_mood_distribution(user, period="last_30"):
|
||||
scrobbles = _mood_scrobbles(user, period)
|
||||
mood_counts = Counter()
|
||||
type_counts = Counter()
|
||||
@ -120,7 +125,7 @@ def compute_mood_distribution(user, period="all_time"):
|
||||
}
|
||||
|
||||
|
||||
def compute_mood_streaks(user, period="all_time"):
|
||||
def compute_mood_streaks(user, period="last_30"):
|
||||
scrobbles = list(
|
||||
_mood_scrobbles(user, period).order_by("timestamp")
|
||||
)
|
||||
@ -169,13 +174,13 @@ def compute_mood_streaks(user, period="all_time"):
|
||||
return {"streaks": streaks[:10], "current_streak": current_streak}
|
||||
|
||||
|
||||
def compute_mood_weather(user, period="all_time"):
|
||||
def compute_mood_weather(user, period="last_30"):
|
||||
scrobbles = _mood_scrobbles(user, period)
|
||||
by_condition = defaultdict(list)
|
||||
by_temp_range = defaultdict(list)
|
||||
|
||||
for s in scrobbles:
|
||||
quality = s.log.get("mood_quality")
|
||||
quality = _parse_quality(s.log.get("mood_quality"))
|
||||
if quality is None:
|
||||
continue
|
||||
desc = s.log.get("weather_description")
|
||||
@ -183,7 +188,11 @@ def compute_mood_weather(user, period="all_time"):
|
||||
if desc:
|
||||
by_condition[desc].append(quality)
|
||||
if temp is not None:
|
||||
bucket = f"{(int(temp) // 10) * 10}-{(int(temp) // 10) * 10 + 9}F"
|
||||
try:
|
||||
temp_f = float(temp)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
bucket = f"{(int(temp_f) // 10) * 10}-{(int(temp_f) // 10) * 10 + 9}F"
|
||||
by_temp_range[bucket].append(quality)
|
||||
|
||||
conditions = [
|
||||
|
||||
@ -3,3 +3,6 @@ from django.apps import AppConfig
|
||||
|
||||
class WebpagesConfig(AppConfig):
|
||||
name = "webpages"
|
||||
|
||||
def ready(self):
|
||||
import webpages.signals # noqa
|
||||
|
||||
0
vrobbler/apps/webpages/management/__init__.py
Normal file
0
vrobbler/apps/webpages/management/__init__.py
Normal file
@ -0,0 +1,68 @@
|
||||
import re
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from scrobbles.utils import tokenize_title_to_tags
|
||||
from webpages.models import WebPage
|
||||
|
||||
|
||||
def _clean(s: str) -> str:
|
||||
return re.sub(r"[^\x20-\x7e]", "", s)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill auto tags on webpages from domain and title"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Actually add tags",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options["commit"]
|
||||
|
||||
webpages = WebPage.objects.all()
|
||||
total = webpages.count()
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for i, webpage in enumerate(webpages.iterator(), start=1):
|
||||
new_tags = set()
|
||||
|
||||
if webpage.domain:
|
||||
parts = webpage.domain.root.split(".")
|
||||
for part in parts:
|
||||
part = part.strip().lower()
|
||||
if part and part != "www":
|
||||
new_tags.add(part)
|
||||
|
||||
if webpage.title:
|
||||
title_tags = tokenize_title_to_tags(webpage.title)
|
||||
new_tags.update(title_tags)
|
||||
|
||||
existing_tags = {
|
||||
t.name for t in webpage.tags.all()
|
||||
}
|
||||
tags_to_add = new_tags - existing_tags
|
||||
|
||||
if tags_to_add:
|
||||
updated_count += 1
|
||||
if commit:
|
||||
for tag in tags_to_add:
|
||||
webpage.tags.add(tag)
|
||||
self.stdout.write(
|
||||
f"[{i}/{total}] Added tags to {_clean(str(webpage))}: "
|
||||
f"{sorted(tags_to_add)}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"[{i}/{total}] [DRY RUN] Would add tags to "
|
||||
f"{_clean(str(webpage))}: {sorted(tags_to_add)}"
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
self.stdout.write(f"\nDone. {updated_count} webpages to update, "
|
||||
f"{skipped_count} already up to date.")
|
||||
29
vrobbler/apps/webpages/signals.py
Normal file
29
vrobbler/apps/webpages/signals.py
Normal file
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from scrobbles.utils import tokenize_title_to_tags
|
||||
from webpages.models import WebPage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@receiver(post_save, sender=WebPage)
|
||||
def add_auto_tags_to_webpage(sender, instance, **kwargs):
|
||||
existing_tags = {t.name for t in instance.tags.all()}
|
||||
|
||||
if instance.domain:
|
||||
domain_parts = instance.domain.root.split(".")
|
||||
for part in domain_parts:
|
||||
part = part.strip().lower()
|
||||
if part and part != "www" and part not in existing_tags:
|
||||
instance.tags.add(part)
|
||||
existing_tags.add(part)
|
||||
|
||||
if instance.title:
|
||||
title_tags = tokenize_title_to_tags(instance.title)
|
||||
for tag in title_tags:
|
||||
if tag not in existing_tags:
|
||||
instance.tags.add(tag)
|
||||
existing_tags.add(tag)
|
||||
@ -16,8 +16,23 @@ from webpages.models import WebPage
|
||||
class WebPageListView(ScrobbleableListView):
|
||||
model = WebPage
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
tag_name = self.request.GET.get("tag")
|
||||
if tag_name:
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from taggit.models import TaggedItem
|
||||
ct = ContentType.objects.get_for_model(WebPage)
|
||||
webpages = TaggedItem.objects.filter(
|
||||
content_type=ct,
|
||||
tag__name__iexact=tag_name,
|
||||
).values_list("object_id", flat=True)
|
||||
queryset = queryset.filter(pk__in=webpages)
|
||||
return queryset
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["active_tag"] = self.request.GET.get("tag", "")
|
||||
user = self.request.user
|
||||
now = timezone.now()
|
||||
start_day_of_week = now - datetime.timedelta(days=now.weekday())
|
||||
|
||||
@ -322,8 +322,8 @@
|
||||
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
|
||||
</div>
|
||||
<p class="action-buttons">
|
||||
<a href="{% url "scrobbles:cancel" scrobble.uuid %}">Cancel</a>
|
||||
<a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>
|
||||
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
|
||||
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
|
||||
</p>
|
||||
{% if not forloop.last %}<hr/>{% endif %}
|
||||
</div>
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% 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>
|
||||
</tr>
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
{% for scrobble in scrobbles.all|dictsortreversed:"timestamp" %}
|
||||
<tr>
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
<td>{% if scrobble.in_progress %}Now reading{% else %}{{scrobble.session_pages_read}}{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{% load humanize %}
|
||||
{% load naturalduration %}
|
||||
<tr {% if scrobble.in_progress %}class="in-progress"{% endif %}>
|
||||
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.uuid %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
|
||||
<td>{% if scrobble.in_progress %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj.strings.verb}} now</a> | <a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>{% else %}<a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp|naturaltime}}</a>{% endif %}</td>
|
||||
<td>
|
||||
{% if scrobble.media_type in "Task" %}
|
||||
<p><em><a href="{{scrobble.media_obj.get_absolute_url}}">{{scrobble.media_obj.title|truncatechars_html:45}} - {% if scrobble.logdata %}{% if scrobble.logdata.title %}{{scrobble.logdata.title}}{% endif %}{% endif %}</a></em></p>
|
||||
|
||||
@ -205,7 +205,7 @@ header.navbar { display: none !important; }
|
||||
style="background:{% if not cd.is_today %}{{ cd.color }}{% endif %};">
|
||||
<div class="day-number"><a href="{% url 'vrobbler-home' %}?date={{ year }}-{{ month|stringformat:'02d' }}-{{ cd.day|stringformat:'02d' }}" style="color:inherit;text-decoration:none;">{{ cd.day }}</a>{% if cd.total_count > 0 %} <span style="font-weight:400;color:#999;font-size:0.75rem;">{{ cd.total_count }}</span>{% endif %}</div>
|
||||
{% for s in cd.scrobbles %}
|
||||
<a href="{% url 'scrobbles:detail' uuid=s.uuid %}"
|
||||
<a href="{% url 'scrobbles:detail' pk=s.id %}"
|
||||
class="event-card"
|
||||
title="{{ s.title }} — {{ s.media_type }}">{{ s.emoji }} {{ s.title }}</a>
|
||||
{% endfor %}
|
||||
|
||||
@ -88,7 +88,7 @@
|
||||
{% endif %}">
|
||||
{{ object.get_visibility_display }}
|
||||
</span>
|
||||
<form method="post" action="{% url 'scrobbles:change-visibility' object.uuid %}" class="d-inline-flex align-items-center gap-1">
|
||||
<form method="post" action="{% url 'scrobbles:change-visibility' object.id %}" class="d-inline-flex align-items-center gap-1">
|
||||
{% csrf_token %}
|
||||
<select name="visibility" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
<option value="private" {% if object.visibility == 'private' %}selected{% endif %}>Private</option>
|
||||
@ -100,25 +100,25 @@
|
||||
<span class="small text-muted">Share link:</span>
|
||||
<code class="small" id="share-link">{{ request.scheme }}://{{ request.get_host }}{{ object.get_share_url }}</code>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="navigator.clipboard.writeText(document.getElementById('share-link').textContent.trim())">Copy</button>
|
||||
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.uuid %}" class="d-inline">
|
||||
<form method="post" action="{% url 'scrobbles:regenerate-share-token' object.id %}" class="d-inline">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">Regenerate</button>
|
||||
</form>
|
||||
{% if object.share_view_count %}
|
||||
<span class="text-muted small ms-2">{{ object.share_view_count }} view{{ object.share_view_count|pluralize }}</span>
|
||||
{% endif %}
|
||||
<a href="{% url 'scrobbles:share-analytics' object.uuid %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
|
||||
<a href="{% url 'scrobbles:share-analytics' object.id %}" class="btn btn-sm btn-outline-info ms-2">Analytics</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.media_type == "Track" and has_mopidy_uri and user.profile.mopidy_api_url %}
|
||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
|
||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.id %}" class="mb-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
|
||||
</form>
|
||||
{% if user.profile.monthly_mopidy_playlist_pattern %}
|
||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.uuid %}" class="mb-1">
|
||||
<form method="post" action="{% url 'scrobbles:add-to-mopidy-monthly-playlist' object.id %}" class="mb-1">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">add to monthly playlist</button>
|
||||
</form>
|
||||
@ -185,6 +185,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.screenshot %}
|
||||
<div class="mb-3">
|
||||
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Tags:
|
||||
{% if object.tags.all %}
|
||||
@ -282,7 +288,7 @@
|
||||
<tbody>
|
||||
{% for scrobble in related_scrobbles %}
|
||||
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
|
||||
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.uuid %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
|
||||
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.id %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
|
||||
<td>
|
||||
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
|
||||
</td>
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
{% for scrobble in scrobbles %}
|
||||
<div class="result-item">
|
||||
<div class="result-title">
|
||||
<a href="{% url 'scrobbles:detail' scrobble.uuid %}">
|
||||
<a href="{% url 'scrobbles:detail' scrobble.id %}">
|
||||
{{ scrobble|truncatechars:100 }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -117,6 +117,12 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if object.screenshot %}
|
||||
<div class="mb-3">
|
||||
<img src="{{ object.screenshot_large.url }}" class="img-fluid rounded" style="max-height: 600px;" alt="Screenshot" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
Tags:
|
||||
{% if object.tags.all %}
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
<td><a href="{{scrobble.get_media_source_url}}">{{scrobble.logdata.title}}</a></td>
|
||||
<td>{{scrobble.logdata.notes_as_str}}</td>
|
||||
<td>{{scrobble.source}}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">No</a>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@ -87,7 +87,7 @@
|
||||
<tr>
|
||||
|
||||
<td><a href="{{scrobble.get_absolute_url}}">{{scrobble.local_timestamp}}</a></td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' uuid=scrobble.uuid %}">Not yet</a>{% endif %}</td>
|
||||
<td>{% if scrobble.long_play_complete == True %}Yes <small><a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">(not complete?)</a></small>{% else %}<a href="{% url 'scrobbles:longplay-finish' media_uuid=scrobble.uuid %}">Not yet</a>{% 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>{% if scrobble.videogame_save_data %}<a href="{{scrobble.videogame_save_data.url}}">Save data</a>{% else %}Not yet{% endif %}</td>
|
||||
|
||||
@ -204,6 +204,13 @@
|
||||
<p>Source: <a href="{{object.url}}">{{object.domain}}</a></p>
|
||||
{% if object.date %}<p>Published: <em>{{object.date}}</em></p>{% endif %}
|
||||
<p>Time to read: {{object.estimated_time_to_read_in_minutes}} minutes</p>
|
||||
{% if object.tags.all %}
|
||||
<p>Tags:
|
||||
{% for tag in object.tags.all %}
|
||||
<a href="{% url 'webpages:webpage_list' %}?tag={{ tag.name|urlencode }}">{{ tag.name }}</a>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if object.extract %}
|
||||
<div class="col">
|
||||
|
||||
Reference in New Issue
Block a user