bloombase/docs/superpowers/plans/2026-05-30-plantnet-photo-id.md
Stephan Kerkman 1fa1f71291 docs: add Pl@ntNet photo ID implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 17:25:19 +02:00

49 KiB

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:

PLANTNET_API_KEY = os.environ.get('PLANTNET_API_KEY', '')
  • Step 2: Commit
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:

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
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v

Expected: ERRORplants.services.plantnet module not found.

  • Step 3: Implement plants/services/plantnet.py
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
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_plantnet.py -v

Expected: all 7 tests PASS.

  • Step 5: Commit
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:

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:

from plants.views import dashboard, plants, pruning, species, identify

Add after the existing plants/scan/ path:

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:

{% extends "plants/base.html" %}
{% block title %}Identify plant — PlantDB{% endblock %}
{% block content %}
<p>Upload step (coming soon)</p>
{% endblock %}

Create plants/templates/plants/identify_confirm.html:

{% extends "plants/base.html" %}
{% block title %}Confirm species — PlantDB{% endblock %}
{% block content %}
<p>Confirm step (coming soon)</p>
{% endblock %}

Create plants/templates/plants/identify_fields.html:

{% extends "plants/base.html" %}
{% block title %}Review fields — PlantDB{% endblock %}
{% block content %}
<p>Fields step (coming soon)</p>
{% endblock %}
  • Step 4: Verify server starts without errors
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:

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
cd /home/stephan/devel/python/plantdb && python -m pytest plants/tests/test_identify_views.py -v

Expected: 3 tests PASS.

  • Step 7: Commit
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:

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
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:

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
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:

{% extends "plants/base.html" %}
{% block title %}Identify plant — PlantDB{% endblock %}
{% block content %}

<div class="d-flex align-items-center gap-2 mb-3">
    <a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
    <h5 class="mb-0">Identify plant</h5>
</div>

<p class="text-muted small mb-3">
    Add one or more photos, tag what each shows, then tap Identify.
</p>

{% if error %}
<div class="alert alert-warning py-2 small mb-3">{{ error }}</div>
{% endif %}

<form method="post" enctype="multipart/form-data" id="identify-form">
    {% csrf_token %}
    <input type="file" name="images" accept="image/jpeg,image/png" class="d-none" id="file-picker">

    <div id="photo-rows" class="mb-3"></div>

    <div id="add-btn-wrap" class="mb-3">
        <label class="btn btn-outline-secondary w-100 py-3" for="file-picker" id="add-photo-label">
            📷 Add photo <span id="photo-count" class="text-muted small"></span>
        </label>
    </div>

    <button type="submit" class="btn btn-success w-100" id="identify-btn" disabled>
        Identify →
    </button>
</form>

<div class="text-center mt-3">
    <a href="{% url 'plant_add' %}" class="text-muted small">Or enter name manually</a>
</div>

<div id="identify-spinner" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,.88); z-index:9999; align-items:center; justify-content:center; flex-direction:column; gap:12px;">
    <div class="spinner-border text-success" role="status"><span class="visually-hidden">Identifying…</span></div>
    <p class="text-muted small mb-0">Identifying plant…</p>
</div>

<script>
const ORGANS = [
    {val: 'flower', label: '🌸 flower'},
    {val: 'leaf',   label: '🍃 leaf'},
    {val: 'habit',  label: '🌿 habit'},
    {val: 'fruit',  label: '🍎 fruit'},
    {val: 'bark',   label: '🪵 bark'},
    {val: 'auto',   label: '✨ auto'},
];
const selected = [];  // {file, organ, objectUrl}
const picker = document.getElementById('file-picker');

picker.addEventListener('change', function () {
    if (!this.files[0] || selected.length >= 5) return;
    selected.push({file: this.files[0], organ: 'auto', objectUrl: URL.createObjectURL(this.files[0])});
    this.value = '';
    render();
});

function setOrgan(idx, organ) {
    selected[idx].organ = organ;
    render();
}

