Compare commits
10 commits
f308d006e4
...
798240f968
| Author | SHA1 | Date | |
|---|---|---|---|
| 798240f968 | |||
| d1e588bbc3 | |||
| bee9fe0cd8 | |||
| 6e2f477c09 | |||
| 1a9aa24ebf | |||
| be9d6caf94 | |||
| cbd4ba09aa | |||
| 0f53adc6a8 | |||
| fdbe821c8a | |||
| c75757a70c |
7 changed files with 102 additions and 25 deletions
|
|
@ -75,3 +75,29 @@ PLANTNET_API_KEY = os.environ.get('PLANTNET_API_KEY', '')
|
|||
_trusted = os.environ.get('CSRF_TRUSTED_ORIGINS', '')
|
||||
if _trusted:
|
||||
CSRF_TRUSTED_ORIGINS = [o.strip() for o in _trusted.split(',')]
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
'propagate': False,
|
||||
},
|
||||
'plants': {
|
||||
'handlers': ['console'],
|
||||
'level': 'DEBUG',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ class Species(models.Model):
|
|||
pruning_months = models.JSONField(default=list)
|
||||
bloom_months = models.JSONField(default=list)
|
||||
api_image_url = models.URLField(blank=True)
|
||||
frost_hardiness_c = models.SmallIntegerField(null=True, blank=True)
|
||||
planting_density_m2 = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
perenual_id = models.IntegerField(unique=True, null=True, blank=True)
|
||||
vpc_slug = models.CharField(max_length=200, blank=True, db_index=True)
|
||||
vpc_raw = models.JSONField(null=True, blank=True)
|
||||
|
|
|
|||
|
|
@ -53,14 +53,17 @@ def identify(images):
|
|||
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]
|
||||
]
|
||||
try:
|
||||
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]
|
||||
]
|
||||
except (KeyError, TypeError) as exc:
|
||||
raise PlantNetError('Unexpected API response format.') from exc
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
{% if not photo.is_thumbnail %}
|
||||
<form method="post" action="{% url 'set_thumbnail' photo.pk %}"
|
||||
hx-post="{% url 'set_thumbnail' photo.pk %}"
|
||||
hx-target="#photo-gallery" hx-swap="outerHTML">
|
||||
hx-target="#photo-gallery" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-success btn-sm py-0 px-1" style="font-size:.7rem;" title="Set as thumbnail">★</button>
|
||||
</form>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
{% endif %}
|
||||
<form method="post" action="{% url 'delete_photo' photo.pk %}"
|
||||
hx-post="{% url 'delete_photo' photo.pk %}"
|
||||
hx-target="#photo-gallery" hx-swap="outerHTML">
|
||||
hx-target="#photo-gallery" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1" style="font-size:.7rem;" title="Delete">✕</button>
|
||||
</form>
|
||||
|
|
@ -34,13 +34,26 @@
|
|||
{% endwith %}
|
||||
|
||||
<form method="post" action="{% url 'upload_photo' plant.pk %}" enctype="multipart/form-data"
|
||||
hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-gallery" hx-swap="outerHTML"
|
||||
hx-encoding="multipart/form-data">
|
||||
hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-gallery" hx-swap="innerHTML"
|
||||
hx-encoding="multipart/form-data" hx-indicator="#photo-upload-spinner">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="file" name="image" accept="image/*" capture="environment"
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<input type="file" name="image" accept="image/*"
|
||||
class="form-control form-control-sm" style="max-width:220px;">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button>
|
||||
<span id="photo-upload-spinner" class="htmx-indicator text-muted small">Uploading…</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if request.htmx %}
|
||||
<div id="plant-hero-container" hx-swap-oob="true">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
|
||||
{% elif plant.thumbnail_url %}
|
||||
<img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
|
||||
{% else %}
|
||||
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
|
||||
</div>
|
||||
|
||||
<div id="plant-hero-container">
|
||||
{% if plant.species and plant.species.api_image_url %}
|
||||
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
|
||||
{% elif plant.thumbnail_url %}
|
||||
|
|
@ -15,6 +16,7 @@
|
|||
{% else %}
|
||||
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from django.shortcuts import render, redirect
|
||||
from django.core.files.base import ContentFile
|
||||
|
|
@ -5,7 +7,9 @@ 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
|
||||
from plants.models import Plant, PlantPhoto, Species
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SESSION_KEYS = [
|
||||
'identify_matches', 'identify_image_paths', 'identify_selected',
|
||||
|
|
@ -45,13 +49,23 @@ def identify_upload(request):
|
|||
saved_paths.append({'path': name, 'organ': organ})
|
||||
images.append((file_bytes, organ))
|
||||
|
||||
error_msg = None
|
||||
try:
|
||||
matches = plantnet.identify(images)
|
||||
except PlantNetError as e:
|
||||
error_msg = str(e)
|
||||
except Exception as e:
|
||||
logger.exception('identify_upload unexpected error: %s', e)
|
||||
error_msg = 'Identification failed — please try again.'
|
||||
|
||||
if error_msg:
|
||||
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)})
|
||||
try:
|
||||
if default_storage.exists(p['path']):
|
||||
default_storage.delete(p['path'])
|
||||
except Exception:
|
||||
pass
|
||||
return render(request, 'plants/identify_upload.html', {'error': error_msg})
|
||||
|
||||
request.session['identify_matches'] = matches
|
||||
request.session['identify_image_paths'] = saved_paths
|
||||
|
|
@ -63,6 +77,8 @@ def identify_confirm(request):
|
|||
return redirect('identify_upload')
|
||||
|
||||
matches = request.session['identify_matches']
|
||||
if not matches:
|
||||
return redirect('identify_upload')
|
||||
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
|
|
@ -156,9 +172,23 @@ def identify_fields(request):
|
|||
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'])
|
||||
image_paths = request.session.get('identify_image_paths', [])
|
||||
for i, p in enumerate(image_paths):
|
||||
if not default_storage.exists(p['path']):
|
||||
continue
|
||||
try:
|
||||
with default_storage.open(p['path'], 'rb') as fh:
|
||||
data = fh.read()
|
||||
filename = os.path.basename(p['path'])
|
||||
photo = PlantPhoto(plant=plant, is_thumbnail=(i == 0))
|
||||
photo.image.save(filename, ContentFile(data), save=True)
|
||||
except Exception:
|
||||
logger.exception('identify_fields: failed to save photo %s', p['path'])
|
||||
finally:
|
||||
try:
|
||||
default_storage.delete(p['path'])
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_clear_session(request)
|
||||
|
||||
|
|
|
|||
|
|
@ -211,9 +211,10 @@ def crop_thumbnail(request, pk):
|
|||
|
||||
|
||||
def upload_photo(request, pk):
|
||||
plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk)
|
||||
plant = get_object_or_404(Plant, pk=pk)
|
||||
if request.FILES.get('image'):
|
||||
PlantPhoto.objects.create(plant=plant, image=request.FILES['image'])
|
||||
plant = Plant.objects.prefetch_related('photos').get(pk=pk)
|
||||
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue