70 KiB
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
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:
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
[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
python manage.py migrate
python manage.py runserver
Expected: server starts at http://127.0.0.1:8000, no errors.
- Step 7: Commit
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:
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
pytest plants/tests/test_models.py -v
Expected: ImportError — models not defined yet.
- Step 3: Write
plants/models.py
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
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
python manage.py makemigrations plants
python manage.py migrate
Expected: migrations created and applied, no errors.
- Step 6: Run tests — expect pass
pytest plants/tests/test_models.py -v
Expected: 5 tests pass.
- Step 7: Commit
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:
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
pytest plants/tests/test_pruning.py -v
Expected: ImportError — utils.pruning not defined yet.
- Step 3: Write
plants/utils/pruning.py
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
pytest plants/tests/test_pruning.py -v
Expected: 11 tests pass.
- Step 5: Commit
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:
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
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:
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
pytest plants/tests/test_perenual.py -v
Expected: ImportError.
- Step 3: Write
plants/services/perenual.py
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
pytest plants/tests/test_perenual.py -v
Expected: 4 tests pass.
- Step 5: Commit
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
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)
from django.urls import path
urlpatterns = []
-
Step 3: Create
plants/views/__init__.py(empty) -
Step 4: Create the templates directory and write
base.html
mkdir -p plants/templates/plants/partials
Write plants/templates/plants/base.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
python manage.py check
Expected: "System check identified no issues."
- Step 6: Commit
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:
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
pytest plants/tests/test_views.py::TestDashboard -v
Expected: NoReverseMatch — 'dashboard' URL not defined.
- Step 3: Write
plants/views/dashboard.py
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
from django.urls import path
from plants.views import dashboard
urlpatterns = [
path('', dashboard.dashboard, name='dashboard'),
]
- Step 5: Write
plants/templates/plants/dashboard.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
pytest plants/tests/test_views.py::TestDashboard -v
Expected: 4 tests pass.
- Step 7: Commit
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:
@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
pytest plants/tests/test_views.py::TestPlantList -v
Expected: NoReverseMatch.
- Step 3: Create
plants/views/plants.py(plant_list only)
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
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
{% 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
{% 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
pytest plants/tests/test_views.py::TestPlantList -v
Expected: 5 tests pass.
- Step 8: Commit
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:
@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
pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v
Expected: NoReverseMatch.
- Step 3: Add
plant_detailandlog_pruningtoplants/views/plants.py
Add after the existing plant_list function:
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
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 10–12; add them now as stubs in views/plants.py to avoid import errors:)
Append to plants/views/plants.py:
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
<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:
<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
{% 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
pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v
Expected: 5 tests pass.
- Step 8: Commit
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:
@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
pytest plants/tests/test_views.py::TestSpeciesSearch -v
Expected: NoReverseMatch.
- Step 3: Write
plants/views/species.py
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
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:
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:
{% extends "plants/base.html" %}
{% block content %}<p>Pruning calendar coming soon.</p>{% endblock %}
- Step 5: Write
plants/templates/plants/species_search.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
{% 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
pytest plants/tests/test_views.py::TestSpeciesSearch -v
Expected: 4 tests pass.
- Step 8: Commit
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:
@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
pytest plants/tests/test_views.py::TestPlantAdd -v
Expected: failures (stub plant_add returns 200 but no form context).
- Step 3: Replace
plant_addstub inplants/views/plants.py
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
{% 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
pytest plants/tests/test_views.py::TestPlantAdd -v
Expected: 3 tests pass.
- Step 6: Commit
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:
@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
pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v
Expected: failures (stubs return None).
- Step 3: Replace
plant_editandplant_deletestubs inplants/views/plants.py
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
{% 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
pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v
Expected: 4 tests pass.
- Step 6: Commit
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:
@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
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
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
{% 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
pytest plants/tests/test_views.py::TestPruningCalendar -v
Expected: 4 tests pass.
- Step 6: Run the full test suite
pytest -v
Expected: all tests pass.
- Step 7: Commit
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
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
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
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
.envfrom the example (on the homelab server)
cp .env.example .env
# Edit .env: set SECRET_KEY, ALLOWED_HOSTS, PERENUAL_API_KEY
- Step 5: Build and start
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
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 |