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 = '' 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 = '' 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'
{render_to_string("cards/_background_form.html", {"form": BackgroundImageForm()}, request=request)}
' 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'
{form_html}
' 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 generic preview which can accept design too return render(request, 'cards/preview.html', 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 _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')