diff --git a/plants/templates/plants/identify_upload.html b/plants/templates/plants/identify_upload.html
index b6abd76..3ddbf44 100644
--- a/plants/templates/plants/identify_upload.html
+++ b/plants/templates/plants/identify_upload.html
@@ -1,5 +1,118 @@
{% extends "plants/base.html" %}
{% block title %}Identify plant — PlantDB{% endblock %}
{% block content %}
-
Upload step (coming soon)
+
+
+
+
+ Add one or more photos, tag what each shows, then tap Identify.
+
+
+{% if error %}
+{{ error }}
+{% endif %}
+
+
+
+
+
+
+
Identifying…
+
Identifying plant…
+
+
+
{% endblock %}
diff --git a/plants/tests/test_identify_views.py b/plants/tests/test_identify_views.py
index b46d556..6d1146b 100644
--- a/plants/tests/test_identify_views.py
+++ b/plants/tests/test_identify_views.py
@@ -1,6 +1,9 @@
import pytest
from django.test import Client
from django.urls import reverse
+import io
+from unittest.mock import patch
+from django.core.files.uploadedfile import SimpleUploadedFile
@pytest.fixture
@@ -23,3 +26,50 @@ 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']
+
+
+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
+
+
+@pytest.mark.django_db
+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
diff --git a/plants/views/identify.py b/plants/views/identify.py
index c48baf8..20425c6 100644
--- a/plants/views/identify.py
+++ b/plants/views/identify.py
@@ -1,10 +1,19 @@
+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
+from plants.models import Plant, Species
_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:
@@ -12,29 +21,152 @@ def _clear_session(request):
def identify_upload(request):
- if request.method == 'POST':
- return redirect('identify_confirm')
- return render(request, 'plants/identify_upload.html')
+ 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 = []
+ images = []
+
+ for i, f in enumerate(files[:5]):
+ organ = organs[i] if i < len(organs) else 'auto'
+ if organ not in _VALID_ORGANS:
+ organ = 'auto'
+ file_bytes = f.read()
+ name = f'plants/temp_identify/{scan_id}_{i}.jpg'
+ default_storage.save(name, ContentFile(file_bytes))
+ saved_paths.append({'path': name, 'organ': organ})
+ images.append((file_bytes, organ))
+
+ try:
+ 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')
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': request.session.get('identify_matches', []),
- })
+
+ return render(request, 'plants/identify_confirm.html', {'matches': matches})
+
+
+def _apply_vpc_care_fields(species, vpc_result):
+ 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)
+
+ 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':
- _clear_session(request)
- return redirect('plant_list')
+ 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)
+
+ 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': request.session.get('identify_selected', {}),
- 'vpc_results': request.session.get('identify_vpc_results', []),
- 'vpc_idx': request.session.get('identify_vpc_idx', 0),
+ 'selected': selected,
+ 'vpc_results': vpc_results,
+ 'vpc_idx': vpc_idx,
+ 'vpc_match': vpc_match,
})