[charts] Clean up, add tests and signals
All checks were successful
build & deploy / test (push) Successful in 1m43s
build & deploy / deploy (push) Successful in 21s

This commit is contained in:
2026-03-23 11:58:05 -04:00
parent d9a7a1cfd9
commit 881450eb8d
5 changed files with 365 additions and 7 deletions

View File

@ -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.

View File

@ -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

View File

@ -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()
)

View File

@ -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

View File

@ -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}")