sessions / 5aa89b7e0ad29ae0a4ef4ad3169b0ac35cb21d56

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit 5aa89b7e0ad29ae0a4ef4ad3169b0ac35cb21d56Author: Colin Powell <colin@unbl.ink>Date:   Sat Jul 4 01:16:14 2026 -0400    [boardgames] Add idea of board game variantsdiff --git a/PROJECT.org b/PROJECT.orgindex 2878cf4..c8510b4 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/23] :vrobbler:project:personal:+* Backlog [1/26] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES:@@ -604,6 +604,44 @@ a helper method to create board game scrobbles given a json blob. It's independent of the email flow it was originally creatdd for  ** TODO [#B] Is there way to create unique slugs for media instances :media_types:+** DONE [#A] Add BoardGameVariant model :boardgames:+:PROPERTIES:+:ID:       0ffb20d5-252f-b13d-473d-5529014602ff+:END:++*** Description++Variants represent unique boards being used per scrobble or scenarios when+playing a game. Scrobbles of a board game may have one or more+boardgame_variant_ids assocaited with their log data, and a variant is created+for one specific board game.+++** TODO [#A] Lookup all Expansions for a game when creating it :boardgames:++*** Description++We don't want to blow up the BGG API, but if possible with not too+many calls, when we scrobble a board game, in order to allow+populating the "Expansions" multi select, we should fetch any +expansions for the board game when creating it for the first time.++We should also create a managemnt script to update existing board games.++** TODO [#A] Update how board game scrobbles work :boardgames:++*** Description++When we scrobble a board game from a BGG URL, instead of going to the media+detail page, we should go to the scrobble detail page, with the Edit Log form+expanded by default.++The Edit log form should have from top to bottom:++  - Board/Variant (one or many BoardGameVariant in a multi-select widget)+  - People (which should be similar to the Bird widget on BirdLocation and allow setting per user score, win true/false, rank, new true/false, seat_ordrer)+  - Expansion ids (which should a multi-select widget of expansions for this game)+  - Location (which should be a drop down of BoardGameLocations for this user)  * Version 58.8 [1/1] ** DONE [#B] Clean up trend templates :trends:templates:diff --git a/vrobbler/apps/boardgames/admin.py b/vrobbler/apps/boardgames/admin.pyindex 207dfb3..cee2ada 100644--- a/vrobbler/apps/boardgames/admin.py+++ b/vrobbler/apps/boardgames/admin.py@@ -5,6 +5,7 @@ from boardgames.models import (     BoardGameLocation,     BoardGamePublisher,     BoardGameDesigner,+    BoardGameVariant, )  from scrobbles.admin import ScrobbleInline@@ -42,6 +43,19 @@ class BoardGameLocationAdmin(admin.ModelAdmin):     ordering = ("-created",)  +@admin.register(BoardGameVariant)+class BoardGameVariantAdmin(admin.ModelAdmin):+    date_hierarchy = "created"+    list_display = (+        "name",+        "board_game",+        "uuid",+    )+    raw_id_fields = ("board_game",)+    search_fields = ("name", "board_game__title")+    ordering = ("-created",)++ @admin.register(BoardGame) class BoardGameAdmin(admin.ModelAdmin):     date_hierarchy = "created"diff --git a/vrobbler/apps/boardgames/api/serializers.py b/vrobbler/apps/boardgames/api/serializers.pyindex 3d6570a..336da64 100644--- a/vrobbler/apps/boardgames/api/serializers.py+++ b/vrobbler/apps/boardgames/api/serializers.py@@ -20,6 +20,12 @@ class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):         fields = "__all__"  +class BoardGameVariantSerializer(serializers.HyperlinkedModelSerializer):+    class Meta:+        model = models.BoardGameVariant+        fields = "__all__"++ class BoardGameSerializer(serializers.HyperlinkedModelSerializer):     class Meta:         model = models.BoardGamediff --git a/vrobbler/apps/boardgames/api/views.py b/vrobbler/apps/boardgames/api/views.pyindex dea0500..d11d81d 100644--- a/vrobbler/apps/boardgames/api/views.py+++ b/vrobbler/apps/boardgames/api/views.py@@ -22,6 +22,12 @@ class BoardGameLocationViewSet(viewsets.ModelViewSet):     permission_classes = [permissions.IsAuthenticated]  +class BoardGameVariantViewSet(viewsets.ModelViewSet):+    queryset = models.BoardGameVariant.objects.all().order_by("-created")+    serializer_class = serializers.BoardGameVariantSerializer+    permission_classes = [permissions.IsAuthenticated]++ class BoardGameViewSet(viewsets.ModelViewSet):     queryset = models.BoardGame.objects.all().order_by("-created")     serializer_class = serializers.BoardGameSerializerdiff --git a/vrobbler/apps/boardgames/management/__init__.py b/vrobbler/apps/boardgames/management/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/boardgames/management/commands/__init__.py b/vrobbler/apps/boardgames/management/commands/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/boardgames/management/commands/convert_board_to_variants.py b/vrobbler/apps/boardgames/management/commands/convert_board_to_variants.pynew file mode 100644index 0000000..da28df8--- /dev/null+++ b/vrobbler/apps/boardgames/management/commands/convert_board_to_variants.py@@ -0,0 +1,63 @@+import logging++from django.core.management.base import BaseCommand++from boardgames.utils import board_names_to_variants+from scrobbles.models import Scrobble++logger = logging.getLogger(__name__)+++class Command(BaseCommand):+    help = "Convert existing board scrobble log 'board' keys to 'variant_ids'"++    def add_arguments(self, parser):+        parser.add_argument(+            "--commit",+            action="store_true",+            help="Persist changes to the database",+        )++    def handle(self, *args, **options):+        commit = options.get("commit", False)++        board_scrobbles = Scrobble.objects.filter(+            board_game__isnull=False,+            log__board__isnull=False,+        ).exclude(log__board="")++        total = board_scrobbles.count()+        self.stdout.write(f"Found {total} scrobbles with a 'board' key in log data")++        if total == 0:+            return++        updated = 0+        for scrobble in board_scrobbles.iterator(chunk_size=100):+            log = scrobble.log+            board_value = log.pop("board", None)+            if not board_value:+                continue++            variant_ids = board_names_to_variants(+                scrobble.board_game, [board_value]+            )+            if variant_ids:+                log["variant_ids"] = variant_ids++            if commit:+                Scrobble.objects.filter(pk=scrobble.pk).update(log=log)+                updated += 1+            else:+                updated += 1++        if commit:+            self.stdout.write(+                self.style.SUCCESS(+                    f"Updated {updated} scrobbles (changes committed)"+                )+            )+        else:+            self.stdout.write(+                f"Would update {updated} scrobbles (pass --commit to persist)"+            )diff --git a/vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py b/vrobbler/apps/boardgames/migrations/0016_boardgamevariant.pynew file mode 100644index 0000000..a9ac0e1--- /dev/null+++ b/vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py@@ -0,0 +1,62 @@+# Generated by Django 4.2.29 on 2026-07-02 22:23++from django.db import migrations, models+import django.db.models.deletion+import django_extensions.db.fields+import uuid+++class Migration(migrations.Migration):++    dependencies = [+        ("boardgames", "0015_alter_boardgame_genre"),+    ]++    operations = [+        migrations.CreateModel(+            name="BoardGameVariant",+            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"+                    ),+                ),+                ("name", models.CharField(max_length=255)),+                ("description", models.TextField(blank=True, null=True)),+                (+                    "uuid",+                    models.UUIDField(+                        blank=True, default=uuid.uuid4, editable=False, null=True+                    ),+                ),+                (+                    "board_game",+                    models.ForeignKey(+                        on_delete=django.db.models.deletion.CASCADE,+                        related_name="variants",+                        to="boardgames.boardgame",+                    ),+                ),+            ],+            options={+                "get_latest_by": "modified",+                "abstract": False,+            },+        ),+    ]diff --git a/vrobbler/apps/boardgames/models.py b/vrobbler/apps/boardgames/models.pyindex debae3d..0e9a7da 100644--- a/vrobbler/apps/boardgames/models.py+++ b/vrobbler/apps/boardgames/models.py@@ -76,6 +76,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):     rated: Optional[str] = None     speed: Optional[str] = None     variant: Optional[str] = None+    variant_ids: Optional[list[int]] = None     lichess_id: Optional[int] = None     board: Optional[str] = None     rounds: Optional[int] = None@@ -106,10 +107,23 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):                 required=False,                 widget=forms.Select(),             ),+            "variant_ids": forms.ModelMultipleChoiceField(+                queryset=BoardGameVariant.objects.all(),+                required=False,+                widget=forms.SelectMultiple(attrs={"size": 5}),+            ),         }         fields.update(custom_fields)         return fields +    @cached_property+    def variants(self) -> list["BoardGameVariant"]:+        if not self.variant_ids:+            return []+        return list(+            BoardGameVariant.objects.filter(id__in=self.variant_ids)+        )+     @cached_property     def location(self):         if not self.location_id:@@ -135,6 +149,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):         if self.board:             html_parts.append(f'<div class="boardgame-board">{self.board}</div>') +        if self.variants:+            variant_names = ", ".join(v.name for v in self.variants)+            html_parts.append(+                f'<div class="boardgame-variants">Variants: {variant_names}</div>'+            )+         if self.location:             html_parts.append(f'<div class="boardgame-location">{self.location}</div>') @@ -352,7 +372,21 @@ class BoardGame(ScrobblableMixin):                 "Board game exists in database.",                 extra={"lookup_id": lookup_id, "data": data},             )-            return game+        return game+++class BoardGameVariant(TimeStampedModel):+    name = models.CharField(max_length=255)+    board_game = models.ForeignKey(+        BoardGame,+        on_delete=models.CASCADE,+        related_name="variants",+    )+    description = models.TextField(**BNULL)+    uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)++    def __str__(self) -> str:+        return f"{self.name} ({self.board_game.title})"          if data.get("bggId"):             bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))diff --git a/vrobbler/apps/boardgames/tests/__init__.py b/vrobbler/apps/boardgames/tests/__init__.pynew file mode 100644index 0000000..e69de29diff --git a/vrobbler/apps/boardgames/tests/test_models.py b/vrobbler/apps/boardgames/tests/test_models.pynew file mode 100644index 0000000..c4a2134--- /dev/null+++ b/vrobbler/apps/boardgames/tests/test_models.py@@ -0,0 +1,65 @@+import pytest+from django.contrib.auth import get_user_model++from boardgames.models import BoardGame, BoardGameVariant++User = get_user_model()+++@pytest.mark.django_db+def test_board_game_variant_creation():+    game = BoardGame.objects.create(title="Test Game")+    variant = BoardGameVariant.objects.create(+        name="Test Variant",+        board_game=game,+        description="A test variant",+    )+    assert variant.name == "Test Variant"+    assert variant.board_game == game+    assert variant.description == "A test variant"+    assert variant.uuid is not None+++@pytest.mark.django_db+def test_board_game_variant_str():+    game = BoardGame.objects.create(title="Test Game")+    variant = BoardGameVariant.objects.create(+        name="Test Variant",+        board_game=game,+    )+    assert str(variant) == "Test Variant (Test Game)"+++@pytest.mark.django_db+def test_board_game_variant_optional_description():+    game = BoardGame.objects.create(title="Test Game")+    variant = BoardGameVariant.objects.create(+        name="Test Variant",+        board_game=game,+    )+    assert variant.description is None+++@pytest.mark.django_db+def test_board_game_variant_related_name():+    game = BoardGame.objects.create(title="Test Game")+    variant1 = BoardGameVariant.objects.create(+        name="Variant 1",+        board_game=game,+    )+    variant2 = BoardGameVariant.objects.create(+        name="Variant 2",+        board_game=game,+    )+    assert list(game.variants.all()) == [variant1, variant2]+++@pytest.mark.django_db+def test_board_game_variant_cascade_delete():+    game = BoardGame.objects.create(title="Test Game")+    variant = BoardGameVariant.objects.create(+        name="Test Variant",+        board_game=game,+    )+    game.delete()+    assert BoardGameVariant.objects.count() == 0diff --git a/vrobbler/apps/boardgames/tests/test_utils.py b/vrobbler/apps/boardgames/tests/test_utils.pynew file mode 100644index 0000000..7d56780--- /dev/null+++ b/vrobbler/apps/boardgames/tests/test_utils.py@@ -0,0 +1,106 @@+import pytest+from django.contrib.auth import get_user_model++from boardgames.models import BoardGame, BoardGameVariant+from boardgames.utils import board_names_to_variants+from scrobbles.models import Scrobble++User = get_user_model()+++@pytest.mark.django_db+def test_board_names_to_variants_creates_variant():+    game = BoardGame.objects.create(title="Test Game")+    ids = board_names_to_variants(game, ["Map A"])+    assert len(ids) == 1+    variant = BoardGameVariant.objects.get(id=ids[0])+    assert variant.name == "Map A"+    assert variant.board_game == game+++@pytest.mark.django_db+def test_board_names_to_variants_reuses_existing():+    game = BoardGame.objects.create(title="Test Game")+    existing = BoardGameVariant.objects.create(+        name="Map A", board_game=game+    )+    ids = board_names_to_variants(game, ["Map A"])+    assert len(ids) == 1+    assert ids[0] == existing.id+++@pytest.mark.django_db+def test_board_names_to_variants_multiple_names():+    game = BoardGame.objects.create(title="Test Game")+    ids = board_names_to_variants(game, ["Map A", "Map B"])+    assert len(ids) == 2+    names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))+    assert names == {"Map A", "Map B"}+++@pytest.mark.django_db+def test_board_names_to_variants_splits_fullwidth_slash():+    game = BoardGame.objects.create(title="Test Game")+    ids = board_names_to_variants(game, ["Map A/Map B"])+    assert len(ids) == 2+    names = set(BoardGameVariant.objects.filter(id__in=ids).values_list("name", flat=True))+    assert names == {"Map A", "Map B"}+++@pytest.mark.django_db+def test_board_names_to_variants_skips_empty_parts():+    game = BoardGame.objects.create(title="Test Game")+    ids = board_names_to_variants(game, ["/Map A/"])+    assert len(ids) == 1+    assert BoardGameVariant.objects.get(id=ids[0]).name == "Map A"+++@pytest.mark.django_db+def test_board_names_to_variants_different_games_independent():+    game1 = BoardGame.objects.create(title="Game 1")+    game2 = BoardGame.objects.create(title="Game 2")+    ids1 = board_names_to_variants(game1, ["Map A"])+    ids2 = board_names_to_variants(game2, ["Map A"])+    assert ids1 != ids2+    assert BoardGameVariant.objects.count() == 2+++@pytest.mark.django_db+def test_management_command_dry_run(capsys):+    from django.core.management import call_command++    game = BoardGame.objects.create(title="Test Game")+    user = User.objects.create(username="tester")+    scrobble = Scrobble.objects.create(+        user=user,+        board_game=game,+        log={"board": "Map A"},+    )++    call_command("convert_board_to_variants")+    captured = capsys.readouterr()+    assert "Would update 1 scrobbles" in captured.out++    scrobble.refresh_from_db()+    assert "board" in scrobble.log+++@pytest.mark.django_db+def test_management_command_commit():+    from django.core.management import call_command++    game = BoardGame.objects.create(title="Test Game")+    user = User.objects.create(username="tester")+    scrobble = Scrobble.objects.create(+        user=user,+        board_game=game,+        log={"board": "Map A"},+    )++    call_command("convert_board_to_variants", commit=True)++    scrobble.refresh_from_db()+    assert "board" not in scrobble.log+    assert "variant_ids" in scrobble.log+    variant = BoardGameVariant.objects.get(board_game=game, name="Map A")+    assert variant.id in scrobble.log["variant_ids"]diff --git a/vrobbler/apps/boardgames/utils.py b/vrobbler/apps/boardgames/utils.pyindex e69de29..b5ad499 100644--- a/vrobbler/apps/boardgames/utils.py+++ b/vrobbler/apps/boardgames/utils.py@@ -0,0 +1,37 @@+import logging++from boardgames.models import BoardGame, BoardGameVariant++logger = logging.getLogger(__name__)+++def board_names_to_variants(+    board_game: BoardGame, board_names: list[str]+) -> list[int]:+    """Given a board game and a list of board/scenario names, find or create+    BoardGameVariant records and return their IDs.++    Splits each name on the full-width slash ``/`` so that a single field+    containing ``Map A/Map B`` produces two separate variants.+    """+    variant_ids: list[int] = []+    for raw_name in board_names:+        for part in raw_name.split("/"):+            name = part.strip()+            if not name:+                continue+            variant, was_created = BoardGameVariant.objects.get_or_create(+                board_game=board_game,+                name=name,+            )+            logger.debug(+                "Resolved board variant",+                extra={+                    "board_game_id": board_game.id,+                    "variant_name": name,+                    "variant_id": variant.id,+                    "was_created": was_created,+                },+            )+            variant_ids.append(variant.id)+    return variant_idsdiff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex 0329645..88e6dce 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -8,6 +8,7 @@ import pytz import requests from beers.models import Beer from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation+from boardgames.utils import board_names_to_variants from books.constants import READCOMICSONLINE_URL from books.models import Book, BookLogData, BookPageLogData, Paper from books.utils import parse_readcomicsonline_uri@@ -495,7 +496,9 @@ def email_scrobble_board_game(         if play_dict.get("rounds", False):             log_data["rounds"] = play_dict.get("rounds")         if play_dict.get("board", False):-            log_data["board"] = play_dict.get("board")+            log_data["variant_ids"] = board_names_to_variants(+                base_game, [play_dict.get("board")]+            )          log_data["players"] = []         for score_dict in play_dict.get("playerScores", []):diff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex fd6f00c..5dd15c2 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -103,7 +103,13 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" TIME_ZONE = os.getenv("VROBBLER_TIME_ZONE", "America/New_York")  ALLOWED_HOSTS = ["*"]-CSRF_TRUSTED_ORIGINS = [os.getenv("VROBBLER_TRUSTED_ORIGINS", "http://localhost:8000")]+CSRF_TRUSTED_ORIGINS = [+    os.getenv(+        "VROBBLER_TRUSTED_ORIGINS",+        "http://localhost:8000",+    ),+    "https://dev-vrobbler.lab.unbl.ink",+] X_FRAME_OPTIONS = "SAMEORIGIN"  REDIS_URL = os.getenv("VROBBLER_REDIS_URL", None)diff --git a/vrobbler/urls.py b/vrobbler/urls.pyindex 0c90d6f..5ee8ef9 100644--- a/vrobbler/urls.py+++ b/vrobbler/urls.py@@ -16,6 +16,7 @@ from vrobbler.apps.boardgames.api.views import (     BoardGameDesignerViewSet,     BoardGamePublisherViewSet,     BoardGameLocationViewSet,+    BoardGameVariantViewSet, )  from vrobbler.apps.books import urls as book_urls@@ -146,6 +147,7 @@ router.register(r"boardgames", BoardGameViewSet) router.register(r"boardgame-designers", BoardGameDesignerViewSet) router.register(r"boardgame-publishers", BoardGamePublisherViewSet) router.register(r"boardgame-locations", BoardGameLocationViewSet)+router.register(r"boardgame-variants", BoardGameVariantViewSet) router.register(r"podcast-producers", ProducerViewSet) router.register(r"podcast-episodes", PodcastEpisodeViewSet) router.register(r"podcasts", PodcastViewSet)