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)