[charts] Clean up, add tests and signals
This commit is contained in:
14
AGENTS.md
14
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.
|
||||
|
||||
268
vrobbler/apps/charts/tests/test_utils.py
Normal file
268
vrobbler/apps/charts/tests/test_utils.py
Normal 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
|
||||
@ -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()
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
73
vrobbler/apps/scrobbles/signals.py
Normal file
73
vrobbler/apps/scrobbles/signals.py
Normal 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}")
|
||||
Reference in New Issue
Block a user