[widgets] Add first run at widgets
All checks were successful
build & deploy / test (push) Successful in 2m36s
build & deploy / deploy (push) Successful in 29s

This commit is contained in:
2026-03-20 13:11:25 -04:00
parent 7a74f7f882
commit 64ec3c1cca
10 changed files with 600 additions and 119 deletions

View File

@ -6,14 +6,10 @@ from profiles.models import UserProfile
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request") self.request = kwargs.pop("request")
self.profile = UserProfile.objects.filter( self.profile = UserProfile.objects.filter(user=self.request.user).first()
user=self.request.user
).first()
if not self.profile: if not self.profile:
raise Exception raise Exception
super(UserProfileForm, self).__init__( super(UserProfileForm, self).__init__(*args, instance=self.profile, **kwargs)
*args, **kwargs, instance=self.profile
)
class Meta: class Meta:
model = UserProfile model = UserProfile
@ -36,6 +32,7 @@ class UserProfileForm(forms.ModelForm):
"ntfy_url", "ntfy_url",
"ntfy_enabled", "ntfy_enabled",
"redirect_to_webpage", "redirect_to_webpage",
"enable_public_widgets",
] ]
widgets = { widgets = {
"lastfm_password": forms.PasswordInput(render_value=True), "lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-20 16:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0028_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="enable_public_widgets",
field=models.BooleanField(default=False),
),
]

View File

@ -17,9 +17,7 @@ logger = logging.getLogger(__name__)
class UserProfile(TimeStampedModel): class UserProfile(TimeStampedModel):
user = models.OneToOneField( user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
User, on_delete=models.CASCADE, related_name="profile"
)
timezone = models.CharField( timezone = models.CharField(
max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC" max_length=255, choices=PRETTY_TIMEZONE_CHOICES, default="UTC"
) )
@ -63,6 +61,8 @@ class UserProfile(TimeStampedModel):
redirect_to_webpage = models.BooleanField(default=True) redirect_to_webpage = models.BooleanField(default=True)
enable_public_widgets = models.BooleanField(default=False)
def __str__(self): def __str__(self):
return f"User profile for {self.user}" return f"User profile for {self.user}"

View File

@ -1,3 +1,4 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django.views.generic import FormView from django.views.generic import FormView
@ -5,7 +6,7 @@ from django.views.generic import FormView
from profiles.forms import UserProfileForm from profiles.forms import UserProfileForm
class ProfileFormView(FormView): class ProfileFormView(LoginRequiredMixin, FormView):
form_class = UserProfileForm form_class = UserProfileForm
template_name = "profiles/settings_form.html" template_name = "profiles/settings_form.html"
success_url = reverse_lazy("profiles:profile_settings") success_url = reverse_lazy("profiles:profile_settings")

View File

