Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28a53d70eb | |||
| 98d4e8bacb | |||
| 8f97131b8d | |||
| 7c1f709f96 | |||
| 4b005e0e5b | |||
| d5dd63be0d | |||
| 5aa89b7e0a |
55
PROJECT.org
55
PROJECT.org
@ -88,7 +88,7 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/23] :vrobbler:project:personal:
|
||||
* Backlog [0/24] :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,59 @@ 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:
|
||||
** 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 59.1 [1/1]
|
||||
** DONE Fix bug when expansions have no image :boardgames:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 9fee96c9-c6a0-32d9-b6f8-212c60fc3540
|
||||
:END:
|
||||
|
||||
* Version 59.0 [3/3]
|
||||
** 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.
|
||||
|
||||
|
||||
** DONE [#A] Lookup all Expansions for a game when creating it :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 8a84b06d-555c-4701-9058-ff364c89c198
|
||||
:END:
|
||||
|
||||
*** 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.
|
||||
|
||||
** DONE [#A] Board game expansion lookup should be async on URL scrobbles :boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 968d8dde-f906-cdf0-af4e-b87ce28ddbbb
|
||||
:END:
|
||||
|
||||
* Version 58.8 [1/1]
|
||||
** DONE [#B] Clean up trend templates :trends:templates:
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "58.8"
|
||||
version = "59.1"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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
vrobbler/apps/boardgames/management/__init__.py
Normal file
0
vrobbler/apps/boardgames/management/__init__.py
Normal 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)"
|
||||
)
|
||||
@ -0,0 +1,78 @@
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.sources.bgg import lookup_boardgame_from_bgg
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch and link expansions for existing board games from BGG"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Persist changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bggeek-id",
|
||||
type=str,
|
||||
help="Only process a single game by BGG ID",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options.get("commit", False)
|
||||
bggeek_id = options.get("bggeek_id")
|
||||
|
||||
games = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(
|
||||
bggeek_id=""
|
||||
)
|
||||
if bggeek_id:
|
||||
games = games.filter(bggeek_id=bggeek_id)
|
||||
|
||||
total = games.count()
|
||||
self.stdout.write(f"Found {total} board games with BGG IDs")
|
||||
|
||||
if total == 0:
|
||||
return
|
||||
|
||||
updated = 0
|
||||
for game in games.iterator(chunk_size=100):
|
||||
try:
|
||||
data = lookup_boardgame_from_bgg(lookup_id=str(game.bggeek_id))
|
||||
except Exception as e:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(
|
||||
f"Failed to fetch BGG data for {game.title} ({game.bggeek_id}): {e}"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
expansions = data.get("expansions", [])
|
||||
if not expansions:
|
||||
continue
|
||||
|
||||
if commit:
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
updated += 1
|
||||
self.stdout.write(
|
||||
f" Linked {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" Would link {len(expansions)} expansions to {game.title}"
|
||||
)
|
||||
updated += 1
|
||||
|
||||
if commit:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {updated} games (changes committed)")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f"Would update {updated} games (pass --commit to persist)"
|
||||
)
|
||||
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal file
62
vrobbler/apps/boardgames/migrations/0016_boardgamevariant.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -71,11 +71,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
difficulty: Optional[int] = None
|
||||
solo: Optional[bool] = None
|
||||
two_handed: Optional[bool] = None
|
||||
expansion_ids: Optional[int] = None
|
||||
expansion_ids: Optional[list[int]] = None
|
||||
moves: Optional[list] = None
|
||||
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,30 @@ 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}),
|
||||
),
|
||||
"expansion_ids": forms.ModelMultipleChoiceField(
|
||||
queryset=BoardGame.objects.filter(
|
||||
expansion_for_boardgame__isnull=False
|
||||
),
|
||||
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 +156,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>')
|
||||
|
||||
@ -306,18 +333,28 @@ class BoardGame(ScrobblableMixin):
|
||||
if not data:
|
||||
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
|
||||
|
||||
cover_url = data.pop("cover_url")
|
||||
year = data.pop("year_published")
|
||||
publisher_name = data.pop("publisher_name")
|
||||
expansions = data.pop("expansions", [])
|
||||
cover_url = data.pop("cover_url", "")
|
||||
year_published = data.pop("year_published", None)
|
||||
if year_published is None:
|
||||
year_published = data.pop("published_year", None)
|
||||
publisher_name = data.pop("publisher_name", "")
|
||||
|
||||
if year:
|
||||
data["published_year"] = int(year)
|
||||
if year_published:
|
||||
data["published_year"] = int(year_published)
|
||||
|
||||
if not data["min_players"]:
|
||||
data.pop("min_players")
|
||||
if not data["min_players"]:
|
||||
data.pop("max_players")
|
||||
|
||||
# Pop extra BGG metadata that isn't a model field
|
||||
data.pop("mechanics", None)
|
||||
data.pop("categories", None)
|
||||
data.pop("designers", None)
|
||||
data.pop("publishers", None)
|
||||
data.pop("publisher", None)
|
||||
|
||||
# Fun trick for updating all fields at once
|
||||
BoardGame.objects.filter(pk=self.id).update(**data)
|
||||
self.refresh_from_db()
|
||||
@ -333,6 +370,10 @@ class BoardGame(ScrobblableMixin):
|
||||
if cover_url and not self.cover:
|
||||
self.save_image_from_url(cover_url)
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(self, expansions)
|
||||
|
||||
def save_image_from_url(self, url):
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
@ -341,7 +382,12 @@ class BoardGame(ScrobblableMixin):
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
@classmethod
|
||||
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
|
||||
def find_or_create(
|
||||
cls,
|
||||
lookup_id: str,
|
||||
data: dict[str, Any] = {},
|
||||
defer_expansions: bool = False,
|
||||
) -> "BoardGame":
|
||||
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
|
||||
game = cls.objects.filter(bggeek_id=lookup_id).first()
|
||||
if not game:
|
||||
@ -364,16 +410,18 @@ class BoardGame(ScrobblableMixin):
|
||||
else:
|
||||
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
|
||||
|
||||
expansions = bgg_data.pop("expansions", [])
|
||||
mechanics = bgg_data.pop("mechanics", [])
|
||||
designers = bgg_data.pop("designers", [])
|
||||
categories = bgg_data.pop("categories", [])
|
||||
publishers = bgg_data.pop("publishers", [])
|
||||
publisher = bgg_data.pop("publisher", [])
|
||||
cover_url = bgg_data.pop("cover_url")
|
||||
cover_url = bgg_data.pop("cover_url") or ""
|
||||
|
||||
game = cls.objects.create(**bgg_data)
|
||||
|
||||
game.save_image_from_url(cover_url)
|
||||
if cover_url:
|
||||
game.save_image_from_url(cover_url)
|
||||
game.cooperative = data.get("cooperative", False)
|
||||
game.highest_wins = data.get("highestWins", True)
|
||||
game.no_points = data.get("noPoints", False)
|
||||
@ -396,4 +444,27 @@ class BoardGame(ScrobblableMixin):
|
||||
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
|
||||
game.publishers.add(publisher)
|
||||
|
||||
if defer_expansions:
|
||||
from boardgames.tasks import fetch_board_game_expansions
|
||||
|
||||
fetch_board_game_expansions.delay(game.id, expansions)
|
||||
else:
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, expansions)
|
||||
|
||||
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})"
|
||||
|
||||
@ -36,5 +36,8 @@ def lookup_boardgame_from_bgg(
|
||||
game_dict["publishers"] = game.publishers
|
||||
if game.publishers:
|
||||
game_dict["publisher"] = game.publishers[0]
|
||||
game_dict["expansions"] = [
|
||||
{"id": exp.id, "name": exp.name} for exp in game.expansions
|
||||
]
|
||||
|
||||
return game_dict
|
||||
|
||||
29
vrobbler/apps/boardgames/tasks.py
Normal file
29
vrobbler/apps/boardgames/tasks.py
Normal file
@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task
|
||||
def fetch_board_game_expansions(board_game_id, expansions_data):
|
||||
from boardgames.models import BoardGame
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
game = BoardGame.objects.filter(id=board_game_id).first()
|
||||
if not game:
|
||||
logger.warning(
|
||||
"Board game not found for expansion linking",
|
||||
extra={"board_game_id": board_game_id},
|
||||
)
|
||||
return
|
||||
|
||||
fetch_and_link_expansions(game, expansions_data)
|
||||
logger.info(
|
||||
"Linked expansions for board game",
|
||||
extra={
|
||||
"board_game_id": board_game_id,
|
||||
"title": game.title,
|
||||
"count": len(expansions_data),
|
||||
},
|
||||
)
|
||||
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
0
vrobbler/apps/boardgames/tests/__init__.py
Normal file
225
vrobbler/apps/boardgames/tests/test_models.py
Normal file
225
vrobbler/apps/boardgames/tests/test_models.py
Normal file
@ -0,0 +1,225 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _mock_bgg_game(bggeek_id, title, expansions=None):
|
||||
"""Build a fake BGG game object shape used by lookup_boardgame_from_bgg."""
|
||||
class FakeGame:
|
||||
id = bggeek_id
|
||||
name = title
|
||||
description = f"Description of {title}"
|
||||
yearpublished = 2020
|
||||
image = "https://example.com/cover.jpg"
|
||||
minplayers = 1
|
||||
maxplayers = 4
|
||||
minage = 8
|
||||
rating_average = 7.5
|
||||
bgg_rank = 100
|
||||
playingtime = 60
|
||||
mechanics = []
|
||||
categories = []
|
||||
designers = []
|
||||
publishers = []
|
||||
|
||||
@property
|
||||
def expansions(self):
|
||||
if expansions is None:
|
||||
return []
|
||||
return expansions
|
||||
|
||||
return FakeGame()
|
||||
|
||||
|
||||
def _mock_bgg_client(return_game):
|
||||
"""Return a callable that creates a fake BGGClient instance."""
|
||||
class FakeBGGClient:
|
||||
def __init__(self, access_token=None):
|
||||
pass
|
||||
|
||||
def game(self, game_id=None, name=None):
|
||||
return return_game
|
||||
|
||||
return FakeBGGClient()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_links_expansions(mock_bgg, mock_get):
|
||||
exp1 = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp2 = type("Thing", (), {"id": 201, "name": "Expansion 2"})()
|
||||
base_game = _mock_bgg_game("100", "Base Game", expansions=[exp1, exp2])
|
||||
exp1_game = _mock_bgg_game("200", "Expansion 1")
|
||||
exp2_game = _mock_bgg_game("201", "Expansion 2")
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_game),
|
||||
_mock_bgg_client(exp1_game),
|
||||
_mock_bgg_client(exp2_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game = BoardGame.find_or_create("100")
|
||||
assert game.title == "Base Game"
|
||||
|
||||
expansions = BoardGame.objects.filter(expansion_for_boardgame=game)
|
||||
assert expansions.count() == 2
|
||||
assert {e.title for e in expansions} == {"Expansion 1", "Expansion 2"}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_find_or_create_skips_expansions_for_existing_game(mock_bgg):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
mock_bgg.assert_not_called()
|
||||
|
||||
result = BoardGame.find_or_create("100")
|
||||
assert result == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fetch_and_link_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
mock_bgg.side_effect = [_mock_bgg_client(exp_game)]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
from boardgames.utils import fetch_and_link_expansions
|
||||
|
||||
fetch_and_link_expansions(game, [{"id": 200, "name": "Expansion 1"}])
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_fix_metadata_links_expansions(mock_bgg, mock_get):
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
# first call = fix_metadata -> lookup_boardgame_from_bgg(lookup_id='100')
|
||||
# second call = find_or_create inside fetch_and_link_expansions for expansion
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
game.fix_metadata(force_update=True)
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_dry_run(mock_bgg, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [_mock_bgg_client(base_with_exp)]
|
||||
|
||||
call_command("fetch_expansions")
|
||||
captured = capsys.readouterr()
|
||||
assert "Would link 1 expansions" in captured.out
|
||||
|
||||
assert BoardGame.objects.filter(bggeek_id="200").count() == 0
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("boardgames.models.requests.get")
|
||||
@patch("boardgames.sources.bgg.BGGClient")
|
||||
def test_management_command_fetch_expansions_commit(mock_bgg, mock_get, capsys):
|
||||
from django.core.management import call_command
|
||||
|
||||
game = BoardGame.objects.create(title="Base Game", bggeek_id="100")
|
||||
exp_thing = type("Thing", (), {"id": 200, "name": "Expansion 1"})()
|
||||
exp_game = _mock_bgg_game("200", "Expansion 1")
|
||||
base_with_exp = _mock_bgg_game("100", "Base Game", expansions=[exp_thing])
|
||||
mock_bgg.side_effect = [
|
||||
_mock_bgg_client(base_with_exp),
|
||||
_mock_bgg_client(exp_game),
|
||||
]
|
||||
mock_get.return_value.status_code = 200
|
||||
mock_get.return_value.content = b"fake_image_data"
|
||||
|
||||
call_command("fetch_expansions", commit=True)
|
||||
captured = capsys.readouterr()
|
||||
assert "Updated 1 games" in captured.out
|
||||
|
||||
expansion = BoardGame.objects.get(bggeek_id="200")
|
||||
assert expansion.expansion_for_boardgame == game
|
||||
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal file
106
vrobbler/apps/boardgames/tests/test_utils.py
Normal 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 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,62 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
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
|
||||
|
||||
|
||||
def fetch_and_link_expansions(
|
||||
board_game: BoardGame, expansions_data: list[dict[str, Any]]
|
||||
) -> None:
|
||||
"""Given a board game and a list of expansion dicts (with 'id' and 'name'),
|
||||
find or create each expansion BoardGame and link it via expansion_for_boardgame.
|
||||
"""
|
||||
for exp_data in expansions_data:
|
||||
exp_id = exp_data.get("id")
|
||||
if not exp_id:
|
||||
continue
|
||||
expansion = BoardGame.find_or_create(str(exp_id))
|
||||
if expansion and expansion.id != board_game.id:
|
||||
expansion.expansion_for_boardgame = board_game
|
||||
expansion.save(update_fields=["expansion_for_boardgame"])
|
||||
logger.info(
|
||||
"Linked expansion to board game",
|
||||
extra={
|
||||
"board_game_id": board_game.id,
|
||||
"expansion_id": expansion.id,
|
||||
"expansion_name": expansion.title,
|
||||
},
|
||||
)
|
||||
|
||||
@ -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
|
||||
@ -370,7 +371,7 @@ def manual_scrobble_board_game(
|
||||
source: str = "BGG",
|
||||
action: Optional[str] = None,
|
||||
) -> Scrobble | None:
|
||||
boardgame = BoardGame.find_or_create(bggeek_id)
|
||||
boardgame = BoardGame.find_or_create(bggeek_id, defer_expansions=True)
|
||||
|
||||
if not boardgame:
|
||||
logger.error(f"No board game found for ID {bggeek_id}")
|
||||
@ -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", []):
|
||||
|
||||
@ -1235,6 +1235,15 @@ class ScrobbleDetailView(DetailView):
|
||||
def get_form_class(self):
|
||||
return self.object.media_obj.logdata_cls().form()
|
||||
|
||||
def _update_expansion_ids_queryset(self, form):
|
||||
from boardgames.models import BoardGame
|
||||
|
||||
if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields:
|
||||
expansions = BoardGame.objects.filter(
|
||||
expansion_for_boardgame=self.object.media_obj
|
||||
)
|
||||
form.fields["expansion_ids"].queryset = expansions
|
||||
|
||||
def get_form(self):
|
||||
FormClass = self.get_form_class()
|
||||
|
||||
@ -1245,12 +1254,15 @@ class ScrobbleDetailView(DetailView):
|
||||
else:
|
||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||
|
||||
return FormClass(initial=log)
|
||||
form = FormClass(initial=log)
|
||||
self._update_expansion_ids_queryset(form)
|
||||
return form
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
FormClass = self.get_form_class()
|
||||
form = FormClass(request.POST)
|
||||
self._update_expansion_ids_queryset(form)
|
||||
|
||||
if form.is_valid():
|
||||
data = form.cleaned_data.copy()
|
||||
@ -1263,6 +1275,12 @@ class ScrobbleDetailView(DetailView):
|
||||
if data.get("with_people_ids") is not None:
|
||||
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
|
||||
|
||||
if data.get("expansion_ids") is not None:
|
||||
data["expansion_ids"] = [e.id for e in data["expansion_ids"]]
|
||||
|
||||
if data.get("variant_ids") is not None:
|
||||
data["variant_ids"] = [v.id for v in data["variant_ids"]]
|
||||
|
||||
if data.get("mood_reason_ids") is not None:
|
||||
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user