[scrobbles] Add PersonScrobble junction table
Some checks failed
build & deploy / test (push) Successful in 6m3s
build & deploy / deploy (push) Failing after 19s

This commit is contained in:
2026-03-21 13:44:37 -04:00
parent 10af7190ab
commit 6a2cb4a881
8 changed files with 401 additions and 48 deletions

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from people.models import Person
from people.models import Person, PersonScrobble
@admin.register(Person)
@ -8,3 +8,11 @@ class PersonAdmin(admin.ModelAdmin):
list_display = ("name", "bgg_username", "bgstats_id")
ordering = ("-created",)
search_fields = ("name",)
@admin.register(PersonScrobble)
class PersonScrobbleAdmin(admin.ModelAdmin):
list_display = ("person", "user", "scrobble", "created")
list_filter = ("created",)
ordering = ("-created",)
raw_id_fields = ("person", "user", "scrobble")

View File

@ -1,15 +1,24 @@
from django.core.management.base import BaseCommand
from people.models import Person
from people.models import PersonScrobble
from people.signals import get_person_ids_from_log
from scrobbles.models import Scrobble
class Command(BaseCommand):
help = "Backfill scrobble counts for all people"
help = "Backfill PersonScrobble junction table from existing scrobbles"
def handle(self, *args, **options):
for person in Person.objects.all():
person.update_scrobble_count()
self.stdout.write(
f"{person.name}: {person.scrobble_count} scrobbles"
)
count = 0
for scrobble in Scrobble.objects.all():
person_ids = get_person_ids_from_log(scrobble.log)
for person_id in person_ids:
obj, created = PersonScrobble.objects.get_or_create(
person_id=person_id,
user=scrobble.user,
scrobble=scrobble,
)
if created:
count += 1
self.stdout.write(f"Created {count} PersonScrobble records")
self.stdout.write(self.style.SUCCESS("Done!"))

View File

@ -0,0 +1,84 @@
# Generated by Django 4.2.29 on 2026-03-21 17:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0072_add_scrobble_indexes"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("people", "0005_person_boardgamearena_id"),
]
operations = [
migrations.RemoveField(
model_name="person",
name="scrobble_count",
),
migrations.CreateModel(
name="PersonScrobble",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"person",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="scrobble_associations",
to="people.person",
),
),
(
"scrobble",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="scrobbles.scrobble",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="person_scrobbles",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["person", "user"],
name="people_pers_person__b7ebc7_idx",
),
models.Index(
fields=["user", "scrobble"],
name="people_pers_user_id_4f5fe7_idx",
),
],
"unique_together": {("person", "scrobble")},
},
),
]

View File

@ -1,5 +1,6 @@
from django.contrib.auth import get_user_model
from django.db import models
from django.shortcuts import get_object_or_404
from django_extensions.db.models import TimeStampedModel
User = get_user_model()
@ -22,25 +23,51 @@ class Person(TimeStampedModel):
bgg_username = models.CharField(max_length=100, **BNULL)
lichess_username = models.CharField(max_length=100, **BNULL)
bio = models.TextField(**BNULL)
scrobble_count = models.IntegerField(default=0)
def __str__(self):
return self.name
def update_scrobble_count(self):
def get_scrobble_count(self, user=None):
if user is None:
user = self.created_by
return PersonScrobble.objects.filter(person=self, user=user).count()
def get_scrobbles(self, user=None):
if user is None:
user = self.created_by
from scrobbles.models import Scrobble
count = 0
for scrobble in Scrobble.objects.filter(user=self.created_by):
if scrobble.log and isinstance(scrobble.log, dict):
person_ids = scrobble.log.get("with_people_ids") or []
if person_ids and self.id in person_ids:
count += 1
continue
players = scrobble.log.get("players") or []
for player in players:
if isinstance(player, dict) and player.get("person_id") == self.id:
count += 1
break
self.scrobble_count = count
self.save(update_fields=["scrobble_count"])
return Scrobble.objects.filter(
id__in=PersonScrobble.objects.filter(person=self, user=user).values_list(
"scrobble_id", flat=True
)
).order_by("-timestamp")
class PersonScrobble(TimeStampedModel):
"""Tracks the relationship between a Person and a Scrobble."""
person = models.ForeignKey(
Person,
on_delete=models.CASCADE,
related_name="scrobble_associations",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="person_scrobbles",
)
scrobble = models.ForeignKey(
"scrobbles.Scrobble",
on_delete=models.CASCADE,
)
class Meta:
unique_together = ["person", "scrobble"]
indexes = [
models.Index(fields=["person", "user"]),
models.Index(fields=["user", "scrobble"]),
]
def __str__(self):
return f"{self.person.name} - {self.scrobble_id}"

View File