function removePhoto(idx) {
    URL.revokeObjectURL(selected[idx].objectUrl);
    selected.splice(idx, 1);
    render();
}

function render() {
    const rows = document.getElementById('photo-rows');
    rows.innerHTML = '';
    selected.forEach((item, i) => {
        const row = document.createElement('div');
        row.className = 'd-flex gap-2 align-items-start mb-3';
        row.innerHTML = `
            <div style="position:relative;flex-shrink:0;">
                <img src="${item.objectUrl}" style="width:72px;height:72px;object-fit:cover;border-radius:8px;" alt="">
                <button type="button" onclick="removePhoto(${i})"
                    style="position:absolute;top:2px;right:2px;background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:50%;width:20px;height:20px;font-size:10px;cursor:pointer;padding:0;line-height:1;">✕</button>
            </div>
            <div style="flex:1;">
                <div class="text-muted small mb-1">What's shown?</div>
                <div class="d-flex flex-wrap gap-1">
                    ${ORGANS.map(o => `
                        <span onclick="setOrgan(${i}, '${o.val}')"
                              style="cursor:pointer; padding:2px 8px; border-radius:10px; font-size:11px; ${item.organ === o.val ? 'background:#52b788;color:#fff;' : 'background:#e9ecef;color:#6c757d;'}">
                            ${o.label}
                        </span>
                    `).join('')}
                </div>
                <input type="hidden" name="organs" value="${item.organ}" id="organ-${i}">
            </div>`;
        // Update hidden organ input when chip changes
        rows.appendChild(row);
    });

    // Rebuild organ hidden inputs after render
    document.querySelectorAll('input[name="organs"]').forEach((el, i) => {
        if (selected[i]) el.value = selected[i].organ;
    });

    document.getElementById('photo-count').textContent = selected.length ? `(${selected.length}/5)` : '';
    document.getElementById('add-btn-wrap').style.display = selected.length >= 5 ? 'none' : '';
    document.getElementById('identify-btn').disabled = selected.length === 0;
}

document.getElementById('identify-form').addEventListener('submit', function (e) {
    if (selected.length === 0) { e.preventDefault(); return; }
    const dt = new DataTransfer();
    selected.forEach(s => dt.items.add(s.file));
    picker.files = dt.files;
    document.getElementById('identify-spinner').style.display = 'flex';
});
</script>
{% endblock %}
  • Step 6: Commit
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:

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
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:

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
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:

{% extends "plants/base.html" %}
{% block title %}Confirm species — PlantDB{% endblock %}
{% block content %}

<div class="d-flex align-items-center gap-2 mb-3">
    <a href="{% url 'identify_upload' %}" class="btn btn-sm btn-outline-secondary"></a>
    <h5 class="mb-0">Is this your plant?</h5>
</div>

<p class="text-muted small mb-3">Tap the correct species, then confirm.</p>

<form method="post" id="confirm-form">
    {% csrf_token %}
    <input type="hidden" name="match_idx" id="match_idx_input" value="0">

    <div class="d-flex flex-column gap-2 mb-4" id="match-list">
        {% for m in matches %}
        <div class="match-card d-flex align-items-center gap-3 p-3 border rounded"
             style="cursor:pointer; {% if forloop.first %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
             data-idx="{{ forloop.counter0 }}"
             onclick="selectMatch(this)">
            <div class="flex-grow-1">
                <div class="fw-bold">{{ m.scientific_name }}</div>
                {% if m.common_names %}
                <div class="text-muted small">{{ m.common_names|join:", " }}</div>
                {% endif %}
                <div class="text-muted small fst-italic">{{ m.family }}</div>
            </div>
            <div>
                {% with pct=m.score|floatformat:0 %}
                <span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
                    {{ m.score|floatformat:"0%" }}
                </span>
                {% endwith %}
            </div>
        </div>
        {% endfor %}
    </div>

    <button type="submit" class="btn btn-success w-100" id="confirm-btn">
        Use selected species →
    </button>
