Compare commits

..

No commits in common. "1fe2b9fad83116dd12dec93849de27d61c68ec65" and "70c2213d984afb33c9c0918472a52f24d1f864df" have entirely different histories.

12 changed files with 25 additions and 72 deletions

View file

@ -2,13 +2,12 @@
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, identify them by photo, and get care info auto-filled from the Perenual and Pl@ntNet APIs. Track your plants, log pruning activity, and get care info auto-filled from the Perenual species database.
## Features ## Features
- **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 into the plant form and cached locally so the API is only called once per species
- **Species search** — search Perenual API by name; care data (watering, sunlight, pruning months) is auto-filled and cached locally - **Plant list** — filterable by name, location, indoor/outdoor
- **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
@ -85,7 +84,6 @@ 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.
@ -107,7 +105,6 @@ 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,7 +62,6 @@ 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,12 +20,6 @@
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,6 +13,7 @@
<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 gap-2"> <div class="d-flex justify-content-between align-items-start">
<div> <div>
<strong>✂️ Pruning</strong> <strong>✂️ Pruning</strong>
{% if plant.pruning_months %} {% if plant.pruning_months %}
@ -8,18 +8,16 @@
{% 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 text-nowrap">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div> <div class="small text-muted mt-1">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
hx-post="{% url 'log_pruning' plant.pk %}" hx-post="{% url 'log_pruning' plant.pk %}"
@ -28,7 +26,7 @@
class="mt-2" class="mt-2"
> >
{% csrf_token %} {% csrf_token %}
<div class="d-flex gap-2 align-items-end flex-wrap"> <div class="d-flex gap-2 align-items-end">
{{ 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,10 +85,6 @@
</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,11 +15,10 @@
> >
</div> </div>
<div class="btn-group w-100 mb-3 flex-wrap" role="group"> <div class="btn-group w-100 mb-3" role="group">
<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' %}" class="btn btn-sm {% if not filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
{% for loc in locations %} <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' %}?location={{ loc.pk }}" class="btn btn-sm {% if location_filter == loc.pk|stringformat:'s' %}btn-success{% else %}btn-outline-success{% endif %}">{{ loc.name }}</a> <a href="{% url 'plant_list' %}?filter=outdoor" class="btn btn-sm {% if filter == 'outdoor' %}btn-success{% else %}btn-outline-success{% endif %}">Outdoor</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 flex-shrink-0">Overdue</span> <span class="badge bg-danger">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 flex-shrink-0">This month</span> <span class="badge bg-warning text-dark">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 flex-shrink-0">{{ next_month_name }}</span> <span class="badge bg-secondary">{{ next_month_name }}</span>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -1,7 +1,6 @@
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
@ -173,19 +172,6 @@ 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']):
@ -194,7 +180,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 and not plantnet_img_url)) photo = PlantPhoto(plant=plant, is_thumbnail=(i == 0))
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, Location from plants.models import Plant, Species, PruningLog, PlantPhoto, PlantCardPhoto
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,24 +10,21 @@ 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', '')
location_filter = request.GET.get('location', '') indoor_filter = request.GET.get('filter', '')
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 location_filter: if indoor_filter == 'indoor':
qs = qs.filter(location__pk=location_filter) qs = qs.filter(is_indoor=True)
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, { return render(request, template, {'plants': qs, 'q': q, 'filter': indoor_filter})
'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": "open", # stays open until Naomi ticks Verified "Done": "closed",
} }
IN_PROGRESS_LABEL_ID = 4 IN_PROGRESS_LABEL_ID = 4
@ -69,7 +69,6 @@ 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)
@ -80,25 +79,12 @@ 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} -->"
) )
# Only close the Forgejo issue once Naomi has verified
if status == "Done" and verified:
state = "closed"
else:
state = STATUS_MAP.get(status, "open") state = STATUS_MAP.get(status, "open")
if notion_id in existing: if notion_id in existing: