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

70 KiB
Raw Permalink Blame History

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"

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_detail and log_pruning to plants/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 1012; 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_add stub in plants/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_edit and plant_delete stubs in plants/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 .env from 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