This commit is contained in:
Ross
2025-12-06 19:54:00 +00:00
parent 679af14739
commit 4a2afdc0c7
8 changed files with 317 additions and 32 deletions
+83 -13
View File
@@ -1,30 +1,100 @@
{% comment %} Preview fragment for pasted bulk import data. {% endcomment %}
<div class="box">
<h4 class="title is-6">Parsed suggestions</h4>
{% if suggestions %}
{% if preview_items %}
<form hx-post="{% url 'cards:import_apply_bulk' %}" hx-target="#import-preview" hx-swap="innerHTML">
{% csrf_token %}
<textarea name="data" style="display:none">{{ suggestions_json }}</textarea>
<table class="table is-fullwidth is-striped">
<thead>
<tr><th>Match</th><th>Name / PK</th><th>Speed</th><th>Weight</th><th>Height</th><th>Intelligence</th><th>Facts</th></tr>
<tr>
<th></th>
<th>Match</th>
<th>Name / PK</th>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
<th>Apply?</th>
</tr>
</thead>
<tbody>
{% for s in suggestions %}
<tr>
<td>{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ s.speed_kmh }}</td>
<td>{{ s.weight_kg }}</td>
<td>{{ s.height_m }}</td>
<td>{{ s.intelligence_score }}</td>
<td>{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}</td>
</tr>
{% for item in preview_items %}
{% with s=item.suggestion i=item.index %}
{% if item.diffs %}
{% for field in item.diffs %}
<tr>
<td>{% if forloop.first %}#{{ forloop.parentloop.counter }}{% else %}&nbsp;{% endif %}</td>
<td>{% if item.matched %}pk:{{ item.matched.pk }}{% elif s.name %}name{% else %}&mdash;{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td>{{ field }}</td>
<td>
{% if item.existing %}
{% if field == 'facts' %}
{% if item.existing.facts %}{{ item.existing.facts|join:"; " }}{% endif %}
{% elif field == 'name' %}
{{ item.existing.name|default:"—" }}
{% elif field == 'era' %}
{{ item.existing.era|default:"—" }}
{% elif field == 'diet' %}
{{ item.existing.diet|default:"—" }}
{% elif field == 'speed_kmh' %}
{{ item.existing.speed_kmh|default:"—" }}
{% elif field == 'weight_kg' %}
{{ item.existing.weight_kg|default:"—" }}
{% elif field == 'height_m' %}
{{ item.existing.height_m|default:"—" }}
{% elif field == 'intelligence_score' %}
{{ item.existing.intelligence_score|default:"—" }}
{% else %}
{{ item.existing|default:"—" }}
{% endif %}
{% else %}
(new)
{% endif %}
</td>
<td>
{% if field == 'facts' %}
{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}
{% elif field == 'name' %}
{{ item.proposed.name|default:"" }}
{% elif field == 'era' %}
{{ item.proposed.era|default:"" }}
{% elif field == 'diet' %}
{{ item.proposed.diet|default:"" }}
{% elif field == 'speed_kmh' %}
{{ item.proposed.speed_kmh|default:"" }}
{% elif field == 'weight_kg' %}
{{ item.proposed.weight_kg|default:"" }}
{% elif field == 'height_m' %}
{{ item.proposed.height_m|default:"" }}
{% elif field == 'intelligence_score' %}
{{ item.proposed.intelligence_score|default:"" }}
{% else %}
{{ item.proposed|default:"" }}
{% endif %}
</td>
<td>
<input type="checkbox" name="apply-{{ i }}-{{ field }}" checked>
{% if not item.matched and forloop.first %}
<br><label style="font-size:0.85em"><input type="checkbox" name="apply-{{ i }}-create" checked> Create record</label>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td>#{{ forloop.counter }}</td>
<td>{% if item.matched %}pk:{{ item.matched.pk }}{% else %}&mdash;{% endif %}</td>
<td>{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}</td>
<td colspan="4"><em>No changes detected</em></td>
</tr>
{% endif %}
{% endwith %}
{% endfor %}
</tbody>
</table>
<div style="margin-top:0.5rem">
<button class="button is-primary" type="submit">Apply all suggestions</button>
<button class="button is-primary" type="submit">Apply selected changes</button>
<button class="button" type="button" onclick="document.getElementById('import-preview').innerHTML=''">Cancel</button>
</div>
</form>
@@ -43,6 +43,16 @@
</div>
</div>
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
{% if prev_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Previous">← Prev</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
{% endif %}
{% if next_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Next">Next →</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
{% endif %}
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 1">1</a>
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 2">2</a>
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 3">3</a>
@@ -42,6 +42,16 @@
</div>
</div>
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
{% if prev_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Previous">← Prev</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
{% endif %}
{% if next_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Next">Next →</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
{% endif %}
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 1">1</a>
<a class="button is-medium {% if design == 2 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 2">2</a>
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 3">3</a>
@@ -48,6 +48,16 @@
</div>
</div>
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
{% if prev_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Previous">← Prev</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
{% endif %}
{% if next_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Next">Next →</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
{% endif %}
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 1">1</a>
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 2">2</a>
<a class="button is-medium {% if design == 3 %}is-primary is-link{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 3">3</a>
@@ -51,6 +51,16 @@
</div>
</div>
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
{% if prev_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Previous">← Prev</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
{% endif %}
{% if next_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Next">Next →</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
{% endif %}
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 1">1</a>
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 2">2</a>
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 3">3</a>
@@ -51,6 +51,16 @@
</div>
</div>
<div style="position:absolute; top:12px; right:12px; display:flex; gap:8px;">
{% if prev_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' prev_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Previous">← Prev</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">← Prev</button>
{% endif %}
{% if next_pk %}
<a class="button is-medium is-info" hx-get="{% url 'cards:preview' next_pk %}?design={{ design }}" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Next">Next →</a>
{% else %}
<button class="button is-medium is-static" disabled aria-hidden="true">Next →</button>
{% endif %}
<a class="button is-medium {% if design == 1 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=1" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 1">1</a>
<a class="button is-medium {% if design == 2 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=2" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 2">2</a>
<a class="button is-medium {% if design == 3 %}is-primary{% else %}is-light{% endif %}" hx-get="{% url 'cards:preview' card.pk %}?design=3" hx-target="#modal-root" hx-swap="innerHTML" aria-label="Design 3">3</a>
+184 -19
View File
@@ -4,6 +4,9 @@ from django.template.loader import render_to_string
import io
import zipfile
import os
import json
import csv
from io import StringIO
from .models import Dinosaur, BackgroundImage
from .forms import DinosaurBulkFormSet
from .forms import DinosaurForm
@@ -288,7 +291,21 @@ def preview_card(request, pk):
design = 1
template_name = f'cards/_card_preview_design{design}.html'
template_name = f'cards/_card_preview_design{design}.html'
context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name}
# Compute previous/next PKs using a stable ordering (by name)
qs = list(Dinosaur.objects.order_by('name').values_list('pk', flat=True))
prev_pk = None
next_pk = None
try:
idx = qs.index(card.pk)
if idx > 0:
prev_pk = qs[idx - 1]
if idx < len(qs) - 1:
next_pk = qs[idx + 1]
except ValueError:
prev_pk = None
next_pk = None
context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name, 'prev_pk': prev_pk, 'next_pk': next_pk}
if is_htmx(request):
return render(request, template_name, context)
# For non-HTMX full page preview, render the generic preview which can accept design too
@@ -438,6 +455,8 @@ def import_from_paste(request):
entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)}
s = {
'pk': entry.get('pk') or entry.get('id'),
'era': entry.get('era'),
'diet': entry.get('diet'),
'name': entry.get('name'),
'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'),
'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'),
@@ -454,9 +473,106 @@ def import_from_paste(request):
s['facts'] = parts
suggestions.append(s)
# provide a JSON-serialized version of suggestions so the preview form can post it
# Build contextual preview: match suggestions to existing dinosaurs and compute field-level diffs
from .models import Dinosaur
def _map_choice(val, choices):
"""Map an incoming value (code or display) to the internal choice code.
Returns the code or None if not found."""
if val is None:
return None
s = str(val).strip()
if not s:
return None
s_low = s.lower()
# Try exact code match first
for code, label in choices:
if s_low == str(code).lower() or s_low == str(label).lower():
return code
return None
preview_items = []
for idx, s in enumerate(suggestions):
pk = s.get('pk')
name = s.get('name')
matched = None
if pk:
try:
matched = Dinosaur.objects.get(pk=pk)
except Dinosaur.DoesNotExist:
matched = None
if matched is None and name:
try:
matched = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist:
matched = None
# normalize proposed values for comparison
def _to_float(v):
try:
return float(v)
except Exception:
return None
proposed = {
'name': s.get('name'),
'era': _map_choice(s.get('era'), Dinosaur.ERA_CHOICES),
'diet': _map_choice(s.get('diet'), Dinosaur.DIET_CHOICES),
'speed_kmh': _to_float(s.get('speed_kmh')),
'weight_kg': _to_float(s.get('weight_kg')),
'height_m': _to_float(s.get('height_m')),
'intelligence_score': _to_float(s.get('intelligence_score')),
'facts': s.get('facts')
}
existing = None
diffs = []
if matched:
existing = {
'name': matched.name,
'era': matched.era,
'diet': matched.diet,
'speed_kmh': float(matched.speed) if matched.speed is not None else None,
'weight_kg': float(matched.weight) if matched.weight is not None else None,
'height_m': float(matched.height) if matched.height is not None else None,
'intelligence_score': float(matched.intelligence) if matched.intelligence is not None else None,
'facts': matched.facts_list
}
# compare field-by-field (tolerant numeric comparison)
for field in ['name', 'era', 'diet']:
if proposed.get(field) is not None and str(proposed.get(field)) != str(existing.get(field)):
diffs.append(field)
for field in ['speed_kmh', 'weight_kg', 'height_m', 'intelligence_score']:
pv = proposed.get(field)
ev = existing.get(field)
if pv is not None and ev is not None:
try:
if abs(float(pv) - float(ev)) > 0.0001:
diffs.append(field)
except Exception:
diffs.append(field)
elif pv is not None and ev is None:
diffs.append(field)
# facts: compare list vs list/string
prop_facts = proposed.get('facts')
if isinstance(prop_facts, str):
prop_list = [l.strip() for l in prop_facts.splitlines() if l.strip()]
elif isinstance(prop_facts, list):
prop_list = [str(x).strip() for x in prop_facts if str(x).strip()]
else:
prop_list = []
if prop_list and prop_list != existing.get('facts'):
diffs.append('facts')
else:
# New record: everything proposed counts as a diff
existing = None
diffs = [k for k, v in proposed.items() if v is not None and v != ""]
preview_items.append({'index': idx, 'suggestion': s, 'matched': matched, 'existing': existing, 'proposed': proposed, 'diffs': diffs})
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, 'suggestions_json': suggestions_json})
return render(request, 'cards/_bulk_import_preview.html', {'preview_items': preview_items, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
@@ -473,7 +589,13 @@ def apply_bulk_import(request):
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
for item in parsed:
def _to_float(v):
try:
return float(v)
except Exception:
return None
for idx, item in enumerate(parsed):
name = item.get('name') if isinstance(item, dict) else None
pk = item.get('pk') if isinstance(item, dict) else None
d = None
@@ -487,34 +609,77 @@ def apply_bulk_import(request):
d = Dinosaur.objects.get(name__iexact=name)
except Dinosaur.DoesNotExist:
d = None
# If no existing dinosaur matched, allow creation when user checked the create box
create_requested = bool(request.POST.get(f'apply-{idx}-create'))
if not d and create_requested:
d = Dinosaur()
is_new = True
if not d:
continue
# apply numeric fields if present
def _to_float(v):
try:
return float(v)
except Exception:
return None
# determine whether the user checked to apply this field for this item
def _should_apply(field_name):
return bool(request.POST.get(f'apply-{idx}-{field_name}'))
speed = _to_float(item.get('speed_kmh'))
weight = _to_float(item.get('weight_kg'))
height = _to_float(item.get('height_m'))
intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence'))
if speed is not None:
if speed is not None and _should_apply('speed_kmh'):
d.speed = int(round(speed))
if weight is not None:
if weight is not None and _should_apply('weight_kg'):
d.weight = int(round(weight))
if height is not None:
if height is not None and _should_apply('height_m'):
d.height = Decimal(str(round(height, 1)))
if intelligence is not None:
if intelligence is not None and _should_apply('intelligence_score'):
if 0 <= intelligence <= 5:
d.intelligence = int(round((intelligence / 5.0) * 100))
else:
d.intelligence = int(round(max(0, min(100, intelligence))))
facts = item.get('facts')
if isinstance(facts, list):
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
elif isinstance(facts, str):
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
# Name
if item.get('name') and _should_apply('name'):
d.name = item.get('name')
# Ensure new records have a name (fallback)
if getattr(d, 'name', None) in (None, ''):
d.name = item.get('name') or f'Imported Dinosaur {idx + 1}'
# Era and diet: accept code or display label
if item.get('era') and _should_apply('era'):
era_val = item.get('era')
if isinstance(era_val, str):
mapped = None
for code, label in Dinosaur.ERA_CHOICES:
if era_val.lower() == code.lower() or era_val.lower() == label.lower():
mapped = code
break
if mapped:
d.era = mapped
else:
d.era = era_val
if item.get('diet') and _should_apply('diet'):
diet_val = item.get('diet')
if isinstance(diet_val, str):
mapped = None
for code, label in Dinosaur.DIET_CHOICES:
if diet_val.lower() == code.lower() or diet_val.lower() == label.lower():
mapped = code
break
if mapped:
d.diet = mapped
else:
d.diet = diet_val
# Facts
if _should_apply('facts'):
facts = item.get('facts')
if isinstance(facts, list):
d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()])
elif isinstance(facts, str):
d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()])
d.save()
applied += 1
BIN
View File
Binary file not shown.