@ -4,25 +4,55 @@ from django.dispatch import receiver
from scrobbles.models import Scrobble
@receiver(post_save, sender=Scrobble)
def update_person_scrobble_count_on_save(sender, instance, **kwargs):
if instance.log and isinstance(instance.log, dict):
person_ids = instance.log.get("with_people_ids", [])
for person_id in person_ids:
from people.models import Person
def get_person_ids_from_log(log):
if not log or not isinstance(log, dict):
return set()
person = Person.objects.filter(id=person_id).first()
if person:
person.update_scrobble_count()
person_ids = set()
with_people_ids = log.get("with_people_ids") or []
if isinstance(with_people_ids, list):
person_ids.update(with_people_ids)
players = log.get("players") or []
if isinstance(players, list):
for player in players:
if isinstance(player, dict) and player.get("person_id"):
person_ids.add(player["person_id"])
return person_ids
@receiver(post_save, sender=Scrobble)
def sync_person_scrobbles_on_save(sender, instance, **kwargs):
from people.models import Person, PersonScrobble
person_ids = get_person_ids_from_log(instance.log)
existing = set(
PersonScrobble.objects.filter(scrobble=instance).values_list(
"person_id", flat=True
)
)
for person_id in person_ids:
person = Person.objects.filter(id=person_id).first()
if person and person.user and person.user == instance.user:
continue
if person_id not in existing:
PersonScrobble.objects.get_or_create(
person_id=person_id,
user=instance.user,
scrobble=instance,
)
PersonScrobble.objects.filter(scrobble=instance).exclude(
person_id__in=person_ids
).delete()
@receiver(post_delete, sender=Scrobble)
def update_person_scrobble_count_on_delete(sender, instance, **kwargs):
if instance.log and isinstance(instance.log, dict):
person_ids = instance.log.get("with_people_ids", [])
for person_id in person_ids:
from people.models import Person
def sync_person_scrobbles_on_delete(sender, instance, **kwargs):
from people.models import PersonScrobble
person = Person.objects.filter(id=person_id).first()
if person:
person.update_scrobble_count()
PersonScrobble.objects.filter(scrobble=instance).delete()

View File

View File

@ -0,0 +1,180 @@
import pytest
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from people.models import Person, PersonScrobble
User = get_user_model()
@pytest.mark.django_db
def test_signal_creates_person_scrobble_on_save_with_people_ids():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Alice", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person, user=other_user).count() == 1
@pytest.mark.django_db
def test_signal_creates_person_scrobble_on_save_with_players():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Bob", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"players": [
{"person_id": person.id, "win": True, "rank": 1},
]
},
)
assert PersonScrobble.objects.filter(person=person, user=other_user).count() == 1
@pytest.mark.django_db
def test_signal_removes_person_scrobble_when_person_removed_from_with_people_ids():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Charlie", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.log = {}
scrobble.save()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_removes_person_scrobble_when_person_removed_from_players():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Diana", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"players": [
{"person_id": person.id, "win": True, "rank": 1},
]
},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.log = {"players": []}
scrobble.save()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_deletes_person_scrobble_on_scrobble_delete():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Eve", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 1
scrobble.delete()
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_skips_when_person_user_matches_scrobble_user():
user = User.objects.create_user(username="testuser", password="testpass123")
person = Person.objects.create(name="Frank", created_by=user, user=user)
scrobble = Scrobble.objects.create(
user=user,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person).count() == 0
@pytest.mark.django_db
def test_signal_works_with_different_users():
user1 = User.objects.create_user(username="user1", password="testpass123")
user2 = User.objects.create_user(username="user2", password="testpass123")
person = Person.objects.create(name="Grace", created_by=user1, user=user1)
scrobble1 = Scrobble.objects.create(
user=user1,
log={"with_people_ids": [person.id]},
)
scrobble2 = Scrobble.objects.create(
user=user2,
log={"with_people_ids": [person.id]},
)
assert PersonScrobble.objects.filter(person=person, user=user1).count() == 0
assert PersonScrobble.objects.filter(person=person, user=user2).count() == 1
@pytest.mark.django_db
def test_person_get_scrobble_count():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Henry", created_by=creator, user=creator)
Scrobble.objects.create(user=other_user, log={"with_people_ids": [person.id]})
Scrobble.objects.create(user=other_user, log={"with_people_ids": [person.id]})
Scrobble.objects.create(user=other_user, log={})
assert person.get_scrobble_count(user=other_user) == 2
@pytest.mark.django_db
def test_person_get_scrobbles():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Iris", created_by=creator, user=creator)
scrobble1 = Scrobble.objects.create(
user=other_user, log={"with_people_ids": [person.id]}
)
scrobble2 = Scrobble.objects.create(
user=other_user, log={"with_people_ids": [person.id]}
)
Scrobble.objects.create(user=other_user, log={})
scrobbles = person.get_scrobbles(user=other_user)
assert scrobbles.count() == 2
assert scrobble1 in scrobbles
assert scrobble2 in scrobbles
@pytest.mark.django_db
def test_person_scrobble_unique_together():
creator = User.objects.create_user(username="creator", password="testpass123")
other_user = User.objects.create_user(username="other", password="testpass123")
person = Person.objects.create(name="Jack", created_by=creator, user=creator)
scrobble = Scrobble.objects.create(
user=other_user,
log={
"with_people_ids": [person.id],
"players": [{"person_id": person.id}],
},
)
assert PersonScrobble.objects.filter(person=person, scrobble=scrobble).count() == 1

View File

@ -1,7 +1,8 @@
from django.views import generic
from django.urls import reverse_lazy
from django.db.models import Count, Q
from people.models import Person
from people.models import Person, PersonScrobble
class PersonCreateUpdateView(generic.CreateView):
@ -24,9 +25,16 @@ class PersonCreateUpdateView(generic.CreateView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["people"] = Person.objects.filter(
created_by=self.request.user
).order_by("-scrobble_count", "name")
context_data["people"] = (
Person.objects.filter(created_by=self.request.user)
.annotate(
scrobble_count=Count(
"scrobble_associations",
filter=Q(scrobble_associations__user=self.request.user),
)
)
.order_by("-scrobble_count", "name")
)
return context_data
def form_valid(self, form):
@ -49,7 +57,14 @@ class PersonUpdateView(generic.UpdateView):
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["people"] = Person.objects.filter(
created_by=self.request.user
).order_by("-scrobble_count", "name")
context_data["people"] = (
Person.objects.filter(created_by=self.request.user)
.annotate(
scrobble_count=Count(
"scrobble_associations",
filter=Q(scrobble_associations__user=self.request.user),
)
)
.order_by("-scrobble_count", "name")
)
return context_data