Files
toptrumps/cards/views.py
T
Ross 322c22f14e .
2025-12-05 22:31:16 +00:00

438 lines
18 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 os
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,
}
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)
# Also return an out-of-band fragment to replace/clear the form container
form_html = render_to_string('cards/_form.html', {'form': DinosaurForm(), 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(card_html + oob)
return redirect('cards:list')
else:
if is_htmx(request):
# Return the form as an out-of-band fragment so it updates the form container
form_html = render_to_string('cards/_form.html', {'form': form, 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob)
else:
form = DinosaurForm()
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 form container via OOB so the edit form disappears after successful save
oob = '<div id="htmx-form" 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.html', {'form': form, 'card': card, 'background': background, 'backgrounds': backgrounds}, request=request)
oob = f'<div id="htmx-form" hx-swap-oob="true">{form_html}</div>'
return HttpResponse(oob)
else:
form = DinosaurForm(instance=card)
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 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 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
if is_htmx(request):
return render(request, 'cards/_card_preview.html', {'card': card, 'background': background})
return render(request, 'cards/preview.html', {'card': card, 'background': background})
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)
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
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()
return render(request, 'cards/bulk_edit.html', {'formset': formset, 'backgrounds': backgrounds})
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,
'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 _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'),
'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)
# provide a JSON-serialized version of suggestions so the preview form can post it
suggestions_json = json.dumps(suggestions, ensure_ascii=False)
return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, '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
for item in 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 not d:
continue
# apply numeric fields if present
def _to_float(v):
try:
return float(v)
except Exception:
return None
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:
d.speed = int(round(speed))
if weight is not None:
d.weight = int(round(weight))
if height is not None:
d.height = Decimal(str(round(height, 1)))
if intelligence is not None:
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()])
d.save()
applied += 1
return HttpResponse(f'Applied updates to {applied} dinosaurs')