From 5aa89b7e0ad29ae0a4ef4ad3169b0ac35cb21d56 Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Sat, 4 Jul 2026 01:16:14 -0400 Subject: [PATCH] [boardgames] Add idea of board game variants --- PROJECT.org | 40 ++++++- vrobbler/apps/boardgames/admin.py | 14 +++ vrobbler/apps/boardgames/api/serializers.py | 6 + vrobbler/apps/boardgames/api/views.py | 6 + .../apps/boardgames/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/convert_board_to_variants.py | 63 +++++++++++ .../migrations/0016_boardgamevariant.py | 62 ++++++++++ vrobbler/apps/boardgames/models.py | 36 +++++- vrobbler/apps/boardgames/tests/__init__.py | 0 vrobbler/apps/boardgames/tests/test_models.py | 65 +++++++++++ vrobbler/apps/boardgames/tests/test_utils.py | 106 ++++++++++++++++++ vrobbler/apps/boardgames/utils.py | 37 ++++++ vrobbler/apps/scrobbles/scrobblers.py | 5 +- vrobbler/settings.py | 8 +- vrobbler/urls.py | 2 + 16 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 vrobbler/apps/boardgames/management/__init__.py create mode 100644 vrobbler/apps/boardgames/management/commands/__init__.py create mode 100644 vrobbler/apps/boardgames/management/commands/convert_board_to_variants.py create mode 100644 vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py create mode 100644 vrobbler/apps/boardgames/tests/__init__.py create mode 100644 vrobbler/apps/boardgames/tests/test_models.py create mode 100644 vrobbler/apps/boardgames/tests/test_utils.py diff --git a/PROJECT.org b/PROJECT.org index 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.py index 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.py index 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.BoardGame diff --git a/vrobbler/apps/boardgames/api/views.py b/vrobbler/apps/boardgames/api/views.py index 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.BoardGameSerializer diff --git a/vrobbler/apps/boardgames/management/__init__.py b/vrobbler/apps/boardgames/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/boardgames/management/commands/__init__.py b/vrobbler/apps/boardgames/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/boardgames/management/commands/convert_board_to_variants.py b/vrobbler/apps/boardgames/management/commands/convert_board_to_variants.py new file mode 100644 index 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.py new file mode 100644 index 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.py index 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'
{self.board}
') + if self.variants: + variant_names = ", ".join(v.name for v in self.variants) + html_parts.append( + f'
Variants: {variant_names}
' + ) + if self.location: html_parts.append(f'
{self.location}
') @@ -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__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/boardgames/tests/test_models.py b/vrobbler/apps/boardgames/tests/test_models.py new file mode 100644 index 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() == 0 diff --git a/vrobbler/apps/boardgames/tests/test_utils.py b/vrobbler/apps/boardgames/tests/test_utils.py new file mode 100644 index 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.py index 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_ids diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 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.py index 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.py index 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)