Compare commits

...

4 commits

Author SHA1 Message Date
1fe2b9fad8 docs: add Pl@ntNet to README (features, env vars, data sources)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 13:18:59 +02:00
b5cdda54f8 feat: location filter, mobile overflow fixes, card scan icon
- 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>
2026-05-31 13:11:22 +02:00
21f9759d3a feat: require Naomi verification before closing Forgejo issues
Done items stay open in Forgejo until Naomi ticks the new Verified
checkbox in Notion. Shows ' Awaiting Naomi's verification' or
' Verified by Naomi' in the issue body accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:57:17 +02:00
92b682a1bd feat: show Pl@ntNet reference images on confirm screen + save as thumbnail
Extract image_url from Pl@ntNet API response (medium size). Show thumbnail
in each match card on identify_confirm. On plant creation, download the
selected match's reference image and save as PlantPhoto thumbnail. Scan
photos saved as gallery photos (non-thumbnail) with fallback to first scan
photo as thumbnail when Pl@ntNet has no image.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 12:51:28 +02:00
12 changed files with 72 additions and 25 deletions

View file

@ -2,12 +2,13 @@
A personal plant management web app built with Django 5, Bootstrap 5, and HTMX.
Track your plants, log pruning activity, and get care info auto-filled from the Perenual species database.
Track your plants, identify them by photo, and get care info auto-filled from the Perenual and Pl@ntNet APIs.
## Features
- **Species search** — search Perenual API by name; care data (watering, sunlight, pruning months) is auto-filled into the plant form and cached locally so the API is only called once per species
- **Plant list** — filterable by name, location, indoor/outdoor
- **Plant identification** — upload a photo; Pl@ntNet identifies it, shows top matches with reference images, pre-fills name and species, and saves the Pl@ntNet reference image as the plant thumbnail
- **Species search** — search Perenual API by name; care data (watering, sunlight, pruning months) is auto-filled and cached locally
- **Plant list** — filterable by name and location
- **Plant detail** — shows care info, pruning status, and pruning history
- **Pruning log** — log a pruning event with date and notes via HTMX partial update
- **Pruning calendar** — all plants grouped by urgency: overdue, due this month, due next month, later
@ -84,6 +85,7 @@ ssh dockerhost "cd /home/stephan/stacks/plantdb && docker compose up -d --build
| `ALLOWED_HOSTS` | yes | `localhost,127.0.0.1,dockerhost` |
| `CSRF_TRUSTED_ORIGINS` | yes (non-localhost) | `http://dockerhost:8083` |
| `PERENUAL_API_KEY` | no | `sk-...` |
| `PLANTNET_API_KEY` | no | `2b10...` (get at my.plantnet.org) |
`CSRF_TRUSTED_ORIGINS` is required when accessing the app on a non-standard port or hostname — Django 5 rejects POSTs without it.
@ -105,6 +107,7 @@ The app currently uses **Perenual** (free tier, 100 req/day) for species data. O
| **OpenFarm** | Shut down April 2025 | Dead. GitHub repo is a Ruby/MongoDB app with no data dump. |
| **Trefle** | Active | Botanical taxonomy only — no care schedules. Not a replacement. |
| **Wikipedia API** | Active | Returns name, description, thumbnail. No structured care data. |
| **Pl@ntNet** | Active | Plant identification from photos. Free tier: 500 req/day. Used for the identify-by-photo flow; reference image saved as plant thumbnail. |
| **Google Knowledge Panel** | N/A | Has structured care data but scraping violates ToS and is technically brittle. |
### Future opportunity

View file

@ -62,6 +62,7 @@ def identify(images):
'gbif_id': (r.get('gbif') or {}).get('id'),
'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''),
'genus': r['species'].get('genus', {}).get('scientificNameWithoutAuthor', ''),
'image_url': ((r.get('images') or [{}])[0].get('url') or {}).get('m'),
}
for r in results[:5]
]

View file

@ -30,7 +30,7 @@
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a>
<div class="d-flex gap-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-sm btn-outline-light" title="Add plant by card scan">📷</a>
<a href="{% url 'add_plant_from_scan' %}" class="btn btn-sm btn-outline-light" title="Add plant by card scan">🪪</a>
<a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
</div>
</nav>

View file

@ -20,6 +20,12 @@
data-idx="{{ forloop.counter0 }}"
data-name="{{ m.scientific_name }}"
onclick="selectMatch(this)">
{% if m.image_url %}
<img src="{{ m.image_url }}" alt="{{ m.scientific_name }}"
style="width:64px;height:64px;object-fit:cover;border-radius:8px;flex-shrink:0;">
{% else %}
<div style="width:64px;height:64px;background:#e9ecef;border-radius:8px;flex-shrink:0;"></div>
{% endif %}
<div class="flex-grow-1">
<div class="fw-bold">{{ m.scientific_name }}</div>
{% if m.common_names %}

View file

