Compare commits
4 commits
70c2213d98
...
1fe2b9fad8
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fe2b9fad8 | |||
| b5cdda54f8 | |||
| 21f9759d3a | |||
| 92b682a1bd |
12 changed files with 72 additions and 25 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue