Files
Ross 6bbc281a9b .
2025-12-06 23:11:17 +00:00

877 lines
35 KiB
Python

from django.shortcuts import render, get_object_or_404, redirect
from django.http import HttpResponse, HttpResponseBadRequest
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
from .forms import BackgroundImageForm
from decimal import Decimal
def is_htmx(request):
return request.headers.get('HX-Request') == 'true'
def card_list(request):
# Basic filtering/search/sort via GET params so the UI can request
# updated lists using HTMX or a regular browser GET.
qs = Dinosaur.objects.all()
# Search by name
q = (request.GET.get('q') or '').strip()
if q:
qs = qs.filter(name__icontains=q)
# Filter by era and diet
era = request.GET.get('era')
if era and era != 'all':
qs = qs.filter(era=era)
diet = request.GET.get('diet')
if diet and diet != 'all':
qs = qs.filter(diet=diet)
# Sorting: allow a small whitelist of fields to avoid unexpected orders
sort_map = {
'name': 'name',
'created': 'created_at',
'speed': 'speed',
'weight': 'weight',
'height': 'height',
'intelligence': 'intelligence',
}
sort_by = request.GET.get('sort')
order = request.GET.get('order', 'asc')
if sort_by in sort_map:
field = sort_map[sort_by]
if order == 'desc':
field = '-' + field
qs = qs.order_by(field)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
context = {
'cards': qs,
'background': background,
'eras': Dinosaur.ERA_CHOICES,
'diets': Dinosaur.DIET_CHOICES,
'current_q': q,
'current_era': era or 'all',
'current_diet': diet or 'all',
'current_sort': sort_by or '',
'current_order': order,
}
# Total (or filtered) card count for UI summary
context['card_count'] = qs.count()
if is_htmx(request):
return render(request, 'cards/_cards_list.html', context)
return render(request, 'cards/list.html', context)
def card_create(request):
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
backgrounds = BackgroundImage.objects.all()
if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES)
if form.is_valid():
# Save instance with commit=False so we can set a default name
# from the uploaded image filename when the name is empty.
card = form.save(commit=False)
if not card.name:
image_file = form.cleaned_data.get('image')
if image_file and getattr(image_file, 'name', None):
base = os.path.splitext(image_file.name)[0]
# replace separators and title-case for a nicer default
card.name = base.replace('_', ' ').replace('-', ' ').strip().title()
card.save()
if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Clear the modal via out-of-band so the modal-root is emptied
oob = '<div id="modal-root" hx-swap-oob="true"></div>'
return HttpResponse(card_html + oob)
return redirect('cards:list')
else:
if is_htmx(request):
# Return the form wrapped in a modal fragment so it updates the modal-root
form_html = render_to_string('cards/_form_modal.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request)
return HttpResponse(form_html)
else:
form = DinosaurForm()
# If this is an HTMX request, return the modal-wrapped form so it can be
# injected into `#modal-root`. Otherwise return the standalone form page.
if is_htmx(request):
return render(request, 'cards/_form_modal.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
return render(request, 'cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds})
def card_edit(request, pk):
card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
backgrounds = BackgroundImage.objects.all()
if request.method == 'POST':
form = DinosaurForm(request.POST, request.FILES, instance=card)
if form.is_valid():
# Save with commit=False to allow setting a default name from an
# uploaded image if the instance has no name.
card = form.save(commit=False)
if not card.name:
image_file = form.cleaned_data.get('image')
if image_file and getattr(image_file, 'name', None):
base = os.path.splitext(image_file.name)[0]
card.name = base.replace('_', ' ').replace('-', ' ').strip().title()
card.save()
if is_htmx(request):
card_html = render_to_string('cards/_card_item.html', {'card': card, 'background': background}, request=request)
# Clear the modal via OOB so the modal-root is emptied
oob = '<div id="modal-root" hx-swap-oob="true"></div>'
return HttpResponse(card_html + oob)
return redirect('cards:list')
else:
if is_htmx(request):
form_html = render_to_string('cards/_form_modal.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds}, request=request)
return HttpResponse(form_html)
else:
form = DinosaurForm(instance=card)
if is_htmx(request):
return render(request, 'cards/_form_modal.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds})
return render(request, 'cards/_form.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds})
def card_delete(request, pk):
card = get_object_or_404(Dinosaur, pk=pk)
if request.method == 'POST':
card.delete()
if is_htmx(request):
# Return an empty 200 response so HTMX can replace the
# targeted element (e.g. via `hx-swap="outerHTML"`) and
# remove it from the DOM immediately.
return HttpResponse('')
return redirect('cards:list')
return HttpResponseBadRequest('Only POST allowed')
def background_list(request):
"""Show existing backgrounds and provide a form to upload a new one."""
backgrounds = BackgroundImage.objects.all()
form = BackgroundImageForm()
if is_htmx(request):
return render(request, 'cards/_backgrounds_list.html', {'backgrounds': backgrounds, 'form': form})
return render(request, 'cards/backgrounds_list.html', {'backgrounds': backgrounds, 'form': form})
def backgrounds_download_all(request):
"""Provide a ZIP archive download containing all uploaded background images.
Creates the ZIP in-memory and returns it as an attachment. Skips missing
files gracefully.
"""
backgrounds = BackgroundImage.objects.all()
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
for bg in backgrounds:
if not getattr(bg, 'image', None):
continue
try:
filepath = bg.image.path
except Exception:
# If storage backend doesn't expose a local path, skip.
continue
if not os.path.exists(filepath):
continue
arcname = os.path.basename(bg.image.name) if getattr(bg.image, 'name', None) else f'background-{bg.pk}'
# Prefix with id to avoid duplicate names
arcname = f"{bg.pk}_{arcname}"
zf.write(filepath, arcname)
buffer.seek(0)
resp = HttpResponse(buffer.getvalue(), content_type='application/zip')
resp['Content-Disposition'] = 'attachment; filename=backgrounds.zip'
return resp
def background_create(request):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
form = BackgroundImageForm(request.POST, request.FILES)
if form.is_valid():
bg = form.save()
# If marked active, unset others
if bg.is_active:
BackgroundImage.objects.exclude(pk=bg.pk).update(is_active=False)
if is_htmx(request):
# return the updated list fragment and clear the form OOB
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
oob = f'<div id="htmx-background-form" hx-swap-oob="true">{render_to_string("cards/_background_form.html", {"form": BackgroundImageForm()}, request=request)}</div>'
return HttpResponse(list_html + oob)
return redirect('cards:backgrounds')
else:
if is_htmx(request):
form_html = render_to_string('cards/_background_form.html', {'form': form}, request=request)
oob = f'<div id="htmx-background-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob)
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
def background_activate(request, pk):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
bg = get_object_or_404(BackgroundImage, pk=pk)
BackgroundImage.objects.update(is_active=False)
bg.is_active = True
bg.save()
if is_htmx(request):
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
return HttpResponse(list_html)
return redirect('cards:backgrounds')
def background_delete(request, pk):
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
bg = get_object_or_404(BackgroundImage, pk=pk)
bg.delete()
if is_htmx(request):
# See comment in `card_delete`: return empty body so HTMX can
# remove the corresponding element via `hx-swap="outerHTML"`.
return HttpResponse('')
return redirect('cards:backgrounds')
def background_edit(request, pk):
"""Edit an existing BackgroundImage's metadata (title, image, is_active).
Designed to work with HTMX: GET returns a small edit form fragment which
replaces the background card. POST saves and returns the updated
backgrounds list fragment so the UI stays in sync.
"""
bg = get_object_or_404(BackgroundImage, pk=pk)
if request.method == 'POST':
form = BackgroundImageForm(request.POST, request.FILES, instance=bg)
if form.is_valid():
bg = form.save()
# If marked active, unset others
if bg.is_active:
BackgroundImage.objects.exclude(pk=bg.pk).update(is_active=False)
if is_htmx(request):
list_html = render_to_string('cards/_backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': BackgroundImageForm()}, request=request)
return HttpResponse(list_html)
return redirect('cards:backgrounds')
else:
if is_htmx(request):
form_html = render_to_string('cards/_background_title_form.html', {'bg': bg, 'form': form}, request=request)
return HttpResponse(form_html)
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
else:
form = BackgroundImageForm(instance=bg)
if is_htmx(request):
return render(request, 'cards/_background_title_form.html', {'bg': bg, 'form': form})
return render(request, 'cards/backgrounds_list.html', {'backgrounds': BackgroundImage.objects.all(), 'form': form})
def preview_card(request, pk):
"""Render a full preview of a card. HTMX loads the modal fragment into #modal-root."""
card = get_object_or_404(Dinosaur, pk=pk)
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
# Support multiple visual designs selectable via `?design=1..5`.
try:
design = int(request.GET.get('design', '1'))
except Exception:
design = 1
if design < 1 or design > 5:
design = 1
template_name = f'cards/_card_preview_design{design}.html'
template_name = f'cards/_card_preview_design{design}.html'
# 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 main list page and include the
# preview fragment so the page shows the normal card list with the modal
# already injected. This keeps the surrounding UI consistent when users
# open a preview URL directly.
qs = Dinosaur.objects.all()
list_context = {
'cards': qs,
'background': background,
'eras': Dinosaur.ERA_CHOICES,
'diets': Dinosaur.DIET_CHOICES,
'current_q': '',
'current_era': 'all',
'current_diet': 'all',
'current_sort': '',
'current_order': 'asc',
'card_count': qs.count(),
}
# Render the fragment HTML server-side and pass it to the list template so
# it can be injected into `#modal-root` immediately on load.
fragment_html = render_to_string(template_name, context, request=request)
list_context['modal_fragment'] = fragment_html
return render(request, 'cards/list.html', list_context)
def cards_overview(request):
"""Overview page showing all cards in a table for quick edits."""
backgrounds = BackgroundImage.objects.all()
qs = Dinosaur.objects.order_by('name')
# Use a modelformset so the template renders the management form
formset = DinosaurBulkFormSet(queryset=qs)
card_count = qs.count()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds, 'card_count': card_count})
def cards_bulk_update(request):
"""Handle POST from the bulk edit formset and save changes."""
if request.method != 'POST':
# If someone visits the bulk-update URL with GET, redirect them back
# to the overview page instead of returning a 400 Bad Request.
return redirect('cards:overview')
formset = None
qs = Dinosaur.objects.order_by('name')
try:
# Pass the same queryset so management/initial forms align with the rendered formset
formset = DinosaurBulkFormSet(request.POST, queryset=qs)
except Exception:
return HttpResponseBadRequest('Invalid submission')
if formset.is_valid():
formset.save()
return redirect('cards:overview')
# If invalid, re-render the overview with errors
backgrounds = BackgroundImage.objects.all()
card_count = qs.count()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds, 'card_count': card_count})
def cards_bulk_create_images(request):
"""Create multiple Dinosaur cards from uploaded images (one card per image).
Expects a multipart POST with one or more files in the `images` field.
The card `name` is derived from the filename if not provided.
"""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
files = request.FILES.getlist('images')
if not files:
return HttpResponseBadRequest('No images uploaded')
created = []
for f in files:
# derive a friendly name from filename
base = os.path.splitext(getattr(f, 'name', '') or '')[0]
name = base.replace('_', ' ').replace('-', ' ').strip().title() or 'New Dinosaur'
d = Dinosaur(name=name, image=f)
d.save()
created.append(d)
# Redirect back to overview
return redirect('cards:overview')
def export_table(request):
"""Render a page containing a TSV/CSV/Markdown table of all dinosaurs that
can be copied and pasted into an LLM web interface."""
qs = Dinosaur.objects.order_by('name')
# produce a JSON array of objects for easy copy/paste
rows = []
for d in qs:
rows.append({
'pk': d.pk,
'name': d.name,
'era': d.era,
'era_display': d.get_era_display(),
'diet': d.diet,
'diet_display': d.get_diet_display(),
'speed_kmh': d.speed,
'weight_kg': d.weight,
'height_m': float(d.height),
'intelligence': d.intelligence,
'facts': d.facts_list,
})
import json as _json
json_text = _json.dumps(rows, ensure_ascii=False, indent=2)
return render(request, 'cards/export_table.html', {'json_text': json_text})
def print_cards(request):
"""Selection UI where the user picks which cards to print and which design."""
qs = Dinosaur.objects.order_by('name')
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
return render(request, 'cards/print.html', {'cards': qs, 'background': background})
def print_preview(request):
"""Render a printable view of selected cards.
POST params:
- cards: repeated card pks
- design: design number
- scale: optional scale factor
Returns an HTML A4-friendly document that the browser can print or save as PDF.
"""
# Accept POST or GET for easier testing
data = request.POST if request.method == 'POST' else request.GET
pks = data.getlist('cards')
if not pks:
return HttpResponseBadRequest('No cards selected')
try:
design = int(data.get('design', '1'))
except Exception:
design = 1
scale = float(data.get('scale', '1')) if data.get('scale') else 1.0
try:
per_page = int(data.get('per_page', '2'))
except Exception:
per_page = 2
if per_page not in (1, 2, 4):
per_page = 2
# Fetch cards preserving the given order
cards = list(Dinosaur.objects.filter(pk__in=pks))
# Reorder according to provided pks
pk_to_card = {str(c.pk): c for c in cards}
ordered = [pk_to_card[pk] for pk in pks if pk in pk_to_card]
# Build pages: per_page cards per A4 page
pages = []
for i in range(0, len(ordered), per_page):
pages.append(ordered[i:i+per_page])
# -- Top-trump calculation across the full selection -----------------
# For each card compute its best stat percent (0-100) and the stat(s)
# that achieve that percent. Then find the global maximum best-value and
# mark any card whose best-value equals that maximum as a top-trump.
# Compute selection maxima for speed/weight/height/intelligence so we
# can scale these metrics relative to the selected cards. This prevents
# many large absolute values from all clamping to 100% and getting 5★.
max_speed = 0.0
max_weight = 0.0
max_height = 0.0
max_intel = 0.0
for c in ordered:
try:
sv = float(c.speed or 0)
except Exception:
sv = 0.0
try:
wv = float(c.weight or 0)
except Exception:
wv = 0.0
try:
hv = float(c.height or 0)
except Exception:
hv = 0.0
try:
iv = float(c.intelligence or 0)
except Exception:
iv = 0.0
if sv > max_speed:
max_speed = sv
if wv > max_weight:
max_weight = wv
if hv > max_height:
max_height = hv
if iv > max_intel:
max_intel = iv
all_best_values = []
for c in ordered:
# raw values
try:
raw_s = float(c.speed or 0)
except Exception:
raw_s = 0.0
try:
raw_w = float(c.weight or 0)
except Exception:
raw_w = 0.0
try:
raw_h = float(c.height or 0)
except Exception:
raw_h = 0.0
try:
raw_it = float(c.intelligence or 0)
except Exception:
raw_it = 0.0
# Compute selection-relative percents so the largest in the
# selection maps to 100% for each metric.
if max_speed > 0:
s = min(100, int(round((raw_s / max_speed) * 100)))
else:
s = 0
if max_weight > 0:
w = min(100, int(round((raw_w / max_weight) * 100)))
else:
w = 0
if max_height > 0:
h = min(100, int(round((raw_h / max_height) * 100)))
else:
h = 0
if max_intel > 0:
it = min(100, int(round((raw_it / max_intel) * 100)))
else:
it = 0
# Attach adjusted star visuals for the printable preview so
# templates reflect selection-relative scaling. These instance
# attributes shadow the class properties during template rendering.
setattr(c, 'speed_star_types_adj', c._star_types_from_percent(s))
setattr(c, 'speed_stars_adj', c._stars_string(s))
setattr(c, 'weight_star_types_adj', c._star_types_from_percent(w))
setattr(c, 'weight_stars_adj', c._stars_string(w))
setattr(c, 'height_star_types_adj', c._star_types_from_percent(h))
setattr(c, 'height_stars_adj', c._stars_string(h))
setattr(c, 'intelligence_star_types_adj', c._star_types_from_percent(it))
setattr(c, 'intelligence_stars_adj', c._stars_string(it))
setattr(c, 'speed_percent_adj', s)
setattr(c, 'weight_percent_adj', w)
setattr(c, 'height_percent_adj', h)
setattr(c, 'intelligence_percent_adj', it)
# map names to values (independent stats) using adjusted percents
stats = {'speed': s, 'weight': w, 'intelligence': it, 'height': h}
max_val = max(stats.values())
best_stats = [name for name, val in stats.items() if val == max_val]
# attach computed attributes so templates can read them
setattr(c, 'best_value', max_val)
setattr(c, 'best_stats', best_stats)
all_best_values.append(max_val)
# determine global maximum and mark cards as top-trump if they match
global_max = max(all_best_values) if all_best_values else 0
for c in ordered:
setattr(c, 'is_top_trump', getattr(c, 'best_value', 0) == global_max)
# ---------------------------------------------------------------------
# Determine grid columns for layout: use 1 column for 1-up, 2 columns for 2/4-up
columns = 1 if per_page == 1 else 2
# Include the active background (if any) so fragments that fall back to
# a global `background` variable can render correctly in the printable view.
background = BackgroundImage.get_active() if BackgroundImage.objects.exists() else None
return render(request, 'cards/printable.html', {
'pages': pages,
'design': design,
'scale': scale,
'background': background,
'per_page': per_page,
'columns': columns,
})
def _parse_pasted_input(text):
"""Try to parse pasted LLM output into a list of suggestion dicts.
Accepts JSON array/object, newline-delimited JSON objects, or CSV/TSV with headers.
Each suggestion should contain at least a `name` or `pk` and optional fields:
`speed_kmh`, `weight_kg`, `height_m`, `intelligence_score`, `facts` (list or string).
Returns list of dicts.
"""
text = (text or '').strip()
if not text:
return []
# Try JSON
try:
data = json.loads(text)
if isinstance(data, dict):
return [data]
if isinstance(data, list):
return data
except Exception:
pass
# Try newline-delimited JSON
lines = [l.strip() for l in text.splitlines() if l.strip()]
if len(lines) > 1:
maybe = []
for l in lines:
try:
obj = json.loads(l)
maybe.append(obj)
except Exception:
maybe = []
break
if maybe:
return maybe
# Try CSV/TSV
try:
sniffer = csv.Sniffer()
dialect = sniffer.sniff(text[:1024])
f = StringIO(text)
reader = csv.DictReader(f, dialect=dialect)
out = []
for row in reader:
out.append({k.strip(): (v.strip() if v is not None else '') for k, v in row.items()})
if out:
return out
except Exception:
pass
# As last resort return single-line fallback
return [{'raw': text}]
def import_from_paste(request):
"""Endpoint that accepts pasted LLM output and returns a preview fragment."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
text = request.POST.get('pasted', '')
parsed = _parse_pasted_input(text)
# Normalize parsed entries into suggestion dicts with named fields
suggestions = []
for item in parsed:
# lower-case keys for convenience
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'),
'height_m': entry.get('height_m') or entry.get('height') or entry.get('height_meters'),
'intelligence_score': entry.get('intelligence_score') or entry.get('intelligence') or entry.get('iq'),
'facts': entry.get('facts') or entry.get('fact') or entry.get('raw'),
'sources': entry.get('sources') or entry.get('source')
}
# convert facts string to list if needed
if isinstance(s['facts'], str):
parts = [p.strip() for p in s['facts'].split('\n') if p.strip()]
if len(parts) == 1 and '|' in parts[0]:
parts = [p.strip() for p in parts[0].split('|') if p.strip()]
s['facts'] = parts
suggestions.append(s)
# 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', {'preview_items': preview_items, 'suggestions_json': suggestions_json})
def apply_bulk_import(request):
"""Apply pasted suggestions. For now apply all suggestions provided in the
`data` POST field (JSON array). Returns updated list fragment or a simple message."""
if request.method != 'POST':
return HttpResponseBadRequest('Only POST allowed')
data = request.POST.get('data')
if not data:
return HttpResponseBadRequest('No data provided')
try:
parsed = json.loads(data)
except Exception:
return HttpResponseBadRequest('Failed to parse JSON data')
applied = 0
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
if pk:
try:
d = Dinosaur.objects.get(pk=pk)
except Dinosaur.DoesNotExist:
d = None
if d is None and name:
try:
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
# 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 and _should_apply('speed_kmh'):
d.speed = int(round(speed))
if weight is not None and _should_apply('weight_kg'):
d.weight = int(round(weight))
if height is not None and _should_apply('height_m'):
d.height = Decimal(str(round(height, 1)))
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))))
# 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
return HttpResponse(f'Applied updates to {applied} dinosaurs')