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. 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 ## 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 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
- **Plant list** — filterable by name, location, indoor/outdoor - **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 - **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 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 - **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` | | `ALLOWED_HOSTS` | yes | `localhost,127.0.0.1,dockerhost` |
| `CSRF_TRUSTED_ORIGINS` | yes (non-localhost) | `http://dockerhost:8083` | | `CSRF_TRUSTED_ORIGINS` | yes (non-localhost) | `http://dockerhost:8083` |
| `PERENUAL_API_KEY` | no | `sk-...` | | `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. `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. | | **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. | | **Trefle** | Active | Botanical taxonomy only — no care schedules. Not a replacement. |
| **Wikipedia API** | Active | Returns name, description, thumbnail. No structured care data. | | **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. | | **Google Knowledge Panel** | N/A | Has structured care data but scraping violates ToS and is technically brittle. |
### Future opportunity ### Future opportunity

View file

@ -62,6 +62,7 @@ def identify(images):
'gbif_id': (r.get('gbif') or {}).get('id'), 'gbif_id': (r.get('gbif') or {}).get('id'),
'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''), 'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''),
'genus': r['species'].get('genus', {}).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] for r in results[:5]
] ]

View file

@ -30,7 +30,7 @@
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a> <a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌸 BloomBase</a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a> <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> <a href="{% url 'identify_upload' %}" class="btn btn-sm btn-light fw-bold">+</a>
</div> </div>
</nav> </nav>

View file

@ -20,6 +20,12 @@
data-idx="{{ forloop.counter0 }}" data-idx="{{ forloop.counter0 }}"
data-name="{{ m.scientific_name }}" data-name="{{ m.scientific_name }}"
onclick="selectMatch(this)"> 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="flex-grow-1">
<div class="fw-bold">{{ m.scientific_name }}</div> <div class="fw-bold">{{ m.scientific_name }}</div>
{% if m.common_names %} {% if m.common_names %}

View file

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

View file

@ -1,6 +1,6 @@
{% load plant_extras %} {% load plant_extras %}
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end"> <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> <div>
<strong>✂️ Pruning</strong> <strong>✂️ Pruning</strong>
{% if plant.pruning_months %} {% if plant.pruning_months %}
@ -8,15 +8,17 @@
{% endif %} {% endif %}
{% with plant.pruning_logs.first as last_log %} {% with plant.pruning_logs.first as last_log %}
{% if 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 %} {% endif %}
{% endwith %} {% endwith %}
</div> </div>
<div class="flex-shrink-0">
{% if pruning_status == 'overdue' %} {% if pruning_status == 'overdue' %}
<span class="badge bg-danger">Overdue</span> <span class="badge bg-danger">Overdue</span>
{% elif pruning_status == 'due_this_month' %} {% elif pruning_status == 'due_this_month' %}
<span class="badge bg-warning text-dark">Due this month</span> <span class="badge bg-warning text-dark">Due this month</span>
{% endif %} {% endif %}
</div>
</div> </div>
<form <form
@ -26,7 +28,7 @@
class="mt-2" class="mt-2"
> >
{% csrf_token %} {% 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 }} {{ log_form.pruned_on }}
<button type="submit" class="btn btn-sm btn-success">Log pruning</button> <button type="submit" class="btn btn-sm btn-success">Log pruning</button>
</div> </div>

View file

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

View file

@ -15,10 +15,11 @@
> >
</div> </div>
<div class="btn-group w-100 mb-3" role="group"> <div class="btn-group w-100 mb-3 flex-wrap" 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' %}" class="btn btn-sm {% if not location_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> {% for loc in locations %}
<a href="{% url 'plant_list' %}?filter=outdoor" class="btn btn-sm {% if filter == 'outdoor' %}btn-success{% else %}btn-outline-success{% endif %}">Outdoor</a> <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>
<div id="plant-list"> <div id="plant-list">

View file

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

View file

@ -1,6 +1,7 @@
import logging import logging
import os import os
import uuid import uuid
import requests
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
@ -172,6 +173,19 @@ def identify_fields(request):
plant = Plant.objects.create(name=name, species=species) plant = Plant.objects.create(name=name, species=species)
try: 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', []) image_paths = request.session.get('identify_image_paths', [])
for i, p in enumerate(image_paths): for i, p in enumerate(image_paths):
if not default_storage.exists(p['path']): if not default_storage.exists(p['path']):
@ -180,7 +194,7 @@ def identify_fields(request):
with default_storage.open(p['path'], 'rb') as fh: with default_storage.open(p['path'], 'rb') as fh:
data = fh.read() data = fh.read()
filename = os.path.basename(p['path']) 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) photo.image.save(filename, ContentFile(data), save=True)
except Exception: except Exception:
logger.exception('identify_fields: failed to save photo %s', p['path']) 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.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST, require_GET 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.forms import PlantForm, PruningLogForm
from plants.utils.pruning import pruning_status from plants.utils.pruning import pruning_status
@ -10,21 +10,24 @@ from plants.utils.pruning import pruning_status
def plant_list(request): def plant_list(request):
qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all() qs = Plant.objects.select_related('species', 'location').prefetch_related('photos').all()
q = request.GET.get('q', '') q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '') location_filter = request.GET.get('location', '')
if q: if q:
qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q) qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q)
if indoor_filter == 'indoor': if location_filter:
qs = qs.filter(is_indoor=True) qs = qs.filter(location__pk=location_filter)
elif indoor_filter == 'outdoor':
qs = qs.filter(is_indoor=False)
locations = Location.objects.all()
template = ( template = (
'plants/partials/plant_list_results.html' 'plants/partials/plant_list_results.html'
if request.htmx if request.htmx
else 'plants/plant_list.html' 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): def plant_detail(request, pk):

View file

@ -29,7 +29,7 @@ LABEL_MAP = {
STATUS_MAP = { STATUS_MAP = {
"Open": "open", "Open": "open",
"In progress": "open", # + "In progress" label (id=4) "In progress": "open", # + "In progress" label (id=4)
"Done": "closed", "Done": "open", # stays open until Naomi ticks Verified
} }
IN_PROGRESS_LABEL_ID = 4 IN_PROGRESS_LABEL_ID = 4
@ -69,6 +69,7 @@ def sync_item(item, existing):
title = item["title"].strip() title = item["title"].strip()
category = item.get("category") category = item.get("category")
status = item.get("status", "Open") status = item.get("status", "Open")
verified = item.get("verified", False)
notion_url = item["url"] notion_url = item["url"]
has_screenshot = item.get("has_screenshot", False) has_screenshot = item.get("has_screenshot", False)
@ -79,13 +80,26 @@ def sync_item(item, existing):
labels.append(IN_PROGRESS_LABEL_ID) labels.append(IN_PROGRESS_LABEL_ID)
screenshot_note = "\n\n📎 Screenshot attached in Notion" if has_screenshot else "" 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 = ( body = (
f"*Synced from [🌸 BloomBase Notion]({notion_url})*" f"*Synced from [🌸 BloomBase Notion]({notion_url})*"
f"{screenshot_note}" f"{screenshot_note}"
f"{verification_note}"
f"\n\n<!-- {NOTION_MARKER}{notion_id} -->" f"\n\n<!-- {NOTION_MARKER}{notion_id} -->"
) )
state = STATUS_MAP.get(status, "open") # 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: if notion_id in existing:
issue_num = existing[notion_id] issue_num = existing[notion_id]