Compare commits

..

8 Commits
59.2 ... main

Author SHA1 Message Date
77d92f6c96 [release] Bump to version 59.5
All checks were successful
ci / test (push) Successful in 2m13s
ci / build-and-deploy (push) Successful in 38s
- Fix bug where all variants for board games are in form
2026-07-05 00:15:58 -04:00
d5d0eb6cd8 [boardgames] Make sure variants are filtered by game 2026-07-05 00:15:26 -04:00
d6f71e0761 [release] Bump to version 59.4
All checks were successful
ci / test (push) Successful in 2m14s
ci / build-and-deploy (push) Successful in 37s
- Fix bug in fetching expansions for board games
- Board games should have genres extracted from family data
2026-07-04 11:53:28 -04:00
b00ebf49dd [boardgames] Fix getting BGG id 2026-07-04 11:53:05 -04:00
2385e9c7bd [release] Bump to version 59.3
All checks were successful
ci / test (push) Successful in 2m18s
ci / build-and-deploy (push) Successful in 37s
- Exclude some board games from auto-expansion imports
- Should be able to add new variants to board games via the log data form
2026-07-04 11:41:49 -04:00
d78529efe2 [boardgames] Add genres and categories 2026-07-04 11:41:24 -04:00
f373e98e3d [boardgames] Skip CCG games for auto expansion download 2026-07-04 11:32:17 -04:00
7559ce7824 [boardgames] Add ability to add new variants to form 2026-07-04 11:28:41 -04:00
16 changed files with 391 additions and 20 deletions

View File

@ -619,6 +619,60 @@ The Edit log form should have from top to bottom:
- 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.5 [1/1]
** DONE [#A] Fix bug where all variants for board games are in form :boardgames:
:PROPERTIES:
:ID: 9c4cd193-580a-5b33-8832-1feffea7bd53
:END:
* Version 59.4 [2/2]
** DONE Fix bug in fetching expansions for board games :boardgames:
:PROPERTIES:
:ID: 17995312-e76e-4a50-b591-0eab78cb59ab
:END:
#+begin_src python
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/management/commands/fetch_expansions.py", line 60, in handle
fetch_and_link_expansions(game, expansions)
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/utils.py", line 51, in fetch_and_link_expansions
expansion = BoardGame.find_or_create(str(exp_id))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/models.py", line 409, in find_or_create
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/boardgames/sources/bgg.py", line 15, in lookup_boardgame_from_bgg
game = bgg.game(game_id=lookup_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/boardgamegeek/api.py", line 1045, in game
raise BGGApiError(msg)
boardgamegeek.exceptions.BGGApiError: invalid data for game id: 242117
#+end_src
** DONE Board games should have genres extracted from family data :boardgames:metadata:
:PROPERTIES:
:ID: 7214b270-dccc-4b98-ac58-ff4f76c8cda9
:END:
* Version 59.3 [2/2]
** DONE Exclude some board games from auto-expansion imports :boardgames:
:PROPERTIES:
:ID: 51ffdf20-e732-4774-b781-c3501d26d46f
:END:
*** Description
Some board games, especially trading card games, have silly amounts of expansions.
We should have a setting SKIP_AUTO_EXPANSION_DOWNLOAD that is a list of BGG ids of
games where expansions should not be download automatically. This exclusion should also auto-include
any games with "Collectible Card Games" in it's family.
** DONE Should be able to add new variants to board games via the log data form :boardgames:
:PROPERTIES:
:ID: 5ed0dd25-3026-3da8-dc5c-f2a75751af9a
:END:
* Version 59.2 [1/1]
** DONE Fix test failure in discgolf app :discgolf:tests:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "59.2"
version = "59.5"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -0,0 +1,81 @@
import logging
import time
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Refresh board game metadata from BGG (categories→genres, families→tags)"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Persist changes to the database",
)
parser.add_argument(
"--force",
action="store_true",
help="Update all games even if they already have a published_date",
)
parser.add_argument(
"--batch-size",
type=int,
default=50,
help="Number of games to process per batch (default: 50)",
)
parser.add_argument(
"--sleep",
type=float,
default=1.0,
help="Seconds to sleep between API calls (default: 1.0)",
)
def handle(self, *args, **options):
from boardgames.models import BoardGame
commit = options["commit"]
force = options["force"]
batch_size = options["batch_size"]
sleep_secs = options["sleep"]
qs = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(bggeek_id="")
total = qs.count()
self.stdout.write(f"Found {total} board games with BGG IDs")
if not commit:
self.stdout.write(
"Dry run — no API calls will be made. Use --commit to run lookups."
)
return
enriched = 0
skipped = 0
for batch_num, offset in enumerate(range(0, total, batch_size)):
batch = qs[offset : offset + batch_size]
for game in batch:
try:
game.fix_metadata(force_update=force)
enriched += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(
f" [SKIPPED] {game.title} (BGG {game.bggeek_id}): {e}"
)
)
skipped += 1
time.sleep(sleep_secs)
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}"
f"enriched: {enriched}, skipped: {skipped}"
)
self.stdout.write(
f"\nResults:\n"
f" Games enriched: {enriched}\n"
f" Games skipped: {skipped}"
)

