bloombase/docs/superpowers/plans/2026-05-27-plantdb.md
Stephan Kerkman 0b43830875 feat: add PlantDB implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 19:35:18 +02:00

2312 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# PlantDB Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a mobile-first Django web app for a family plant and flower registry with pruning calendar and Perenual API integration.
**Architecture:** Server-rendered Django templates with HTMX for targeted partial updates (species search, pruning log). A single `plants` Django app owns all models, views, and templates. SQLite for storage, served via Gunicorn behind Nginx in Docker Compose.
**Tech Stack:** Django 5.1, Bootstrap 5.3, HTMX 1.9, django-htmx, Pillow, requests, pytest-django
---
## File Map
```
plantdb/
├── .env.example
├── .gitignore
├── Dockerfile
├── docker-compose.yml
├── pytest.ini
├── requirements.txt
├── nginx/
│ └── default.conf
├── manage.py ← generated by django-admin
├── plantdb/ ← Django project package
│ ├── __init__.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
└── plants/ ← sole Django app
├── __init__.py
├── admin.py
├── apps.py
├── forms.py ← PlantForm, PruningLogForm
├── models.py ← Species, Plant, PruningLog
├── urls.py
├── services/
│ ├── __init__.py
│ └── perenual.py ← API search only; no caching logic
├── utils/
│ ├── __init__.py
│ └── pruning.py ← pruning_status, months_before
├── views/
│ ├── __init__.py
│ ├── dashboard.py
│ ├── plants.py ← list, detail, add, edit, delete, log_pruning
│ ├── pruning.py ← pruning calendar
│ └── species.py ← HTMX species search + select
├── templates/plants/
│ ├── base.html
│ ├── dashboard.html
│ ├── plant_list.html
│ ├── plant_detail.html
│ ├── plant_form.html ← shared by add and edit
│ ├── plant_confirm_delete.html
│ ├── species_search.html ← step 1 of add-plant flow
│ ├── pruning_calendar.html
│ └── partials/
│ ├── plant_list_results.html ← HTMX search swap target
│ ├── species_results.html ← HTMX species lookup results
│ └── pruning_strip.html ← HTMX pruning log swap target
└── tests/
├── __init__.py
├── test_models.py
├── test_pruning.py
├── test_perenual.py
└── test_views.py
```
---
## Task 1: Project Scaffold
**Files:**
- Create: `requirements.txt`
- Create: `pytest.ini`
- Create: `.env.example`
- Create: `.gitignore`
- Create: `plantdb/settings.py`
- Create: `plantdb/urls.py`
- Create: `plants/apps.py`
- [ ] **Step 1: Install Django and scaffold the project**
```bash
cd /home/stephan/devel/python/plantdb
python -m venv .venv
source .venv/bin/activate
pip install django==5.1.* django-htmx pillow requests gunicorn pytest-django
pip freeze > requirements.txt
django-admin startproject plantdb .
python manage.py startapp plants
```
- [ ] **Step 2: Write `plantdb/settings.py`**
Replace the generated file with:
```python
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_htmx',
'plants',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django_htmx.middleware.HtmxMiddleware',
]
ROOT_URLCONF = 'plantdb.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'plantdb.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Europe/Amsterdam'
USE_I18N = True
USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
PERENUAL_API_KEY = os.environ.get('PERENUAL_API_KEY', '')
```
- [ ] **Step 3: Write `pytest.ini`**
```ini
[pytest]
DJANGO_SETTINGS_MODULE = plantdb.settings
```
- [ ] **Step 4: Write `.env.example`**
```
SECRET_KEY=change-me-in-production
DEBUG=False
ALLOWED_HOSTS=192.168.1.100,plantdb.home
PERENUAL_API_KEY=your-perenual-key-here
```
- [ ] **Step 5: Write `.gitignore`**
```
.venv/
__pycache__/
*.pyc
db.sqlite3
media/
staticfiles/
.env
.superpowers/
```
- [ ] **Step 6: Verify the dev server starts**
```bash
python manage.py migrate
python manage.py runserver
```
Expected: server starts at http://127.0.0.1:8000, no errors.
- [ ] **Step 7: Commit**
```bash
git add requirements.txt pytest.ini .env.example .gitignore plantdb/ plants/
git commit -m "feat: scaffold Django project with plants app"
```
---
## Task 2: Data Models + Admin
**Files:**
- Create: `plants/models.py`
- Create: `plants/admin.py`
- Create: `plants/tests/test_models.py`
- [ ] **Step 1: Write the failing test**
Create `plants/tests/__init__.py` (empty) and `plants/tests/test_models.py`:
```python
import pytest
from plants.models import Species, Plant, PruningLog
from datetime import date
@pytest.mark.django_db
def test_species_str():
s = Species.objects.create(common_name='Rose')
assert str(s) == 'Rose'
@pytest.mark.django_db
def test_plant_str():
p = Plant.objects.create(name='Kitchen Fern', location='Kitchen')
assert str(p) == 'Kitchen Fern'
@pytest.mark.django_db
def test_plant_species_nullable():
p = Plant.objects.create(name='Unknown plant', location='Hallway')
assert p.species is None
@pytest.mark.django_db
def test_pruning_log_str():
p = Plant.objects.create(name='Rose', location='Garden')
log = PruningLog.objects.create(plant=p, pruned_on=date(2026, 5, 1))
assert 'Rose' in str(log)
assert '2026-05-01' in str(log)
@pytest.mark.django_db
def test_plant_pruning_months_default():
p = Plant.objects.create(name='Fern', location='Office')
assert p.pruning_months == []
```
- [ ] **Step 2: Run tests — expect failure**
```bash
pytest plants/tests/test_models.py -v
```
Expected: `ImportError` — models not defined yet.
- [ ] **Step 3: Write `plants/models.py`**
```python
from django.db import models
class Species(models.Model):
common_name = models.CharField(max_length=200)
scientific_name = models.CharField(max_length=200, blank=True)
description = models.TextField(blank=True)
watering = models.CharField(max_length=100, blank=True)
sunlight = models.CharField(max_length=200, blank=True)
shadow_tolerance = models.CharField(max_length=100, blank=True)
max_height_cm = models.IntegerField(null=True, blank=True)
growth_rate = models.CharField(max_length=100, blank=True)
growth_season = models.CharField(max_length=100, blank=True)
pruning_months = models.JSONField(default=list)
api_image_url = models.URLField(blank=True)
perenual_id = models.IntegerField(unique=True, null=True, blank=True)
class Meta:
ordering = ['common_name']
verbose_name_plural = 'species'
def __str__(self):
return self.common_name
class Plant(models.Model):
name = models.CharField(max_length=200)
species = models.ForeignKey(
Species, null=True, blank=True,
on_delete=models.SET_NULL, related_name='plants',
)
location = models.CharField(max_length=200)
is_indoor = models.BooleanField(default=False)
pruning_months = models.JSONField(default=list)
photo = models.ImageField(upload_to='plants/', blank=True)
notes = models.TextField(blank=True)
date_added = models.DateField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class PruningLog(models.Model):
plant = models.ForeignKey(
Plant, on_delete=models.CASCADE, related_name='pruninglogs',
)
pruned_on = models.DateField()
notes = models.TextField(blank=True)
class Meta:
ordering = ['-pruned_on']
def __str__(self):
return f'{self.plant.name} pruned on {self.pruned_on}'
```
- [ ] **Step 4: Write `plants/admin.py`**
```python
from django.contrib import admin
from .models import Species, Plant, PruningLog
@admin.register(Species)
class SpeciesAdmin(admin.ModelAdmin):
list_display = ['common_name', 'scientific_name', 'perenual_id']
search_fields = ['common_name', 'scientific_name']
@admin.register(Plant)
class PlantAdmin(admin.ModelAdmin):
list_display = ['name', 'species', 'location', 'is_indoor', 'date_added']
list_filter = ['is_indoor']
search_fields = ['name', 'location']
@admin.register(PruningLog)
class PruningLogAdmin(admin.ModelAdmin):
list_display = ['plant', 'pruned_on']
list_filter = ['pruned_on']
```
- [ ] **Step 5: Run migrations**
```bash
python manage.py makemigrations plants
python manage.py migrate
```
Expected: migrations created and applied, no errors.
- [ ] **Step 6: Run tests — expect pass**
```bash
pytest plants/tests/test_models.py -v
```
Expected: 5 tests pass.
- [ ] **Step 7: Commit**
```bash
git add plants/models.py plants/admin.py plants/tests/ plants/migrations/
git commit -m "feat: add Species, Plant, PruningLog models"
```
---
## Task 3: Pruning Logic
**Files:**
- Create: `plants/utils/__init__.py`
- Create: `plants/utils/pruning.py`
- Create: `plants/tests/test_pruning.py`
- [ ] **Step 1: Write the failing tests**
Create `plants/utils/__init__.py` (empty) and `plants/tests/test_pruning.py`:
```python
import pytest
from datetime import date
from plants.models import Plant, PruningLog
from plants.utils.pruning import pruning_status, months_before
def test_months_before_same_year():
d = date(2026, 5, 27)
assert months_before(d, 1) == date(2026, 4, 1)
assert months_before(d, 4) == date(2026, 1, 1)
def test_months_before_crosses_year():
d = date(2026, 5, 27)
assert months_before(d, 5) == date(2025, 12, 1)
assert months_before(d, 12) == date(2025, 5, 1)
def test_months_before_january():
d = date(2026, 1, 15)
assert months_before(d, 1) == date(2025, 12, 1)
assert months_before(d, 2) == date(2025, 11, 1)
@pytest.mark.django_db
def test_no_schedule():
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[])
assert pruning_status(plant) == 'no_schedule'
@pytest.mark.django_db
def test_due_this_month():
today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5])
assert pruning_status(plant, today) == 'due_this_month'
@pytest.mark.django_db
def test_due_this_month_already_logged():
today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5])
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 1))
assert pruning_status(plant, today) == 'upcoming'
@pytest.mark.django_db
def test_overdue_when_past_month_not_logged():
today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3])
assert pruning_status(plant, today) == 'overdue'
@pytest.mark.django_db
def test_not_overdue_when_past_month_logged():
today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3])
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 3, 10))
assert pruning_status(plant, today) == 'upcoming'
@pytest.mark.django_db
def test_upcoming_future_month():
today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[8])
assert pruning_status(plant, today) == 'upcoming'
@pytest.mark.django_db
def test_overdue_crosses_year_boundary():
# Pruning month December, checked in January
today = date(2026, 1, 10)
plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[12])
assert pruning_status(plant, today) == 'overdue'
```
- [ ] **Step 2: Run tests — expect failure**
```bash
pytest plants/tests/test_pruning.py -v
```
Expected: `ImportError` — utils.pruning not defined yet.
- [ ] **Step 3: Write `plants/utils/pruning.py`**
```python
from datetime import date
def months_before(d, n):
"""Return a date on the 1st of the month n months before d."""
m = (d.month - 1 - n) % 12 + 1
y = d.year + (d.month - 1 - n) // 12
return date(y, m, 1)
def pruning_status(plant, today=None):
"""
Returns one of: 'overdue', 'due_this_month', 'upcoming', 'no_schedule'.
Checks the most recent past pruning month (within 12 months). If it was
not logged the plant is overdue. Uses a 12-month rolling window so
December is correctly caught when checked in January.
"""
if today is None:
today = date.today()
months = plant.pruning_months or []
if not months:
return 'no_schedule'
logs = plant.pruninglogs.all()
logged = {(log.pruned_on.year, log.pruned_on.month) for log in logs}
if today.month in months and (today.year, today.month) not in logged:
return 'due_this_month'
for i in range(1, 13):
past = months_before(today, i)
if past.month in months:
if (past.year, past.month) not in logged:
return 'overdue'
return 'upcoming'
return 'upcoming'
```
- [ ] **Step 4: Run tests — expect pass**
```bash
pytest plants/tests/test_pruning.py -v
```
Expected: 11 tests pass.
- [ ] **Step 5: Commit**
```bash
git add plants/utils/ plants/tests/test_pruning.py
git commit -m "feat: add pruning_status logic with 12-month rolling window"
```
---
## Task 4: Forms
**Files:**
- Create: `plants/forms.py`
- [ ] **Step 1: Write `plants/forms.py`**
No TDD for forms — they are tested implicitly through view tests. Write the file directly:
```python
from django import forms
from .models import Plant, PruningLog
MONTH_CHOICES = [
('1', 'Jan'), ('2', 'Feb'), ('3', 'Mar'), ('4', 'Apr'),
('5', 'May'), ('6', 'Jun'), ('7', 'Jul'), ('8', 'Aug'),
('9', 'Sep'), ('10', 'Oct'), ('11', 'Nov'), ('12', 'Dec'),
]
class PlantForm(forms.ModelForm):
pruning_months = forms.MultipleChoiceField(
choices=MONTH_CHOICES,
widget=forms.CheckboxSelectMultiple,
required=False,
label='Pruning months',
)
class Meta:
model = Plant
fields = ['name', 'location', 'is_indoor', 'pruning_months', 'photo', 'notes']
widgets = {
'notes': forms.Textarea(attrs={'rows': 3}),
'name': forms.TextInput(attrs={'class': 'form-control'}),
'location': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# JSONField stores ints; CheckboxSelectMultiple expects strings matching choice values
if self.instance.pk and self.instance.pruning_months:
self.initial['pruning_months'] = [str(m) for m in self.instance.pruning_months]
elif not self.instance.pk and self.initial.get('pruning_months'):
self.initial['pruning_months'] = [str(m) for m in self.initial['pruning_months']]
def clean_pruning_months(self):
return [int(m) for m in self.cleaned_data.get('pruning_months', [])]
class PruningLogForm(forms.ModelForm):
class Meta:
model = PruningLog
fields = ['pruned_on', 'notes']
widgets = {
'pruned_on': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}),
'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}),
}
```
- [ ] **Step 2: Commit**
```bash
git add plants/forms.py
git commit -m "feat: add PlantForm and PruningLogForm"
```
---
## Task 5: Perenual Service
**Files:**
- Create: `plants/services/__init__.py`
- Create: `plants/services/perenual.py`
- Create: `plants/tests/test_perenual.py`
- [ ] **Step 1: Write the failing tests**
Create `plants/services/__init__.py` (empty) and `plants/tests/test_perenual.py`:
```python
import pytest
from unittest.mock import patch, MagicMock
from plants.services.perenual import search_species
def test_search_returns_empty_without_api_key(settings):
settings.PERENUAL_API_KEY = ''
results = search_species('rose')
assert results == []
def test_search_returns_data(settings):
settings.PERENUAL_API_KEY = 'test-key'
mock_resp = MagicMock()
mock_resp.json.return_value = {
'data': [{'id': 1, 'common_name': 'Rose', 'scientific_name': ['Rosa multiflora']}]
}
mock_resp.raise_for_status = MagicMock()
with patch('plants.services.perenual.requests.get', return_value=mock_resp):
results = search_species('rose')
assert len(results) == 1
assert results[0]['common_name'] == 'Rose'
def test_search_returns_empty_on_timeout(settings):
settings.PERENUAL_API_KEY = 'test-key'
with patch('plants.services.perenual.requests.get', side_effect=Exception('timeout')):
results = search_species('rose')
assert results == []
def test_search_returns_empty_on_bad_status(settings):
settings.PERENUAL_API_KEY = 'test-key'
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception('404')
with patch('plants.services.perenual.requests.get', return_value=mock_resp):
results = search_species('rose')
assert results == []
```
- [ ] **Step 2: Run tests — expect failure**
```bash
pytest plants/tests/test_perenual.py -v
```
Expected: `ImportError`.
- [ ] **Step 3: Write `plants/services/perenual.py`**
```python
import requests
from django.conf import settings
_BASE = 'https://perenual.com/api'
def search_species(query):
"""Query Perenual species-list endpoint. Returns list of result dicts or [] on any error."""
if not settings.PERENUAL_API_KEY:
return []
try:
resp = requests.get(
f'{_BASE}/species-list',
params={'q': query, 'key': settings.PERENUAL_API_KEY},
timeout=5,
)
resp.raise_for_status()
return resp.json().get('data', [])
except Exception:
return []
```
- [ ] **Step 4: Run tests — expect pass**
```bash
pytest plants/tests/test_perenual.py -v
```
Expected: 4 tests pass.
- [ ] **Step 5: Commit**
```bash
git add plants/services/ plants/tests/test_perenual.py
git commit -m "feat: add Perenual API service with error fallback"
```
---
## Task 6: URL Routing + Base Template
**Files:**
- Modify: `plantdb/urls.py`
- Create: `plants/urls.py`
- Create: `plants/views/__init__.py`
- Create: `plants/templates/plants/base.html`
- [ ] **Step 1: Write `plantdb/urls.py`**
```python
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('plants.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
```
- [ ] **Step 2: Write `plants/urls.py`** (stub — fill in as views are added)
```python
from django.urls import path
urlpatterns = []
```
- [ ] **Step 3: Create `plants/views/__init__.py`** (empty)
- [ ] **Step 4: Create the templates directory and write `base.html`**
```bash
mkdir -p plants/templates/plants/partials
```
Write `plants/templates/plants/base.html`:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
<title>{% block title %}PlantDB{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
<style>
body { padding-bottom: 1rem; }
.plant-hero { width: 100%; height: 220px; object-fit: cover; }
.plant-hero-placeholder { width: 100%; height: 220px; background: #d8f3dc; display: flex; align-items: center; justify-content: center; color: #52b788; font-size: 3rem; }
.care-chip { display: inline-block; padding: .2rem .55rem; border-radius: 1rem; font-size: .8rem; margin: .15rem; }
.pruning-strip { border-left: 4px solid #ffc107; background: #fff9c4; padding: .5rem .75rem; border-radius: 0 .375rem .375rem 0; }
.pruning-strip.overdue { border-color: #dc3545; background: #fff5f5; }
.month-checkboxes .form-check { display: inline-flex; margin-right: .5rem; }
</style>
</head>
<body>
<nav class="navbar navbar-dark px-3" style="background-color: #2d6a4f;">
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a>
<div class="d-flex gap-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
<a href="{% url 'pruning_calendar' %}" class="btn btn-sm btn-outline-light">✂️</a>
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
</div>
</nav>
<div class="container-fluid px-3 pt-3">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
```
- [ ] **Step 5: Verify template directory is found**
```bash
python manage.py check
```
Expected: "System check identified no issues."
- [ ] **Step 6: Commit**
```bash
git add plantdb/urls.py plants/urls.py plants/views/ plants/templates/
git commit -m "feat: URL routing skeleton and base template"
```
---
## Task 7: Dashboard View
**Files:**
- Create: `plants/views/dashboard.py`
- Create: `plants/templates/plants/dashboard.html`
- Modify: `plants/urls.py`
- Create: `plants/tests/test_views.py`
- [ ] **Step 1: Write the failing test**
Create `plants/tests/test_views.py`:
```python
import pytest
from django.urls import reverse
from datetime import date
from plants.models import Plant, PruningLog
@pytest.mark.django_db
class TestDashboard:
def test_returns_200(self, client):
resp = client.get(reverse('dashboard'))
assert resp.status_code == 200
def test_shows_total_plant_count(self, client):
Plant.objects.create(name='Fern', location='Office')
Plant.objects.create(name='Rose', location='Garden')
resp = client.get(reverse('dashboard'))
assert resp.context['total_plants'] == 2
def test_overdue_plant_appears_in_context(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3])
# No log — overdue relative to May 2026
resp = client.get(reverse('dashboard'))
assert plant in resp.context['overdue']
def test_due_this_month_plant_appears_in_context(self, client):
today = date.today()
plant = Plant.objects.create(
name='Apple', location='Garden', pruning_months=[today.month]
)
resp = client.get(reverse('dashboard'))
assert plant in resp.context['due_this_month']
```
- [ ] **Step 2: Run tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestDashboard -v
```
Expected: `NoReverseMatch` — 'dashboard' URL not defined.
- [ ] **Step 3: Write `plants/views/dashboard.py`**
```python
from datetime import date
from django.shortcuts import render
from plants.models import Plant
from plants.utils.pruning import pruning_status
def dashboard(request):
today = date.today()
all_plants = list(
Plant.objects.select_related('species').prefetch_related('pruninglogs')
)
overdue, due_this_month = [], []
for plant in all_plants:
status = pruning_status(plant, today)
if status == 'overdue':
overdue.append(plant)
elif status == 'due_this_month':
due_this_month.append(plant)
return render(request, 'plants/dashboard.html', {
'total_plants': len(all_plants),
'overdue': overdue,
'due_this_month': due_this_month,
'today': today,
})
```
- [ ] **Step 4: Update `plants/urls.py`**
```python
from django.urls import path
from plants.views import dashboard
urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
]
```
- [ ] **Step 5: Write `plants/templates/plants/dashboard.html`**
```html
{% extends "plants/base.html" %}
{% block title %}Dashboard — PlantDB{% endblock %}
{% block content %}
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
<div class="row g-2 mb-3">
<div class="col-6">
<a href="{% url 'plant_list' %}" class="text-decoration-none">
<div class="card text-center h-100" style="background:#d8f3dc;">
<div class="card-body py-3">
<div class="display-5 fw-bold" style="color:#1b4332;">{{ total_plants }}</div>
<div class="small" style="color:#2d6a4f;">Plants</div>
</div>
</div>
</a>
</div>
<div class="col-6">
<a href="{% url 'pruning_calendar' %}" class="text-decoration-none">
<div class="card text-center h-100" style="background:#ffe8cc;">
<div class="card-body py-3">
<div class="display-5 fw-bold" style="color:#7d4a00;">
{{ overdue|length|add:due_this_month|length }}
</div>
<div class="small" style="color:#7d4a00;">Need pruning</div>
</div>
</div>
</a>
</div>
</div>
{% if overdue or due_this_month %}
<div class="card mb-3">
<div class="card-header fw-semibold">✂️ Needs attention</div>
<div class="list-group list-group-flush">
{% for plant in overdue %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small>
</div>
<span class="badge bg-danger">overdue</span>
</a>
{% endfor %}
{% for plant in due_this_month %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small>
</div>
<span class="badge bg-warning text-dark">this month</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="d-grid">
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
</div>
{% endblock %}
```
- [ ] **Step 6: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestDashboard -v
```
Expected: 4 tests pass.
- [ ] **Step 7: Commit**
```bash
git add plants/views/dashboard.py plants/templates/plants/dashboard.html plants/urls.py plants/tests/test_views.py
git commit -m "feat: dashboard with plant count and pruning summary"
```
---
## Task 8: Plant List + HTMX Search
**Files:**
- Create: `plants/views/plants.py` (plant_list only for now)
- Create: `plants/templates/plants/plant_list.html`
- Create: `plants/templates/plants/partials/plant_list_results.html`
- Modify: `plants/urls.py`
- [ ] **Step 1: Add failing tests to `plants/tests/test_views.py`**
Append to the existing file:
```python
@pytest.mark.django_db
class TestPlantList:
def test_returns_200(self, client):
resp = client.get(reverse('plant_list'))
assert resp.status_code == 200
def test_lists_plants(self, client):
Plant.objects.create(name='Monstera', location='Living room')
resp = client.get(reverse('plant_list'))
assert 'Monstera' in resp.content.decode()
def test_search_filters_by_name(self, client):
Plant.objects.create(name='Monstera', location='Living room')
Plant.objects.create(name='Rose', location='Garden')
resp = client.get(reverse('plant_list') + '?q=Rose')
content = resp.content.decode()
assert 'Rose' in content
assert 'Monstera' not in content
def test_htmx_search_uses_partial_template(self, client):
resp = client.get(
reverse('plant_list') + '?q=rose',
HTTP_HX_REQUEST='true',
)
assert resp.status_code == 200
assert resp.templates[0].name == 'plants/partials/plant_list_results.html'
def test_indoor_filter(self, client):
Plant.objects.create(name='Fern', location='Office', is_indoor=True)
Plant.objects.create(name='Rose', location='Garden', is_indoor=False)
resp = client.get(reverse('plant_list') + '?filter=indoor')
content = resp.content.decode()
assert 'Fern' in content
assert 'Rose' not in content
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestPlantList -v
```
Expected: `NoReverseMatch`.
- [ ] **Step 3: Create `plants/views/plants.py`** (plant_list only)
```python
from datetime import date
from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.http import require_POST
from plants.models import Plant, Species, PruningLog
from plants.forms import PlantForm, PruningLogForm
from plants.utils.pruning import pruning_status
def plant_list(request):
qs = Plant.objects.select_related('species').all()
q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '')
if q:
qs = qs.filter(name__icontains=q) | qs.filter(location__icontains=q)
if indoor_filter == 'indoor':
qs = qs.filter(is_indoor=True)
elif indoor_filter == 'outdoor':
qs = qs.filter(is_indoor=False)
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})
```
- [ ] **Step 4: Update `plants/urls.py`**
```python
from django.urls import path
from plants.views import dashboard, plants
urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
path('plants/', plants.plant_list, name='plant_list'),
]
```
- [ ] **Step 5: Write `plants/templates/plants/plant_list.html`**
```html
{% extends "plants/base.html" %}
{% block title %}Plants — PlantDB{% endblock %}
{% block content %}
<div class="mb-3">
<input
type="search"
name="q"
value="{{ q }}"
class="form-control"
placeholder="🔍 Search plants..."
hx-get="{% url 'plant_list' %}"
hx-trigger="input changed delay:300ms"
hx-target="#plant-list"
hx-swap="innerHTML"
>
</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>
<div id="plant-list">
{% include "plants/partials/plant_list_results.html" %}
</div>
{% endblock %}
```
- [ ] **Step 6: Write `plants/templates/plants/partials/plant_list_results.html`**
```html
{% for plant in plants %}
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
<div class="card mb-2">
<div class="card-body d-flex align-items-center gap-3 py-2">
{% if plant.photo %}
<img src="{{ plant.photo.url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% elif plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% else %}
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
{% endif %}
<div>
<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>
</div>
</a>
{% empty %}
<p class="text-muted text-center mt-4">No plants found.</p>
{% endfor %}
```
- [ ] **Step 7: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestPlantList -v
```
Expected: 5 tests pass.
- [ ] **Step 8: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_list.html plants/templates/plants/partials/plant_list_results.html plants/urls.py
git commit -m "feat: plant list with HTMX live search and indoor/outdoor filter"
```
---
## Task 9: Plant Detail + Inline Pruning Log
**Files:**
- Modify: `plants/views/plants.py` (add plant_detail, log_pruning)
- Create: `plants/templates/plants/plant_detail.html`
- Create: `plants/templates/plants/partials/pruning_strip.html`
- Modify: `plants/urls.py`
- [ ] **Step 1: Add failing tests**
Append to `plants/tests/test_views.py`:
```python
@pytest.mark.django_db
class TestPlantDetail:
def test_returns_200(self, client):
plant = Plant.objects.create(name='Fern', location='Office')
resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert resp.status_code == 200
def test_404_for_missing_plant(self, client):
resp = client.get(reverse('plant_detail', args=[9999]))
assert resp.status_code == 404
def test_context_contains_plant_and_status(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[])
resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert resp.context['plant'] == plant
assert resp.context['pruning_status'] == 'no_schedule'
@pytest.mark.django_db
class TestLogPruning:
def test_creates_pruning_log(self, client):
plant = Plant.objects.create(name='Rose', location='Garden')
resp = client.post(
reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''},
)
assert resp.status_code == 200
assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists()
def test_returns_pruning_strip_partial(self, client):
plant = Plant.objects.create(name='Rose', location='Garden')
resp = client.post(
reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''},
)
assert resp.templates[0].name == 'plants/partials/pruning_strip.html'
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v
```
Expected: `NoReverseMatch`.
- [ ] **Step 3: Add `plant_detail` and `log_pruning` to `plants/views/plants.py`**
Add after the existing `plant_list` function:
```python
def plant_detail(request, pk):
plant = get_object_or_404(
Plant.objects.select_related('species').prefetch_related('pruninglogs'),
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('pruninglogs'), 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}),
})
```
- [ ] **Step 4: Update `plants/urls.py`**
```python
from django.urls import path
from plants.views import dashboard, plants
urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
path('plants/', plants.plant_list, name='plant_list'),
path('plants/add/', plants.plant_add, name='plant_add'),
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
]
```
(plant_add, plant_edit, plant_delete will be added in Tasks 1012; add them now as stubs in views/plants.py to avoid import errors:)
Append to `plants/views/plants.py`:
```python
def plant_add(request):
return render(request, 'plants/species_search.html')
def plant_edit(request, pk):
pass
def plant_delete(request, pk):
pass
```
- [ ] **Step 5: Write `plants/templates/plants/partials/pruning_strip.html`**
```html
<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>
<strong>✂️ Pruning</strong>
{% if plant.pruning_months %}
<div class="small">
Scheduled:
{% for m in plant.pruning_months %}
{{ m|date:"N" }}{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{% with plant.pruninglogs.first as last_log %}
{% if last_log %}
<div class="small text-muted">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
{% endif %}
{% endwith %}
</div>
{% 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>
<form
hx-post="{% url 'log_pruning' plant.pk %}"
hx-target="#pruning-strip"
hx-swap="outerHTML"
class="mt-2"
>
{% csrf_token %}
<div class="d-flex gap-2 align-items-end">
{{ log_form.pruned_on }}
<button type="submit" class="btn btn-sm btn-success">Log pruning</button>
</div>
</form>
</div>
```
Note: Django's `{{ m|date:"N" }}` won't work on plain integers. Use a template filter or hardcode a month lookup. Instead, replace the pruning months display with:
```html
<div class="small">
Scheduled: {{ plant.pruning_months|join:", " }}
</div>
```
(A nicer month-name display can be added later; the data is correct.)
- [ ] **Step 6: Write `plants/templates/plants/plant_detail.html`**
```html
{% extends "plants/base.html" %}
{% block title %}{{ plant.name }} — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5>
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
</div>
{% if plant.photo %}
<img src="{{ plant.photo.url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% elif plant.species and plant.species.api_image_url %}
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
{% else %}
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
{% endif %}
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<h6 class="mb-0">{{ plant.name }}</h6>
{% if plant.species %}<small class="text-muted fst-italic">{{ plant.species.scientific_name }}</small>{% endif %}
</div>
<span class="badge {% if plant.is_indoor %}bg-info{% else %}bg-success{% endif %}">
{% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
</span>
</div>
<p class="text-muted small mb-2">📍 {{ plant.location }}</p>
{% if plant.species %}
<div class="mb-3">
{% if plant.species.watering %}
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</span>
{% endif %}
{% if plant.species.sunlight %}
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</span>
{% endif %}
{% if plant.species.max_height_cm %}
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
{% endif %}
{% if plant.species.growth_rate %}
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
{% endif %}
</div>
{% if plant.species.api_image_url and plant.photo %}
<div class="mb-3 d-flex align-items-center gap-2">
<img src="{{ plant.species.api_image_url }}" width="60" height="60" class="rounded" style="object-fit:cover;">
<small class="text-muted">Species reference: {{ plant.species.common_name }}</small>
</div>
{% endif %}
{% endif %}
{% include "plants/partials/pruning_strip.html" %}
{% if plant.notes %}
<div class="card mb-3">
<div class="card-body py-2">
<small class="text-muted">{{ plant.notes|linebreaksbr }}</small>
</div>
</div>
{% endif %}
{% if plant.pruninglogs.all %}
<div class="mb-3">
<h6>Pruning history</h6>
<ul class="list-group list-group-flush">
{% for log in plant.pruninglogs.all %}
<li class="list-group-item py-1">
<strong>{{ log.pruned_on|date:"j M Y" }}</strong>
{% if log.notes %}<small class="text-muted ms-2">{{ log.notes }}</small>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm">Delete plant</a>
{% endblock %}
```
- [ ] **Step 7: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v
```
Expected: 5 tests pass.
- [ ] **Step 8: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_detail.html plants/templates/plants/partials/pruning_strip.html plants/urls.py
git commit -m "feat: plant detail with photo hero and inline HTMX pruning log"
```
---
## Task 10: Species Search (HTMX)
**Files:**
- Create: `plants/views/species.py`
- Create: `plants/templates/plants/species_search.html`
- Create: `plants/templates/plants/partials/species_results.html`
- Modify: `plants/urls.py`
- [ ] **Step 1: Add failing tests**
Append to `plants/tests/test_views.py`:
```python
@pytest.mark.django_db
class TestSpeciesSearch:
def test_search_page_returns_200(self, client):
resp = client.get(reverse('plant_add'))
assert resp.status_code == 200
def test_htmx_search_returns_partial(self, client, settings):
settings.PERENUAL_API_KEY = ''
resp = client.get(
reverse('species_search') + '?q=rose',
HTTP_HX_REQUEST='true',
)
assert resp.status_code == 200
assert resp.templates[0].name == 'plants/partials/species_results.html'
def test_species_select_creates_species_and_redirects(self, client):
resp = client.post(reverse('species_select'), {
'perenual_id': '42',
'common_name': 'Test Rose',
'scientific_name': 'Rosa testii',
'watering': 'Weekly',
'sunlight': 'Full sun',
'pruning_months': 'February, August',
'api_image_url': '',
})
assert resp.status_code == 302
from plants.models import Species
assert Species.objects.filter(perenual_id=42).exists()
def test_species_select_deduplicates(self, client):
from plants.models import Species
Species.objects.create(perenual_id=99, common_name='Existing')
client.post(reverse('species_select'), {
'perenual_id': '99',
'common_name': 'Existing',
'scientific_name': '',
'watering': '',
'sunlight': '',
'pruning_months': '',
'api_image_url': '',
})
assert Species.objects.filter(perenual_id=99).count() == 1
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestSpeciesSearch -v
```
Expected: `NoReverseMatch`.
- [ ] **Step 3: Write `plants/views/species.py`**
```python
from django.shortcuts import render, redirect
from django.urls import reverse
from plants.models import Species
from plants.services.perenual import search_species
_MONTH_MAP = {
'January': 1, 'February': 2, 'March': 3, 'April': 4,
'May': 5, 'June': 6, 'July': 7, 'August': 8,
'September': 9, 'October': 10, 'November': 11, 'December': 12,
}
def species_search(request):
"""HTMX endpoint: search Perenual and return results partial."""
q = request.GET.get('q', '').strip()
results = search_species(q) if len(q) >= 2 else []
return render(request, 'plants/partials/species_results.html', {
'results': results, 'q': q,
})
def species_select(request):
"""POST: cache selected species from search results data, redirect to plant form."""
perenual_id = int(request.POST['perenual_id'])
if not Species.objects.filter(perenual_id=perenual_id).exists():
pruning_str = request.POST.get('pruning_months', '')
pruning_months = [
_MONTH_MAP[m.strip()]
for m in pruning_str.split(',')
if m.strip() in _MONTH_MAP
]
Species.objects.create(
perenual_id=perenual_id,
common_name=request.POST.get('common_name', ''),
scientific_name=request.POST.get('scientific_name', ''),
watering=request.POST.get('watering', ''),
sunlight=request.POST.get('sunlight', ''),
pruning_months=pruning_months,
api_image_url=request.POST.get('api_image_url', ''),
)
species = Species.objects.get(perenual_id=perenual_id)
return redirect(f"{reverse('plant_add')}?species_id={species.pk}")
```
- [ ] **Step 4: Update `plants/urls.py`**
```python
from django.urls import path
from plants.views import dashboard, plants, species, pruning
urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
path('plants/', plants.plant_list, name='plant_list'),
path('plants/add/', plants.plant_add, name='plant_add'),
path('plants/<int:pk>/', plants.plant_detail, name='plant_detail'),
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
path('species/search/', species.species_search, name='species_search'),
path('species/select/', species.species_select, name='species_select'),
]
```
Also add a stub for `pruning_calendar` in `plants/views/pruning.py`:
```python
from django.shortcuts import render
def pruning_calendar(request):
return render(request, 'plants/pruning_calendar.html', {})
```
And a stub template `plants/templates/plants/pruning_calendar.html`:
```html
{% extends "plants/base.html" %}
{% block content %}<p>Pruning calendar coming soon.</p>{% endblock %}
```
- [ ] **Step 5: Write `plants/templates/plants/species_search.html`**
```html
{% extends "plants/base.html" %}
{% block title %}Add Plant — PlantDB{% endblock %}
{% block content %}
<h5 class="mb-3">Add a plant</h5>
<p class="text-muted small">Search for the species to auto-fill care info, or skip to enter details manually.</p>
<input
type="search"
class="form-control mb-2"
placeholder="🔍 Search species, e.g. 'climbing rose'"
hx-get="{% url 'species_search' %}"
hx-trigger="input changed delay:400ms"
hx-target="#species-results"
hx-swap="innerHTML"
name="q"
autocomplete="off"
>
<div id="species-results" class="list-group mb-3"></div>
<div class="text-center">
<a href="{% url 'plant_add' %}?species_id=skip" class="text-muted small">
Skip — enter details manually
</a>
</div>
{% endblock %}
```
- [ ] **Step 6: Write `plants/templates/plants/partials/species_results.html`**
```html
{% for result in results %}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2 mb-1">
{% if result.default_image and result.default_image.thumbnail %}
<img src="{{ result.default_image.thumbnail }}" width="40" height="40" class="rounded" style="object-fit:cover; flex-shrink:0;">
{% else %}
<div style="width:40px; height:40px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
{% endif %}
<div>
<div class="fw-semibold">{{ result.common_name }}</div>
<small class="text-muted fst-italic">{{ result.scientific_name|join:", " }}</small>
</div>
</div>
<form method="POST" action="{% url 'species_select' %}">
{% csrf_token %}
<input type="hidden" name="perenual_id" value="{{ result.id }}">
<input type="hidden" name="common_name" value="{{ result.common_name }}">
<input type="hidden" name="scientific_name" value="{{ result.scientific_name|join:', ' }}">
<input type="hidden" name="watering" value="{{ result.watering|default:'' }}">
<input type="hidden" name="sunlight" value="{{ result.sunlight|join:', ' }}">
<input type="hidden" name="pruning_months" value="{{ result.pruning_month|join:', ' }}">
<input type="hidden" name="api_image_url" value="{{ result.default_image.regular_url|default:'' }}">
<button type="submit" class="btn btn-sm btn-success">Select →</button>
</form>
</div>
{% empty %}
{% if q %}<p class="list-group-item text-muted">No results for "{{ q }}"</p>{% endif %}
{% endfor %}
```
- [ ] **Step 7: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestSpeciesSearch -v
```
Expected: 4 tests pass.
- [ ] **Step 8: Commit**
```bash
git add plants/views/species.py plants/views/pruning.py plants/templates/plants/species_search.html plants/templates/plants/partials/species_results.html plants/templates/plants/pruning_calendar.html plants/urls.py
git commit -m "feat: species search with HTMX and Perenual caching on select"
```
---
## Task 11: Add Plant Form
**Files:**
- Modify: `plants/views/plants.py` (replace plant_add stub)
- Create: `plants/templates/plants/plant_form.html`
- [ ] **Step 1: Add failing tests**
Append to `plants/tests/test_views.py`:
```python
@pytest.mark.django_db
class TestPlantAdd:
def test_add_with_species_shows_prefilled_form(self, client):
from plants.models import Species
s = Species.objects.create(common_name='Rose', pruning_months=[2, 8])
resp = client.get(reverse('plant_add') + f'?species_id={s.pk}')
assert resp.status_code == 200
assert resp.context['species'] == s
def test_post_creates_plant_and_redirects(self, client):
resp = client.post(reverse('plant_add'), {
'name': 'New Fern',
'location': 'Office',
'is_indoor': 'on',
'notes': '',
'pruning_months': [],
})
assert Plant.objects.filter(name='New Fern').exists()
plant = Plant.objects.get(name='New Fern')
assert resp.status_code == 302
assert resp['Location'] == f'/plants/{plant.pk}/'
def test_post_with_species_links_species(self, client):
from plants.models import Species
s = Species.objects.create(common_name='Rose', pruning_months=[2])
client.post(
reverse('plant_add') + f'?species_id={s.pk}',
{'name': 'Garden Rose', 'location': 'Garden', 'notes': '', 'pruning_months': ['2']},
)
plant = Plant.objects.get(name='Garden Rose')
assert plant.species == s
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestPlantAdd -v
```
Expected: failures (stub plant_add returns 200 but no form context).
- [ ] **Step 3: Replace `plant_add` stub in `plants/views/plants.py`**
```python
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:
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 = {'pruning_months': species.pruning_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',
})
```
- [ ] **Step 4: Write `plants/templates/plants/plant_form.html`**
```html
{% extends "plants/base.html" %}
{% block title %}{{ title }} — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary"></a>
<h5 class="mb-0">{{ title }}</h5>
</div>
{% if species %}
<div class="alert alert-success py-2 d-flex align-items-center gap-2">
{% if species.api_image_url %}
<img src="{{ species.api_image_url }}" width="36" height="36" class="rounded" style="object-fit:cover;">
{% endif %}
<div>
<strong>{{ species.common_name }}</strong>
{% if species.scientific_name %}<small class="text-muted fst-italic ms-1">{{ species.scientific_name }}</small>{% endif %}
<div class="small">Care info pre-filled from species database.</div>
</div>
</div>
{% endif %}
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="species_id" value="{{ species_id|default:'' }}">
<div class="mb-3">
<label class="form-label fw-semibold">Your name for this plant *</label>
{{ form.name }}
{% if form.name.errors %}<div class="text-danger small">{{ form.name.errors }}</div>{% endif %}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Location</label>
{{ form.location }}
{% if form.location.errors %}<div class="text-danger small">{{ form.location.errors }}</div>{% endif %}
</div>
<div class="mb-3 form-check">
{{ form.is_indoor }}
<label class="form-check-label" for="{{ form.is_indoor.id_for_label }}">Indoor plant</label>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Pruning months</label>
<div class="month-checkboxes d-flex flex-wrap gap-1">
{% for widget in form.pruning_months %}
<div class="form-check form-check-inline border rounded px-2 py-1">
{{ widget.tag }}
<label class="form-check-label" for="{{ widget.id_for_label }}">{{ widget.choice_label }}</label>
</div>
{% endfor %}
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Photo</label>
{{ form.photo }}
<div class="form-text">Take a photo with your camera or upload one.</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
{{ form.notes }}
</div>
<div class="d-grid">
<button type="submit" class="btn btn-success">Save plant</button>
</div>
</form>
{% endblock %}
```
- [ ] **Step 5: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestPlantAdd -v
```
Expected: 3 tests pass.
- [ ] **Step 6: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_form.html
git commit -m "feat: add plant form with species pre-fill"
```
---
## Task 12: Edit + Delete Plant
**Files:**
- Modify: `plants/views/plants.py` (replace stubs)
- Create: `plants/templates/plants/plant_confirm_delete.html`
- [ ] **Step 1: Add failing tests**
Append to `plants/tests/test_views.py`:
```python
@pytest.mark.django_db
class TestPlantEdit:
def test_edit_form_shows_current_values(self, client):
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[3])
resp = client.get(reverse('plant_edit', args=[plant.pk]))
assert resp.status_code == 200
assert resp.context['plant'] == plant
def test_post_updates_plant(self, client):
plant = Plant.objects.create(name='Fern', location='Office')
client.post(reverse('plant_edit', args=[plant.pk]), {
'name': 'Updated Fern',
'location': 'Bedroom',
'notes': '',
'pruning_months': [],
})
plant.refresh_from_db()
assert plant.name == 'Updated Fern'
@pytest.mark.django_db
class TestPlantDelete:
def test_delete_confirm_page(self, client):
plant = Plant.objects.create(name='Fern', location='Office')
resp = client.get(reverse('plant_delete', args=[plant.pk]))
assert resp.status_code == 200
def test_post_deletes_plant(self, client):
plant = Plant.objects.create(name='Fern', location='Office')
client.post(reverse('plant_delete', args=[plant.pk]))
assert not Plant.objects.filter(pk=plant.pk).exists()
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v
```
Expected: failures (stubs return None).
- [ ] **Step 3: Replace `plant_edit` and `plant_delete` stubs in `plants/views/plants.py`**
```python
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,
'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})
```
- [ ] **Step 4: Write `plants/templates/plants/plant_confirm_delete.html`**
```html
{% extends "plants/base.html" %}
{% block title %}Delete {{ plant.name }} — PlantDB{% endblock %}
{% block content %}
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Delete "{{ plant.name }}"?</h5>
<p class="card-text text-muted">This will also delete all pruning history for this plant. This cannot be undone.</p>
<form method="POST">
{% csrf_token %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Yes, delete</button>
<a href="{% url 'plant_detail' plant.pk %}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
```
- [ ] **Step 5: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v
```
Expected: 4 tests pass.
- [ ] **Step 6: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_confirm_delete.html
git commit -m "feat: plant edit and delete with confirmation"
```
---
## Task 13: Pruning Calendar
**Files:**
- Modify: `plants/views/pruning.py` (replace stub)
- Modify: `plants/templates/plants/pruning_calendar.html` (replace stub)
- [ ] **Step 1: Add failing tests**
Append to `plants/tests/test_views.py`:
```python
@pytest.mark.django_db
class TestPruningCalendar:
def test_returns_200(self, client):
resp = client.get(reverse('pruning_calendar'))
assert resp.status_code == 200
def test_overdue_plant_in_context(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3])
resp = client.get(reverse('pruning_calendar'))
assert plant in resp.context['overdue']
def test_due_this_month_in_context(self, client):
from datetime import date
today = date.today()
plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[today.month])
resp = client.get(reverse('pruning_calendar'))
assert plant in resp.context['due_this_month']
def test_no_schedule_plants_excluded(self, client):
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[])
resp = client.get(reverse('pruning_calendar'))
assert plant not in resp.context['overdue']
assert plant not in resp.context['due_this_month']
```
- [ ] **Step 2: Run new tests — expect failure**
```bash
pytest plants/tests/test_views.py::TestPruningCalendar -v
```
Expected: context keys missing from stub.
- [ ] **Step 3: Replace pruning_calendar stub in `plants/views/pruning.py`**
```python
from datetime import date
from django.shortcuts import render
from plants.models import Plant
from plants.utils.pruning import pruning_status, months_before
def pruning_calendar(request):
today = date.today()
next_month_num = today.month % 12 + 1
next_month_year = today.year + (1 if today.month == 12 else 0)
next_month_date = date(next_month_year, next_month_num, 1)
all_plants = list(
Plant.objects.select_related('species').prefetch_related('pruninglogs')
)
overdue, due_this_month, next_month_plants, later = [], [], [], []
for plant in all_plants:
if not plant.pruning_months:
continue
status = pruning_status(plant, today)
if status == 'overdue':
overdue.append(plant)
elif status == 'due_this_month':
due_this_month.append(plant)
elif next_month_num in plant.pruning_months:
next_month_plants.append(plant)
else:
later.append(plant)
return render(request, 'plants/pruning_calendar.html', {
'overdue': overdue,
'due_this_month': due_this_month,
'next_month_plants': next_month_plants,
'later': later,
'today': today,
'month_name': today.strftime('%B'),
'next_month_name': next_month_date.strftime('%B'),
})
```
- [ ] **Step 4: Replace `plants/templates/plants/pruning_calendar.html`**
```html
{% extends "plants/base.html" %}
{% block title %}Pruning Calendar — PlantDB{% endblock %}
{% block content %}
<h5 class="mb-3">✂️ Pruning Calendar</h5>
{% if overdue %}
<h6 class="text-danger">⚠ Overdue</h6>
<div class="list-group mb-3">
{% for plant in overdue %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action list-group-item-danger d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-danger">Overdue</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if due_this_month %}
<h6 style="color:#7d4a00;">This month — {{ month_name }}</h6>
<div class="list-group mb-3">
{% for plant in due_this_month %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action list-group-item-warning d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-warning text-dark">This month</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if next_month_plants %}
<h6 class="text-muted">Next — {{ next_month_name }}</h6>
<div class="list-group mb-3">
{% for plant in next_month_plants %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small>{{ plant.location }}</small>
</div>
<span class="badge bg-secondary">{{ next_month_name }}</span>
</a>
{% endfor %}
</div>
{% endif %}
{% if later %}
<details>
<summary class="text-muted mb-2">Later this year ({{ later|length }})</summary>
<div class="list-group mb-3">
{% for plant in later %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action">
<div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }} · Months: {{ plant.pruning_months|join:", " }}</small>
</a>
{% endfor %}
</div>
</details>
{% endif %}
{% if not overdue and not due_this_month and not next_month_plants and not later %}
<p class="text-muted text-center mt-4">No pruning schedules set. Add pruning months to your plants.</p>
{% endif %}
{% endblock %}
```
- [ ] **Step 5: Run tests — expect pass**
```bash
pytest plants/tests/test_views.py::TestPruningCalendar -v
```
Expected: 4 tests pass.
- [ ] **Step 6: Run the full test suite**
```bash
pytest -v
```
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add plants/views/pruning.py plants/templates/plants/pruning_calendar.html
git commit -m "feat: pruning calendar grouped by urgency"
```
---
## Task 14: Docker Compose Deployment
**Files:**
- Create: `Dockerfile`
- Create: `docker-compose.yml`
- Create: `nginx/default.conf`
- [ ] **Step 1: Write `Dockerfile`**
```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
```
- [ ] **Step 2: Write `docker-compose.yml`**
```yaml
services:
web:
build: .
env_file: .env
volumes:
- db:/app/db.sqlite3
- media:/app/media
- static:/app/staticfiles
expose:
- "8000"
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 2"
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- media:/app/media:ro
- static:/app/staticfiles:ro
depends_on:
- web
restart: unless-stopped
volumes:
db:
media:
static:
```
- [ ] **Step 3: Write `nginx/default.conf`**
```nginx
upstream django {
server web:8000;
}
server {
listen 80;
client_max_body_size 20M;
location /static/ {
alias /app/staticfiles/;
}
location /media/ {
alias /app/media/;
}
location / {
proxy_pass http://django;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
- [ ] **Step 4: Create `.env` from the example** (on the homelab server)
```bash
cp .env.example .env
# Edit .env: set SECRET_KEY, ALLOWED_HOSTS, PERENUAL_API_KEY
```
- [ ] **Step 5: Build and start**
```bash
docker compose up -d --build
```
Expected: containers start, app accessible at `http://<homelab-ip>/`.
- [ ] **Step 6: Smoke test**
Open `http://<homelab-ip>/` in a browser. Verify:
- Dashboard loads with green plant count tile
- "Add a plant" button works
- Species search returns results (if API key set)
- Pruning calendar loads
- [ ] **Step 7: Commit**
```bash
git add Dockerfile docker-compose.yml nginx/
git commit -m "feat: Docker Compose deployment with Nginx and Gunicorn"
```
---
## Self-Review Checklist
| Spec requirement | Task |
|---|---|
| Django 5 + Bootstrap 5 + HTMX | Task 1, 6 |
| SQLite | Task 1 |
| Species model (all care fields) | Task 2 |
| Plant model with personal photo + pruning_months | Task 2 |
| PruningLog model | Task 2 |
| Pruning status logic (12-month rolling window) | Task 3 |
| PlantForm with month picker | Task 4 |
| Perenual search with error fallback | Task 5 |
| Dashboard with tiles and urgency list | Task 7 |
| Plant list with HTMX live search and filter | Task 8 |
| Plant detail — photo hero, care chips, species reference image | Task 9 |
| HTMX inline pruning log | Task 9 |
| Species search HTMX, caching on select | Task 10 |
| Add plant with species pre-fill | Task 11 |
| Edit + delete plant | Task 12 |
| Pruning calendar grouped by urgency | Task 13 |
| Docker Compose homelab deployment | Task 14 |
| No user accounts | ✓ (never added) |
| Mobile-first (Bootstrap, viewport meta) | Task 6 |
| API image + personal photo both shown | Task 9 |