feat: implement identify views and upload template
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ae919a3fce
commit
c10c4ebc2f
3 changed files with 307 additions and 12 deletions
|
|
@ -1,5 +1,118 @@
|
||||||
{% extends "plants/base.html" %}
|
{% extends "plants/base.html" %}
|
||||||
{% block title %}Identify plant — PlantDB{% endblock %}
|
{% block title %}Identify plant — PlantDB{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>Upload step (coming soon)</p>
|
|
||||||
|
<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>`;
|
||||||
|
rows.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import pytest
|
import pytest
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
import io
|
||||||
|
from unittest.mock import patch
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -23,3 +26,50 @@ def test_fields_redirects_without_session(client):
|
||||||
resp = client.get(reverse('identify_fields'))
|
resp = client.get(reverse('identify_fields'))
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
assert reverse('identify_upload') in resp['Location']
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,19 @@
|
||||||
|
import uuid
|
||||||
from django.shortcuts import render, redirect
|
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 = [
|
_SESSION_KEYS = [
|
||||||
'identify_matches', 'identify_image_paths', 'identify_selected',
|
'identify_matches', 'identify_image_paths', 'identify_selected',
|
||||||
'identify_vpc_results', 'identify_vpc_idx',
|
'identify_vpc_results', 'identify_vpc_idx',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_VALID_ORGANS = {'flower', 'leaf', 'fruit', 'bark', 'habit', 'auto'}
|
||||||
|
|
||||||
|
|
||||||
def _clear_session(request):
|
def _clear_session(request):
|
||||||
for key in _SESSION_KEYS:
|
for key in _SESSION_KEYS:
|
||||||
|
|
@ -12,29 +21,152 @@ def _clear_session(request):
|
||||||
|
|
||||||
|
|
||||||
def identify_upload(request):
|
def identify_upload(request):
|
||||||
if request.method == 'POST':
|
if request.method != 'POST':
|
||||||
return redirect('identify_confirm')
|
|
||||||
return render(request, 'plants/identify_upload.html')
|
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):
|
def identify_confirm(request):
|
||||||
if 'identify_matches' not in request.session:
|
if 'identify_matches' not in request.session:
|
||||||
return redirect('identify_upload')
|
return redirect('identify_upload')
|
||||||
|
|
||||||
|
matches = request.session['identify_matches']
|
||||||
|
|
||||||
if request.method == 'POST':
|
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 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):
|
def identify_fields(request):
|
||||||
if 'identify_selected' not in request.session:
|
if 'identify_selected' not in request.session:
|
||||||
return redirect('identify_upload')
|
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':
|
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)
|
||||||
|
|
||||||
|
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)
|
_clear_session(request)
|
||||||
return redirect('plant_list')
|
|
||||||
|
return redirect('plant_detail', pk=plant.pk)
|
||||||
|
|
||||||
return render(request, 'plants/identify_fields.html', {
|
return render(request, 'plants/identify_fields.html', {
|
||||||
'selected': request.session.get('identify_selected', {}),
|
'selected': selected,
|
||||||
'vpc_results': request.session.get('identify_vpc_results', []),
|
'vpc_results': vpc_results,
|
||||||
'vpc_idx': request.session.get('identify_vpc_idx', 0),
|
'vpc_idx': vpc_idx,
|
||||||
|
'vpc_match': vpc_match,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue