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 %} + +
+ +
Identify plant
+
+ +

+ Add one or more photos, tag what each shows, then tap Identify. +

+ +{% if error %} +
{{ error }}
+{% endif %} + +
+ {% csrf_token %} + + +
+ +
+ +
+ + +
+ +
+ Or enter name manually +
+ + + + +{% endblock %} +``` + +- [ ] **Step 6: Commit** + +```bash +git add plants/views/identify.py plants/templates/plants/identify_upload.html plants/tests/test_identify_views.py +git commit -m "feat: implement step 1 upload view and template" +``` + +--- + +## Task 5: Step 2 — Confirm view + template + +**Files:** +- Modify: `plants/views/identify.py` +- Modify: `plants/templates/plants/identify_confirm.html` +- Modify: `plants/tests/test_identify_views.py` + +- [ ] **Step 1: Write failing confirm view tests** + +Append to `plants/tests/test_identify_views.py`: + +```python +def _set_matches(client): + session = client.session + session['identify_matches'] = [ + {'scientific_name': 'Liatris spicata', 'common_names': ['Blazing star'], + 'score': 0.94, 'gbif_id': '123', 'family': 'Asteraceae', 'genus': 'Liatris'}, + {'scientific_name': 'Liatris pycnostachya', 'common_names': ['Prairie blazing star'], + 'score': 0.04, 'gbif_id': '456', 'family': 'Asteraceae', 'genus': 'Liatris'}, + ] + session['identify_image_paths'] = [{'path': 'plants/temp_identify/x_0.jpg', 'organ': 'flower'}] + session.save() + + +def test_confirm_get_with_session(client): + _set_matches(client) + resp = client.get(reverse('identify_confirm')) + assert resp.status_code == 200 + assert b'Liatris spicata' in resp.content + + +def test_confirm_post_stores_selection_and_redirects(client): + _set_matches(client) + with patch('plants.views.identify.vpc_search', return_value=[ + {'slug': 'liatris-spicata', 'common_name': 'Liatris spicata', 'scientific_name': 'Liatris spicata', 'thumbnail_url': '', 'source': 'vpc'}, + ]): + resp = client.post(reverse('identify_confirm'), {'match_idx': '0'}) + assert resp.status_code == 302 + assert reverse('identify_fields') in resp['Location'] + assert client.session['identify_selected']['scientific_name'] == 'Liatris spicata' + assert len(client.session['identify_vpc_results']) == 1 + + +def test_confirm_post_handles_vpc_failure(client): + _set_matches(client) + from plants.services.vpc import VPCError + with patch('plants.views.identify.vpc_search', side_effect=VPCError('down')): + resp = client.post(reverse('identify_confirm'), {'match_idx': '0'}) + assert resp.status_code == 302 + assert client.session['identify_vpc_results'] == [] +``` + +- [ ] **Step 2: Run to confirm tests fail** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_confirm_get_with_session plants/tests/test_identify_views.py::test_confirm_post_stores_selection_and_redirects plants/tests/test_identify_views.py::test_confirm_post_handles_vpc_failure -v +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement confirm view** + +Replace `identify_confirm` in `plants/views/identify.py`: + +```python +def identify_confirm(request): + if 'identify_matches' not in request.session: + return redirect('identify_upload') + + matches = request.session['identify_matches'] + + if request.method == 'POST': + try: + idx = int(request.POST.get('match_idx', 0)) + idx = max(0, min(idx, len(matches) - 1)) + except (ValueError, TypeError): + idx = 0 + + selected = matches[idx] + request.session['identify_selected'] = selected + + try: + vpc_results = vpc_search(selected['scientific_name'])[:3] + except Exception: + vpc_results = [] + request.session['identify_vpc_results'] = vpc_results + request.session['identify_vpc_idx'] = 0 + + return redirect('identify_fields') + + return render(request, 'plants/identify_confirm.html', {'matches': matches}) +``` + +- [ ] **Step 4: Run confirm tests** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py::test_confirm_get_with_session plants/tests/test_identify_views.py::test_confirm_post_stores_selection_and_redirects plants/tests/test_identify_views.py::test_confirm_post_handles_vpc_failure -v +``` + +Expected: all 3 PASS. + +- [ ] **Step 5: Build confirm template** + +Replace `plants/templates/plants/identify_confirm.html`: + +```html +{% extends "plants/base.html" %} +{% block title %}Confirm species — PlantDB{% endblock %} +{% block content %} + +
+ +
Is this your plant?
+
+ +

Tap the correct species, then confirm.

+ +
+ {% csrf_token %} + + +
+ {% for m in matches %} +
+
+
{{ m.scientific_name }}
+ {% if m.common_names %} +
{{ m.common_names|join:", " }}
+ {% endif %} +
{{ m.family }}
+
+
+ {% with pct=m.score|floatformat:0 %} + + {{ m.score|floatformat:"0%" }} + + {% endwith %} +
+
+ {% endfor %} +
+ + +
+ + +{% 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 %} + +
+ +
Review & save
+
+ +

Tap a tile to switch source. Green border = selected.

+ +{% if vpc_match %} +
+ 🏪 +
+
{{ vpc_match.common_name }}
+
vasteplantencatalogus.nl
+
+ {% if vpc_results|length > 1 %} + + {% endif %} +
+{% else %} +
+ No VPC data found — using Pl@ntNet data only. +
+{% endif %} + +
+ {% csrf_token %} + + +
+
Common name
+
+
+ Pl@ntNet +
{{ selected.common_names.0|default:selected.scientific_name }}
+ {% if selected.common_names|length > 1 %} +
{{ selected.common_names.1 }}
+ {% endif %} +
+ {% if vpc_match %} +
+ VPC +
{{ vpc_match.common_name }}
+
+ {% endif %} +
+ +
+ + +
+
Scientific name
+
+
+ Pl@ntNet +
{{ selected.scientific_name }}
+
+ {% if vpc_match %} +
+ VPC +
{{ vpc_match.scientific_name }}
+
+ {% endif %} +
+ +
+ + {% if vpc_match %} +
+
+ VPC care data from VPC +
+
+
+
+
Loaded after save
+
bloom · height · sunlight · frost · density
+
+
+
+ {% endif %} + +
+ +
+ + +
How you refer to this plant in your collection
+
+ + +
+ + +{% endblock %} +``` + +Note on VPC care data: the view calls `_apply_vpc_care_fields` which scrapes the full VPC plant page on save. The tile above says "Loaded after save" to be honest about when the data arrives. This can be refined later to show a preview by scraping VPC during step 2 instead. + +- [ ] **Step 7: Commit** + +```bash +git add plants/views/identify.py plants/templates/plants/identify_fields.html plants/tests/test_identify_views.py +git commit -m "feat: implement step 3 field picker view and template" +``` + +--- + +## Task 7: Nav, species_search cleanup, delete button fix + +**Files:** +- Modify: `plants/templates/plants/base.html` +- Modify: `plants/templates/plants/species_search.html` +- Modify: `plants/templates/plants/plant_detail.html` + +- [ ] **Step 1: Update nav "+" button** + +In `plants/templates/plants/base.html`, find: + +```html ++ +``` + +Change to: + +```html ++ +``` + +- [ ] **Step 2: Remove Perenual from species_search** + +In `plants/templates/plants/species_search.html`, remove the entire `include_perenual` checkbox section if present. Also remove the `hx-include` reference to it from the search button. The current `species_search.html` has no Perenual checkbox in it yet (it was in the search results partial). Check `plants/templates/plants/partials/species_results.html` — if it has a Perenual toggle, remove it. + +Open `plants/templates/plants/partials/species_results.html` and remove any `include_perenual` checkbox or Perenual results section. + +- [ ] **Step 3: Fix delete button on plant detail** + +In `plants/templates/plants/plant_detail.html`, find the action buttons section near the bottom: + +```html +🪧 Plant card +{% if plant.card_photos.all %} +✂️ Crop thumbnail +{% endif %} + +Delete plant +``` + +Replace with: + +```html +
+ 🪧 Plant card + {% if plant.card_photos.all %} + ✂️ Crop thumbnail + {% endif %} + Delete plant +
+``` + +- [ ] **Step 4: Run full test suite** + +```bash +cd /home/stephan/devel/python/plantdb && python -m pytest -v +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add plants/templates/plants/base.html \ + plants/templates/plants/species_search.html \ + plants/templates/plants/partials/species_results.html \ + plants/templates/plants/plant_detail.html +git commit -m "feat: update nav, remove Perenual UI, fix delete button alignment" +``` + +--- + +## Task 8: Manual smoke test + deploy + +- [ ] **Step 1: Add `PLANTNET_API_KEY` to `.env` on dockerhost** + +Get an API key from https://my.plantnet.org/signup (free, instant). Then on dockerhost: + +```bash +echo "PLANTNET_API_KEY=your-key-here" >> /home/stephan/stacks/plantdb/.env +``` + +- [ ] **Step 2: Deploy** + +```bash +rsync -av --exclude='*.pyc' --exclude='__pycache__' --exclude='.venv' --exclude='data/' --exclude='.git' --exclude='.playwright-mcp' --exclude='.superpowers' . dockerhost:/home/stephan/stacks/plantdb/ +ssh dockerhost "cd /home/stephan/stacks/plantdb && docker compose up -d --build web" +``` + +- [ ] **Step 3: Smoke test** + +Open `http://dockerhost:8083`, tap "+", check: +1. Upload page loads +2. Add a photo, verify organ chips appear, default is `auto` +3. Tap Identify → spinner appears → species confirm page loads with matches +4. Pick a species → field picker loads with Pl@ntNet + VPC data +5. Edit plant name → Save → redirected to plant detail +6. Verify species data (bloom months, sunlight etc.) populated from VPC +7. On plant detail: verify action buttons (Plant card / Crop thumbnail / Delete) are in a consistent row on mobile + +- [ ] **Step 4: Final commit if any smoke-test fixes** + +```bash +git add -p +git commit -m "fix: smoke test corrections" +```