sessions / 7559ce7824d3681c7d0ca5cfa17bc7a4eb8f55a8

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit 7559ce7824d3681c7d0ca5cfa17bc7a4eb8f55a8Author: Colin Powell <colin@unbl.ink>Date:   Sat Jul 4 11:28:41 2026 -0400    [boardgames] Add ability to add new variants to formdiff --git a/PROJECT.org b/PROJECT.orgindex e2a1e43..0c78a46 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -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:diff --git a/vrobbler/apps/boardgames/models.py b/vrobbler/apps/boardgames/models.pyindex 3d5e9be..3878f41 100644--- a/vrobbler/apps/boardgames/models.py+++ b/vrobbler/apps/boardgames/models.py@@ -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(diff --git a/vrobbler/apps/boardgames/templates/boardgames/widgets/variant_select.html b/vrobbler/apps/boardgames/templates/boardgames/widgets/variant_select.htmlnew file mode 100644index 0000000..e65d448--- /dev/null+++ b/vrobbler/apps/boardgames/templates/boardgames/widgets/variant_select.html@@ -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>diff --git a/vrobbler/apps/boardgames/urls.py b/vrobbler/apps/boardgames/urls.pyindex 0e77e81..190a7a1 100644--- a/vrobbler/apps/boardgames/urls.py+++ b/vrobbler/apps/boardgames/urls.py@@ -20,4 +20,9 @@ urlpatterns = [         views.BoardGamePublisherDetailView.as_view(),         name="publisher_detail",     ),+    path(+        "variants/ajax-create/",+        views.ajax_create_variant,+        name="ajax-create-variant",+    ), ]diff --git a/vrobbler/apps/boardgames/views.py b/vrobbler/apps/boardgames/views.pyindex 987680f..7510e11 100644--- a/vrobbler/apps/boardgames/views.py+++ b/vrobbler/apps/boardgames/views.py@@ -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 diff --git a/vrobbler/apps/boardgames/widgets.py b/vrobbler/apps/boardgames/widgets.pynew file mode 100644index 0000000..aaad4ba--- /dev/null+++ b/vrobbler/apps/boardgames/widgets.py@@ -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 contextdiff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex a47612d..fe2a782 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -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()