[trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s

This commit is contained in:
2026-06-25 18:58:23 -04:00
parent 0a411bedf4
commit 41a68291a4
13 changed files with 184 additions and 19 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [1/24] :vrobbler:project:personal:
* Backlog [2/25] :vrobbler:project:personal:
** 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:
:PROPERTIES:
@ -605,6 +605,10 @@ independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
** 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

View File

@ -42,6 +42,7 @@ class UserProfileForm(forms.ModelForm):
"home_scrobble_limit",
"live_now_playing",
"weigh_in_units",
"trends_disabled",
]
widgets = {
"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,
)
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):
return f"User profile for {self.user}"

View File

@ -38,15 +38,36 @@ class Command(BaseCommand):
overall_start = timezone.now()
ok_count = 0
fail_count = 0
skipped_count = 0
for user in users:
total_trends = len(TREND_REGISTRY)
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
try:
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_ok = 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)
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
for period in periods:
@ -76,7 +97,7 @@ class Command(BaseCommand):
overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write(
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))"
)
)

View File

@ -26,7 +26,17 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id)
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(
"Computing %d trends for user %s (%d)",
total,
@ -34,7 +44,7 @@ def compute_user_trends(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)
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)
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)
for period in periods:

View File

@ -3,6 +3,10 @@
{% block title %}{{ trend.title }}{% endblock %}
{% block lists %}
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% else %}
<div class="row mb-3">
<div class="col-12">
<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 %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% 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>
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% elif data is None %}
{% if data is None %}
<div class="alert alert-info">
No data computed yet for this period. Trends are updated once daily, check back later.
</div>
@ -85,5 +97,6 @@
{% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -25,11 +25,23 @@
</a>
</h5>
<p class="card-text text-muted">{{ trend.description }}</p>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<div>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% 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>

View File

@ -1,9 +1,18 @@
from django.urls import path
from trends.views import TrendDetailView, TrendListView
from trends.views import (
ToggleTrendDisabledView,
TrendDetailView,
TrendListView,
)
app_name = "trends"
urlpatterns = [
path("trends/", TrendListView.as_view(), name="trends-home"),
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 trends.models import PERIOD_CHOICES, TrendResult
from trends.trends import TREND_REGISTRY
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):
if trend_slug in TREND_PERIOD_OVERRIDES:
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.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.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 = {
"activity-distribution": {
@ -78,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
disabled = get_disabled_trends(self.request.user)
results = TrendResult.objects.filter(
user=self.request.user,
).order_by("trend_slug", "-computed_at")
@ -89,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
trends = []
for slug in TREND_REGISTRY:
if slug in disabled:
continue
meta = TREND_METADATA.get(slug, {})
result = latest_by_slug.get(slug)
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
trends.append(
{
"slug": slug,
@ -99,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""),
"computed_at": result.computed_at if result else None,
"has_data": result is not None,
"disabled": is_disabled,
}
)
ctx["trends"] = trends
@ -148,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["computed_at"] = 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
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>
</div>
{% endif %}
{% elif field.name == "trends_disabled" %}
<p class="checkbox-row">
{{ field }}
{{ field.label_tag }}
</p>
{% elif field.name == "home_scrobble_limit" %}
<p>
{{ field.label_tag }}

View File

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