feat: implement step 3 field picker view and template

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stephan Kerkman 2026-05-30 17:48:53 +02:00
parent 62fdbf7029
commit adea702a77
2 changed files with 221 additions and 2 deletions

View file

@ -1,5 +1,133 @@
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% block title %}Review fields — PlantDB{% endblock %} {% block title %}Review & save — PlantDB{% endblock %}
{% block content %} {% block content %}
<p>Fields step (coming soon)</p>
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'identify_confirm' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">Review & save</h5>
</div>
<p class="text-muted small mb-3">Tap a tile to switch source. Green border = selected.</p>
{% if vpc_match %}
<div class="d-flex align-items-center gap-2 p-2 rounded mb-3" style="background:#d8f3dc;">
<span style="font-size:18px;">🏪</span>
<div class="flex-grow-1 small">
<div class="fw-semibold">{{ vpc_match.common_name }}</div>
<div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div>
</div>
{% if vpc_results|length > 1 %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
change
</button>
<ul class="dropdown-menu dropdown-menu-end">
{% for r in vpc_results %}
<li>
<a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
{{ r.common_name }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% else %}
<div class="alert alert-warning py-2 small mb-3">
No VPC data found — using Pl@ntNet data only.
</div>
{% endif %}
<form method="post">
{% csrf_token %}
<!-- Common name -->
<div class="mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Common name</div>
<div class="d-flex gap-2">
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; {% if not vpc_match %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
data-field="common_name" data-value="plantnet" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
<div class="small fw-semibold">{{ selected.common_names.0|default:selected.scientific_name }}</div>
{% if selected.common_names|length > 1 %}
<div class="text-muted" style="font-size:10px;">{{ selected.common_names.1 }}</div>
{% endif %}
</div>
{% if vpc_match %}
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
data-field="common_name" data-value="vpc" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
<div class="small fw-semibold">{{ vpc_match.common_name }}</div>
</div>
{% endif %}
</div>
<input type="hidden" name="common_name_source" id="common_name_source"
value="{% if vpc_match %}vpc{% else %}plantnet{% endif %}">
</div>
<!-- Scientific name -->
<div class="mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Scientific name</div>
<div class="d-flex gap-2">
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
data-field="scientific_name" data-value="plantnet" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
<div class="small fw-semibold fst-italic">{{ selected.scientific_name }}</div>
</div>
{% if vpc_match %}
<div class="flex-fill source-tile p-2 border rounded"
style="cursor:pointer;"
data-field="scientific_name" data-value="vpc" onclick="selectSource(this)">
<span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
<div class="small fw-semibold fst-italic">{{ vpc_match.scientific_name }}</div>
</div>
{% endif %}
</div>
<input type="hidden" name="scientific_name_source" id="scientific_name_source" value="plantnet">
</div>
{% if vpc_match %}
<hr class="my-3">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">
VPC care data <span class="badge ms-1" style="background:#e9ecef; color:#6c757d; font-size:9px;">from VPC</span>
</div>
<div class="row g-2 mb-3">
<div class="col-12">
<div class="p-2 rounded" style="background:#f8f9fa; font-size:12px;">
<div class="text-muted" style="font-size:10px; text-transform:uppercase; margin-bottom:2px;">Loaded after save</div>
<div class="small text-muted">bloom months · height · sunlight · frost hardiness · planting density</div>
</div>
</div>
</div>
{% endif %}
<hr class="my-3">
<div class="mb-3">
<label class="form-label fw-semibold small">My plant name <span class="text-danger">*</span></label>
<input type="text" name="name" class="form-control"
value="{{ selected.scientific_name }}" required autofocus>
<div class="text-muted" style="font-size:11px; margin-top:3px;">How you refer to this plant in your collection</div>
</div>
<button type="submit" class="btn btn-success w-100">Save plant ✓</button>
</form>
<script>
function selectSource(el) {
const field = el.dataset.field;
document.querySelectorAll(`.source-tile[data-field="${field}"]`).forEach(t => {
t.style.borderColor = '';
t.style.background = '';
});
el.style.borderColor = '#52b788';
el.style.background = '#f0faf4';
document.getElementById(field + '_source').value = el.dataset.value;
}
</script>
{% endblock %} {% endblock %}

View file

@ -116,3 +116,94 @@ def test_confirm_post_handles_vpc_failure(client):
resp = client.post(reverse('identify_confirm'), {'match_idx': '0'}) resp = client.post(reverse('identify_confirm'), {'match_idx': '0'})
assert resp.status_code == 302 assert resp.status_code == 302
assert client.session['identify_vpc_results'] == [] assert client.session['identify_vpc_results'] == []
from plants.models import Plant, Species
def _set_fields_session(client, vpc_results=None):
session = client.session
session['identify_selected'] = {
'scientific_name': 'Liatris spicata',
'common_names': ['Blazing star', 'Pronkster'],
'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris',
}
session['identify_vpc_results'] = vpc_results if vpc_results is not None else [
{'slug': 'liatris-spicata', 'common_name': 'Liatris spicata', 'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'},
]
session['identify_vpc_idx'] = 0
session['identify_matches'] = []
session['identify_image_paths'] = []
session.save()
@pytest.mark.django_db
def test_fields_get_with_session(client):
_set_fields_session(client)
resp = client.get(reverse('identify_fields'))
assert resp.status_code == 200
assert b'Liatris spicata' in resp.content
@pytest.mark.django_db
def test_fields_post_creates_species_and_plant(client):
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
with patch('plants.views.identify._apply_vpc_care_fields'):
resp = client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert resp.status_code == 302
assert Plant.objects.filter(name='Liatris').exists()
species = Species.objects.get(scientific_name='Liatris spicata')
assert species.common_name == 'Blazing star'
@pytest.mark.django_db
def test_fields_post_deduplicates_species(client):
Species.objects.create(scientific_name='Liatris spicata', common_name='Old name')
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
with patch('plants.views.identify._apply_vpc_care_fields'):
client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert Species.objects.filter(scientific_name='Liatris spicata').count() == 1
@pytest.mark.django_db
def test_fields_post_clears_session(client):
_set_fields_session(client)
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
with patch('plants.views.identify._apply_vpc_care_fields'):
client.post(reverse('identify_fields'), {
'common_name_source': 'plantnet',
'scientific_name_source': 'plantnet',
'name': 'Liatris',
})
assert 'identify_selected' not in client.session
@pytest.mark.django_db
def test_fields_post_uses_vpc_common_name(client):
_set_fields_session(client, vpc_results=[
{'slug': 'liatris-spicata-kobold', 'common_name': "Liatris 'Kobold'",
'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'},
])
with patch('plants.views.identify.default_storage') as mock_storage:
mock_storage.exists.return_value = False
with patch('plants.views.identify._apply_vpc_care_fields'):
client.post(reverse('identify_fields'), {
'common_name_source': 'vpc',
'scientific_name_source': 'plantnet',
'name': 'Liatris Kobold',
})
species = Species.objects.get(scientific_name='Liatris spicata')
assert species.common_name == "Liatris 'Kobold'"