@ -6,6 +6,26 @@ app_name = "scrobbles"
urlpatterns = [ urlpatterns = [
path("status/", views.ScrobbleStatusView.as_view(), name="status"), path("status/", views.ScrobbleStatusView.as_view(), name="status"),
path(
"widget/top-artists/<int:user_id>/",
views.EmbeddableTopArtistWidget.as_view(),
name="embeddable-top-artists",
),
path(
"widget/top-board-games/month/<int:user_id>/",
views.EmbeddableTopBoardGamesMonthWidget.as_view(),
name="embeddable-top-board-games-month",
),
path(
"widget/top-board-games/week/<int:user_id>/",
views.EmbeddableTopBoardGamesWeekWidget.as_view(),
name="embeddable-top-board-games-week",
),
path(
"widget/top-board-games/year/<int:user_id>/",
views.EmbeddableTopBoardGamesYearWidget.as_view(),
name="embeddable-top-board-games-year",
),
path( path(
"manual/lookup/", "manual/lookup/",
views.ManualScrobbleView.as_view(), views.ManualScrobbleView.as_view(),

View File

@ -9,10 +9,11 @@ from dateutil.relativedelta import relativedelta
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.contrib.auth import get_user_model
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q from django.db.models import Count, Max, Q
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, Http404, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils import timezone from django.utils import timezone
@ -66,8 +67,11 @@ from scrobbles.utils import (
get_long_plays_completed, get_long_plays_completed,
get_long_plays_in_progress, get_long_plays_in_progress,
) )
from profiles.models import UserProfile
from profiles.utils import now_user_timezone from profiles.utils import now_user_timezone
User = get_user_model()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -163,25 +167,17 @@ class RecentScrobbleList(ListView):
next_date = date + timedelta(weeks=+2) next_date = date + timedelta(weeks=+2)
prev_date = date + timedelta(weeks=-1) prev_date = date + timedelta(weeks=-1)
if date.isocalendar()[1] < today.isocalendar()[1]: if date.isocalendar()[1] < today.isocalendar()[1]:
data[ data["next_link"] = f"?date={next_date.strftime('%Y-W%W')}"
"next_link"
] = f"?date={next_date.strftime('%Y-W%W')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-W%W')}" data["prev_link"] = f"?date={prev_date.strftime('%Y-W%W')}"
data[ data["title"] = f"Week {date.strftime('%-W')} of {date.year}"
"title"
] = f"Week {date.strftime('%-W')} of {date.year}"
data = data | Scrobble.as_dict_by_type( data = data | Scrobble.as_dict_by_type(
Scrobble.for_week( Scrobble.for_week(user_id, date.year, date.isocalendar()[1])
user_id, date.year, date.isocalendar()[1]
)
) )
elif date_str == "this_month" or date_str.count("-") == 1: elif date_str == "this_month" or date_str.count("-") == 1:
next_date = date + relativedelta(months=1) next_date = date + relativedelta(months=1)
prev_date = date + relativedelta(months=-1) prev_date = date + relativedelta(months=-1)
if date.month < today.month: if date.month < today.month:
data[ data["next_link"] = f"?date={next_date.strftime('%Y-%m')}"
"next_link"
] = f"?date={next_date.strftime('%Y-%m')}"
data["title"] = f"{date.strftime('%B %Y')}" data["title"] = f"{date.strftime('%B %Y')}"
data["prev_link"] = f"?date={prev_date.strftime('%Y-%m')}" data["prev_link"] = f"?date={prev_date.strftime('%Y-%m')}"
data = data | Scrobble.as_dict_by_type( data = data | Scrobble.as_dict_by_type(
@ -190,24 +186,16 @@ class RecentScrobbleList(ListView):
elif date_str == "today" or date_str.count("-") == 2: elif date_str == "today" or date_str.count("-") == 2:
next_date = date + timedelta(days=1) next_date = date + timedelta(days=1)
prev_date = date - timedelta(days=1) prev_date = date - timedelta(days=1)
data[ data["prev_link"] = f"?date={prev_date.strftime('%Y-%m-%d')}"
"prev_link"
] = f"?date={prev_date.strftime('%Y-%m-%d')}"
if date < today: if date < today:
data[ data["next_link"] = f"?date={next_date.strftime('%Y-%m-%d')}"
"next_link"
] = f"?date={next_date.strftime('%Y-%m-%d')}"
if date == today: if date == today:
data["title"] = "Today" data["title"] = "Today"
else: else:
data["title"] = f"{date.strftime('%Y-%m-%d')}" data["title"] = f"{date.strftime('%Y-%m-%d')}"
data[ data["today_link"] = f"?date={today.strftime('%Y-%m-%d')}"
"today_link"
] = f"?date={today.strftime('%Y-%m-%d')}"
data = data | Scrobble.as_dict_by_type( data = data | Scrobble.as_dict_by_type(
Scrobble.for_day( Scrobble.for_day(user_id, date.year, date.month, date.day)
user_id, date.year, date.month, date.day
)
) )
elif date_str == "this_year" or date_str.count("-") == 0: elif date_str == "this_year" or date_str.count("-") == 0:
next_date = date + relativedelta(years=+1) next_date = date + relativedelta(years=+1)
@ -230,9 +218,7 @@ class RecentScrobbleList(ListView):
data = data | Scrobble.as_dict_by_type( data = data | Scrobble.as_dict_by_type(
Scrobble.for_day(user_id, date.year, date.month, date.day) Scrobble.for_day(user_id, date.year, date.month, date.day)
) )
data[ data["today_link"] = "" # f"?date={today.strftime('%Y-%m-%d')}"
"today_link"
] = "" # f"?date={today.strftime('%Y-%m-%d')}"
data["active_imports"] = AudioScrobblerTSVImport.objects.filter( data["active_imports"] = AudioScrobblerTSVImport.objects.filter(
processing_started__isnull=False, processing_started__isnull=False,
@ -261,9 +247,7 @@ class ScrobbleLongPlaysView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
context_data["view"] = self.request.GET.get("view", "grid") context_data["view"] = self.request.GET.get("view", "grid")
context_data["in_progress"] = get_long_plays_in_progress( context_data["in_progress"] = get_long_plays_in_progress(self.request.user)
self.request.user
)
context_data["completed"] = get_long_plays_completed(self.request.user) context_data["completed"] = get_long_plays_completed(self.request.user)
return context_data return context_data
@ -340,9 +324,7 @@ class ManualScrobbleView(FormView):
scrobble_fn = MANUAL_SCROBBLE_FNS[key] scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id) scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
return HttpResponseRedirect( return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))
scrobble.redirect_url(self.request.user.id)
)
class JsonableResponseMixin: class JsonableResponseMixin:
@ -388,9 +370,7 @@ class AudioScrobblerImportCreateView(
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER")) return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class KoReaderImportCreateView( class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
model = KoReaderImport model = KoReaderImport
fields = ["sqlite_file"] fields = ["sqlite_file"]
template_name = "scrobbles/upload_form.html" template_name = "scrobbles/upload_form.html"
@ -449,9 +429,7 @@ def web_scrobbler_webhook(request):
playing_state = "started" playing_state = "started"
if request.POST.get("isPlaying") == "false": if request.POST.get("isPlaying") == "false":
playing_state = "stopped" playing_state = "stopped"
scrobble = web_scrobbler_scrobble_media( scrobble = web_scrobbler_scrobble_media(youtube_id, user_id, status=playing_state)
youtube_id, user_id, status=playing_state
)
if not scrobble: if not scrobble:
logger.warning( logger.warning(
"[web_scrobbler_webhook] failed", "[web_scrobbler_webhook] failed",
@ -469,9 +447,7 @@ def web_scrobbler_webhook(request):
"[web_scrobbler_webhook] finished", "[web_scrobbler_webhook] finished",
extra={"scrobble_id": scrobble.id}, extra={"scrobble_id": scrobble.id},
) )
return JsonResponse( return JsonResponse({"scrobble_id": scrobble.id}, status=status.HTTP_200_OK)
{"scrobble_id": scrobble.id}, status=status.HTTP_200_OK
)
@csrf_exempt @csrf_exempt
@ -576,18 +552,12 @@ def import_audioscrobbler_file(request):
scrobbles_created = [] scrobbles_created = []
# tsv_file = request.FILES[0] # tsv_file = request.FILES[0]
file_serializer = serializers.AudioScrobblerTSVImportSerializer( file_serializer = serializers.AudioScrobblerTSVImportSerializer(data=request.data)
data=request.data
)
if file_serializer.is_valid(): if file_serializer.is_valid():
import_file = file_serializer.save() import_file = file_serializer.save()
return Response( return Response({"scrobble_ids": scrobbles_created}, status=status.HTTP_200_OK)
{"scrobble_ids": scrobbles_created}, status=status.HTTP_200_OK
)
else: else:
return Response( return Response(file_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
file_serializer.errors, status=status.HTTP_400_BAD_REQUEST
)
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@ -768,9 +738,7 @@ class ChartRecordView(TemplateView):
).order_by("rank") ).order_by("rank")
if charts.count() == 0: if charts.count() == 0:
ChartRecord.build( ChartRecord.build(user=self.request.user, model_str=media_type, **kwargs)
user=self.request.user, model_str=media_type, **kwargs
)
charts = ChartRecord.objects.filter( charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **kwargs media_filter, user=self.request.user, **kwargs
).order_by("rank") ).order_by("rank")
@ -802,9 +770,7 @@ class ChartRecordView(TemplateView):
media_type = self.request.GET.get("media", "Track") media_type = self.request.GET.get("media", "Track")
user = self.request.user user = self.request.user
params = {} params = {}
context_data["chart_type"] = self.request.GET.get( context_data["chart_type"] = self.request.GET.get("chart_type", "maloja")
"chart_type", "maloja"
)
context_data["artist_charts"] = {} context_data["artist_charts"] = {}
if not date: if not date:
@ -842,9 +808,7 @@ class ChartRecordView(TemplateView):
tzinfo = now.tzinfo tzinfo = now.tzinfo
now = now.date() now = now.date()
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo) start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta( start_day_of_week = start_of_today - timedelta(days=now.isoweekday() % 7)
days=now.isoweekday() % 7
)
start_day_of_month = now.replace(day=1) start_day_of_month = now.replace(day=1)
# TV Series Scrobbles # TV Series Scrobbles
@ -889,35 +853,25 @@ class ChartRecordView(TemplateView):
context_data["tv_show_charts"] = { context_data["tv_show_charts"] = {
"today": list(live_tv_charts(user=user, chart_period="today")), "today": list(live_tv_charts(user=user, chart_period="today")),
"last7": list(live_tv_charts(user=user, chart_period="last7")), "last7": list(live_tv_charts(user=user, chart_period="last7")),
"last30": list( "last30": list(live_tv_charts(user=user, chart_period="last30")),
live_tv_charts(user=user, chart_period="last30")
),
"year": list(live_tv_charts(user=user, chart_period="year")), "year": list(live_tv_charts(user=user, chart_period="year")),
"all": list(live_tv_charts(user=user, chart_period="all")), "all": list(live_tv_charts(user=user, chart_period="all")),
} }
context_data["youtube_channel_charts"] = { context_data["youtube_channel_charts"] = {
"today": list( "today": list(
live_youtube_channel_charts( live_youtube_channel_charts(user=user, chart_period="today")
user=user, chart_period="today"
)
), ),
"last7": list( "last7": list(
live_youtube_channel_charts( live_youtube_channel_charts(user=user, chart_period="last7")
user=user, chart_period="last7"
)
), ),
"last30": list( "last30": list(
live_youtube_channel_charts( live_youtube_channel_charts(user=user, chart_period="last30")
user=user, chart_period="last30"
)
), ),
"year": list( "year": list(
live_youtube_channel_charts(user=user, chart_period="year") live_youtube_channel_charts(user=user, chart_period="year")
), ),
"all": list( "all": list(live_youtube_channel_charts(user=user, chart_period="all")),
live_youtube_channel_charts(user=user, chart_period="all")
),
} }
return context_data return context_data
@ -955,9 +909,7 @@ class ChartRecordView(TemplateView):
params["day"] = day params["day"] = day
month_str = calendar.month_name[month] month_str = calendar.month_name[month]
name = f"Chart for {month_str} {day}, {year}" name = f"Chart for {month_str} {day}, {year}"
in_progress = ( in_progress = now.month == month and now.year == year and now.day == day
now.month == month and now.year == year and now.day == day
)
media_filter = self.get_media_filter("Track") media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter( track_charts = ChartRecord.objects.filter(
@ -969,17 +921,13 @@ class ChartRecordView(TemplateView):
).order_by("rank") ).order_by("rank")
if track_charts.count() == 0 and not in_progress: if track_charts.count() == 0 and not in_progress:
ChartRecord.build( ChartRecord.build(user=self.request.user, model_str="Track", **params)
user=self.request.user, model_str="Track", **params
)
media_filter = self.get_media_filter("Track") media_filter = self.get_media_filter("Track")
track_charts = ChartRecord.objects.filter( track_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params media_filter, user=self.request.user, **params
).order_by("rank") ).order_by("rank")
if artist_charts.count() == 0 and not in_progress: if artist_charts.count() == 0 and not in_progress:
ChartRecord.build( ChartRecord.build(user=self.request.user, model_str="Artist", **params)
user=self.request.user, model_str="Artist", **params
)
media_filter = self.get_media_filter("Artist") media_filter = self.get_media_filter("Artist")
artist_charts = ChartRecord.objects.filter( artist_charts = ChartRecord.objects.filter(
media_filter, user=self.request.user, **params media_filter, user=self.request.user, **params
@ -1000,38 +948,24 @@ class ScrobbleStatusView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs) data = super().get_context_data(**kwargs)
user_scrobble_qs = Scrobble.objects.filter().order_by("-timestamp") user_scrobble_qs = Scrobble.objects.filter().order_by("-timestamp")
progress_plays = user_scrobble_qs.filter( progress_plays = user_scrobble_qs.filter(in_progress=True, is_paused=False)
in_progress=True, is_paused=False
)
data["listening"] = progress_plays.filter( data["listening"] = progress_plays.filter(
Q(track__isnull=False) | Q(podcast_episode__isnull=False) Q(track__isnull=False) | Q(podcast_episode__isnull=False)
).first() ).first()
data["watching"] = progress_plays.filter(video__isnull=False).first() data["watching"] = progress_plays.filter(video__isnull=False).first()
data["going"] = progress_plays.filter( data["going"] = progress_plays.filter(geo_location__isnull=False).first()
geo_location__isnull=False data["playing"] = progress_plays.filter(board_game__isnull=False).first()
).first() data["sporting"] = progress_plays.filter(sport_event__isnull=False).first()
data["playing"] = progress_plays.filter( data["browsing"] = progress_plays.filter(web_page__isnull=False).first()
board_game__isnull=False data["participating"] = progress_plays.filter(life_event__isnull=False).first()
).first()
data["sporting"] = progress_plays.filter(
sport_event__isnull=False
).first()
data["browsing"] = progress_plays.filter(
web_page__isnull=False
).first()
data["participating"] = progress_plays.filter(
life_event__isnull=False
).first()
data["working"] = progress_plays.filter(task__isnull=False).first() data["working"] = progress_plays.filter(task__isnull=False).first()
long_plays = user_scrobble_qs.filter( long_plays = user_scrobble_qs.filter(
long_play_complete=False, played_to_completion=True long_play_complete=False, played_to_completion=True
) )
data["reading"] = long_plays.filter(book__isnull=False).first() data["reading"] = long_plays.filter(book__isnull=False).first()
data["sessioning"] = long_plays.filter( data["sessioning"] = long_plays.filter(video_game__isnull=False).first()
video_game__isnull=False
).first()
return data return data
@ -1066,9 +1000,7 @@ class ScrobbleDetailView(DetailView):
data[field_name] = original_value data[field_name] = original_value
if data.get("with_people_ids", False): if data.get("with_people_ids", False):
data["with_people_ids"] = [ data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
p.id for p in data["with_people_ids"]
]
if data.get("platform_id", False): if data.get("platform_id", False):
data["platform_id"] = data["platform_id"].id data["platform_id"] = data["platform_id"].id
@ -1085,3 +1017,168 @@ class ScrobbleDetailView(DetailView):
if "log_form" not in context: if "log_form" not in context:
context["log_form"] = self.get_form() context["log_form"] = self.get_form()
return context return context
class EmbeddableTopArtistWidget(TemplateView):
template_name = "scrobbles/embeddable_top_artist.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user_id = self.kwargs.get("user_id")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
context["error"] = "User not found"
return context
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
context["user"] = user
artist = {
"user": user,
"media_type": "Artist",
"limit": 10,
}
context["top_artists"] = list(live_charts(**artist, chart_period="last7"))
return context
class EmbeddableTopBoardGamesMonthWidget(TemplateView):
template_name = "scrobbles/embeddable_top_board_games_month.html"
def get_context_data(self, **kwargs):
from django.db.models import Count
from django.utils import timezone
from datetime import timedelta
from boardgames.models import BoardGame
context = super().get_context_data(**kwargs)
user_id = self.kwargs.get("user_id")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
context["error"] = "User not found"
return context
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
context["user"] = user
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
now = now.date()
start_day_of_month = now.replace(day=1)
top_games = (
BoardGame.objects.filter(
scrobble__user=user,
scrobble__timestamp__gte=start_day_of_month,
scrobble__played_to_completion=True,
)
.annotate(num_plays=Count("scrobble", distinct=True))
.order_by("-num_plays")[:10]
)
context["top_board_games"] = list(top_games)
context["month"] = now.strftime("%B %Y")
return context
class EmbeddableTopBoardGamesWeekWidget(TemplateView):
template_name = "scrobbles/embeddable_top_board_games_week.html"
def get_context_data(self, **kwargs):
from django.db.models import Count
from django.utils import timezone
from datetime import timedelta
from boardgames.models import BoardGame
context = super().get_context_data(**kwargs)
user_id = self.kwargs.get("user_id")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
context["error"] = "User not found"
return context
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
context["user"] = user
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
now = now.date()
start_day_of_week = now - timedelta(days=now.isoweekday() % 7)
top_games = (
BoardGame.objects.filter(
scrobble__user=user,
scrobble__timestamp__gte=start_day_of_week,
scrobble__played_to_completion=True,
)
.annotate(num_plays=Count("scrobble", distinct=True))
.order_by("-num_plays")[:10]
)
context["top_board_games"] = list(top_games)
return context
class EmbeddableTopBoardGamesYearWidget(TemplateView):
template_name = "scrobbles/embeddable_top_board_games_year.html"
def get_context_data(self, **kwargs):
from django.db.models import Count
from django.utils import timezone
from boardgames.models import BoardGame
context = super().get_context_data(**kwargs)
user_id = self.kwargs.get("user_id")
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
context["error"] = "User not found"
return context
profile = user.profile
if not profile.enable_public_widgets:
raise Http404("Public widgets are not enabled for this user")
context["user"] = user
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
now = now.date()
start_day_of_year = now.replace(month=1, day=1)
top_games = (
BoardGame.objects.filter(
scrobble__user=user,
scrobble__timestamp__gte=start_day_of_year,
scrobble__played_to_completion=True,
)
.annotate(num_plays=Count("scrobble", distinct=True))
.order_by("-num_plays")[:10]
)
context["top_board_games"] = list(top_games)
context["year"] = now.strftime("%Y")
return context

View File

@ -0,0 +1,87 @@
{% load static %}
<style>
.vrobbler-widget {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 400px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.vrobbler-widget h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.vrobbler-artist-list {
list-style: none;
padding: 0;
margin: 0;
}
.vrobbler-artist-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.vrobbler-artist-item:last-child {
border-bottom: none;
}
.vrobbler-rank {
width: 24px;
font-weight: bold;
color: #666;
}
.vrobbler-artist-info {
flex: 1;
}
.vrobbler-artist-name {
font-weight: 500;
color: #333;
}
.vrobbler-scrobble-count {
font-size: 12px;
color: #888;
}
.vrobbler-artist-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
margin-right: 10px;
}
.vrobbler-error {
color: #d32f2f;
}
.vrobbler-no-data {
color: #666;
font-style: italic;
}
</style>
<div class="vrobbler-widget">
<h3>Last 7 Days Top Artists{% if user %} - {{ user.username }}{% endif %}</h3>
{% if error %}
<p class="vrobbler-error">{{ error }}</p>
{% elif top_artists %}
<ul class="vrobbler-artist-list">
{% for artist in top_artists %}
<li class="vrobbler-artist-item">
<span class="vrobbler-rank">{{ forloop.counter }}</span>
{% if artist.thumbnail %}
<img class="vrobbler-artist-thumb" src="{{ artist.thumbnail_small.url }}" alt="{{ artist.name }}">
{% else %}
<img class="vrobbler-artist-thumb" src="{% static 'images/not-found.jpg' %}" alt="{{ artist.name }}">
{% endif %}
<div class="vrobbler-artist-info">
<div class="vrobbler-artist-name">{{ artist.name }}</div>
<div class="vrobbler-scrobble-count">{{ artist.num_scrobbles }} scrobbles</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="vrobbler-no-data">No scrobbles in the last 7 days</p>
{% endif %}
</div>

View File

@ -0,0 +1,87 @@
{% load static %}
<style>
.vrobbler-widget {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 400px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.vrobbler-widget h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.vrobbler-board-game-list {
list-style: none;
padding: 0;
margin: 0;
}
.vrobbler-board-game-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.vrobbler-board-game-item:last-child {
border-bottom: none;
}
.vrobbler-rank {
width: 24px;
font-weight: bold;
color: #666;
}
.vrobbler-board-game-info {
flex: 1;
}
.vrobbler-board-game-title {
font-weight: 500;
color: #333;
}
.vrobbler-play-count {
font-size: 12px;
color: #888;
}
.vrobbler-board-game-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
margin-right: 10px;
}
.vrobbler-error {
color: #d32f2f;
}
.vrobbler-no-data {
color: #666;
font-style: italic;
}
</style>
<div class="vrobbler-widget">
<h3>Top Board Games - {{ month }}{% if user %} - {{ user.username }}{% endif %}</h3>
{% if error %}
<p class="vrobbler-error">{{ error }}</p>
{% elif top_board_games %}
<ul class="vrobbler-board-game-list">
{% for game in top_board_games %}
<li class="vrobbler-board-game-item">
<span class="vrobbler-rank">{{ forloop.counter }}</span>
{% if game.cover %}
<img class="vrobbler-board-game-thumb" src="{{ game.cover_small.url }}" alt="{{ game.title }}">
{% else %}
<img class="vrobbler-board-game-thumb" src="{% static 'images/not-found.jpg' %}" alt="{{ game.title }}">
{% endif %}
<div class="vrobbler-board-game-info">
<div class="vrobbler-board-game-title">{{ game.title }}</div>
<div class="vrobbler-play-count">{{ game.num_plays }} plays</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="vrobbler-no-data">No board games played this month</p>
{% endif %}
</div>

View File

@ -0,0 +1,87 @@
{% load static %}
<style>
.vrobbler-widget {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 400px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.vrobbler-widget h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.vrobbler-board-game-list {
list-style: none;
padding: 0;
margin: 0;
}
.vrobbler-board-game-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.vrobbler-board-game-item:last-child {
border-bottom: none;
}
.vrobbler-rank {
width: 24px;
font-weight: bold;
color: #666;
}
.vrobbler-board-game-info {
flex: 1;
}
.vrobbler-board-game-title {
font-weight: 500;
color: #333;
}
.vrobbler-play-count {
font-size: 12px;
color: #888;
}
.vrobbler-board-game-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
margin-right: 10px;
}
.vrobbler-error {
color: #d32f2f;
}
.vrobbler-no-data {
color: #666;
font-style: italic;
}
</style>
<div class="vrobbler-widget">
<h3>Top Board Games - This Week{% if user %} - {{ user.username }}{% endif %}</h3>
{% if error %}
<p class="vrobbler-error">{{ error }}</p>
{% elif top_board_games %}
<ul class="vrobbler-board-game-list">
{% for game in top_board_games %}
<li class="vrobbler-board-game-item">
<span class="vrobbler-rank">{{ forloop.counter }}</span>
{% if game.cover %}
<img class="vrobbler-board-game-thumb" src="{{ game.cover_small.url }}" alt="{{ game.title }}">
{% else %}
<img class="vrobbler-board-game-thumb" src="{% static 'images/not-found.jpg' %}" alt="{{ game.title }}">
{% endif %}
<div class="vrobbler-board-game-info">
<div class="vrobbler-board-game-title">{{ game.title }}</div>
<div class="vrobbler-play-count">{{ game.num_plays }} plays</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="vrobbler-no-data">No board games played this week</p>
{% endif %}
</div>

View File

@ -0,0 +1,87 @@
{% load static %}
<style>
.vrobbler-widget {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 400px;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
background: #fff;
}
.vrobbler-widget h3 {
margin: 0 0 12px 0;
font-size: 16px;
color: #333;
}
.vrobbler-board-game-list {
list-style: none;
padding: 0;
margin: 0;
}
.vrobbler-board-game-item {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.vrobbler-board-game-item:last-child {
border-bottom: none;
}
.vrobbler-rank {
width: 24px;
font-weight: bold;
color: #666;
}
.vrobbler-board-game-info {
flex: 1;
}
.vrobbler-board-game-title {
font-weight: 500;
color: #333;
}
.vrobbler-play-count {
font-size: 12px;
color: #888;
}
.vrobbler-board-game-thumb {
width: 40px;
height: 40px;
border-radius: 4px;
object-fit: cover;
margin-right: 10px;
}
.vrobbler-error {
color: #d32f2f;
}
.vrobbler-no-data {
color: #666;
font-style: italic;
}
</style>
<div class="vrobbler-widget">
<h3>Top Board Games - {{ year }}{% if user %} - {{ user.username }}{% endif %}</h3>
{% if error %}
<p class="vrobbler-error">{{ error }}</p>
{% elif top_board_games %}
<ul class="vrobbler-board-game-list">
{% for game in top_board_games %}
<li class="vrobbler-board-game-item">
<span class="vrobbler-rank">{{ forloop.counter }}</span>
{% if game.cover %}
<img class="vrobbler-board-game-thumb" src="{{ game.cover_small.url }}" alt="{{ game.title }}">
{% else %}
<img class="vrobbler-board-game-thumb" src="{% static 'images/not-found.jpg' %}" alt="{{ game.title }}">
{% endif %}
<div class="vrobbler-board-game-info">
<div class="vrobbler-board-game-title">{{ game.title }}</div>
<div class="vrobbler-play-count">{{ game.num_plays }} plays</div>
</div>
</li>
{% endfor %}
</ul>
{% else %}
<p class="vrobbler-no-data">No board games played this year</p>
{% endif %}
</div>