Compare commits

...

3 Commits
58.3 ... 58.4

Author SHA1 Message Date
c0d2881585 [release] Bump to version 58.4
All checks were successful
build / test (push) Successful in 2m13s
deploy / test (push) Successful in 2m10s
deploy / build-and-deploy (push) Successful in 53s
- Allow people all trends or individual trends
- Fix a bug in board game scorelog data
2026-06-25 20:02:34 -04:00
41a68291a4 [trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s
2026-06-25 18:58:23 -04:00
0a411bedf4 [boardgames] Fix bug in logdata
All checks were successful
build / test (push) Successful in 2m23s
2026-06-24 19:09:43 -04:00
15 changed files with 200 additions and 22 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources *** Metadata sources
**** Scraper **** Scraper
* Backlog [0/22] :vrobbler:project:personal: * Backlog [0/23] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES: :PROPERTIES:
@ -605,6 +605,16 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types: ** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* Version 58.4 [2/2]
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
:PROPERTIES:
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
:END:
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
:PROPERTIES:
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
:END:
* Version 58.3 [1/1] * Version 58.3 [1/1]
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps: ** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:
:PROPERTIES: :PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "vrobbler" name = "vrobbler"
version = "58.3" version = "58.4"
description = "" description = ""
authors = ["Colin Powell <colin@unbl.ink>"] authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -120,7 +120,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
def player_log(self) -> str: def player_log(self) -> str:
if self.players: if self.players:
return ", ".join( return ", ".join(
[BoardGameScoreLogData(**player).__str__() for player in self.players] [
BoardGameScoreLogData(
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
).__str__()
for player in self.players
]
) )
return "" return ""
@ -136,7 +141,9 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.players: if self.players:
players_html = [] players_html = []
for player_data in self.players: for player_data in self.players:
player = BoardGameScoreLogData(**player_data) player = BoardGameScoreLogData(
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
)
player_info = player.name player_info = player.name
if player.score: if player.score:
player_info += f" ({player.score})" player_info += f" ({player.score})"

View File

@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit", "home_scrobble_limit",
"live_now_playing", "live_now_playing",
"weigh_in_units", "weigh_in_units",
"trends_disabled",
] ]
widgets = { widgets = {
"lastfm_password": forms.PasswordInput(render_value=True), "lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.29 on 2026-06-25 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0039_userprofile_live_now_playing"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="disabled_trends",
field=models.JSONField(
blank=True,
default=list,
help_text="List of trend slugs the user has disabled",
),
),
migrations.AddField(
model_name="userprofile",
name="trends_disabled",
field=models.BooleanField(default=False),
),
]

View File

@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC, default=WeighUnit.METRIC,
) )
trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField(
default=list,
blank=True,
help_text="List of trend slugs the user has disabled",
)
def __str__(self): def __str__(self):
return f"User profile for {self.user}" return f"User profile for {self.user}"

View File

@ -38,15 +38,36 @@ class Command(BaseCommand):
overall_start = timezone.now() overall_start = timezone.now()
ok_count = 0 ok_count = 0
fail_count = 0 fail_count = 0
skipped_count = 0
for user in users: for user in users:
total_trends = len(TREND_REGISTRY) try:
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...") profile = user.profile
if profile.trends_disabled:
self.stdout.write(
self.style.WARNING(
f" {user} ({user.id}): trends disabled globally, skipping"
)
)
skipped_count += len(TREND_REGISTRY)
continue
disabled_trends = set(profile.disabled_trends or [])
except Exception:
disabled_trends = set()
active_slugs = [
s for s in TREND_REGISTRY if s not in disabled_trends
]
total_trends = len(active_slugs)
self.stdout.write(
f" {user} ({user.id}): {total_trends} trends ("
f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..."
)
user_start = timezone.now() user_start = timezone.now()
user_ok = 0 user_ok = 0
user_fail = 0 user_fail = 0
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): for idx, slug in enumerate(active_slugs, start=1):
periods = get_supported_periods(slug) periods = get_supported_periods(slug)
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n") self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
for period in periods: for period in periods:
@ -76,7 +97,7 @@ class Command(BaseCommand):
overall_elapsed = (timezone.now() - overall_start).total_seconds() overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(
f"Done! {ok_count} OK, {fail_count} failed " f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped "
f"({overall_elapsed:.1f}s across {total_users} user(s))" f"({overall_elapsed:.1f}s across {total_users} user(s))"
) )
) )

View File

@ -26,7 +26,17 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id) logger.warning("User %s not found, skipping trends", user_id)
return return
total = len(TREND_REGISTRY) try:
profile = user.profile
if profile.trends_disabled:
logger.info("User %s (%d) has trends disabled, skipping", user, user_id)
return
disabled = set(profile.disabled_trends or [])
except Exception:
disabled = set()
active_slugs = [s for s in TREND_REGISTRY if s not in disabled]
total = len(active_slugs)
logger.info( logger.info(
"Computing %d trends for user %s (%d)", "Computing %d trends for user %s (%d)",
total, total,
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
user_id, user_id,
) )
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1): for idx, slug in enumerate(active_slugs, start=1):
compute_single_trend.delay(user_id, slug) compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id) logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug):
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id) logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return return
try:
profile = user.profile
if profile.trends_disabled:
logger.info(
"User %d has trends disabled, skipping '%s'", user_id, slug
)
return
if slug in (profile.disabled_trends or []):
logger.info(
"User %d has trend '%s' disabled, skipping", user_id, slug
)
return
except Exception:
pass
periods = get_supported_periods(slug) periods = get_supported_periods(slug)
for period in periods: for period in periods:

View File

