# 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 %}