diff --git a/docs/superpowers/plans/2026-05-30-plantnet-photo-id.md b/docs/superpowers/plans/2026-05-30-plantnet-photo-id.md new file mode 100644 index 0000000..653e219 --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-plantnet-photo-id.md @@ -0,0 +1,1399 @@ +# Pl@ntNet Photo ID Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Perenual + VPC text-search entry point with a Pl@ntNet photo-identification flow that lets users identify unknown plants by photo, then review and pick enrichment data field-by-field from Pl@ntNet and VPC. + +**Architecture:** New 3-step flow at `/identify/` (upload → species confirm → field picker) backed by a new `plantnet.py` service and `identify.py` view module. Django session carries state between steps; temp images stored via `default_storage` and deleted on completion. VPC enrichment runs automatically after species confirm. Card scan flow is unchanged. + +**Tech Stack:** Django 5.2 · Bootstrap 5.3 · requests · Pl@ntNet API v2 (`my-api.plantnet.org`) · Django sessions · pytest-django · unittest.mock + +--- + +## File Map + +| Action | Path | Responsibility | +|---|---|---| +| Create | `plants/services/plantnet.py` | Pl@ntNet API wrapper | +| Create | `plants/views/identify.py` | 3-step identify views | +| Create | `plants/templates/plants/identify_upload.html` | Step 1: photo upload UI | +| Create | `plants/templates/plants/identify_confirm.html` | Step 2: species picker UI | +| Create | `plants/templates/plants/identify_fields.html` | Step 3: field picker + save UI | +| Create | `plants/tests/test_plantnet.py` | Service unit tests | +| Create | `plants/tests/test_identify_views.py` | View integration tests | +| Modify | `plants/urls.py` | Add 3 identify URLs | +| Modify | `plantdb/settings.py` | Add `PLANTNET_API_KEY` | +| Modify | `plants/templates/plants/base.html` | "+" → `/identify/` | +| Modify | `plants/templates/plants/species_search.html` | Remove Perenual checkbox | +| Modify | `plants/templates/plants/plant_detail.html` | Fix delete button alignment | + +--- + +## Task 1: Settings + +**Files:** +- Modify: `plantdb/settings.py` + +Note: `.superpowers/` is already in `.gitignore` — nothing to do there. + +- [ ] **Step 1: Add `PLANTNET_API_KEY` to settings** + +In `plantdb/settings.py`, after the `PERENUAL_API_KEY` line: + +```python +PLANTNET_API_KEY = os.environ.get('PLANTNET_API_KEY', '') +``` + +- [ ] **Step 2: Commit** + +```bash +git add plantdb/settings.py +git commit -m "feat: add PLANTNET_API_KEY setting" +``` + +--- + +## Task 2: Pl@ntNet service (TDD) + +**Files:** +- Create: `plants/services/plantnet.py` +- Create: `plants/tests/test_plantnet.py` + +- [ ] **Step 1: Write failing tests** + +Create `plants/tests/test_plantnet.py`: + +```python +import pytest +import requests as req_lib +from unittest.mock import patch, MagicMock +from plants.services.plantnet import identify, PlantNetError + + +def _mock_response(results=None, status_code=200): + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = {'results': results or []} + if status_code == 429: + mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock) + elif status_code >= 400: + mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock) + else: + mock.raise_for_status = MagicMock() + return mock + + +def _api_results(): + return [ + { + 'score': 0.94, + 'species': { + 'scientificNameWithoutAuthor': 'Liatris spicata', + 'commonNames': ['Blazing star', 'Pronkster'], + 'family': {'scientificNameWithoutAuthor': 'Asteraceae'}, + 'genus': {'scientificNameWithoutAuthor': 'Liatris'}, + }, + 'gbif': {'id': '2889739'}, + }, + { + 'score': 0.04, + 'species': { + 'scientificNameWithoutAuthor': 'Liatris pycnostachya', + 'commonNames': ['Prairie blazing star'], + 'family': {'scientificNameWithoutAuthor': 'Asteraceae'}, + 'genus': {'scientificNameWithoutAuthor': 'Liatris'}, + }, + 'gbif': {'id': '1234567'}, + }, + ] + + +def test_identify_returns_empty_without_api_key(settings): + settings.PLANTNET_API_KEY = '' + with pytest.raises(PlantNetError, match='API key'): + identify([(b'fake', 'flower')]) + + +def test_identify_parses_results(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(_api_results())): + results = identify([(b'fake', 'flower')]) + + assert len(results) == 2 + assert results[0]['scientific_name'] == 'Liatris spicata' + assert results[0]['score'] == pytest.approx(0.94) + assert 'Blazing star' in results[0]['common_names'] + assert results[0]['family'] == 'Asteraceae' + assert results[0]['genus'] == 'Liatris' + assert results[0]['gbif_id'] == '2889739' + + +def test_identify_raises_on_no_results(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response([])): + with pytest.raises(PlantNetError, match='No match'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_quota_exceeded(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(status_code=429)): + with pytest.raises(PlantNetError, match='Daily limit'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_timeout(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + side_effect=req_lib.Timeout): + with pytest.raises(PlantNetError, match='timed out'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_other_http_error(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(status_code=500)): + with pytest.raises(PlantNetError): + identify([(b'fake', 'flower')]) + + +def test_identify_sends_correct_organs(settings): + settings.PLANTNET_API_KEY = 'test-key' + mock_post = MagicMock(return_value=_mock_response(_api_results())) + with patch('plants.services.plantnet.requests.post', mock_post): + identify([(b'img1', 'flower'), (b'img2', 'leaf')]) + + call_kwargs = mock_post.call_args + data = call_kwargs[1]['data'] + assert ('organs', 'flower') in data + assert ('organs', 'leaf') in data +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v +``` + +Expected: `ERROR` — `plants.services.plantnet` module not found. + +- [ ] **Step 3: Implement `plants/services/plantnet.py`** + +```python +import requests +from django.conf import settings + +_API_BASE = 'https://my-api.plantnet.org/v2/identify/all' +_TIMEOUT = 15 + + +class PlantNetError(Exception): + pass + + +def identify(images): + """ + Call Pl@ntNet identify API. + + images: list of (image_bytes, organ) tuples + organ: 'flower' | 'leaf' | 'fruit' | 'bark' | 'habit' | 'auto' + + Returns list of match dicts (up to 5), each: + scientific_name, common_names, score, gbif_id, family, genus + + Raises PlantNetError on any failure. + """ + if not settings.PLANTNET_API_KEY: + raise PlantNetError('API key not configured.') + + files = [ + ('images', (f'image{i}.jpg', img_bytes, 'image/jpeg')) + for i, (img_bytes, _) in enumerate(images) + ] + data = [('organs', organ) for _, organ in images] + + try: + resp = requests.post( + _API_BASE, + params={'api-key': settings.PLANTNET_API_KEY, 'lang': 'en'}, + files=files, + data=data, + timeout=_TIMEOUT, + ) + except requests.Timeout: + raise PlantNetError('Pl@ntNet timed out — try again.') + + if resp.status_code == 429: + raise PlantNetError('Daily limit reached (500/day).') + + try: + resp.raise_for_status() + except requests.HTTPError: + raise PlantNetError(f'Pl@ntNet API error ({resp.status_code}).') + + results = resp.json().get('results', []) + if not results: + raise PlantNetError('No match found — try a different photo or organ.') + + return [ + { + 'scientific_name': r['species']['scientificNameWithoutAuthor'], + 'common_names': r['species'].get('commonNames', []), + 'score': r['score'], + 'gbif_id': (r.get('gbif') or {}).get('id'), + 'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''), + 'genus': r['species'].get('genus', {}).get('scientificNameWithoutAuthor', ''), + } + for r in results[:5] + ] +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v +``` + +Expected: all 7 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plants/services/plantnet.py plants/tests/test_plantnet.py +git commit -m "feat: add Pl@ntNet identification service" +``` + +--- + +## Task 3: URL wiring + view stubs + +**Files:** +- Create: `plants/views/identify.py` +- Modify: `plants/urls.py` + +- [ ] **Step 1: Create view stubs** + +Create `plants/views/identify.py`: + +```python +from django.shortcuts import render, redirect + +_SESSION_KEYS = [ + 'identify_matches', 'identify_image_paths', 'identify_selected', + 'identify_vpc_results', 'identify_vpc_idx', +] + + +def _clear_session(request): + for key in _SESSION_KEYS: + request.session.pop(key, None) + + +def identify_upload(request): + if request.method == 'POST': + return redirect('identify_confirm') + return render(request, 'plants/identify_upload.html') + + +def identify_confirm(request): + if 'identify_matches' not in request.session: + return redirect('identify_upload') + if request.method == 'POST': + return redirect('identify_fields') + return render(request, 'plants/identify_confirm.html', { + 'matches': request.session.get('identify_matches', []), + }) + + +def identify_fields(request): + if 'identify_selected' not in request.session: + return redirect('identify_upload') + if request.method == 'POST': + _clear_session(request) + return redirect('plant_list') + return render(request, 'plants/identify_fields.html', { + 'selected': request.session.get('identify_selected', {}), + 'vpc_results': request.session.get('identify_vpc_results', []), + 'vpc_idx': request.session.get('identify_vpc_idx', 0), + }) +``` + +- [ ] **Step 2: Register URLs** + +In `plants/urls.py`, add import and 3 paths: + +```python +from plants.views import dashboard, plants, pruning, species, identify +``` + +Add after the existing `plants/scan/` path: + +```python +path('identify/', identify.identify_upload, name='identify_upload'), +path('identify/confirm/', identify.identify_confirm, name='identify_confirm'), +path('identify/fields/', identify.identify_fields, name='identify_fields'), +``` + +- [ ] **Step 3: Create minimal templates so views render** + +Create `plants/templates/plants/identify_upload.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Identify plant — PlantDB{% endblock %} +{% block content %} +
Upload step (coming soon)
+{% endblock %} +``` + +Create `plants/templates/plants/identify_confirm.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Confirm species — PlantDB{% endblock %} +{% block content %} +Confirm step (coming soon)
+{% endblock %} +``` + +Create `plants/templates/plants/identify_fields.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Review fields — PlantDB{% endblock %} +{% block content %} +Fields step (coming soon)
+{% endblock %} +``` + +- [ ] **Step 4: Verify server starts without errors** + +```bash +cd /home/stephan/devel/python/plantdb && python manage.py check +``` + +Expected: `System check identified no issues (0 silenced).` + +- [ ] **Step 5: Write guard tests** + +Add to `plants/tests/test_identify_views.py`: + +```python +import pytest +from django.test import Client +from django.urls import reverse + + +@pytest.fixture +def client(): + return Client() + + +def test_upload_get(client): + resp = client.get(reverse('identify_upload')) + assert resp.status_code == 200 + + +def test_confirm_redirects_without_session(client): + resp = client.get(reverse('identify_confirm')) + assert resp.status_code == 302 + assert reverse('identify_upload') in resp['Location'] + + +def test_fields_redirects_without_session(client): + resp = client.get(reverse('identify_fields')) + assert resp.status_code == 302 + assert reverse('identify_upload') in resp['Location'] +``` + +- [ ] **Step 6: Run guard tests** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py -v +``` + +Expected: 3 tests PASS. + +- [ ] **Step 7: Commit** + +```bash +git add plants/views/identify.py plants/urls.py \ + plants/templates/plants/identify_upload.html \ + plants/templates/plants/identify_confirm.html \ + plants/templates/plants/identify_fields.html \ + plants/tests/test_identify_views.py +git commit -m "feat: wire identify URL routes and stub views" +``` + +--- + +## Task 4: Step 1 — Upload view + template + +**Files:** +- Modify: `plants/views/identify.py` +- Modify: `plants/templates/plants/identify_upload.html` +- Modify: `plants/tests/test_identify_views.py` + +The upload view saves images to temp storage, calls the Pl@ntNet service, stores results in session. + +- [ ] **Step 1: Write failing upload view tests** + +Append to `plants/tests/test_identify_views.py`: + +```python +import io +from unittest.mock import patch +from django.core.files.uploadedfile import SimpleUploadedFile + + +def _fake_image(name='test.jpg'): + return SimpleUploadedFile(name, b'fake-image-bytes', content_type='image/jpeg') + + +def test_upload_post_no_images(client): + resp = client.post(reverse('identify_upload'), {}) + assert resp.status_code == 200 + assert b'select at least one' in resp.content.lower() or b'Please' in resp.content + + +def test_upload_post_calls_plantnet_and_redirects(client, settings): + settings.PLANTNET_API_KEY = 'test-key' + fake_matches = [ + {'scientific_name': 'Liatris spicata', 'common_names': ['Blazing star'], + 'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris'}, + ] + with patch('plants.views.identify.plantnet.identify', return_value=fake_matches): + with patch('plants.views.identify.default_storage') as mock_storage: + mock_storage.save.return_value = 'plants/temp_identify/abc_0.jpg' + mock_storage.path.return_value = '/tmp/abc_0.jpg' + resp = client.post(reverse('identify_upload'), { + 'images': [_fake_image()], + 'organs': ['flower'], + }) + assert resp.status_code == 302 + assert reverse('identify_confirm') in resp['Location'] + assert client.session['identify_matches'] == fake_matches + + +def test_upload_post_plantnet_error_rerenders(client, settings): + settings.PLANTNET_API_KEY = 'test-key' + from plants.services.plantnet import PlantNetError + with patch('plants.views.identify.plantnet.identify', + side_effect=PlantNetError('No match found')): + with patch('plants.views.identify.default_storage') as mock_storage: + mock_storage.save.return_value = 'plants/temp_identify/abc_0.jpg' + mock_storage.path.return_value = '/tmp/abc_0.jpg' + mock_storage.exists.return_value = True + resp = client.post(reverse('identify_upload'), { + 'images': [_fake_image()], + 'organs': ['flower'], + }) + assert resp.status_code == 200 + assert b'No match found' in resp.content +``` + +- [ ] **Step 2: Run to confirm tests fail** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_upload_post_no_images plants/tests/test_identify_views.py::test_upload_post_calls_plantnet_and_redirects plants/tests/test_identify_views.py::test_upload_post_plantnet_error_rerenders -v +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement upload view** + +Replace `identify_upload` in `plants/views/identify.py`: + +```python +import uuid +from django.shortcuts import render, redirect +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from plants.services import plantnet +from plants.services.plantnet import PlantNetError +from plants.services.vpc import search_species as vpc_search + + +_SESSION_KEYS = [ + 'identify_matches', 'identify_image_paths', 'identify_selected', + 'identify_vpc_results', 'identify_vpc_idx', +] + +_VALID_ORGANS = {'flower', 'leaf', 'fruit', 'bark', 'habit', 'auto'} + + +def _clear_session(request): + for key in _SESSION_KEYS: + request.session.pop(key, None) + + +def identify_upload(request): + if request.method != 'POST': + return render(request, 'plants/identify_upload.html') + + files = request.FILES.getlist('images') + organs = request.POST.getlist('organs') + + if not files: + return render(request, 'plants/identify_upload.html', + {'error': 'Please select at least one photo.'}) + + scan_id = uuid.uuid4().hex + saved_paths = [] + + for i, f in enumerate(files[:5]): + organ = organs[i] if i < len(organs) else 'auto' + if organ not in _VALID_ORGANS: + organ = 'auto' + name = f'plants/temp_identify/{scan_id}_{i}.jpg' + default_storage.save(name, ContentFile(f.read())) + saved_paths.append({'path': name, 'organ': organ}) + + try: + images = [ + (open(default_storage.path(p['path']), 'rb').read(), p['organ']) + for p in saved_paths + ] + matches = plantnet.identify(images) + except PlantNetError as e: + for p in saved_paths: + if default_storage.exists(p['path']): + default_storage.delete(p['path']) + return render(request, 'plants/identify_upload.html', {'error': str(e)}) + + request.session['identify_matches'] = matches + request.session['identify_image_paths'] = saved_paths + return redirect('identify_confirm') +``` + +- [ ] **Step 4: Run upload tests** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_upload_post_no_images plants/tests/test_identify_views.py::test_upload_post_calls_plantnet_and_redirects plants/tests/test_identify_views.py::test_upload_post_plantnet_error_rerenders -v +``` + +Expected: all 3 PASS. + +- [ ] **Step 5: Build upload template** + +Replace `plants/templates/plants/identify_upload.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Identify plant — PlantDB{% endblock %} +{% block content %} + ++ Add one or more photos, tag what each shows, then tap Identify. +
+ +{% if error %} +Tap the correct species, then confirm.
+ + + + +{% endblock %} +``` + +Note: `{{ m.score|floatformat:"0%" }}` won't produce a percentage automatically in Django — replace with: + +```html +{% widthratio m.score 1 100 %}% +``` + +So the badge line becomes: + +```html + + {% widthratio m.score 1 100 %}% + +``` + +- [ ] **Step 6: Commit** + +```bash +git add plants/views/identify.py plants/templates/plants/identify_confirm.html plants/tests/test_identify_views.py +git commit -m "feat: implement step 2 species confirm view and template" +``` + +--- + +## Task 6: Step 3 — Field picker view + template + +**Files:** +- Modify: `plants/views/identify.py` +- Modify: `plants/templates/plants/identify_fields.html` +- Modify: `plants/tests/test_identify_views.py` + +- [ ] **Step 1: Write failing field picker tests** + +Append to `plants/tests/test_identify_views.py`: + +```python +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 + 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): + existing = 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 + 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 + 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 + 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'" +``` + +- [ ] **Step 2: Run to confirm tests fail** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_fields_get_with_session plants/tests/test_identify_views.py::test_fields_post_creates_species_and_plant plants/tests/test_identify_views.py::test_fields_post_deduplicates_species plants/tests/test_identify_views.py::test_fields_post_clears_session plants/tests/test_identify_views.py::test_fields_post_uses_vpc_common_name -v +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement field picker view** + +First, add missing imports at the top of `plants/views/identify.py` (after the existing imports): + +```python +from plants.models import Plant, Species +``` + +Then replace `identify_fields` in `plants/views/identify.py` and add `_apply_vpc_care_fields` helper: + +```python +from plants.models import Plant, Species + + +def _apply_vpc_care_fields(species, vpc_result): + """Enrich species with care data from a VPC search result dict. + + vpc_search returns lightweight dicts — no care data. We need scrape_plant + for the full detail. This is called only when the user saves (step 3 POST). + """ + from plants.services.vpc import scrape_plant, VPCError + slug = vpc_result.get('slug', '') + if not slug: + return + try: + data = scrape_plant(slug) + except VPCError: + return + for field in ('bloom_months', 'max_height_cm', 'sunlight', 'growth_rate', + 'description', 'api_image_url', 'frost_hardiness_c', + 'planting_density_m2'): + val = data.get(field) + if val not in (None, '', []): + setattr(species, field, val) + species.vpc_slug = slug + species.vpc_raw = data + species.save() + + +def identify_fields(request): + if 'identify_selected' not in request.session: + return redirect('identify_upload') + + selected = request.session['identify_selected'] + vpc_results = request.session.get('identify_vpc_results', []) + vpc_idx = request.session.get('identify_vpc_idx', 0) + + # Handle VPC source change (GET with vpc_idx param) + if request.method == 'GET' and 'vpc_idx' in request.GET: + try: + new_idx = int(request.GET['vpc_idx']) + vpc_idx = max(0, min(new_idx, len(vpc_results) - 1)) + request.session['identify_vpc_idx'] = vpc_idx + except (ValueError, TypeError): + pass + return redirect('identify_fields') + + vpc_match = vpc_results[vpc_idx] if vpc_results else None + + if request.method == 'POST': + common_name_source = request.POST.get('common_name_source', 'plantnet') + scientific_name_source = request.POST.get('scientific_name_source', 'plantnet') + name = request.POST.get('name', '').strip() or selected['scientific_name'] + + if common_name_source == 'vpc' and vpc_match: + common_name = vpc_match['common_name'] + else: + common_name = (selected['common_names'] or [selected['scientific_name']])[0] + + if scientific_name_source == 'vpc' and vpc_match: + scientific_name = vpc_match['scientific_name'] + else: + scientific_name = selected['scientific_name'] + + species = Species.objects.filter(scientific_name=scientific_name).first() + if not species: + species = Species.objects.create( + scientific_name=scientific_name, + common_name=common_name, + ) + else: + species.common_name = common_name + species.save(update_fields=['common_name']) + + if vpc_match: + _apply_vpc_care_fields(species, vpc_match) + + plant = Plant.objects.create(name=name, species=species) + + # Clean up temp images + try: + for p in request.session.get('identify_image_paths', []): + if default_storage.exists(p['path']): + default_storage.delete(p['path']) + finally: + _clear_session(request) + + return redirect('plant_detail', pk=plant.pk) + + return render(request, 'plants/identify_fields.html', { + 'selected': selected, + 'vpc_results': vpc_results, + 'vpc_idx': vpc_idx, + 'vpc_match': vpc_match, + }) +``` + +- [ ] **Step 4: Run field picker tests** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_fields_get_with_session plants/tests/test_identify_views.py::test_fields_post_creates_species_and_plant plants/tests/test_identify_views.py::test_fields_post_deduplicates_species plants/tests/test_identify_views.py::test_fields_post_clears_session plants/tests/test_identify_views.py::test_fields_post_uses_vpc_common_name -v +``` + +Expected: all 5 PASS. + +- [ ] **Step 5: Run all identify tests** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py plants/tests/test_plantnet.py -v +``` + +Expected: all tests PASS. + +- [ ] **Step 6: Build field picker template** + +Replace `plants/templates/plants/identify_fields.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Review & save — PlantDB{% endblock %} +{% block content %} + +Tap a tile to switch source. Green border = selected.
+ +{% if vpc_match %} +