877 lines
35 KiB
Python
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')
|
|
|