[trends] Add time periods
This commit is contained in:
@ -1,5 +1,4 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from trends.models import TrendResult
|
||||
|
||||
|
||||
|
||||
@ -3,9 +3,8 @@ import logging
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from trends.tasks import _compute_and_save_trend
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -48,24 +47,21 @@ class Command(BaseCommand):
|
||||
user_fail = 0
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
trend_start = timezone.now()
|
||||
self.stdout.write(
|
||||
f" [{idx}/{total_trends}] {slug}... ", ending=""
|
||||
)
|
||||
try:
|
||||
elapsed = _compute_and_save_trend(user, slug)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"OK ({elapsed:.1f}s)")
|
||||
)
|
||||
user_ok += 1
|
||||
except Exception as e:
|
||||
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
f"FAILED after {elapsed:.1f}s: {e}"
|
||||
periods = get_supported_periods(slug)
|
||||
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
|
||||
for period in periods:
|
||||
trend_start = timezone.now()
|
||||
self.stdout.write(f" {period}... ", ending="")
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
self.stdout.write(self.style.SUCCESS(f"OK ({elapsed:.1f}s)"))
|
||||
user_ok += 1
|
||||
except Exception as e:
|
||||
elapsed = (timezone.now() - trend_start).total_seconds()
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f"FAILED after {elapsed:.1f}s: {e}")
|
||||
)
|
||||
)
|
||||
user_fail += 1
|
||||
user_fail += 1
|
||||
|
||||
user_elapsed = (timezone.now() - user_start).total_seconds()
|
||||
self.stdout.write(
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-16 14:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-17 14:32
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("trends", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="trendresult",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trendresult",
|
||||
name="period",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
("all_time", "All time"),
|
||||
],
|
||||
default="all_time",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="trendresult",
|
||||
unique_together={("user", "trend_slug", "period")},
|
||||
),
|
||||
]
|
||||
@ -4,15 +4,27 @@ from django_extensions.db.models import TimeStampedModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
PERIOD_CHOICES = [
|
||||
("last_30", "Last 30 days"),
|
||||
("last_90", "Last 90 days"),
|
||||
("last_year", "Last year"),
|
||||
("all_time", "All time"),
|
||||
]
|
||||
|
||||
|
||||
class TrendResult(TimeStampedModel):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
trend_slug = models.CharField(max_length=100, db_index=True)
|
||||
period = models.CharField(
|
||||
max_length=20,
|
||||
choices=PERIOD_CHOICES,
|
||||
default="all_time",
|
||||
)
|
||||
computed_at = models.DateTimeField(auto_now_add=True)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "trend_slug"]
|
||||
unique_together = ["user", "trend_slug", "period"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.trend_slug} ({self.computed_at})"
|
||||
return f"{self.user} - {self.trend_slug} ({self.period})"
|
||||
|
||||
@ -3,35 +3,16 @@ import logging
|
||||
from celery import shared_task
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import compute_and_save_trend, get_supported_periods
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def _compute_and_save_trend(user, slug):
|
||||
"""Compute a single trend and persist the result.
|
||||
|
||||
Returns elapsed seconds on success, raises on failure.
|
||||
"""
|
||||
fn = TREND_REGISTRY[slug]
|
||||
start = timezone.now()
|
||||
data = fn(user)
|
||||
TrendResult.objects.update_or_create(
|
||||
user=user,
|
||||
trend_slug=slug,
|
||||
defaults={"data": data, "computed_at": timezone.now()},
|
||||
)
|
||||
return (timezone.now() - start).total_seconds()
|
||||
|
||||
|
||||
@shared_task
|
||||
def compute_all_trends():
|
||||
user_ids = list(
|
||||
User.objects.filter(is_active=True).values_list("id", flat=True)
|
||||
)
|
||||
user_ids = list(User.objects.filter(is_active=True).values_list("id", flat=True))
|
||||
logger.info("Dispatching trend computation for %d users", len(user_ids))
|
||||
for uid in user_ids:
|
||||
compute_user_trends.delay(uid)
|
||||
@ -48,7 +29,9 @@ def compute_user_trends(user_id):
|
||||
total = len(TREND_REGISTRY)
|
||||
logger.info(
|
||||
"Computing %d trends for user %s (%d)",
|
||||
total, user, user_id,
|
||||
total,
|
||||
user,
|
||||
user_id,
|
||||
)
|
||||
|
||||
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
|
||||
@ -62,21 +45,25 @@ def compute_single_trend(user_id, slug):
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
logger.warning(
|
||||
"User %d not found for trend '%s', skipping", user_id, slug
|
||||
)
|
||||
logger.warning("User %d not found for trend '%s', skipping", user_id, slug)
|
||||
return
|
||||
|
||||
if slug not in TREND_REGISTRY:
|
||||
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
|
||||
return
|
||||
|
||||
logger.info("[%s] Computing for user %d...", slug, user_id)
|
||||
try:
|
||||
elapsed = _compute_and_save_trend(user, slug)
|
||||
logger.info(
|
||||
"[%s] Completed for user %d in %.1fs",
|
||||
slug, user_id, elapsed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s] Failed for user %d", slug, user_id)
|
||||
periods = get_supported_periods(slug)
|
||||
|
||||
for period in periods:
|
||||
logger.info("[%s/%s] Computing for user %d...", slug, period, user_id)
|
||||
try:
|
||||
elapsed = compute_and_save_trend(user, slug, period)
|
||||
logger.info(
|
||||
"[%s/%s] Completed for user %d in %.1fs",
|
||||
slug,
|
||||
period,
|
||||
user_id,
|
||||
elapsed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("[%s/%s] Failed for user %d", slug, period, user_id)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div class="col-12">
|
||||
{% if data.distribution %}
|
||||
<p class="text-muted mb-3">
|
||||
Total scrobbles: <strong>{{ data.total_count }}</strong>
|
||||
Total scrobbles{% if current_period_label %} ({{ current_period_label }}){% endif %}: <strong>{{ data.total_count }}</strong>
|
||||
</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
<div class="row">
|
||||
{% if current_period_label %}
|
||||
<div class="col-12 mb-2">
|
||||
<small class="text-muted">Period: {{ current_period_label }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Media Type</th>
|
||||
<th class="text-end">Recent (30 days)</th>
|
||||
<th class="text-end">Previous (30 days)</th>
|
||||
<th class="text-end">Recent ({{ current_period_label }})</th>
|
||||
<th class="text-end">Previous ({{ current_period_label }})</th>
|
||||
<th class="text-end">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@ -8,6 +8,30 @@
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">← All Trends</a>
|
||||
<h2>{{ trend.icon }} {{ trend.title }}</h2>
|
||||
<p class="text-muted">{{ trend.description }}</p>
|
||||
|
||||
{% if supported_periods|length > 1 %}
|
||||
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
|
||||
<nav class="btn-group btn-group-sm" role="group">
|
||||
{% for period_slug, period_label in supported_periods.items %}
|
||||
<a href="?period={{ period_slug }}"
|
||||
class="btn btn-sm {% if period_slug == current_period %}btn-primary{% else %}btn-outline-secondary{% endif %}">
|
||||
{{ period_label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
{% if prev_period or next_period %}
|
||||
<div class="btn-group btn-group-sm">
|
||||
{% if prev_period %}
|
||||
<a href="?period={{ prev_period }}" class="btn btn-outline-secondary">« Prev</a>
|
||||
{% endif %}
|
||||
{% if next_period %}
|
||||
<a href="?period={{ next_period }}" class="btn btn-outline-secondary">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if computed_at %}
|
||||
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
|
||||
{% endif %}
|
||||
@ -19,7 +43,7 @@
|
||||
|
||||
{% elif data is None %}
|
||||
<div class="alert alert-info">
|
||||
No data computed yet. Trends are updated once daily, check back later.
|
||||
No data computed yet for this period. Trends are updated once daily, check back later.
|
||||
</div>
|
||||
|
||||
{% elif trend.slug == "concurrent-listening" %}
|
||||
|
||||
@ -3,11 +3,10 @@ from collections import OrderedDict, defaultdict
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import Extract
|
||||
from django.utils import timezone
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def compute_peak_hours(user):
|
||||
def compute_peak_hours(user, period="all_time"):
|
||||
"""Group scrobbles by hour of day (0-23) and count them.
|
||||
|
||||
Returns dict: {"hours": [{"hour": N, "count": N}, ...]} sorted by hour.
|
||||
@ -28,21 +27,23 @@ def compute_peak_hours(user):
|
||||
return {"hours": hours}
|
||||
|
||||
|
||||
def compute_weekly_rhythm(user):
|
||||
def compute_weekly_rhythm(user, period="all_time"):
|
||||
"""Group scrobble counts by day of the week.
|
||||
|
||||
Uses iso_week_day (1=Monday, 7=Sunday). Returns dict sorted by day index
|
||||
with human-readable day names.
|
||||
"""
|
||||
DAY_NAMES = OrderedDict([
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
])
|
||||
DAY_NAMES = OrderedDict(
|
||||
[
|
||||
(1, "Monday"),
|
||||
(2, "Tuesday"),
|
||||
(3, "Wednesday"),
|
||||
(4, "Thursday"),
|
||||
(5, "Friday"),
|
||||
(6, "Saturday"),
|
||||
(7, "Sunday"),
|
||||
]
|
||||
)
|
||||
|
||||
days_qs = (
|
||||
Scrobble.objects.filter(user=user, timestamp__isnull=False)
|
||||
@ -55,24 +56,35 @@ def compute_weekly_rhythm(user):
|
||||
raw = {row["day"]: row["count"] for row in days_qs}
|
||||
days = []
|
||||
for idx, name in DAY_NAMES.items():
|
||||
days.append({
|
||||
"day_index": idx,
|
||||
"day_name": name,
|
||||
"count": raw.get(idx, 0),
|
||||
})
|
||||
days.append(
|
||||
{
|
||||
"day_index": idx,
|
||||
"day_name": name,
|
||||
"count": raw.get(idx, 0),
|
||||
}
|
||||
)
|
||||
|
||||
return {"days": days}
|
||||
|
||||
|
||||
def compute_activity_distribution(user):
|
||||
def compute_activity_distribution(user, period="all_time"):
|
||||
"""Proportion of total scrobbles per media type.
|
||||
|
||||
Returns dict: {"distribution": [{"media_type": "...", "count": N,
|
||||
"completed": N, "pct": float}, ...]} sorted by count desc, plus
|
||||
"total_count".
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
filters = Q(user=user)
|
||||
if start:
|
||||
filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
filters &= Q(timestamp__lte=end)
|
||||
|
||||
dist_qs = (
|
||||
Scrobble.objects.filter(user=user)
|
||||
Scrobble.objects.filter(filters)
|
||||
.values("media_type")
|
||||
.annotate(
|
||||
count=Count("id"),
|
||||
@ -86,12 +98,14 @@ def compute_activity_distribution(user):
|
||||
|
||||
distribution = []
|
||||
for row in rows:
|
||||
distribution.append({
|
||||
"media_type": row["media_type"],
|
||||
"count": row["count"],
|
||||
"completed": row["completed"],
|
||||
"pct": round((row["count"] / total) * 100, 1),
|
||||
})
|
||||
distribution.append(
|
||||
{
|
||||
"media_type": row["media_type"],
|
||||
"count": row["count"],
|
||||
"completed": row["completed"],
|
||||
"pct": round((row["count"] / total) * 100, 1),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"distribution": distribution,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
@ -21,12 +22,8 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
||||
Returns a dict mapping each anchor scrobble PK to a list of
|
||||
paired scrobble PKs that overlap with it.
|
||||
"""
|
||||
anchor_ranges = {
|
||||
s.pk: _range_for(s) for s in anchor_scrobbles
|
||||
}
|
||||
paired_ranges = {
|
||||
s.pk: _range_for(s) for s in paired_scrobbles
|
||||
}
|
||||
anchor_ranges = {s.pk: _range_for(s) for s in anchor_scrobbles}
|
||||
paired_ranges = {s.pk: _range_for(s) for s in paired_scrobbles}
|
||||
|
||||
anchor_to_paired = defaultdict(list)
|
||||
|
||||
@ -41,7 +38,10 @@ def _find_concurrent(anchor_scrobbles, paired_scrobbles):
|
||||
def _get_media_name(scrobble):
|
||||
"""Return the name of the media object associated with a scrobble."""
|
||||
for attr in [
|
||||
"trail", "geo_location", "book", "track",
|
||||
"trail",
|
||||
"geo_location",
|
||||
"book",
|
||||
"track",
|
||||
]:
|
||||
obj = getattr(scrobble, attr, None)
|
||||
if obj is not None:
|
||||
@ -49,22 +49,45 @@ def _get_media_name(scrobble):
|
||||
return "Unknown"
|
||||
|
||||
|
||||
def compute_concurrent_listening(user):
|
||||
def compute_concurrent_listening(user, period="all_time"):
|
||||
"""Find what music was listened to while on trails or at locations.
|
||||
|
||||
Returns a dict with two keys: 'trails' and 'locations', each containing
|
||||
a list of entries with the trail/location name and the tracks listened to.
|
||||
"""
|
||||
media_types_to_exclude_from_anchor = ("Track", "Book", "Video", "PodcastEpisode",
|
||||
"VideoGame", "BoardGame", "Puzzle", "Food",
|
||||
"Beer", "Task", "WebPage", "LifeEvent",
|
||||
"Mood", "BrickSet", "Channel", "BirdingLocation",
|
||||
"Paper", "SportEvent")
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
media_types_to_exclude_from_anchor = (
|
||||
"Track",
|
||||
"Book",
|
||||
"Video",
|
||||
"PodcastEpisode",
|
||||
"VideoGame",
|
||||
"BoardGame",
|
||||
"Puzzle",
|
||||
"Food",
|
||||
"Beer",
|
||||
"Task",
|
||||
"WebPage",
|
||||
"LifeEvent",
|
||||
"Mood",
|
||||
"BrickSet",
|
||||
"Channel",
|
||||
"BirdingLocation",
|
||||
"Paper",
|
||||
"SportEvent",
|
||||
)
|
||||
|
||||
anchor_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
timestamp__isnull=False,
|
||||
base_filters,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.exclude(media_type__in=media_types_to_exclude_from_anchor)
|
||||
@ -74,9 +97,8 @@ def compute_concurrent_listening(user):
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -131,29 +153,45 @@ def compute_concurrent_listening(user):
|
||||
}
|
||||
|
||||
if anchor.media_type == "Trail":
|
||||
entry["uuid"] = str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
||||
entry["uuid"] = (
|
||||
str(anchor.trail.uuid) if anchor.trail and anchor.trail.uuid else ""
|
||||
)
|
||||
trails.append(entry)
|
||||
else:
|
||||
entry["uuid"] = str(anchor.geo_location.uuid) if anchor.geo_location and anchor.geo_location.uuid else ""
|
||||
entry["uuid"] = (
|
||||
str(anchor.geo_location.uuid)
|
||||
if anchor.geo_location and anchor.geo_location.uuid
|
||||
else ""
|
||||
)
|
||||
locations.append(entry)
|
||||
|
||||
return {
|
||||
"trails": sorted(trails, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
"locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
"locations": sorted(locations, key=lambda x: x["total_sessions"], reverse=True)[
|
||||
:20
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def compute_concurrent_reading(user):
|
||||
def compute_concurrent_reading(user, period="all_time"):
|
||||
"""Find what music was listened to while reading books.
|
||||
|
||||
Returns a dict with key 'books' containing a list of entries with the
|
||||
book title and the tracks listened to while reading.
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
anchor_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -163,9 +201,8 @@ def compute_concurrent_reading(user):
|
||||
|
||||
paired_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
stop_timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -203,19 +240,21 @@ def compute_concurrent_reading(user):
|
||||
}
|
||||
|
||||
book = anchor.book
|
||||
books.append({
|
||||
"book_title": str(book) if book else "Unknown",
|
||||
"book_uuid": str(book.uuid) if book and book.uuid else "",
|
||||
"total_sessions": len(paired_pks),
|
||||
"tracks": sorted(
|
||||
[
|
||||
{**track_details[name], "count": count}
|
||||
for name, count in tracks_by_name.items()
|
||||
],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:20],
|
||||
})
|
||||
books.append(
|
||||
{
|
||||
"book_title": str(book) if book else "Unknown",
|
||||
"book_uuid": str(book.uuid) if book and book.uuid else "",
|
||||
"total_sessions": len(paired_pks),
|
||||
"tracks": sorted(
|
||||
[
|
||||
{**track_details[name], "count": count}
|
||||
for name, count in tracks_by_name.items()
|
||||
],
|
||||
key=lambda x: x["count"],
|
||||
reverse=True,
|
||||
)[:20],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"books": sorted(books, key=lambda x: x["total_sessions"], reverse=True)[:20],
|
||||
|
||||
@ -1,21 +1,30 @@
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def compute_reading_pace_vs_activity(user):
|
||||
def compute_reading_pace_vs_activity(user, period="all_time"):
|
||||
"""Compare reading pace (seconds per session) when music is playing vs. not.
|
||||
|
||||
For each Book scrobble with a playback_position_seconds value, checks
|
||||
whether there is an overlapping Track scrobble and groups the data.
|
||||
Returns average session duration for both groups.
|
||||
"""
|
||||
from trends.utils import get_date_range
|
||||
|
||||
start, end = get_date_range(period)
|
||||
base_filters = Q(user=user, timestamp__isnull=False)
|
||||
if start:
|
||||
base_filters &= Q(timestamp__gte=start)
|
||||
if end:
|
||||
base_filters &= Q(timestamp__lte=end)
|
||||
|
||||
book_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Book",
|
||||
timestamp__isnull=False,
|
||||
playback_position_seconds__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
@ -28,12 +37,10 @@ def compute_reading_pace_vs_activity(user):
|
||||
|
||||
track_scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
base_filters,
|
||||
media_type="Track",
|
||||
timestamp__isnull=False,
|
||||
played_to_completion=True,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
).order_by("-timestamp")
|
||||
)
|
||||
|
||||
track_ranges = []
|
||||
|
||||
@ -2,18 +2,21 @@ from collections import defaultdict
|
||||
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def compute_trending_up(user, days=30):
|
||||
def compute_trending_up(user, period="last_30"):
|
||||
"""Compare scrobble counts per media type between two periods.
|
||||
|
||||
Compares the most recent N days against the N days before that,
|
||||
returning the count for each period and the percentage change.
|
||||
The period controls the window size (e.g. 30, 90, 365 days).
|
||||
|
||||
Returns a dict keyed by media_type with count and change info.
|
||||
"""
|
||||
from trends.utils import get_period_days
|
||||
|
||||
days = get_period_days(period) or 30
|
||||
now = timezone.now()
|
||||
recent_start = now - timezone.timedelta(days=days)
|
||||
previous_start = recent_start - timezone.timedelta(days=days)
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from django.urls import path
|
||||
|
||||
from trends.views import TrendDetailView, TrendListView
|
||||
|
||||
app_name = "trends"
|
||||
|
||||
80
vrobbler/apps/trends/utils.py
Normal file
80
vrobbler/apps/trends/utils.py
Normal file
@ -0,0 +1,80 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from trends.models import PERIOD_CHOICES, TrendResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PERIOD_DAYS = {
|
||||
"last_30": 30,
|
||||
"last_90": 90,
|
||||
"last_year": 365,
|
||||
"all_time": None,
|
||||
}
|
||||
|
||||
PERIOD_LABELS = dict(PERIOD_CHOICES)
|
||||
|
||||
TIME_BOUND_TRENDS = {
|
||||
"activity-distribution",
|
||||
"concurrent-reading",
|
||||
"concurrent-listening",
|
||||
"reading-pace-vs-activity",
|
||||
"trending-up",
|
||||
}
|
||||
|
||||
TREND_PERIOD_OVERRIDES = {
|
||||
"trending-up": ["last_30", "last_90", "last_year"],
|
||||
}
|
||||
|
||||
|
||||
def get_supported_periods(trend_slug):
|
||||
if trend_slug in TREND_PERIOD_OVERRIDES:
|
||||
slugs = TREND_PERIOD_OVERRIDES[trend_slug]
|
||||
return {s: PERIOD_LABELS[s] for s in slugs}
|
||||
if trend_slug in TIME_BOUND_TRENDS:
|
||||
return dict(PERIOD_LABELS)
|
||||
return {"all_time": PERIOD_LABELS["all_time"]}
|
||||
|
||||
|
||||
def get_period_days(period):
|
||||
return PERIOD_DAYS.get(period)
|
||||
|
||||
|
||||
def get_date_range(period):
|
||||
days = get_period_days(period)
|
||||
if days is None:
|
||||
return None, None
|
||||
now = timezone.now()
|
||||
return now - timedelta(days=days), now
|
||||
|
||||
|
||||
def get_period_nav(current_period, trend_slug):
|
||||
supported = get_supported_periods(trend_slug)
|
||||
keys = list(supported.keys())
|
||||
try:
|
||||
idx = keys.index(current_period)
|
||||
except ValueError:
|
||||
return None, None
|
||||
prev_period = keys[idx - 1] if idx > 0 else None
|
||||
next_period = keys[idx + 1] if idx < len(keys) - 1 else None
|
||||
return prev_period, next_period
|
||||
|
||||
|
||||
def compute_and_save_trend(user, slug, period="all_time"):
|
||||
"""Compute a single trend for a given period and persist the result.
|
||||
|
||||
Returns elapsed seconds on success, raises on failure.
|
||||
"""
|
||||
from trends.trends import TREND_REGISTRY
|
||||
|
||||
fn = TREND_REGISTRY[slug]
|
||||
start = timezone.now()
|
||||
data = fn(user, period=period)
|
||||
TrendResult.objects.update_or_create(
|
||||
user=user,
|
||||
trend_slug=slug,
|
||||
period=period,
|
||||
defaults={"data": data, "computed_at": timezone.now()},
|
||||
)
|
||||
return (timezone.now() - start).total_seconds()
|
||||
@ -1,8 +1,8 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from trends.models import TrendResult
|
||||
from trends.trends import TREND_REGISTRY
|
||||
from trends.utils import get_period_nav, get_supported_periods
|
||||
|
||||
TREND_METADATA = {
|
||||
"activity-distribution": {
|
||||
@ -48,24 +48,29 @@ class TrendListView(LoginRequiredMixin, TemplateView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
results = {
|
||||
r.trend_slug: r
|
||||
for r in TrendResult.objects.filter(
|
||||
user=self.request.user
|
||||
)
|
||||
}
|
||||
results = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("trend_slug", "-computed_at")
|
||||
|
||||
latest_by_slug = {}
|
||||
for r in results:
|
||||
if r.trend_slug not in latest_by_slug:
|
||||
latest_by_slug[r.trend_slug] = r
|
||||
|
||||
trends = []
|
||||
for slug in TREND_REGISTRY:
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
result = results.get(slug)
|
||||
trends.append({
|
||||
"slug": slug,
|
||||
"title": meta.get("title", slug),
|
||||
"description": meta.get("description", ""),
|
||||
"icon": meta.get("icon", ""),
|
||||
"computed_at": result.computed_at if result else None,
|
||||
"has_data": result is not None,
|
||||
})
|
||||
result = latest_by_slug.get(slug)
|
||||
trends.append(
|
||||
{
|
||||
"slug": slug,
|
||||
"title": meta.get("title", slug),
|
||||
"description": meta.get("description", ""),
|
||||
"icon": meta.get("icon", ""),
|
||||
"computed_at": result.computed_at if result else None,
|
||||
"has_data": result is not None,
|
||||
}
|
||||
)
|
||||
ctx["trends"] = trends
|
||||
return ctx
|
||||
|
||||
@ -81,6 +86,8 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
ctx["trend_not_found"] = True
|
||||
return ctx
|
||||
|
||||
period = self.request.GET.get("period", "all_time")
|
||||
|
||||
meta = TREND_METADATA.get(slug, {})
|
||||
ctx["trend"] = {
|
||||
"slug": slug,
|
||||
@ -89,9 +96,19 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
|
||||
"icon": meta.get("icon", ""),
|
||||
}
|
||||
|
||||
supported = get_supported_periods(slug)
|
||||
ctx["supported_periods"] = supported
|
||||
ctx["current_period"] = period
|
||||
ctx["current_period_label"] = supported.get(period, "")
|
||||
|
||||
prev_period, next_period = get_period_nav(period, slug)
|
||||
ctx["prev_period"] = prev_period
|
||||
ctx["next_period"] = next_period
|
||||
|
||||
result = TrendResult.objects.filter(
|
||||
user=self.request.user,
|
||||
trend_slug=slug,
|
||||
period=period,
|
||||
).first()
|
||||
|
||||
if result:
|
||||
|
||||
Reference in New Issue
Block a user