# 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" ```