- Replace indoor/outdoor filter with dynamic location buttons - Fix pruning strip date/badge overflow on narrow screens (flex-shrink-0, gap-2, text-nowrap, flex-wrap) - Fix pruning calendar badges compressing on long plant names - Re-add pruning strip to plant detail (was missing) - Change card scan nav icon from 📷 to 🪪 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
510 lines
19 KiB
Python
510 lines
19 KiB
Python
from datetime import date
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
|
from django.views.decorators.http import require_POST, require_GET
|
|
from plants.models import Plant, Species, PruningLog, PlantPhoto, PlantCardPhoto, Location
|
|
from plants.forms import PlantForm, PruningLogForm
|
|
from plants.utils.pruning import pruning_status
|
|
|
|
|
|
def plant_list(request):
|
|
qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all()
|
|
q = request.GET.get('q', '')
|
|
location_filter = request.GET.get('location', '')
|
|
|
|
if q:
|
|
qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q)
|
|
if location_filter:
|
|
qs = qs.filter(location__pk=location_filter)
|
|
|
|
locations = Location.objects.all()
|
|
template = (
|
|
'plants/partials/plant_list_results.html'
|
|
if request.htmx
|
|
else 'plants/plant_list.html'
|
|
)
|
|
return render(request, template, {
|
|
'plants': qs, 'q': q,
|
|
'location_filter': location_filter,
|
|
'locations': locations,
|
|
})
|
|
|
|
|
|
def plant_detail(request, pk):
|
|
plant = get_object_or_404(
|
|
Plant.objects.select_related('species', 'location').prefetch_related('pruning_logs', 'photos', 'card_photos'),
|
|
pk=pk,
|
|
)
|
|
today = date.today()
|
|
return render(request, 'plants/plant_detail.html', {
|
|
'plant': plant,
|
|
'pruning_status': pruning_status(plant, today),
|
|
'log_form': PruningLogForm(initial={'pruned_on': today}),
|
|
'today': today,
|
|
})
|
|
|
|
|
|
@require_POST
|
|
def log_pruning(request, pk):
|
|
plant = get_object_or_404(
|
|
Plant.objects.prefetch_related('pruning_logs'), pk=pk
|
|
)
|
|
form = PruningLogForm(request.POST)
|
|
if form.is_valid():
|
|
log = form.save(commit=False)
|
|
log.plant = plant
|
|
log.save()
|
|
today = date.today()
|
|
return render(request, 'plants/partials/pruning_strip.html', {
|
|
'plant': plant,
|
|
'pruning_status': pruning_status(plant, today),
|
|
'log_form': PruningLogForm(initial={'pruned_on': today}),
|
|
})
|
|
|
|
|
|
@ensure_csrf_cookie
|
|
def plant_add(request):
|
|
species_id = request.GET.get('species_id') or request.POST.get('species_id')
|
|
species = None
|
|
|
|
if species_id and species_id != 'skip':
|
|
species = get_object_or_404(Species, pk=species_id)
|
|
|
|
if species_id is None and request.method == 'GET':
|
|
return render(request, 'plants/species_search.html')
|
|
|
|
if request.method == 'POST':
|
|
form = PlantForm(request.POST, request.FILES)
|
|
if form.is_valid():
|
|
plant = form.save(commit=False)
|
|
plant.species = species
|
|
plant.save()
|
|
return redirect('plant_detail', pk=plant.pk)
|
|
else:
|
|
initial = {'bloom_months': species.bloom_months} if species else {}
|
|
form = PlantForm(initial=initial)
|
|
|
|
return render(request, 'plants/plant_form.html', {
|
|
'form': form,
|
|
'species': species,
|
|
'species_id': species_id,
|
|
'title': 'Add Plant',
|
|
})
|
|
|
|
|
|
def plant_edit(request, pk):
|
|
plant = get_object_or_404(Plant, pk=pk)
|
|
if request.method == 'POST':
|
|
form = PlantForm(request.POST, request.FILES, instance=plant)
|
|
if form.is_valid():
|
|
form.save()
|
|
return redirect('plant_detail', pk=plant.pk)
|
|
else:
|
|
form = PlantForm(instance=plant)
|
|
return render(request, 'plants/plant_form.html', {
|
|
'form': form,
|
|
'plant': plant,
|
|
'species': plant.species,
|
|
'title': 'Edit Plant',
|
|
})
|
|
|
|
|
|
def plant_delete(request, pk):
|
|
plant = get_object_or_404(Plant, pk=pk)
|
|
if request.method == 'POST':
|
|
plant.delete()
|
|
return redirect('plant_list')
|
|
return render(request, 'plants/plant_confirm_delete.html', {'plant': plant})
|
|
|
|
|
|
@require_GET
|
|
def plant_card(request, pk):
|
|
plant = get_object_or_404(
|
|
Plant.objects.prefetch_related('card_photos'), pk=pk
|
|
)
|
|
return render(request, 'plants/plant_card.html', {'plant': plant})
|
|
|
|
|
|
@require_POST
|
|
def upload_card_photo(request, pk):
|
|
plant = get_object_or_404(Plant, pk=pk)
|
|
if request.FILES.get('image') and plant.card_photos.count() < 3:
|
|
PlantCardPhoto.objects.create(plant=plant, image=request.FILES['image'])
|
|
plant = Plant.objects.prefetch_related('card_photos').get(pk=pk)
|
|
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
|
|
|
|
|
|
def _process_card_photo_async(card_photo):
|
|
"""Apply card shape extraction + denoising to the uploaded image in-place."""
|
|
try:
|
|
from plants.services.card_processing import process_card_photo
|
|
from PIL import Image
|
|
import io
|
|
from django.core.files.base import ContentFile
|
|
|
|
original = Image.open(card_photo.image.path).convert('RGB')
|
|
processed = process_card_photo(original)
|
|
|
|
buf = io.BytesIO()
|
|
processed.save(buf, format='JPEG', quality=88)
|
|
filename = card_photo.image.name.split('/')[-1]
|
|
card_photo.image.save(filename, ContentFile(buf.getvalue()), save=True)
|
|
except Exception:
|
|
pass # silently keep the original if processing fails
|
|
|
|
|
|
@require_POST
|
|
def delete_card_photo(request, card_photo_pk):
|
|
card_photo = get_object_or_404(PlantCardPhoto, pk=card_photo_pk)
|
|
plant_pk = card_photo.plant_id
|
|
card_photo.image.delete(save=False)
|
|
card_photo.delete()
|
|
plant = Plant.objects.prefetch_related('card_photos').get(pk=plant_pk)
|
|
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
|
|
|
|
|
|
def crop_thumbnail(request, pk):
|
|
plant = get_object_or_404(Plant, pk=pk)
|
|
card_photos = list(plant.card_photos.all())
|
|
if not card_photos:
|
|
return redirect('plant_detail', pk=pk)
|
|
|
|
if request.method == 'POST':
|
|
from PIL import Image, ImageOps
|
|
import io
|
|
from django.core.files.base import ContentFile
|
|
|
|
try:
|
|
x = int(request.POST['x'])
|
|
y = int(request.POST['y'])
|
|
w = int(request.POST['width'])
|
|
h = int(request.POST['height'])
|
|
except (KeyError, ValueError):
|
|
return redirect('plant_detail', pk=pk)
|
|
|
|
source_url = request.POST.get('source_url', '')
|
|
photo_obj = next(
|
|
(cp for cp in card_photos if cp.image.url == source_url),
|
|
card_photos[0]
|
|
)
|
|
img = Image.open(photo_obj.image.path)
|
|
img = ImageOps.exif_transpose(img)
|
|
cropped = img.crop((x, y, x + w, y + h))
|
|
|
|
max_side = 900
|
|
if max(cropped.width, cropped.height) > max_side:
|
|
scale = max_side / max(cropped.width, cropped.height)
|
|
cropped = cropped.resize(
|
|
(int(cropped.width * scale), int(cropped.height * scale)), Image.LANCZOS
|
|
)
|
|
|
|
buf = io.BytesIO()
|
|
cropped.save(buf, format='JPEG', quality=88)
|
|
|
|
PlantPhoto.objects.filter(plant=plant).update(is_thumbnail=False)
|
|
photo = PlantPhoto(plant=plant, is_thumbnail=True)
|
|
photo.image.save(f'thumb_{plant.pk}.jpg', ContentFile(buf.getvalue()), save=True)
|
|
|
|
return redirect('plant_detail', pk=pk)
|
|
|
|
return render(request, 'plants/crop_thumbnail.html', {
|
|
'plant': plant,
|
|
'card_photos': card_photos,
|
|
})
|
|
|
|
|
|
def upload_photo(request, 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})
|
|
|
|
|
|
@require_POST
|
|
def set_thumbnail(request, photo_pk):
|
|
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
|
|
PlantPhoto.objects.filter(plant=photo.plant).update(is_thumbnail=False)
|
|
photo.is_thumbnail = True
|
|
photo.save(update_fields=['is_thumbnail'])
|
|
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
|
|
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
|
|
|
|
|
|
@require_POST
|
|
def delete_photo(request, photo_pk):
|
|
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
|
|
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
|
|
photo.image.delete(save=False)
|
|
photo.delete()
|
|
plant.refresh_from_db()
|
|
plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk)
|
|
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
|
|
|
|
|
|
def extract_card_data(request, pk):
|
|
plant = get_object_or_404(Plant.objects.prefetch_related('card_photos'), pk=pk)
|
|
card_photos = list(plant.card_photos.all())
|
|
|
|
if not card_photos:
|
|
return redirect('plant_card', pk=pk)
|
|
|
|
if request.method == 'POST':
|
|
return _save_extracted_data(request, plant, card_photos)
|
|
|
|
# Run OCR on all card photos, merging results (later photos fill gaps)
|
|
from plants.services.card_ocr import extract_from_card, extract_plant_photo
|
|
merged = {
|
|
'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
|
|
'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
|
|
}
|
|
for cp in card_photos:
|
|
data = extract_from_card(cp.image.path)
|
|
for key, val in data.items():
|
|
if val:
|
|
merged[key] = val
|
|
|
|
# Extract plant photo from the first card photo (assumed front) and save as preview
|
|
from PIL import ImageOps
|
|
import io
|
|
from django.core.files.base import ContentFile
|
|
from django.core.files.storage import default_storage
|
|
|
|
plant_photo_preview_url = None
|
|
front_img = extract_plant_photo(card_photos[0].image.path)
|
|
if front_img:
|
|
from PIL import Image, ImageEnhance, ImageFilter
|
|
front_img = ImageOps.exif_transpose(front_img)
|
|
# Resize to max 900px on longest side
|
|
max_side = 900
|
|
if max(front_img.width, front_img.height) > max_side:
|
|
scale = max_side / max(front_img.width, front_img.height)
|
|
front_img = front_img.resize(
|
|
(int(front_img.width * scale), int(front_img.height * scale)),
|
|
Image.LANCZOS,
|
|
)
|
|
# Enhance: auto-contrast, boost saturation slightly, sharpen
|
|
front_img = ImageOps.autocontrast(front_img, cutoff=1)
|
|
front_img = ImageEnhance.Color(front_img).enhance(1.2)
|
|
front_img = front_img.filter(ImageFilter.SHARPEN)
|
|
buf = io.BytesIO()
|
|
front_img.save(buf, format='JPEG', quality=88)
|
|
preview_name = f'plants/previews/preview_{plant.pk}.jpg'
|
|
if default_storage.exists(preview_name):
|
|
default_storage.delete(preview_name)
|
|
default_storage.save(preview_name, ContentFile(buf.getvalue()))
|
|
plant_photo_preview_url = default_storage.url(preview_name)
|
|
|
|
# Normalise bloom_months to strings so template `val in bloom_months` works
|
|
merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
|
|
|
|
from plants.forms import MONTH_CHOICES
|
|
from plants.models import SUNLIGHT_CHOICES
|
|
return render(request, 'plants/plant_card_extract.html', {
|
|
'plant': plant,
|
|
'extracted': merged,
|
|
'plant_photo_preview_url': plant_photo_preview_url,
|
|
'month_choices': MONTH_CHOICES,
|
|
'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
|
|
})
|
|
|
|
|
|
def _save_extracted_data(request, plant, card_photos):
|
|
from plants.models import WATERING_CHOICES, SUNLIGHT_CHOICES
|
|
import io
|
|
from django.core.files.base import ContentFile
|
|
from django.core.files.storage import default_storage
|
|
|
|
def _int_or_none(val):
|
|
try:
|
|
return int(val) if val not in (None, '') else None
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
bloom_raw = request.POST.getlist('bloom_months')
|
|
bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
|
|
|
|
fields = {
|
|
'common_name': request.POST.get('scientific_name', '').strip(),
|
|
'scientific_name': request.POST.get('scientific_name', '').strip(),
|
|
'bloom_months': bloom_months,
|
|
'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
|
|
'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
|
|
'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
|
|
'sunlight': request.POST.get('sunlight', ''),
|
|
}
|
|
|
|
if plant.species:
|
|
for attr, val in fields.items():
|
|
if val not in (None, '', []):
|
|
setattr(plant.species, attr, val)
|
|
plant.species.save()
|
|
else:
|
|
species = Species.objects.create(**{k: v for k, v in fields.items() if v not in (None, '', [])})
|
|
plant.species = species
|
|
plant.save(update_fields=['species'])
|
|
|
|
preview_name = f'plants/previews/preview_{plant.pk}.jpg'
|
|
if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
|
|
with default_storage.open(preview_name, 'rb') as f:
|
|
photo = PlantPhoto(plant=plant)
|
|
photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
|
|
|
|
if default_storage.exists(preview_name):
|
|
default_storage.delete(preview_name)
|
|
|
|
return redirect('plant_detail', pk=plant.pk)
|
|
|
|
|
|
def add_plant_from_scan(request):
|
|
from plants.forms import MONTH_CHOICES
|
|
from plants.models import SUNLIGHT_CHOICES
|
|
|
|
if request.method == 'GET':
|
|
return render(request, 'plants/plant_scan_new.html', {'step': 'upload'})
|
|
|
|
if request.POST.get('step') == 'confirm':
|
|
return _create_plant_from_scan(request)
|
|
|
|
# Step 1 POST: save uploaded photos, run OCR, show confirmation
|
|
import uuid, io
|
|
from django.core.files.storage import default_storage
|
|
from django.core.files.base import ContentFile
|
|
from plants.services.card_ocr import extract_from_card, extract_plant_photo
|
|
from PIL import Image, ImageOps, ImageEnhance, ImageFilter
|
|
|
|
files = request.FILES.getlist('images')
|
|
if not files:
|
|
return render(request, 'plants/plant_scan_new.html', {
|
|
'step': 'upload', 'error': 'Please select at least one photo.'
|
|
})
|
|
|
|
scan_id = uuid.uuid4().hex
|
|
temp_names = []
|
|
for i, f in enumerate(files[:3]):
|
|
temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
|
|
default_storage.save(temp_name, ContentFile(f.read()))
|
|
temp_names.append(temp_name)
|
|
|
|
merged = {
|
|
'scientific_name': None, 'bloom_months': [], 'max_height_cm': None,
|
|
'frost_hardiness_c': None, 'planting_density_m2': None, 'bee_attractant': False,
|
|
}
|
|
for temp_name in temp_names:
|
|
data = extract_from_card(default_storage.path(temp_name))
|
|
for key, val in data.items():
|
|
if val:
|
|
merged[key] = val
|
|
|
|
plant_photo_preview_url = None
|
|
front_img = extract_plant_photo(default_storage.path(temp_names[0]))
|
|
if front_img:
|
|
front_img = ImageOps.exif_transpose(front_img)
|
|
max_side = 900
|
|
if max(front_img.width, front_img.height) > max_side:
|
|
scale = max_side / max(front_img.width, front_img.height)
|
|
front_img = front_img.resize(
|
|
(int(front_img.width * scale), int(front_img.height * scale)), Image.LANCZOS
|
|
)
|
|
front_img = ImageOps.autocontrast(front_img, cutoff=1)
|
|
front_img = ImageEnhance.Color(front_img).enhance(1.2)
|
|
front_img = front_img.filter(ImageFilter.SHARPEN)
|
|
buf = io.BytesIO()
|
|
front_img.save(buf, format='JPEG', quality=88)
|
|
preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
|
|
default_storage.save(preview_name, ContentFile(buf.getvalue()))
|
|
plant_photo_preview_url = default_storage.url(preview_name)
|
|
|
|
merged['bloom_months'] = [str(m) for m in merged['bloom_months']]
|
|
|
|
vpc_results = []
|
|
if merged.get('scientific_name'):
|
|
try:
|
|
from plants.services.vpc import search_species, VPCError
|
|
vpc_results = search_species(merged['scientific_name'])[:3]
|
|
except Exception:
|
|
pass
|
|
|
|
return render(request, 'plants/plant_scan_new.html', {
|
|
'step': 'confirm',
|
|
'scan_id': scan_id,
|
|
'extracted': merged,
|
|
'suggested_name': merged.get('scientific_name') or '',
|
|
'plant_photo_preview_url': plant_photo_preview_url,
|
|
'month_choices': MONTH_CHOICES,
|
|
'sunlight_choices': [('', '—')] + list(SUNLIGHT_CHOICES),
|
|
'vpc_results': vpc_results,
|
|
})
|
|
|
|
|
|
def _create_plant_from_scan(request):
|
|
import io
|
|
from django.core.files.storage import default_storage
|
|
from django.core.files.base import ContentFile
|
|
|
|
scan_id = request.POST.get('scan_id', '')
|
|
if not scan_id.isalnum():
|
|
return redirect('add_plant_from_scan')
|
|
|
|
def _int_or_none(val):
|
|
try:
|
|
return int(val) if val not in (None, '') else None
|
|
except (ValueError, TypeError):
|
|
return None
|
|
|
|
bloom_raw = request.POST.getlist('bloom_months')
|
|
bloom_months = [int(m) for m in bloom_raw if m.isdigit()]
|
|
name = request.POST.get('name', '').strip() or request.POST.get('scientific_name', '').strip() or 'New plant'
|
|
|
|
species_fields = {
|
|
'scientific_name': request.POST.get('scientific_name', '').strip(),
|
|
'common_name': request.POST.get('scientific_name', '').strip(),
|
|
'bloom_months': bloom_months,
|
|
'max_height_cm': _int_or_none(request.POST.get('max_height_cm')),
|
|
'frost_hardiness_c': _int_or_none(request.POST.get('frost_hardiness_c')),
|
|
'planting_density_m2': _int_or_none(request.POST.get('planting_density_m2')),
|
|
'sunlight': request.POST.get('sunlight', ''),
|
|
}
|
|
species = Species.objects.create(**{k: v for k, v in species_fields.items() if v not in (None, '', [])})
|
|
plant = Plant.objects.create(name=name, species=species)
|
|
|
|
vpc_slug = request.POST.get('vpc_slug', '').strip()
|
|
if vpc_slug:
|
|
try:
|
|
from plants.services.vpc import scrape_plant, VPCError
|
|
data = scrape_plant(vpc_slug)
|
|
species.scientific_name = data.get('scientific_name') or species.scientific_name
|
|
species.common_name = data.get('common_name') or species.common_name
|
|
species.bloom_months = data.get('bloom_months') or species.bloom_months
|
|
species.max_height_cm = data.get('max_height_cm') or species.max_height_cm
|
|
species.frost_hardiness_c = data.get('frost_hardiness_c') or species.frost_hardiness_c
|
|
species.planting_density_m2 = data.get('planting_density_m2') or species.planting_density_m2
|
|
species.sunlight = data.get('sunlight') or species.sunlight
|
|
species.api_image_url = data.get('api_image_url', '')
|
|
species.vpc_slug = vpc_slug
|
|
species.vpc_raw = data
|
|
species.save()
|
|
except Exception:
|
|
pass
|
|
|
|
i = 0
|
|
while True:
|
|
temp_name = f'plants/temp_scans/{scan_id}_{i}.jpg'
|
|
if not default_storage.exists(temp_name):
|
|
break
|
|
with default_storage.open(temp_name, 'rb') as f:
|
|
card_photo = PlantCardPhoto(plant=plant)
|
|
card_photo.image.save(f'card_{plant.pk}_{i}.jpg', ContentFile(f.read()), save=True)
|
|
default_storage.delete(temp_name)
|
|
i += 1
|
|
|
|
preview_name = f'plants/temp_scans/{scan_id}_preview.jpg'
|
|
if request.POST.get('save_plant_photo') and default_storage.exists(preview_name):
|
|
with default_storage.open(preview_name, 'rb') as f:
|
|
photo = PlantPhoto(plant=plant, is_thumbnail=True)
|
|
photo.image.save(f'card_front_{plant.pk}.jpg', ContentFile(f.read()), save=True)
|
|
|
|
if default_storage.exists(preview_name):
|
|
default_storage.delete(preview_name)
|
|
|
|
return redirect('plant_detail', pk=plant.pk)
|