</form>

<script>
function selectMatch(el) {
    document.querySelectorAll('.match-card').forEach(c => {
        c.style.borderColor = '';
        c.style.background = '';
    });
    el.style.borderColor = '#52b788';
    el.style.background = '#f0faf4';
    document.getElementById('match_idx_input').value = el.dataset.idx;
}
</script>
{% endblock %}

Note: {{ m.score|floatformat:"0%" }} won't produce a percentage automatically in Django — replace with:

{% widthratio m.score 1 100 %}%

So the badge line becomes:

<span class="badge {% if m.score >= 0.70 %}bg-success{% elif m.score >= 0.30 %}bg-warning text-dark{% else %}bg-secondary{% endif %}">
    {% widthratio m.score 1 100 %}%
</span>
  • Step 6: Commit
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:

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
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):

from plants.models import Plant, Species

Then replace identify_fields in plants/views/identify.py and add _apply_vpc_care_fields helper:

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
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
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:

{% extends "plants/base.html" %}
{% block title %}Review & save — PlantDB{% endblock %}
{% block content %}

<div class="d-flex align-items-center gap-2 mb-3">
    <a href="{% url 'identify_confirm' %}" class="btn btn-sm btn-outline-secondary"></a>
    <h5 class="mb-0">Review & save</h5>
</div>

<p class="text-muted small mb-3">Tap a tile to switch source. Green border = selected.</p>

{% if vpc_match %}
<div class="d-flex align-items-center gap-2 p-2 rounded mb-3" style="background:#d8f3dc;">
    <span style="font-size:18px;">🏪</span>
    <div class="flex-grow-1 small">
        <div class="fw-semibold">{{ vpc_match.common_name }}</div>
        <div class="text-muted" style="font-size:11px;">vasteplantencatalogus.nl</div>
    </div>
    {% if vpc_results|length > 1 %}
    <div class="dropdown">
        <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
            change
        </button>
        <ul class="dropdown-menu dropdown-menu-end">
            {% for r in vpc_results %}
            <li>
                <a class="dropdown-item {% if forloop.counter0 == vpc_idx %}active{% endif %}"
                   href="{% url 'identify_fields' %}?vpc_idx={{ forloop.counter0 }}">
                    {{ r.common_name }}
                </a>
            </li>
            {% endfor %}
        </ul>
    </div>
    {% endif %}
</div>
{% else %}
<div class="alert alert-warning py-2 small mb-3">
    No VPC data found — using Pl@ntNet data only.
</div>
{% endif %}

