This commit has no recorded session.
diff
commit 7559ce7824d3681c7d0ca5cfa17bc7a4eb8f55a8Author: Colin Powell <colin@unbl.ink>Date: Sat Jul 4 11:28:41 2026 -0400 [boardgames] Add ability to add new variants to form@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/24] :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:@@ -619,6 +619,19 @@ 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) +** TODO Exclude some board games from auto-expansion imports :boardgames:++*** 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:@@ -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(@@ -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>@@ -20,4 +20,9 @@ urlpatterns = [ views.BoardGamePublisherDetailView.as_view(), name="publisher_detail", ),+ path(+ "variants/ajax-create/",+ views.ajax_create_variant,+ name="ajax-create-variant",+ ), ]@@ -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 @@ -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@@ -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@@ -1235,15 +1235,28 @@ class ScrobbleDetailView(DetailView): def get_form_class(self): return self.object.media_obj.logdata_cls().form() - def _update_expansion_ids_queryset(self, form):+ def _update_board_game_widgets(self, form): from boardgames.models import BoardGame - if isinstance(self.object.media_obj, BoardGame) and "expansion_ids" in form.fields:+ if not isinstance(self.object.media_obj, BoardGame):+ return++ 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"].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 +1268,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()