From 4a2afdc0c7476a07deb81e967c3b2d8cb42a0b00 Mon Sep 17 00:00:00 2001 From: Ross Date: Sat, 6 Dec 2025 19:54:00 +0000 Subject: [PATCH] . --- .../templates/cards/_bulk_import_preview.html | 96 +++++++-- .../cards/_card_preview_design1.html | 10 + .../cards/_card_preview_design2.html | 10 + .../cards/_card_preview_design3.html | 10 + .../cards/_card_preview_design4.html | 10 + .../cards/_card_preview_design5.html | 10 + cards/views.py | 203 ++++++++++++++++-- db.sqlite3 | Bin 151552 -> 151552 bytes 8 files changed, 317 insertions(+), 32 deletions(-) diff --git a/cards/templates/cards/_bulk_import_preview.html b/cards/templates/cards/_bulk_import_preview.html index 7210d6e..c89d6a7 100644 --- a/cards/templates/cards/_bulk_import_preview.html +++ b/cards/templates/cards/_bulk_import_preview.html @@ -1,30 +1,100 @@ {% comment %} Preview fragment for pasted bulk import data. {% endcomment %}

Parsed suggestions

- {% if suggestions %} + {% if preview_items %}
{% csrf_token %} - + + + + + + + + + - {% for s in suggestions %} - - - - - - - - - + {% for item in preview_items %} + {% with s=item.suggestion i=item.index %} + {% if item.diffs %} + {% for field in item.diffs %} + + + + + + + + + + {% endfor %} + {% else %} + + + + + + + {% endif %} + {% endwith %} {% endfor %}
MatchName / PKSpeedWeightHeightIntelligenceFacts
MatchName / PKFieldCurrentProposedApply?
{% if s.pk %}pk:{{ s.pk }}{% elif s.name %}name{% else %}—{% endif %}{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}{{ s.speed_kmh }}{{ s.weight_kg }}{{ s.height_m }}{{ s.intelligence_score }}{% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %}
{% if forloop.first %}#{{ forloop.parentloop.counter }}{% else %} {% endif %}{% if item.matched %}pk:{{ item.matched.pk }}{% elif s.name %}name{% else %}—{% endif %}{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}{{ field }} + {% if item.existing %} + {% if field == 'facts' %} + {% if item.existing.facts %}{{ item.existing.facts|join:"; " }}{% endif %} + {% elif field == 'name' %} + {{ item.existing.name|default:"—" }} + {% elif field == 'era' %} + {{ item.existing.era|default:"—" }} + {% elif field == 'diet' %} + {{ item.existing.diet|default:"—" }} + {% elif field == 'speed_kmh' %} + {{ item.existing.speed_kmh|default:"—" }} + {% elif field == 'weight_kg' %} + {{ item.existing.weight_kg|default:"—" }} + {% elif field == 'height_m' %} + {{ item.existing.height_m|default:"—" }} + {% elif field == 'intelligence_score' %} + {{ item.existing.intelligence_score|default:"—" }} + {% else %} + {{ item.existing|default:"—" }} + {% endif %} + {% else %} + (new) + {% endif %} + + {% if field == 'facts' %} + {% if s.facts %}{% if s.facts|length > 0 %}{{ s.facts|join:"; " }}{% else %}{{ s.facts }}{% endif %}{% endif %} + {% elif field == 'name' %} + {{ item.proposed.name|default:"" }} + {% elif field == 'era' %} + {{ item.proposed.era|default:"" }} + {% elif field == 'diet' %} + {{ item.proposed.diet|default:"" }} + {% elif field == 'speed_kmh' %} + {{ item.proposed.speed_kmh|default:"" }} + {% elif field == 'weight_kg' %} + {{ item.proposed.weight_kg|default:"" }} + {% elif field == 'height_m' %} + {{ item.proposed.height_m|default:"" }} + {% elif field == 'intelligence_score' %} + {{ item.proposed.intelligence_score|default:"" }} + {% else %} + {{ item.proposed|default:"" }} + {% endif %} + + + {% if not item.matched and forloop.first %} +
+ {% endif %} +
#{{ forloop.counter }}{% if item.matched %}pk:{{ item.matched.pk }}{% else %}—{% endif %}{{ s.name|default:"(no name)" }}{% if s.pk %} (pk: {{ s.pk }}){% endif %}No changes detected
- +
diff --git a/cards/templates/cards/_card_preview_design1.html b/cards/templates/cards/_card_preview_design1.html index 0940625..00c6172 100644 --- a/cards/templates/cards/_card_preview_design1.html +++ b/cards/templates/cards/_card_preview_design1.html @@ -43,6 +43,16 @@
+ {% if prev_pk %} + ← Prev + {% else %} + + {% endif %} + {% if next_pk %} + Next → + {% else %} + + {% endif %} 1 2 3 diff --git a/cards/templates/cards/_card_preview_design2.html b/cards/templates/cards/_card_preview_design2.html index dcc63c2..e2a12fa 100644 --- a/cards/templates/cards/_card_preview_design2.html +++ b/cards/templates/cards/_card_preview_design2.html @@ -42,6 +42,16 @@
+ {% if prev_pk %} + ← Prev + {% else %} + + {% endif %} + {% if next_pk %} + Next → + {% else %} + + {% endif %} 1 2 3 diff --git a/cards/templates/cards/_card_preview_design3.html b/cards/templates/cards/_card_preview_design3.html index 73f5ee0..0518ed7 100644 --- a/cards/templates/cards/_card_preview_design3.html +++ b/cards/templates/cards/_card_preview_design3.html @@ -48,6 +48,16 @@
+ {% if prev_pk %} + ← Prev + {% else %} + + {% endif %} + {% if next_pk %} + Next → + {% else %} + + {% endif %} 1 2 3 diff --git a/cards/templates/cards/_card_preview_design4.html b/cards/templates/cards/_card_preview_design4.html index 60e8895..d2b0329 100644 --- a/cards/templates/cards/_card_preview_design4.html +++ b/cards/templates/cards/_card_preview_design4.html @@ -51,6 +51,16 @@
+ {% if prev_pk %} + ← Prev + {% else %} + + {% endif %} + {% if next_pk %} + Next → + {% else %} + + {% endif %} 1 2 3 diff --git a/cards/templates/cards/_card_preview_design5.html b/cards/templates/cards/_card_preview_design5.html index 95ab5c8..af98127 100644 --- a/cards/templates/cards/_card_preview_design5.html +++ b/cards/templates/cards/_card_preview_design5.html @@ -51,6 +51,16 @@
+ {% if prev_pk %} + ← Prev + {% else %} + + {% endif %} + {% if next_pk %} + Next → + {% else %} + + {% endif %} 1 2 3 diff --git a/cards/views.py b/cards/views.py index 47d5591..04438dc 100644 --- a/cards/views.py +++ b/cards/views.py @@ -4,6 +4,9 @@ from django.template.loader import render_to_string import io import zipfile import os +import json +import csv +from io import StringIO from .models import Dinosaur, BackgroundImage from .forms import DinosaurBulkFormSet from .forms import DinosaurForm @@ -288,7 +291,21 @@ def preview_card(request, pk): design = 1 template_name = f'cards/_card_preview_design{design}.html' template_name = f'cards/_card_preview_design{design}.html' - context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name} + # Compute previous/next PKs using a stable ordering (by name) + qs = list(Dinosaur.objects.order_by('name').values_list('pk', flat=True)) + prev_pk = None + next_pk = None + try: + idx = qs.index(card.pk) + if idx > 0: + prev_pk = qs[idx - 1] + if idx < len(qs) - 1: + next_pk = qs[idx + 1] + except ValueError: + prev_pk = None + next_pk = None + + context = {'card': card, 'background': background, 'design': design, 'fragment_template': template_name, 'prev_pk': prev_pk, 'next_pk': next_pk} if is_htmx(request): return render(request, template_name, context) # For non-HTMX full page preview, render the generic preview which can accept design too @@ -438,6 +455,8 @@ def import_from_paste(request): entry = {k.lower(): v for k, v in item.items()} if isinstance(item, dict) else {'raw': str(item)} s = { 'pk': entry.get('pk') or entry.get('id'), + 'era': entry.get('era'), + 'diet': entry.get('diet'), 'name': entry.get('name'), 'speed_kmh': entry.get('speed_kmh') or entry.get('speed') or entry.get('speed_km/h'), 'weight_kg': entry.get('weight_kg') or entry.get('weight') or entry.get('mass_kg'), @@ -454,9 +473,106 @@ def import_from_paste(request): s['facts'] = parts suggestions.append(s) - # provide a JSON-serialized version of suggestions so the preview form can post it + # Build contextual preview: match suggestions to existing dinosaurs and compute field-level diffs + from .models import Dinosaur + + def _map_choice(val, choices): + """Map an incoming value (code or display) to the internal choice code. + Returns the code or None if not found.""" + if val is None: + return None + s = str(val).strip() + if not s: + return None + s_low = s.lower() + # Try exact code match first + for code, label in choices: + if s_low == str(code).lower() or s_low == str(label).lower(): + return code + return None + + preview_items = [] + for idx, s in enumerate(suggestions): + pk = s.get('pk') + name = s.get('name') + matched = None + if pk: + try: + matched = Dinosaur.objects.get(pk=pk) + except Dinosaur.DoesNotExist: + matched = None + if matched is None and name: + try: + matched = Dinosaur.objects.get(name__iexact=name) + except Dinosaur.DoesNotExist: + matched = None + + # normalize proposed values for comparison + def _to_float(v): + try: + return float(v) + except Exception: + return None + + proposed = { + 'name': s.get('name'), + 'era': _map_choice(s.get('era'), Dinosaur.ERA_CHOICES), + 'diet': _map_choice(s.get('diet'), Dinosaur.DIET_CHOICES), + 'speed_kmh': _to_float(s.get('speed_kmh')), + 'weight_kg': _to_float(s.get('weight_kg')), + 'height_m': _to_float(s.get('height_m')), + 'intelligence_score': _to_float(s.get('intelligence_score')), + 'facts': s.get('facts') + } + + existing = None + diffs = [] + if matched: + existing = { + 'name': matched.name, + 'era': matched.era, + 'diet': matched.diet, + 'speed_kmh': float(matched.speed) if matched.speed is not None else None, + 'weight_kg': float(matched.weight) if matched.weight is not None else None, + 'height_m': float(matched.height) if matched.height is not None else None, + 'intelligence_score': float(matched.intelligence) if matched.intelligence is not None else None, + 'facts': matched.facts_list + } + # compare field-by-field (tolerant numeric comparison) + for field in ['name', 'era', 'diet']: + if proposed.get(field) is not None and str(proposed.get(field)) != str(existing.get(field)): + diffs.append(field) + for field in ['speed_kmh', 'weight_kg', 'height_m', 'intelligence_score']: + pv = proposed.get(field) + ev = existing.get(field) + if pv is not None and ev is not None: + try: + if abs(float(pv) - float(ev)) > 0.0001: + diffs.append(field) + except Exception: + diffs.append(field) + elif pv is not None and ev is None: + diffs.append(field) + # facts: compare list vs list/string + prop_facts = proposed.get('facts') + if isinstance(prop_facts, str): + prop_list = [l.strip() for l in prop_facts.splitlines() if l.strip()] + elif isinstance(prop_facts, list): + prop_list = [str(x).strip() for x in prop_facts if str(x).strip()] + else: + prop_list = [] + if prop_list and prop_list != existing.get('facts'): + diffs.append('facts') + + else: + # New record: everything proposed counts as a diff + existing = None + diffs = [k for k, v in proposed.items() if v is not None and v != ""] + + preview_items.append({'index': idx, 'suggestion': s, 'matched': matched, 'existing': existing, 'proposed': proposed, 'diffs': diffs}) + suggestions_json = json.dumps(suggestions, ensure_ascii=False) - return render(request, 'cards/_bulk_import_preview.html', {'suggestions': suggestions, 'suggestions_json': suggestions_json}) + return render(request, 'cards/_bulk_import_preview.html', {'preview_items': preview_items, 'suggestions_json': suggestions_json}) def apply_bulk_import(request): @@ -473,7 +589,13 @@ def apply_bulk_import(request): return HttpResponseBadRequest('Failed to parse JSON data') applied = 0 - for item in parsed: + def _to_float(v): + try: + return float(v) + except Exception: + return None + + for idx, item in enumerate(parsed): name = item.get('name') if isinstance(item, dict) else None pk = item.get('pk') if isinstance(item, dict) else None d = None @@ -487,34 +609,77 @@ def apply_bulk_import(request): d = Dinosaur.objects.get(name__iexact=name) except Dinosaur.DoesNotExist: d = None + # If no existing dinosaur matched, allow creation when user checked the create box + create_requested = bool(request.POST.get(f'apply-{idx}-create')) + if not d and create_requested: + d = Dinosaur() + is_new = True if not d: continue - # apply numeric fields if present - def _to_float(v): - try: - return float(v) - except Exception: - return None + + # determine whether the user checked to apply this field for this item + def _should_apply(field_name): + return bool(request.POST.get(f'apply-{idx}-{field_name}')) + speed = _to_float(item.get('speed_kmh')) weight = _to_float(item.get('weight_kg')) height = _to_float(item.get('height_m')) intelligence = _to_float(item.get('intelligence_score') or item.get('intelligence')) - if speed is not None: + + if speed is not None and _should_apply('speed_kmh'): d.speed = int(round(speed)) - if weight is not None: + if weight is not None and _should_apply('weight_kg'): d.weight = int(round(weight)) - if height is not None: + if height is not None and _should_apply('height_m'): d.height = Decimal(str(round(height, 1))) - if intelligence is not None: + if intelligence is not None and _should_apply('intelligence_score'): if 0 <= intelligence <= 5: d.intelligence = int(round((intelligence / 5.0) * 100)) else: d.intelligence = int(round(max(0, min(100, intelligence)))) - facts = item.get('facts') - if isinstance(facts, list): - d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()]) - elif isinstance(facts, str): - d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()]) + + # Name + if item.get('name') and _should_apply('name'): + d.name = item.get('name') + # Ensure new records have a name (fallback) + if getattr(d, 'name', None) in (None, ''): + d.name = item.get('name') or f'Imported Dinosaur {idx + 1}' + + # Era and diet: accept code or display label + if item.get('era') and _should_apply('era'): + era_val = item.get('era') + if isinstance(era_val, str): + mapped = None + for code, label in Dinosaur.ERA_CHOICES: + if era_val.lower() == code.lower() or era_val.lower() == label.lower(): + mapped = code + break + if mapped: + d.era = mapped + else: + d.era = era_val + + if item.get('diet') and _should_apply('diet'): + diet_val = item.get('diet') + if isinstance(diet_val, str): + mapped = None + for code, label in Dinosaur.DIET_CHOICES: + if diet_val.lower() == code.lower() or diet_val.lower() == label.lower(): + mapped = code + break + if mapped: + d.diet = mapped + else: + d.diet = diet_val + + # Facts + if _should_apply('facts'): + facts = item.get('facts') + if isinstance(facts, list): + d.facts = '\n'.join([str(x).strip() for x in facts if str(x).strip()]) + elif isinstance(facts, str): + d.facts = '\n'.join([l.strip() for l in facts.splitlines() if l.strip()]) + d.save() applied += 1 diff --git a/db.sqlite3 b/db.sqlite3 index ae16086d3aeb96ea98a9d0acc79cd629f0658085..2707ba9a10a287c752643ec7327a0af499db02ab 100644 GIT binary patch delta 1296 zcmZ`&U2GIp82#?u>CDcyySEht%67Xuk^YCYJ{SVZ&-TZ%*19z{ZGgD#WV^OwcV{-U zv&hCk7!qE9ge_BhK|lek515eXLYhLN7=hGi2#JZ3s8Jt$@rCeUG$9i2o!E5S!pqH_ zne(0ReCIoJ_=0@+f?N!u%Tsk-=(0U}bQE11#h1bxzJwroTOilTr{pK{1NoEuL4G3( zQv&kT$^s2=k+cAJrZY$e26;fjh*MTdMP<6Iq!fBiOhF>)2 zpQ#`DWzzZnlzYjPt~kHr7Q0U*peP~#c7##X9zoEnrqfzR%j9!OGi9mCl#$OF8QqCb zebTl!K%hYh`=c&6hoC2|<}yZ3&70-$q-yF;{i)9z_WC(__Jqv0 z`k6J3=*%|T!!m&ogKu@|4GB6`vta0j1503+`m1xLrU_gos!tZutE9N(CqDSB z^vnBSAR#aa_e&q1yNU=i)C&!vu$`zT(4si;Vr^hW7pr)cZr_P-wFv8=2JRuRDHTbF zc(dZyKumb-9~bJXW-Avwao+=95FbVJaLtrkb@3 zb9ozjdN)2q^HID@j712vEA&Pb*R9mjJ>5XPo%o%`-Y|h6vuF$A%qbb(Fb!kO&{vQi zh~Zf}(1q`Gcy$m#u}5kbH^J))5)4==Q`MhPx}zJ%C<-8kf1qUL7O7kco; z*2J>}o?|k0BALmVwY0$cF{9ggAKjRWBe0f%WJw24613%^``un;!Vysk8CKH#7r144a<1S zC|76=z5gnnX&PI{%?~m2+3t_X62$kfiX!{N>2yE7xv786icQ7F^Qx{cicehp*aQlwhO67QZvChYTmCp_EZ)Mm zj$cZb!-rSw-E18;dB}aQB%25j?62@^A;wrJds$`Yl0#{okL0REVF(}EB9#QS72;V4 z%lXmyIzh*9!ri%-V4oRkVQ7YQ#@Rx>NSwqQqAQFq-Pyi91Oap2l$TC&pEx4)<9MsQ zw}hb19K`a$vnrg5>jRT|a%3Pr#N$SlXlcbe-rn8Ydb_C~S0Q#$A4~JmT!3%oNK~J| zC5rAM@SCQFys3vbFC7-v2u`~SQs9E1ISI?lg%QCZA2Eu>>jJwCajcppVCA257;c#(vs1Lxh%W!t;U zM40hMi}m712WD)2rP3u*F)Za=91!aAkW-v5F2ec{}&VMt}*}s