<form method="post">
    {% csrf_token %}

    <!-- Common name -->
    <div class="mb-3">
        <div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Common name</div>
        <div class="d-flex gap-2">
            <div class="flex-fill source-tile p-2 border rounded"
                 style="cursor:pointer; {% if not vpc_match %}border-color:#52b788 !important; background:#f0faf4;{% endif %}"
                 data-field="common_name" data-value="plantnet" onclick="selectSource(this)">
                <span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
                <div class="small fw-semibold">{{ selected.common_names.0|default:selected.scientific_name }}</div>
                {% if selected.common_names|length > 1 %}
                <div class="text-muted" style="font-size:10px;">{{ selected.common_names.1 }}</div>
                {% endif %}
            </div>
            {% if vpc_match %}
            <div class="flex-fill source-tile p-2 border rounded"
                 style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
                 data-field="common_name" data-value="vpc" onclick="selectSource(this)">
                <span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
                <div class="small fw-semibold">{{ vpc_match.common_name }}</div>
            </div>
            {% endif %}
        </div>
        <input type="hidden" name="common_name_source" id="common_name_source"
               value="{% if vpc_match %}vpc{% else %}plantnet{% endif %}">
    </div>

    <!-- Scientific name -->
    <div class="mb-3">
        <div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">Scientific name</div>
        <div class="d-flex gap-2">
            <div class="flex-fill source-tile p-2 border rounded"
                 style="cursor:pointer; border-color:#52b788 !important; background:#f0faf4;"
                 data-field="scientific_name" data-value="plantnet" onclick="selectSource(this)">
                <span class="badge mb-1" style="background:#52b788; font-size:9px;">Pl@ntNet</span>
                <div class="small fw-semibold fst-italic">{{ selected.scientific_name }}</div>
            </div>
            {% if vpc_match %}
            <div class="flex-fill source-tile p-2 border rounded"
                 style="cursor:pointer;"
                 data-field="scientific_name" data-value="vpc" onclick="selectSource(this)">
                <span class="badge mb-1" style="background:#6c757d; font-size:9px;">VPC</span>
                <div class="small fw-semibold fst-italic">{{ vpc_match.scientific_name }}</div>
            </div>
            {% endif %}
        </div>
        <input type="hidden" name="scientific_name_source" id="scientific_name_source" value="plantnet">
    </div>

    {% if vpc_match %}
    <hr class="my-3">
    <div class="text-muted small text-uppercase fw-semibold mb-2" style="letter-spacing:.05em; font-size:10px;">
        VPC care data <span class="badge ms-1" style="background:#e9ecef; color:#6c757d; font-size:9px;">from VPC</span>
    </div>
    <div class="row g-2 mb-3">
        <div class="col-6">
            <div class="p-2 rounded" style="background:#f8f9fa; font-size:12px;">
                <div class="text-muted" style="font-size:10px; text-transform:uppercase;">Loaded after save</div>
                <div class="small text-muted">bloom · height · sunlight · frost · density</div>
            </div>
        </div>
    </div>
    {% endif %}

    <hr class="my-3">

    <div class="mb-3">
        <label class="form-label fw-semibold small">My plant name <span class="text-danger">*</span></label>
        <input type="text" name="name" class="form-control"
               value="{{ selected.scientific_name }}" required autofocus>
        <div class="text-muted" style="font-size:11px; margin-top:3px;">How you refer to this plant in your collection</div>
    </div>

    <button type="submit" class="btn btn-success w-100">Save plant ✓</button>
</form>

<script>
function selectSource(el) {
    const field = el.dataset.field;
    document.querySelectorAll(`.source-tile[data-field="${field}"]`).forEach(t => {
        t.style.borderColor = '';
        t.style.background = '';
    });
    el.style.borderColor = '#52b788';
    el.style.background = '#f0faf4';
    document.getElementById(field + '_source').value = el.dataset.value;
}

// Set initial states
document.querySelectorAll('input[type="hidden"][id$="_source"]').forEach(input => {
    const tile = document.querySelector(`.source-tile[data-field="${input.id.replace('_source','')}"][data-value="${input.value}"]`);
    if (tile) {
        tile.style.borderColor = '#52b788';
        tile.style.background = '#f0faf4';
    }
});
</script>
{% 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
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:

<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>

Change to:

<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
  • 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:

<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 Plant card</a>
{% if plant.card_photos.all %}
<a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">✂️ Crop thumbnail</a>
{% endif %}

<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a>

Replace with:

<div class="d-flex flex-wrap gap-2 mt-2">
    <a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary">🪧 Plant card</a>
    {% if plant.card_photos.all %}
    <a href="{% url 'crop_thumbnail' plant.pk %}" class="btn btn-sm btn-outline-secondary">✂️ Crop thumbnail</a>
    {% endif %}
    <a href="{% url 'plant_delete' plant.pk %}" class="btn btn-sm btn-outline-danger">Delete plant</a>
</div>
  • Step 4: Run full test suite
cd /home/stephan/devel/python/plantdb && python -m pytest -v

Expected: all tests PASS.

  • Step 5: Commit
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:

echo "PLANTNET_API_KEY=your-key-here" >> /home/stephan/stacks/plantdb/.env
  • Step 2: Deploy
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
git add -p
git commit -m "fix: smoke test corrections"