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)

+ +
+ +
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 %} 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, })