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)