[boardgames] Add idea of board game variants

This commit is contained in:
2026-07-04 01:16:14 -04:00
parent cf444e8dd4
commit 5aa89b7e0a
16 changed files with 446 additions and 4 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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)"
)

View File

@ -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,
},
),
]

View File

@ -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"))

View File

@ -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

View File

@ -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 AMap 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"]

View File

@ -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 AMap 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

View File

@ -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", []):

View File

@ -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)

View File

@ -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)