@ -13,7 +13,6 @@
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
<small class="text-muted">
{{ plant.location }}
· {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
</small>
</div>
</div>

View file

@ -1,6 +1,6 @@
{% load plant_extras %}
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end">
<div class="d-flex justify-content-between align-items-start">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<strong>✂️ Pruning</strong>
{% if plant.pruning_months %}
@ -8,16 +8,18 @@
{% endif %}
{% with plant.pruning_logs.first as last_log %}
{% if last_log %}
<div class="small text-muted mt-1">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
<div class="small text-muted mt-1 text-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
{% endif %}
{% endwith %}
</div>
<div class="flex-shrink-0">
{% if pruning_status == 'overdue' %}
<span class="badge bg-danger">Overdue</span>
{% elif pruning_status == 'due_this_month' %}
<span class="badge bg-warning text-dark">Due this month</span>
{% endif %}
</div>
</div>
<form
hx-post="{% url 'log_pruning' plant.pk %}"
@ -26,7 +28,7 @@
class="mt-2"
>
{% csrf_token %}
<div class="d-flex gap-2 align-items-end">
<div class="d-flex gap-2 align-items-end flex-wrap">
{{ log_form.pruned_on }}
<button type="submit" class="btn btn-sm btn-success">Log pruning</button>
</div>

View file

@ -85,6 +85,10 @@
</div>
{% endif %}
{% if plant.pruning_months %}
{% include "plants/partials/pruning_strip.html" %}
{% endif %}
<div id="photo-gallery">
{% include "plants/partials/photo_gallery.html" %}
</div>

View file

@ -15,10 +15,11 @@
>
</div>
<div class="btn-group w-100 mb-3" role="group">
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
<a href="{% url 'plant_list' %}?filter=indoor" class="btn btn-sm {% if filter == 'indoor' %}btn-success{% else %}btn-outline-success{% endif %}">Indoor</a>
<a href="{% url 'plant_list' %}?filter=outdoor" class="btn btn-sm {% if filter == 'outdoor' %}btn-success{% else %}btn-outline-success{% endif %}">Outdoor</a>
<div class="btn-group w-100 mb-3 flex-wrap" role="group">
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not location_filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
{% for loc in locations %}
<a href="{% url 'plant_list' %}?location={{ loc.pk }}" class="btn btn-sm {% if location_filter == loc.pk|stringformat:'s' %}btn-success{% else %}btn-outline-success{% endif %}">{{ loc.name }}</a>
{% endfor %}
</div>
<div id="plant-list">

View file

@ -12,7 +12,7 @@
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-danger">Overdue</span>
<span class="badge bg-danger flex-shrink-0">Overdue</span>
</a>
{% endfor %}
</div>
@ -27,7 +27,7 @@
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-warning text-dark">This month</span>
<span class="badge bg-warning text-dark flex-shrink-0">This month</span>
</a>
{% endfor %}
</div>
@ -42,7 +42,7 @@
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-secondary">{{ next_month_name }}</span>
<span class="badge bg-secondary flex-shrink-0">{{ next_month_name }}</span>
</a>
{% endfor %}
</div>

View file

@ -1,6 +1,7 @@
import logging
import os
import uuid
import requests
from django.shortcuts import render, redirect
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@ -172,6 +173,19 @@ def identify_fields(request):
plant = Plant.objects.create(name=name, species=species)
try:
# Download Pl@ntNet reference image as thumbnail
plantnet_img_url = selected.get('image_url')
if plantnet_img_url:
try:
r = requests.get(plantnet_img_url, timeout=10)
r.raise_for_status()
thumb_filename = f'plantnet_{uuid.uuid4().hex}.jpg'
thumb = PlantPhoto(plant=plant, is_thumbnail=True)
thumb.image.save(thumb_filename, ContentFile(r.content), save=True)
except Exception:
logger.exception('identify_fields: failed to download Pl@ntNet image')
# Save uploaded scan photos; only first becomes thumbnail if no Pl@ntNet image
image_paths = request.session.get('identify_image_paths', [])
for i, p in enumerate(image_paths):
if not default_storage.exists(p['path']):
@ -180,7 +194,7 @@ def identify_fields(request):
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 = PlantPhoto(plant=plant, is_thumbnail=(i == 0 and not plantnet_img_url))
photo.image.save(filename, ContentFile(data), save=True)
except Exception:
logger.exception('identify_fields: failed to save photo %s', p['path'])

View file

@ -2,7 +2,7 @@ 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
from plants.models import Plant, Species, PruningLog, PlantPhoto, PlantCardPhoto, Location
from plants.forms import PlantForm, PruningLogForm
from plants.utils.pruning import pruning_status
@ -10,21 +10,24 @@ 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', '')
indoor_filter = request.GET.get('filter', '')
location_filter = request.GET.get('location', '')
if q:
qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q)
if indoor_filter == 'indoor':
qs = qs.filter(is_indoor=True)
elif indoor_filter == 'outdoor':
qs = qs.filter(is_indoor=False)
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, 'filter': indoor_filter})
return render(request, template, {
'plants': qs, 'q': q,
'location_filter': location_filter,
'locations': locations,
})
def plant_detail(request, pk):

View file

@ -29,7 +29,7 @@ LABEL_MAP = {
STATUS_MAP = {
"Open": "open",
"In progress": "open", # + "In progress" label (id=4)
"Done": "closed",
"Done": "open", # stays open until Naomi ticks Verified
}
IN_PROGRESS_LABEL_ID = 4
@ -69,6 +69,7 @@ def sync_item(item, existing):
title = item["title"].strip()
category = item.get("category")
status = item.get("status", "Open")
verified = item.get("verified", False)
notion_url = item["url"]
has_screenshot = item.get("has_screenshot", False)
@ -79,12 +80,25 @@ def sync_item(item, existing):
labels.append(IN_PROGRESS_LABEL_ID)
screenshot_note = "\n\n📎 Screenshot attached in Notion" if has_screenshot else ""
if status == "Done" and not verified:
verification_note = "\n\n⏳ *Awaiting Naomi's verification*"
elif status == "Done" and verified:
verification_note = "\n\n✅ *Verified by Naomi*"
else:
verification_note = ""
body = (
f"*Synced from [🌸 BloomBase Notion]({notion_url})*"
f"{screenshot_note}"
f"{verification_note}"
f"\n\n<!-- {NOTION_MARKER}{notion_id} -->"
)
# Only close the Forgejo issue once Naomi has verified
if status == "Done" and verified:
state = "closed"
else:
state = STATUS_MAP.get(status, "open")
if notion_id in existing: