.
This commit is contained in:
+184
-19
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user