@ -3,6 +3,10 @@
{% block title %}{{ trend.title }}{% endblock %} {% block title %}{{ trend.title }}{% endblock %}
{% block lists %} {% block lists %}
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% else %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-12"> <div class="col-12">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a> <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a>
@ -35,13 +39,21 @@
{% if computed_at %} {% if computed_at %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small> <small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% endif %} {% endif %}
<div class="mt-2">
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="d-inline">
{% csrf_token %}
{% if trend_disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable this trend</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable this trend</button>
{% endif %}
</form>
</div>
</div> </div>
</div> </div>
{% if trend_not_found %} {% if data is None %}
<div class="alert alert-warning">Trend not found.</div>
{% elif data is None %}
<div class="alert alert-info"> <div class="alert alert-info">
No data computed yet for this period. Trends are updated once daily, check back later. No data computed yet for this period. Trends are updated once daily, check back later.
</div> </div>
@ -85,5 +97,6 @@
{% elif trend.slug == "mood-weather" %} {% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %} {% include "trends/_mood_weather.html" %}
{% endif %}
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

@ -25,11 +25,23 @@
</a> </a>
</h5> </h5>
<p class="card-text text-muted">{{ trend.description }}</p> <p class="card-text text-muted">{{ trend.description }}</p>
{% if trend.computed_at %} <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small> <div>
{% else %} {% if trend.computed_at %}
<span class="badge bg-warning text-dark">Pending</span> <small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% endif %} {% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</div>
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="m-0">
{% csrf_token %}
{% if trend.disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
{% endif %}
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,9 +1,18 @@
from django.urls import path from django.urls import path
from trends.views import TrendDetailView, TrendListView from trends.views import (
ToggleTrendDisabledView,
TrendDetailView,
TrendListView,
)
app_name = "trends" app_name = "trends"
urlpatterns = [ urlpatterns = [
path("trends/", TrendListView.as_view(), name="trends-home"), path("trends/", TrendListView.as_view(), name="trends-home"),
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"), path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
path(
"trends/<slug:trend_slug>/toggle/",
ToggleTrendDisabledView.as_view(),
name="trend-toggle-disabled",
),
] ]

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from trends.models import PERIOD_CHOICES, TrendResult from trends.models import PERIOD_CHOICES, TrendResult
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,6 +36,13 @@ TREND_PERIOD_OVERRIDES = {
} }
def get_disabled_trends(user):
profile = user.profile
if profile.trends_disabled:
return set(TREND_REGISTRY.keys())
return set(profile.disabled_trends or [])
def get_supported_periods(trend_slug): def get_supported_periods(trend_slug):
if trend_slug in TREND_PERIOD_OVERRIDES: if trend_slug in TREND_PERIOD_OVERRIDES:
slugs = TREND_PERIOD_OVERRIDES[trend_slug] slugs = TREND_PERIOD_OVERRIDES[trend_slug]

View File

@ -1,8 +1,11 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import TemplateView, View
from trends.models import TrendResult from trends.models import TrendResult
from trends.trends import TREND_REGISTRY from trends.trends import TREND_REGISTRY
from trends.utils import get_period_nav, get_supported_periods from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods
TREND_METADATA = { TREND_METADATA = {
"activity-distribution": { "activity-distribution": {
@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
disabled = get_disabled_trends(self.request.user)
results = TrendResult.objects.filter( results = TrendResult.objects.filter(
user=self.request.user, user=self.request.user,
).order_by("trend_slug", "-computed_at") ).order_by("trend_slug", "-computed_at")
@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
trends = [] trends = []
for slug in TREND_REGISTRY: for slug in TREND_REGISTRY:
if slug in disabled:
continue
meta = TREND_METADATA.get(slug, {}) meta = TREND_METADATA.get(slug, {})
result = latest_by_slug.get(slug) result = latest_by_slug.get(slug)
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
trends.append( trends.append(
{ {
"slug": slug, "slug": slug,
@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""), "icon": meta.get("icon", ""),
"computed_at": result.computed_at if result else None, "computed_at": result.computed_at if result else None,
"has_data": result is not None, "has_data": result is not None,
"disabled": is_disabled,
} }
) )
ctx["trends"] = trends ctx["trends"] = trends
@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["computed_at"] = None ctx["computed_at"] = None
ctx["data"] = None ctx["data"] = None
disabled = get_disabled_trends(self.request.user)
ctx["trend_disabled"] = slug in disabled
ctx["globally_disabled"] = self.request.user.profile.trends_disabled
return ctx return ctx
class ToggleTrendDisabledView(LoginRequiredMixin, View):
def post(self, request, trend_slug):
profile = request.user.profile
disabled = set(profile.disabled_trends or [])
if trend_slug in disabled:
disabled.discard(trend_slug)
messages.success(request, f"Trend re-enabled.")
else:
disabled.add(trend_slug)
messages.success(request, f"Trend disabled.")
profile.disabled_trends = list(disabled)
profile.save(update_fields=["disabled_trends"])
return HttpResponseRedirect(
request.META.get("HTTP_REFERER", reverse("trends:trends-home"))
)

View File

@ -130,6 +130,11 @@
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
{% elif field.name == "trends_disabled" %}
<p class="checkbox-row">
{{ field }}
{{ field.label_tag }}
</p>
{% elif field.name == "home_scrobble_limit" %} {% elif field.name == "home_scrobble_limit" %}
<p> <p>
{{ field.label_tag }} {{ field.label_tag }}

View File

@ -76,9 +76,11 @@
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a> <a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
</div> </div>
{% if not user.profile.trends_disabled %}
<div class="btn-group me-2"> <div class="btn-group me-2">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a> <a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
</div> </div>
{% endif %}
<div class="btn-group me-2"> <div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %} {% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get"> <form action="{% url 'scrobbles:lastfm-import' %}" method="get">