From 881450eb8d87aba199a7260fe3d1b2d55f9211bc Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Mon, 23 Mar 2026 11:58:05 -0400 Subject: [PATCH] [charts] Clean up, add tests and signals --- AGENTS.md | 14 +- vrobbler/apps/charts/tests/test_utils.py | 268 +++++++++++++++++++++++ vrobbler/apps/charts/utils.py | 16 +- vrobbler/apps/scrobbles/apps.py | 1 + vrobbler/apps/scrobbles/signals.py | 73 ++++++ 5 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 vrobbler/apps/charts/tests/test_utils.py create mode 100644 vrobbler/apps/scrobbles/signals.py diff --git a/AGENTS.md b/AGENTS.md index c3a4932..030aa81 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,13 @@ -This is a Django-based web application that has an API, but primarily functions with traditional Django views with HTML templates to display data that mostly constitutes "scrobbled" items. The app started as a way to track a user's watched videos via a Jellyfin server, but has since grown to keep track of a number of media types: music tracks, tasks, videos, web pages, food, life events, sports events, podcasts, video games, board games, beers, brick (lego) sets, puzzles, books and geolocations. +This is a Django-based web application that has an API, but primarily functions +with traditional Django views with HTML templates to display data that mostly +constitutes "scrobbled" items. The app started as a way to track a user's +watched videos via a Jellyfin server, but has since grown to keep track of a +number of media types: music tracks, tasks, videos, web pages, food, life +events, sports events, podcasts, video games, board games, beers, brick (lego) +sets, puzzles, books and geolocations. -The project is written in Python and prefers to use "fat" models where logical methods are contained in either instance methods on instatiated data models, or classmethods on the Django model class itself. When logic grows too complex, helper functions should be pulled out into utils.py files and the model instance ro class method should call the utility function. +The project is written in Python and prefers to use "fat" models where logical +methods are contained in either instance methods on instatiated data models, or +classmethods on the Django model class itself. When logic grows too complex, +helper functions should be pulled out into utils.py files and the model instance +ro class method should call the utility function. diff --git a/vrobbler/apps/charts/tests/test_utils.py b/vrobbler/apps/charts/tests/test_utils.py new file mode 100644 index 0000000..4bd0364 --- /dev/null +++ b/vrobbler/apps/charts/tests/test_utils.py @@ -0,0 +1,268 @@ +import datetime + +import pytest +from django.contrib.auth import get_user_model +from django.utils import timezone + +from charts.models import ChartRecord +from charts.utils import build_charts + +User = get_user_model() + + +@pytest.fixture +def user(db): + return User.objects.create_user(username="charttester", password="testpass123") + + +@pytest.fixture +def scrobble_data(user, db): + from scrobbles.models import Scrobble + from music.models import Track, Artist, Album + + artist = Artist.objects.create(name="Test Artist") + album = Album.objects.create(name="Test Album", album_artist=artist) + track = Track.objects.create(title="Test Track", artist=artist, album=album) + + return { + "user": user, + "track": track, + } + + +@pytest.mark.django_db +def test_build_daily_charts_creates_daily_record(user, scrobble_data): + from scrobbles.models import Scrobble + + Scrobble.objects.create( + user=user, + track=scrobble_data["track"], + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024, month=3, day=15) + + daily = ChartRecord.objects.filter( + user=user, + year=2024, + month=3, + day=15, + week__isnull=True, + track__isnull=False, + ) + assert daily.exists() + assert daily.first().track == scrobble_data["track"] + + +@pytest.mark.django_db +def test_build_monthly_charts_creates_monthly_record(user, scrobble_data): + from scrobbles.models import Scrobble + + Scrobble.objects.create( + user=user, + track=scrobble_data["track"], + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024, month=3) + + monthly = ChartRecord.objects.filter( + user=user, + year=2024, + month=3, + week__isnull=True, + day__isnull=True, + track__isnull=False, + ) + assert monthly.exists() + assert monthly.first().track == scrobble_data["track"] + + +@pytest.mark.django_db +def test_build_weekly_charts_creates_weekly_record(user, scrobble_data): + from scrobbles.models import Scrobble + + Scrobble.objects.create( + user=user, + track=scrobble_data["track"], + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024, week=11) + + weekly = ChartRecord.objects.filter( + user=user, + year=2024, + week=11, + month__isnull=True, + day__isnull=True, + track__isnull=False, + ) + assert weekly.exists() + assert weekly.first().track == scrobble_data["track"] + + +@pytest.mark.django_db +def test_build_yearly_charts_creates_yearly_record(user, scrobble_data): + from scrobbles.models import Scrobble + + Scrobble.objects.create( + user=user, + track=scrobble_data["track"], + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024) + + yearly = ChartRecord.objects.filter( + user=user, + year=2024, + month__isnull=True, + week__isnull=True, + day__isnull=True, + track__isnull=False, + ) + assert yearly.exists() + assert yearly.first().track == scrobble_data["track"] + + +@pytest.mark.django_db +def test_build_charts_ranks_by_count(user, scrobble_data): + from scrobbles.models import Scrobble + from music.models import Track, Artist + + track1 = scrobble_data["track"] + track2 = Track.objects.create( + title="Test Track 2", + artist=track1.artist, + album=track1.album, + ) + + Scrobble.objects.create( + user=user, + track=track1, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + Scrobble.objects.create( + user=user, + track=track1, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)), + ) + Scrobble.objects.create( + user=user, + track=track2, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 12, 0)), + ) + + build_charts(user, year=2024, month=3, day=15) + + daily = ChartRecord.objects.filter( + user=user, + year=2024, + month=3, + day=15, + track__isnull=False, + ).order_by("rank") + + assert daily.count() == 2 + assert daily[0].track == track1 + assert daily[0].rank == 1 + assert daily[0].count == 2 + assert daily[1].track == track2 + assert daily[1].rank == 2 + assert daily[1].count == 1 + + +@pytest.mark.django_db +def test_build_daily_charts_deletes_existing_daily_record(user, scrobble_data): + from scrobbles.models import Scrobble + from music.models import Track, Artist + + track1 = scrobble_data["track"] + track2 = Track.objects.create( + title="Test Track 2", + artist=track1.artist, + album=track1.album, + ) + + Scrobble.objects.create( + user=user, + track=track1, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024, month=3, day=15) + assert ChartRecord.objects.filter( + user=user, year=2024, month=3, day=15, track=track1 + ).exists() + + Scrobble.objects.create( + user=user, + track=track2, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)), + ) + + build_charts(user, year=2024, month=3, day=15) + + daily = ChartRecord.objects.filter( + user=user, + year=2024, + month=3, + day=15, + track__isnull=False, + ) + assert daily.count() == 2 + assert daily.filter(track=track1).exists() + assert daily.filter(track=track2).exists() + + +@pytest.mark.django_db +def test_build_monthly_charts_deletes_existing_monthly_record(user, scrobble_data): + from scrobbles.models import Scrobble + from music.models import Track, Artist + + track1 = scrobble_data["track"] + track2 = Track.objects.create( + title="Test Track 2", + artist=track1.artist, + album=track1.album, + ) + + Scrobble.objects.create( + user=user, + track=track1, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)), + ) + + build_charts(user, year=2024, month=3) + + ChartRecord.objects.filter(user=user, year=2024, month=3, track=track1).delete() + + Scrobble.objects.create( + user=user, + track=track2, + played_to_completion=True, + timestamp=timezone.make_aware(datetime.datetime(2024, 3, 16, 11, 0)), + ) + + build_charts(user, year=2024, month=3) + + monthly = ChartRecord.objects.filter( + user=user, + year=2024, + month=3, + week__isnull=True, + day__isnull=True, + track__isnull=False, + ) + assert monthly.count() == 2 diff --git a/vrobbler/apps/charts/utils.py b/vrobbler/apps/charts/utils.py index 1efdf88..d8d6718 100644 --- a/vrobbler/apps/charts/utils.py +++ b/vrobbler/apps/charts/utils.py @@ -76,9 +76,9 @@ def build_charts( ] period_filter = Q(year=year) - if day: + if day and month: period_filter = ( - period_filter & Q(day=day) & Q(week__isnull=True) & Q(month__isnull=True) + period_filter & Q(day=day) & Q(month=month) & Q(week__isnull=True) ) elif week: period_filter = ( @@ -88,6 +88,13 @@ def build_charts( period_filter = ( period_filter & Q(month=month) & Q(week__isnull=True) & Q(day__isnull=True) ) + else: + period_filter = ( + period_filter + & Q(month__isnull=True) + & Q(week__isnull=True) + & Q(day__isnull=True) + ) ChartRecord.objects.filter(period_filter, user=user).delete() @@ -311,6 +318,7 @@ def build_daily_charts( build_charts( user=user, year=year, + month=month, day=day, media_types=media_types, ) @@ -322,9 +330,7 @@ def build_charts_since(user, media_types: Optional[list] = None) -> None: Scrobble = apps.get_model("scrobbles", "Scrobble") latest_daily = ( - ChartRecord.objects.filter( - user=user, day__isnull=False, month__isnull=True - ) + ChartRecord.objects.filter(user=user, day__isnull=False) .order_by("-year", "-month", "-day") .first() ) diff --git a/vrobbler/apps/scrobbles/apps.py b/vrobbler/apps/scrobbles/apps.py index 0e43f5a..7f4d441 100644 --- a/vrobbler/apps/scrobbles/apps.py +++ b/vrobbler/apps/scrobbles/apps.py @@ -6,6 +6,7 @@ class ScrobblesConfig(AppConfig): name = "scrobbles" def ready(self): + import scrobbles.signals # noqa from scrobbles.models import Scrobble original_index = admin.site.index diff --git a/vrobbler/apps/scrobbles/signals.py b/vrobbler/apps/scrobbles/signals.py new file mode 100644 index 0000000..9abba06 --- /dev/null +++ b/vrobbler/apps/scrobbles/signals.py @@ -0,0 +1,73 @@ +import logging + +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver +from django.utils import timezone + +from scrobbles.models import Scrobble + +logger = logging.getLogger(__name__) + + +MEDIA_TYPES = [ + "artist", + "album", + "track", + "tv_series", + "video", + "podcast", + "board_game", + "trail", + "food", + "book", +] + + +@receiver(post_save, sender=Scrobble) +def update_charts_on_scrobble_complete(sender, instance, **kwargs): + if not instance.played_to_completion: + return + + if instance.timestamp is None: + return + + _update_charts_for_timestamp(instance.user, instance.timestamp) + + +@receiver(post_delete, sender=Scrobble) +def update_charts_on_scrobble_delete(sender, instance, **kwargs): + if instance.timestamp is None: + return + + _update_charts_for_timestamp(instance.user, instance.timestamp) + + +def _update_charts_for_timestamp(user, ts): + if ts is None: + return + + from charts.utils import ( + build_daily_charts, + build_monthly_charts, + build_weekly_charts, + build_yearly_charts, + ) + + if timezone.is_naive(ts): + ts = timezone.make_aware(ts) + + year = ts.year + month = ts.month + week = ts.isocalendar()[1] + day = ts.day + + try: + build_daily_charts(user, year, month, day, MEDIA_TYPES) + build_weekly_charts(user, year, week, MEDIA_TYPES) + build_monthly_charts(user, year, month, MEDIA_TYPES) + build_yearly_charts(user, year, MEDIA_TYPES) + logger.info( + f"[charts] Updated charts for {user} on {year}-{month:02d}-{day:02d}" + ) + except Exception as e: + logger.error(f"[charts] Failed to update charts: {e}")