View File

@ -28,8 +28,10 @@ class Command(BaseCommand):
commit = options.get("commit", False)
bggeek_id = options.get("bggeek_id")
games = BoardGame.objects.exclude(bggeek_id__isnull=True).exclude(
bggeek_id=""
games = (
BoardGame.objects.exclude(bggeek_id__isnull=True)
.exclude(bggeek_id="")
.exclude(skip_expansions=True)
)
if bggeek_id:
games = games.filter(bggeek_id=bggeek_id)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-07-04 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0016_boardgamevariant"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="skip_expansions",
field=models.BooleanField(default=False),
),
]

View File

@ -93,6 +93,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
@classmethod
def override_fields(cls) -> dict:
from boardgames.widgets import VariantSelectWidget
from scrobbles.forms import NotesDictField
fields = {}
@ -110,7 +111,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
"variant_ids": forms.ModelMultipleChoiceField(
queryset=BoardGameVariant.objects.all(),
required=False,
widget=forms.SelectMultiple(attrs={"size": 5}),
widget=VariantSelectWidget(attrs={"size": 5}),
),
"expansion_ids": forms.ModelMultipleChoiceField(
queryset=BoardGame.objects.filter(
@ -299,6 +300,7 @@ class BoardGame(ScrobblableMixin):
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
skip_expansions = models.BooleanField(default=False)
@property
def subtitle(self) -> str:
@ -349,8 +351,9 @@ class BoardGame(ScrobblableMixin):
data.pop("max_players")
# Pop extra BGG metadata that isn't a model field
categories = data.pop("categories", [])
families = data.pop("families", [])
data.pop("mechanics", None)
data.pop("categories", None)
data.pop("designers", None)
data.pop("publishers", None)
data.pop("publisher", None)
@ -366,13 +369,19 @@ class BoardGame(ScrobblableMixin):
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
self.save()
for cat in categories:
self.genre.add(cat.strip())
for fam in families:
self.tags.add(fam.strip())
# Go get cover image if the URL is present
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)
if not self.skip_expansions:
fetch_and_link_expansions(self, expansions)
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
@ -414,6 +423,7 @@ class BoardGame(ScrobblableMixin):
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
families = bgg_data.pop("families", [])
publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url") or ""
@ -430,6 +440,18 @@ class BoardGame(ScrobblableMixin):
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
game.publisher = publisher
skip_expansions = (
game.bggeek_id is not None
and str(game.bggeek_id).isdigit()
and int(game.bggeek_id) in settings.SKIP_AUTO_EXPANSION_DOWNLOAD
) or any(
c == "Collectible Card Games" for c in categories
)
if skip_expansions:
game.skip_expansions = True
game.save()
if designers:
@ -444,14 +466,20 @@ 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
for cat in categories:
game.genre.add(cat.strip())
for fam in families:
game.tags.add(fam.strip())
fetch_board_game_expansions.delay(game.id, expansions)
else:
from boardgames.utils import fetch_and_link_expansions
if expansions and not game.skip_expansions:
if defer_expansions:
from boardgames.tasks import fetch_board_game_expansions
fetch_and_link_expansions(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

View File

@ -32,6 +32,7 @@ def lookup_boardgame_from_bgg(
)
game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories
game_dict["families"] = game.families
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
if game.publishers:

View File

@ -0,0 +1,97 @@
<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
{% for group_name, group_choices, group_index in widget.optgroups %}
{% for option in group_choices %}
{% include option.template_name with widget=option %}
{% endfor %}
{% endfor %}
</select>
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" data-bs-toggle="modal" data-bs-target="#addVariantModal">
+ Add variant
</button>
<div class="modal fade" id="addVariantModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-sm">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Variant</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="newVariantName" class="form-label">Name</label>
<input type="text" class="form-control" id="newVariantName" placeholder="e.g. Map A">
</div>
<div class="mb-3">
<label for="newVariantDescription" class="form-label">Description (optional)</label>
<input type="text" class="form-control" id="newVariantDescription">
</div>
<p class="text-danger d-none" id="variantError"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveVariantBtn">Add</button>
</div>
</div>
</div>
</div>
<script>
(function() {
var select = document.getElementById('{{ widget.attrs.id }}');
if (!select) return;
var saveBtn = document.getElementById('saveVariantBtn');
if (!saveBtn) return;
var modalEl = document.getElementById('addVariantModal');
var nameInput = document.getElementById('newVariantName');
var descInput = document.getElementById('newVariantDescription');
var errorEl = document.getElementById('variantError');
saveBtn.addEventListener('click', function() {
var name = nameInput.value.trim();
if (!name) return;
var boardGameId = select.getAttribute('data-board-game-id');
var ajaxUrl = select.getAttribute('data-ajax-url');
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
if (!csrfToken) return;
errorEl.classList.add('d-none');
fetch(ajaxUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken.value,
},
body: new URLSearchParams({
name: name,
description: descInput.value.trim(),
board_game_id: boardGameId,
}),
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.error) {
errorEl.textContent = data.error;
errorEl.classList.remove('d-none');
return;
}
var opt = document.createElement('option');
opt.value = data.id;
opt.textContent = data.name;
opt.selected = true;
select.appendChild(opt);
var modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
nameInput.value = '';
descInput.value = '';
})
.catch(function() {
errorEl.textContent = 'Failed to create variant';
errorEl.classList.remove('d-none');
});
});
})();
</script>

