This commit has no recorded session.
diff
commit 5aa89b7e0ad29ae0a4ef4ad3169b0ac35cb21d56Author: Colin Powell <colin@unbl.ink>Date: Sat Jul 4 01:16:14 2026 -0400 [boardgames] Add idea of board game variants@@ -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:@@ -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"@@ -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.BoardGame@@ -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.BoardGameSerializer@@ -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)"+ )@@ -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,+ },+ ),+ ]@@ -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"))@@ -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() == 0@@ -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"]@@ -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_ids@@ -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", []):@@ -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)@@ -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)