View File

@ -83,6 +83,7 @@ def _mock_bgg_game(bggeek_id, title, expansions=None):
playingtime = 60
mechanics = []
categories = []
families = []
designers = []
publishers = []

View File

@ -20,4 +20,9 @@ urlpatterns = [
views.BoardGamePublisherDetailView.as_view(),
name="publisher_detail",
),
path(
"variants/ajax-create/",
views.ajax_create_variant,
name="ajax-create-variant",
),
]

View File

@ -44,6 +44,13 @@ def fetch_and_link_expansions(
"""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.
"""
if board_game.skip_expansions:
logger.info(
"Skipping expansion fetch for board game with skip_expansions=True",
extra={"board_game_id": board_game.id, "title": board_game.title},
)
return
for exp_data in expansions_data:
exp_id = exp_data.get("id")
if not exp_id:

View File

@ -1,6 +1,9 @@
import datetime
from django.http import JsonResponse
from django.utils import timezone
from django.views import generic
from django.views.decorators.http import require_POST
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
from scrobbles.views import (
@ -10,6 +13,38 @@ from scrobbles.views import (
)
@require_POST
def ajax_create_variant(request):
name = request.POST.get("name", "").strip()
board_game_id = request.POST.get("board_game_id")
description = request.POST.get("description", "").strip()
if not name or not board_game_id:
return JsonResponse({"error": "Name and board game are required"}, status=400)
try:
board_game_id = int(board_game_id)
except (ValueError, TypeError):
return JsonResponse({"error": "Invalid board game"}, status=400)
from boardgames.models import BoardGameVariant
variant = BoardGameVariant.objects.filter(
name__iexact=name,
board_game_id=board_game_id,
).first()
if variant:
return JsonResponse({"id": variant.id, "name": variant.name})
variant = BoardGameVariant.objects.create(
name=name,
board_game_id=board_game_id,
description=description or None,
)
return JsonResponse({"id": variant.id, "name": variant.name})
class BoardGameListView(ScrobbleableListView):
model = BoardGame

View File

@ -0,0 +1,9 @@
from django import forms
class VariantSelectWidget(forms.SelectMultiple):
template_name = "boardgames/widgets/variant_select.html"
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
return context

View File

@ -33,7 +33,7 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.dateformat import DateFormat
from django.views.decorators.csrf import csrf_exempt
@ -51,6 +51,7 @@ from music.aggregators import (
scrobble_counts,
week_of_scrobbles,
)
from boardgames.models import BoardGame, BoardGameVariant
from pendulum.parsing.exceptions import ParserError
from profiles.models import UserProfile
from profiles.utils import now_user_timezone
@ -1235,15 +1236,29 @@ 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
def _update_board_game_widgets(self, form):
if not isinstance(self.object.media_obj, BoardGame):
return
if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields:
if "expansion_ids" in form.fields:
expansions = BoardGame.objects.filter(
expansion_for_boardgame=self.object.media_obj
)
form.fields["expansion_ids"].queryset = expansions
if "variant_ids" in form.fields:
form.fields["variant_ids"].queryset = BoardGameVariant.objects.filter(
board_game=self.object.media_obj
)
form.fields["variant_ids"].widget.attrs["data-board-game-id"] = (
self.object.media_obj.id
)
form.fields["variant_ids"].widget.attrs["data-ajax-url"] = (
self.request.build_absolute_uri(
reverse("boardgames:ajax-create-variant")
)
)
def get_form(self):
FormClass = self.get_form_class()
@ -1255,14 +1270,14 @@ class ScrobbleDetailView(DetailView):
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
form = FormClass(initial=log)
self._update_expansion_ids_queryset(form)
self._update_board_game_widgets(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)
self._update_board_game_widgets(form)
if form.is_valid():
data = form.cleaned_data.copy()
@ -1944,8 +1959,6 @@ class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
scrobble_filter = {"scrobble__played_to_completion": True}
def get_items(self, user, start_date, end_date):
from boardgames.models import BoardGame
return super().get_items(user, start_date, end_date, BoardGame)

View File

@ -405,6 +405,12 @@ else:
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
SKIP_AUTO_EXPANSION_DOWNLOAD = [
int(x.strip())
for x in os.getenv("VROBBLER_SKIP_AUTO_EXPANSION_DOWNLOAD", "").split(",")
if x.strip().isdigit()
]
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
LOG_TYPE = "json" if JSON_LOGGING else "log"

View File

@ -46,6 +46,20 @@
<p>
<a href="{{object.start_url}}">Play again</a>
</p>
{% if object.genre.all %}
<p>Genres:
{% for tag in object.genre.all %}
<a href="{% url 'boardgames:boardgame_list' %}?genre={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
{% if object.tags.all %}
<p>Tags:
{% for tag in object.tags.all %}
<a href="{% url 'boardgames:boardgame_list' %}?tag={{ tag.name|urlencode }}" class="badge bg-secondary text-decoration-none">{{ tag.name }}</a>
{% endfor %}
</p>
{% endif %}
</div>
{% if charts %}
<div class="row">