diff --git a/docs/superpowers/plans/2026-05-27-plantdb.md b/docs/superpowers/plans/2026-05-27-plantdb.md new file mode 100644 index 0000000..94add2b --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-plantdb.md @@ -0,0 +1,2312 @@ +# PlantDB Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a mobile-first Django web app for a family plant and flower registry with pruning calendar and Perenual API integration. + +**Architecture:** Server-rendered Django templates with HTMX for targeted partial updates (species search, pruning log). A single `plants` Django app owns all models, views, and templates. SQLite for storage, served via Gunicorn behind Nginx in Docker Compose. + +**Tech Stack:** Django 5.1, Bootstrap 5.3, HTMX 1.9, django-htmx, Pillow, requests, pytest-django + +--- + +## File Map + +``` +plantdb/ +├── .env.example +├── .gitignore +├── Dockerfile +├── docker-compose.yml +├── pytest.ini +├── requirements.txt +├── nginx/ +│ └── default.conf +├── manage.py ← generated by django-admin +├── plantdb/ ← Django project package +│ ├── __init__.py +│ ├── settings.py +│ ├── urls.py +│ └── wsgi.py +└── plants/ ← sole Django app + ├── __init__.py + ├── admin.py + ├── apps.py + ├── forms.py ← PlantForm, PruningLogForm + ├── models.py ← Species, Plant, PruningLog + ├── urls.py + ├── services/ + │ ├── __init__.py + │ └── perenual.py ← API search only; no caching logic + ├── utils/ + │ ├── __init__.py + │ └── pruning.py ← pruning_status, months_before + ├── views/ + │ ├── __init__.py + │ ├── dashboard.py + │ ├── plants.py ← list, detail, add, edit, delete, log_pruning + │ ├── pruning.py ← pruning calendar + │ └── species.py ← HTMX species search + select + ├── templates/plants/ + │ ├── base.html + │ ├── dashboard.html + │ ├── plant_list.html + │ ├── plant_detail.html + │ ├── plant_form.html ← shared by add and edit + │ ├── plant_confirm_delete.html + │ ├── species_search.html ← step 1 of add-plant flow + │ ├── pruning_calendar.html + │ └── partials/ + │ ├── plant_list_results.html ← HTMX search swap target + │ ├── species_results.html ← HTMX species lookup results + │ └── pruning_strip.html ← HTMX pruning log swap target + └── tests/ + ├── __init__.py + ├── test_models.py + ├── test_pruning.py + ├── test_perenual.py + └── test_views.py +``` + +--- + +## Task 1: Project Scaffold + +**Files:** +- Create: `requirements.txt` +- Create: `pytest.ini` +- Create: `.env.example` +- Create: `.gitignore` +- Create: `plantdb/settings.py` +- Create: `plantdb/urls.py` +- Create: `plants/apps.py` + +- [ ] **Step 1: Install Django and scaffold the project** + +```bash +cd /home/stephan/devel/python/plantdb +python -m venv .venv +source .venv/bin/activate +pip install django==5.1.* django-htmx pillow requests gunicorn pytest-django +pip freeze > requirements.txt +django-admin startproject plantdb . +python manage.py startapp plants +``` + +- [ ] **Step 2: Write `plantdb/settings.py`** + +Replace the generated file with: + +```python +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production') +DEBUG = os.environ.get('DEBUG', 'True') == 'True' +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django_htmx', + 'plants', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django_htmx.middleware.HtmxMiddleware', +] + +ROOT_URLCONF = 'plantdb.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'plantdb.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'Europe/Amsterdam' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +PERENUAL_API_KEY = os.environ.get('PERENUAL_API_KEY', '') +``` + +- [ ] **Step 3: Write `pytest.ini`** + +```ini +[pytest] +DJANGO_SETTINGS_MODULE = plantdb.settings +``` + +- [ ] **Step 4: Write `.env.example`** + +``` +SECRET_KEY=change-me-in-production +DEBUG=False +ALLOWED_HOSTS=192.168.1.100,plantdb.home +PERENUAL_API_KEY=your-perenual-key-here +``` + +- [ ] **Step 5: Write `.gitignore`** + +``` +.venv/ +__pycache__/ +*.pyc +db.sqlite3 +media/ +staticfiles/ +.env +.superpowers/ +``` + +- [ ] **Step 6: Verify the dev server starts** + +```bash +python manage.py migrate +python manage.py runserver +``` + +Expected: server starts at http://127.0.0.1:8000, no errors. + +- [ ] **Step 7: Commit** + +```bash +git add requirements.txt pytest.ini .env.example .gitignore plantdb/ plants/ +git commit -m "feat: scaffold Django project with plants app" +``` + +--- + +## Task 2: Data Models + Admin + +**Files:** +- Create: `plants/models.py` +- Create: `plants/admin.py` +- Create: `plants/tests/test_models.py` + +- [ ] **Step 1: Write the failing test** + +Create `plants/tests/__init__.py` (empty) and `plants/tests/test_models.py`: + +```python +import pytest +from plants.models import Species, Plant, PruningLog +from datetime import date + + +@pytest.mark.django_db +def test_species_str(): + s = Species.objects.create(common_name='Rose') + assert str(s) == 'Rose' + + +@pytest.mark.django_db +def test_plant_str(): + p = Plant.objects.create(name='Kitchen Fern', location='Kitchen') + assert str(p) == 'Kitchen Fern' + + +@pytest.mark.django_db +def test_plant_species_nullable(): + p = Plant.objects.create(name='Unknown plant', location='Hallway') + assert p.species is None + + +@pytest.mark.django_db +def test_pruning_log_str(): + p = Plant.objects.create(name='Rose', location='Garden') + log = PruningLog.objects.create(plant=p, pruned_on=date(2026, 5, 1)) + assert 'Rose' in str(log) + assert '2026-05-01' in str(log) + + +@pytest.mark.django_db +def test_plant_pruning_months_default(): + p = Plant.objects.create(name='Fern', location='Office') + assert p.pruning_months == [] +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest plants/tests/test_models.py -v +``` + +Expected: `ImportError` — models not defined yet. + +- [ ] **Step 3: Write `plants/models.py`** + +```python +from django.db import models + + +class Species(models.Model): + common_name = models.CharField(max_length=200) + scientific_name = models.CharField(max_length=200, blank=True) + description = models.TextField(blank=True) + watering = models.CharField(max_length=100, blank=True) + sunlight = models.CharField(max_length=200, blank=True) + shadow_tolerance = models.CharField(max_length=100, blank=True) + max_height_cm = models.IntegerField(null=True, blank=True) + growth_rate = models.CharField(max_length=100, blank=True) + growth_season = models.CharField(max_length=100, blank=True) + pruning_months = models.JSONField(default=list) + api_image_url = models.URLField(blank=True) + perenual_id = models.IntegerField(unique=True, null=True, blank=True) + + class Meta: + ordering = ['common_name'] + verbose_name_plural = 'species' + + def __str__(self): + return self.common_name + + +class Plant(models.Model): + name = models.CharField(max_length=200) + species = models.ForeignKey( + Species, null=True, blank=True, + on_delete=models.SET_NULL, related_name='plants', + ) + location = models.CharField(max_length=200) + is_indoor = models.BooleanField(default=False) + pruning_months = models.JSONField(default=list) + photo = models.ImageField(upload_to='plants/', blank=True) + notes = models.TextField(blank=True) + date_added = models.DateField(auto_now_add=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + +class PruningLog(models.Model): + plant = models.ForeignKey( + Plant, on_delete=models.CASCADE, related_name='pruninglogs', + ) + pruned_on = models.DateField() + notes = models.TextField(blank=True) + + class Meta: + ordering = ['-pruned_on'] + + def __str__(self): + return f'{self.plant.name} pruned on {self.pruned_on}' +``` + +- [ ] **Step 4: Write `plants/admin.py`** + +```python +from django.contrib import admin +from .models import Species, Plant, PruningLog + + +@admin.register(Species) +class SpeciesAdmin(admin.ModelAdmin): + list_display = ['common_name', 'scientific_name', 'perenual_id'] + search_fields = ['common_name', 'scientific_name'] + + +@admin.register(Plant) +class PlantAdmin(admin.ModelAdmin): + list_display = ['name', 'species', 'location', 'is_indoor', 'date_added'] + list_filter = ['is_indoor'] + search_fields = ['name', 'location'] + + +@admin.register(PruningLog) +class PruningLogAdmin(admin.ModelAdmin): + list_display = ['plant', 'pruned_on'] + list_filter = ['pruned_on'] +``` + +- [ ] **Step 5: Run migrations** + +```bash +python manage.py makemigrations plants +python manage.py migrate +``` + +Expected: migrations created and applied, no errors. + +- [ ] **Step 6: Run tests — expect pass** + +```bash +pytest plants/tests/test_models.py -v +``` + +Expected: 5 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add plants/models.py plants/admin.py plants/tests/ plants/migrations/ +git commit -m "feat: add Species, Plant, PruningLog models" +``` + +--- + +## Task 3: Pruning Logic + +**Files:** +- Create: `plants/utils/__init__.py` +- Create: `plants/utils/pruning.py` +- Create: `plants/tests/test_pruning.py` + +- [ ] **Step 1: Write the failing tests** + +Create `plants/utils/__init__.py` (empty) and `plants/tests/test_pruning.py`: + +```python +import pytest +from datetime import date +from plants.models import Plant, PruningLog +from plants.utils.pruning import pruning_status, months_before + + +def test_months_before_same_year(): + d = date(2026, 5, 27) + assert months_before(d, 1) == date(2026, 4, 1) + assert months_before(d, 4) == date(2026, 1, 1) + + +def test_months_before_crosses_year(): + d = date(2026, 5, 27) + assert months_before(d, 5) == date(2025, 12, 1) + assert months_before(d, 12) == date(2025, 5, 1) + + +def test_months_before_january(): + d = date(2026, 1, 15) + assert months_before(d, 1) == date(2025, 12, 1) + assert months_before(d, 2) == date(2025, 11, 1) + + +@pytest.mark.django_db +def test_no_schedule(): + plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[]) + assert pruning_status(plant) == 'no_schedule' + + +@pytest.mark.django_db +def test_due_this_month(): + today = date(2026, 5, 15) + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5]) + assert pruning_status(plant, today) == 'due_this_month' + + +@pytest.mark.django_db +def test_due_this_month_already_logged(): + today = date(2026, 5, 15) + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5]) + PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 1)) + assert pruning_status(plant, today) == 'upcoming' + + +@pytest.mark.django_db +def test_overdue_when_past_month_not_logged(): + today = date(2026, 5, 15) + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) + assert pruning_status(plant, today) == 'overdue' + + +@pytest.mark.django_db +def test_not_overdue_when_past_month_logged(): + today = date(2026, 5, 15) + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) + PruningLog.objects.create(plant=plant, pruned_on=date(2026, 3, 10)) + assert pruning_status(plant, today) == 'upcoming' + + +@pytest.mark.django_db +def test_upcoming_future_month(): + today = date(2026, 5, 15) + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[8]) + assert pruning_status(plant, today) == 'upcoming' + + +@pytest.mark.django_db +def test_overdue_crosses_year_boundary(): + # Pruning month December, checked in January + today = date(2026, 1, 10) + plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[12]) + assert pruning_status(plant, today) == 'overdue' +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest plants/tests/test_pruning.py -v +``` + +Expected: `ImportError` — utils.pruning not defined yet. + +- [ ] **Step 3: Write `plants/utils/pruning.py`** + +```python +from datetime import date + + +def months_before(d, n): + """Return a date on the 1st of the month n months before d.""" + m = (d.month - 1 - n) % 12 + 1 + y = d.year + (d.month - 1 - n) // 12 + return date(y, m, 1) + + +def pruning_status(plant, today=None): + """ + Returns one of: 'overdue', 'due_this_month', 'upcoming', 'no_schedule'. + + Checks the most recent past pruning month (within 12 months). If it was + not logged the plant is overdue. Uses a 12-month rolling window so + December is correctly caught when checked in January. + """ + if today is None: + today = date.today() + + months = plant.pruning_months or [] + if not months: + return 'no_schedule' + + logs = plant.pruninglogs.all() + logged = {(log.pruned_on.year, log.pruned_on.month) for log in logs} + + if today.month in months and (today.year, today.month) not in logged: + return 'due_this_month' + + for i in range(1, 13): + past = months_before(today, i) + if past.month in months: + if (past.year, past.month) not in logged: + return 'overdue' + return 'upcoming' + + return 'upcoming' +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest plants/tests/test_pruning.py -v +``` + +Expected: 11 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add plants/utils/ plants/tests/test_pruning.py +git commit -m "feat: add pruning_status logic with 12-month rolling window" +``` + +--- + +## Task 4: Forms + +**Files:** +- Create: `plants/forms.py` + +- [ ] **Step 1: Write `plants/forms.py`** + +No TDD for forms — they are tested implicitly through view tests. Write the file directly: + +```python +from django import forms +from .models import Plant, PruningLog + +MONTH_CHOICES = [ + ('1', 'Jan'), ('2', 'Feb'), ('3', 'Mar'), ('4', 'Apr'), + ('5', 'May'), ('6', 'Jun'), ('7', 'Jul'), ('8', 'Aug'), + ('9', 'Sep'), ('10', 'Oct'), ('11', 'Nov'), ('12', 'Dec'), +] + + +class PlantForm(forms.ModelForm): + pruning_months = forms.MultipleChoiceField( + choices=MONTH_CHOICES, + widget=forms.CheckboxSelectMultiple, + required=False, + label='Pruning months', + ) + + class Meta: + model = Plant + fields = ['name', 'location', 'is_indoor', 'pruning_months', 'photo', 'notes'] + widgets = { + 'notes': forms.Textarea(attrs={'rows': 3}), + 'name': forms.TextInput(attrs={'class': 'form-control'}), + 'location': forms.TextInput(attrs={'class': 'form-control'}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # JSONField stores ints; CheckboxSelectMultiple expects strings matching choice values + if self.instance.pk and self.instance.pruning_months: + self.initial['pruning_months'] = [str(m) for m in self.instance.pruning_months] + elif not self.instance.pk and self.initial.get('pruning_months'): + self.initial['pruning_months'] = [str(m) for m in self.initial['pruning_months']] + + def clean_pruning_months(self): + return [int(m) for m in self.cleaned_data.get('pruning_months', [])] + + +class PruningLogForm(forms.ModelForm): + class Meta: + model = PruningLog + fields = ['pruned_on', 'notes'] + widgets = { + 'pruned_on': forms.DateInput(attrs={'type': 'date', 'class': 'form-control'}), + 'notes': forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}), + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add plants/forms.py +git commit -m "feat: add PlantForm and PruningLogForm" +``` + +--- + +## Task 5: Perenual Service + +**Files:** +- Create: `plants/services/__init__.py` +- Create: `plants/services/perenual.py` +- Create: `plants/tests/test_perenual.py` + +- [ ] **Step 1: Write the failing tests** + +Create `plants/services/__init__.py` (empty) and `plants/tests/test_perenual.py`: + +```python +import pytest +from unittest.mock import patch, MagicMock +from plants.services.perenual import search_species + + +def test_search_returns_empty_without_api_key(settings): + settings.PERENUAL_API_KEY = '' + results = search_species('rose') + assert results == [] + + +def test_search_returns_data(settings): + settings.PERENUAL_API_KEY = 'test-key' + mock_resp = MagicMock() + mock_resp.json.return_value = { + 'data': [{'id': 1, 'common_name': 'Rose', 'scientific_name': ['Rosa multiflora']}] + } + mock_resp.raise_for_status = MagicMock() + + with patch('plants.services.perenual.requests.get', return_value=mock_resp): + results = search_species('rose') + + assert len(results) == 1 + assert results[0]['common_name'] == 'Rose' + + +def test_search_returns_empty_on_timeout(settings): + settings.PERENUAL_API_KEY = 'test-key' + with patch('plants.services.perenual.requests.get', side_effect=Exception('timeout')): + results = search_species('rose') + assert results == [] + + +def test_search_returns_empty_on_bad_status(settings): + settings.PERENUAL_API_KEY = 'test-key' + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = Exception('404') + with patch('plants.services.perenual.requests.get', return_value=mock_resp): + results = search_species('rose') + assert results == [] +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest plants/tests/test_perenual.py -v +``` + +Expected: `ImportError`. + +- [ ] **Step 3: Write `plants/services/perenual.py`** + +```python +import requests +from django.conf import settings + +_BASE = 'https://perenual.com/api' + + +def search_species(query): + """Query Perenual species-list endpoint. Returns list of result dicts or [] on any error.""" + if not settings.PERENUAL_API_KEY: + return [] + try: + resp = requests.get( + f'{_BASE}/species-list', + params={'q': query, 'key': settings.PERENUAL_API_KEY}, + timeout=5, + ) + resp.raise_for_status() + return resp.json().get('data', []) + except Exception: + return [] +``` + +- [ ] **Step 4: Run tests — expect pass** + +```bash +pytest plants/tests/test_perenual.py -v +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add plants/services/ plants/tests/test_perenual.py +git commit -m "feat: add Perenual API service with error fallback" +``` + +--- + +## Task 6: URL Routing + Base Template + +**Files:** +- Modify: `plantdb/urls.py` +- Create: `plants/urls.py` +- Create: `plants/views/__init__.py` +- Create: `plants/templates/plants/base.html` + +- [ ] **Step 1: Write `plantdb/urls.py`** + +```python +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('plants.urls')), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +``` + +- [ ] **Step 2: Write `plants/urls.py`** (stub — fill in as views are added) + +```python +from django.urls import path + +urlpatterns = [] +``` + +- [ ] **Step 3: Create `plants/views/__init__.py`** (empty) + +- [ ] **Step 4: Create the templates directory and write `base.html`** + +```bash +mkdir -p plants/templates/plants/partials +``` + +Write `plants/templates/plants/base.html`: + +```html + + + + + + {% block title %}PlantDB{% endblock %} + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + +``` + +- [ ] **Step 5: Verify template directory is found** + +```bash +python manage.py check +``` + +Expected: "System check identified no issues." + +- [ ] **Step 6: Commit** + +```bash +git add plantdb/urls.py plants/urls.py plants/views/ plants/templates/ +git commit -m "feat: URL routing skeleton and base template" +``` + +--- + +## Task 7: Dashboard View + +**Files:** +- Create: `plants/views/dashboard.py` +- Create: `plants/templates/plants/dashboard.html` +- Modify: `plants/urls.py` +- Create: `plants/tests/test_views.py` + +- [ ] **Step 1: Write the failing test** + +Create `plants/tests/test_views.py`: + +```python +import pytest +from django.urls import reverse +from datetime import date +from plants.models import Plant, PruningLog + + +@pytest.mark.django_db +class TestDashboard: + def test_returns_200(self, client): + resp = client.get(reverse('dashboard')) + assert resp.status_code == 200 + + def test_shows_total_plant_count(self, client): + Plant.objects.create(name='Fern', location='Office') + Plant.objects.create(name='Rose', location='Garden') + resp = client.get(reverse('dashboard')) + assert resp.context['total_plants'] == 2 + + def test_overdue_plant_appears_in_context(self, client): + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) + # No log — overdue relative to May 2026 + resp = client.get(reverse('dashboard')) + assert plant in resp.context['overdue'] + + def test_due_this_month_plant_appears_in_context(self, client): + today = date.today() + plant = Plant.objects.create( + name='Apple', location='Garden', pruning_months=[today.month] + ) + resp = client.get(reverse('dashboard')) + assert plant in resp.context['due_this_month'] +``` + +- [ ] **Step 2: Run tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestDashboard -v +``` + +Expected: `NoReverseMatch` — 'dashboard' URL not defined. + +- [ ] **Step 3: Write `plants/views/dashboard.py`** + +```python +from datetime import date +from django.shortcuts import render +from plants.models import Plant +from plants.utils.pruning import pruning_status + + +def dashboard(request): + today = date.today() + all_plants = list( + Plant.objects.select_related('species').prefetch_related('pruninglogs') + ) + overdue, due_this_month = [], [] + for plant in all_plants: + status = pruning_status(plant, today) + if status == 'overdue': + overdue.append(plant) + elif status == 'due_this_month': + due_this_month.append(plant) + + return render(request, 'plants/dashboard.html', { + 'total_plants': len(all_plants), + 'overdue': overdue, + 'due_this_month': due_this_month, + 'today': today, + }) +``` + +- [ ] **Step 4: Update `plants/urls.py`** + +```python +from django.urls import path +from plants.views import dashboard + +urlpatterns = [ + path('', dashboard.dashboard, name='dashboard'), +] +``` + +- [ ] **Step 5: Write `plants/templates/plants/dashboard.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}Dashboard — PlantDB{% endblock %} +{% block content %} +
{{ today|date:"F Y" }}
+ +
+
+ +
+
+
{{ total_plants }}
+
Plants
+
+
+
+
+
+ +
+
+
+ {{ overdue|length|add:due_this_month|length }} +
+
Need pruning
+
+
+
+
+
+ +{% if overdue or due_this_month %} +
+
✂️ Needs attention
+
+ {% for plant in overdue %} + +
+
{{ plant.name }}
+ {{ plant.location }} +
+ overdue +
+ {% endfor %} + {% for plant in due_this_month %} + +
+
{{ plant.name }}
+ {{ plant.location }} +
+ this month +
+ {% endfor %} +
+
+{% endif %} + +
+ + Add a plant +
+{% endblock %} +``` + +- [ ] **Step 6: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestDashboard -v +``` + +Expected: 4 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add plants/views/dashboard.py plants/templates/plants/dashboard.html plants/urls.py plants/tests/test_views.py +git commit -m "feat: dashboard with plant count and pruning summary" +``` + +--- + +## Task 8: Plant List + HTMX Search + +**Files:** +- Create: `plants/views/plants.py` (plant_list only for now) +- Create: `plants/templates/plants/plant_list.html` +- Create: `plants/templates/plants/partials/plant_list_results.html` +- Modify: `plants/urls.py` + +- [ ] **Step 1: Add failing tests to `plants/tests/test_views.py`** + +Append to the existing file: + +```python +@pytest.mark.django_db +class TestPlantList: + def test_returns_200(self, client): + resp = client.get(reverse('plant_list')) + assert resp.status_code == 200 + + def test_lists_plants(self, client): + Plant.objects.create(name='Monstera', location='Living room') + resp = client.get(reverse('plant_list')) + assert 'Monstera' in resp.content.decode() + + def test_search_filters_by_name(self, client): + Plant.objects.create(name='Monstera', location='Living room') + Plant.objects.create(name='Rose', location='Garden') + resp = client.get(reverse('plant_list') + '?q=Rose') + content = resp.content.decode() + assert 'Rose' in content + assert 'Monstera' not in content + + def test_htmx_search_uses_partial_template(self, client): + resp = client.get( + reverse('plant_list') + '?q=rose', + HTTP_HX_REQUEST='true', + ) + assert resp.status_code == 200 + assert resp.templates[0].name == 'plants/partials/plant_list_results.html' + + def test_indoor_filter(self, client): + Plant.objects.create(name='Fern', location='Office', is_indoor=True) + Plant.objects.create(name='Rose', location='Garden', is_indoor=False) + resp = client.get(reverse('plant_list') + '?filter=indoor') + content = resp.content.decode() + assert 'Fern' in content + assert 'Rose' not in content +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestPlantList -v +``` + +Expected: `NoReverseMatch`. + +- [ ] **Step 3: Create `plants/views/plants.py`** (plant_list only) + +```python +from datetime import date +from django.shortcuts import render, get_object_or_404, redirect +from django.views.decorators.http import require_POST +from plants.models import Plant, Species, PruningLog +from plants.forms import PlantForm, PruningLogForm +from plants.utils.pruning import pruning_status + + +def plant_list(request): + qs = Plant.objects.select_related('species').all() + q = request.GET.get('q', '') + indoor_filter = request.GET.get('filter', '') + + if q: + qs = qs.filter(name__icontains=q) | qs.filter(location__icontains=q) + if indoor_filter == 'indoor': + qs = qs.filter(is_indoor=True) + elif indoor_filter == 'outdoor': + qs = qs.filter(is_indoor=False) + + template = ( + 'plants/partials/plant_list_results.html' + if request.htmx + else 'plants/plant_list.html' + ) + return render(request, template, {'plants': qs, 'q': q, 'filter': indoor_filter}) +``` + +- [ ] **Step 4: Update `plants/urls.py`** + +```python +from django.urls import path +from plants.views import dashboard, plants + +urlpatterns = [ + path('', dashboard.dashboard, name='dashboard'), + path('plants/', plants.plant_list, name='plant_list'), +] +``` + +- [ ] **Step 5: Write `plants/templates/plants/plant_list.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}Plants — PlantDB{% endblock %} +{% block content %} +
+ +
+ +
+ All + Indoor + Outdoor +
+ +
+ {% include "plants/partials/plant_list_results.html" %} +
+{% endblock %} +``` + +- [ ] **Step 6: Write `plants/templates/plants/partials/plant_list_results.html`** + +```html +{% for plant in plants %} + +
+
+ {% if plant.photo %} + + {% elif plant.species and plant.species.api_image_url %} + + {% else %} +
🌿
+ {% endif %} +
+
{{ plant.name }}
+ + {{ plant.location }} + · {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %} + +
+
+
+
+{% empty %} +

No plants found.

+{% endfor %} +``` + +- [ ] **Step 7: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestPlantList -v +``` + +Expected: 5 tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plants/views/plants.py plants/templates/plants/plant_list.html plants/templates/plants/partials/plant_list_results.html plants/urls.py +git commit -m "feat: plant list with HTMX live search and indoor/outdoor filter" +``` + +--- + +## Task 9: Plant Detail + Inline Pruning Log + +**Files:** +- Modify: `plants/views/plants.py` (add plant_detail, log_pruning) +- Create: `plants/templates/plants/plant_detail.html` +- Create: `plants/templates/plants/partials/pruning_strip.html` +- Modify: `plants/urls.py` + +- [ ] **Step 1: Add failing tests** + +Append to `plants/tests/test_views.py`: + +```python +@pytest.mark.django_db +class TestPlantDetail: + def test_returns_200(self, client): + plant = Plant.objects.create(name='Fern', location='Office') + resp = client.get(reverse('plant_detail', args=[plant.pk])) + assert resp.status_code == 200 + + def test_404_for_missing_plant(self, client): + resp = client.get(reverse('plant_detail', args=[9999])) + assert resp.status_code == 404 + + def test_context_contains_plant_and_status(self, client): + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[]) + resp = client.get(reverse('plant_detail', args=[plant.pk])) + assert resp.context['plant'] == plant + assert resp.context['pruning_status'] == 'no_schedule' + + +@pytest.mark.django_db +class TestLogPruning: + def test_creates_pruning_log(self, client): + plant = Plant.objects.create(name='Rose', location='Garden') + resp = client.post( + reverse('log_pruning', args=[plant.pk]), + {'pruned_on': '2026-05-01', 'notes': ''}, + ) + assert resp.status_code == 200 + assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists() + + def test_returns_pruning_strip_partial(self, client): + plant = Plant.objects.create(name='Rose', location='Garden') + resp = client.post( + reverse('log_pruning', args=[plant.pk]), + {'pruned_on': '2026-05-01', 'notes': ''}, + ) + assert resp.templates[0].name == 'plants/partials/pruning_strip.html' +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v +``` + +Expected: `NoReverseMatch`. + +- [ ] **Step 3: Add `plant_detail` and `log_pruning` to `plants/views/plants.py`** + +Add after the existing `plant_list` function: + +```python +def plant_detail(request, pk): + plant = get_object_or_404( + Plant.objects.select_related('species').prefetch_related('pruninglogs'), + pk=pk, + ) + today = date.today() + return render(request, 'plants/plant_detail.html', { + 'plant': plant, + 'pruning_status': pruning_status(plant, today), + 'log_form': PruningLogForm(initial={'pruned_on': today}), + 'today': today, + }) + + +@require_POST +def log_pruning(request, pk): + plant = get_object_or_404( + Plant.objects.prefetch_related('pruninglogs'), pk=pk + ) + form = PruningLogForm(request.POST) + if form.is_valid(): + log = form.save(commit=False) + log.plant = plant + log.save() + today = date.today() + return render(request, 'plants/partials/pruning_strip.html', { + 'plant': plant, + 'pruning_status': pruning_status(plant, today), + 'log_form': PruningLogForm(initial={'pruned_on': today}), + }) +``` + +- [ ] **Step 4: Update `plants/urls.py`** + +```python +from django.urls import path +from plants.views import dashboard, plants + +urlpatterns = [ + path('', dashboard.dashboard, name='dashboard'), + path('plants/', plants.plant_list, name='plant_list'), + path('plants/add/', plants.plant_add, name='plant_add'), + path('plants//', plants.plant_detail, name='plant_detail'), + path('plants//edit/', plants.plant_edit, name='plant_edit'), + path('plants//delete/', plants.plant_delete, name='plant_delete'), + path('plants//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`: + +```python +def plant_add(request): + return render(request, 'plants/species_search.html') + +def plant_edit(request, pk): + pass + +def plant_delete(request, pk): + pass +``` + +- [ ] **Step 5: Write `plants/templates/plants/partials/pruning_strip.html`** + +```html +
+
+
+ ✂️ Pruning + {% if plant.pruning_months %} +
+ Scheduled: + {% for m in plant.pruning_months %} + {{ m|date:"N" }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} + {% with plant.pruninglogs.first as last_log %} + {% if last_log %} +
Last pruned: {{ last_log.pruned_on|date:"j M Y" }}
+ {% endif %} + {% endwith %} +
+ {% if pruning_status == 'overdue' %} + Overdue + {% elif pruning_status == 'due_this_month' %} + Due this month + {% endif %} +
+ +
+ {% csrf_token %} +
+ {{ log_form.pruned_on }} + +
+
+
+``` + +Note: Django's `{{ m|date:"N" }}` won't work on plain integers. Use a template filter or hardcode a month lookup. Instead, replace the pruning months display with: + +```html +
+ Scheduled: {{ plant.pruning_months|join:", " }} +
+``` + +(A nicer month-name display can be added later; the data is correct.) + +- [ ] **Step 6: Write `plants/templates/plants/plant_detail.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}{{ plant.name }} — PlantDB{% endblock %} +{% block content %} + +
+ +
{{ plant.name }}
+ ✏️ +
+ +{% if plant.photo %} +{{ plant.name }} +{% elif plant.species and plant.species.api_image_url %} +{{ plant.species.common_name }} +{% else %} +
🌿
+{% endif %} + +
+
+
{{ plant.name }}
+ {% if plant.species %}{{ plant.species.scientific_name }}{% endif %} +
+ + {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %} + +
+ +

📍 {{ plant.location }}

+ +{% if plant.species %} +
+ {% if plant.species.watering %} + 💧 {{ plant.species.watering }} + {% endif %} + {% if plant.species.sunlight %} + ☀️ {{ plant.species.sunlight }} + {% endif %} + {% if plant.species.max_height_cm %} + 📏 up to {{ plant.species.max_height_cm }} cm + {% endif %} + {% if plant.species.growth_rate %} + 🌱 {{ plant.species.growth_rate }} + {% endif %} +
+ +{% if plant.species.api_image_url and plant.photo %} +
+ + Species reference: {{ plant.species.common_name }} +
+{% endif %} +{% endif %} + +{% include "plants/partials/pruning_strip.html" %} + +{% if plant.notes %} +
+
+ {{ plant.notes|linebreaksbr }} +
+
+{% endif %} + +{% if plant.pruninglogs.all %} +
+
Pruning history
+
    + {% for log in plant.pruninglogs.all %} +
  • + {{ log.pruned_on|date:"j M Y" }} + {% if log.notes %}{{ log.notes }}{% endif %} +
  • + {% endfor %} +
+
+{% endif %} + +Delete plant + +{% endblock %} +``` + +- [ ] **Step 7: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestPlantDetail plants/tests/test_views.py::TestLogPruning -v +``` + +Expected: 5 tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plants/views/plants.py plants/templates/plants/plant_detail.html plants/templates/plants/partials/pruning_strip.html plants/urls.py +git commit -m "feat: plant detail with photo hero and inline HTMX pruning log" +``` + +--- + +## Task 10: Species Search (HTMX) + +**Files:** +- Create: `plants/views/species.py` +- Create: `plants/templates/plants/species_search.html` +- Create: `plants/templates/plants/partials/species_results.html` +- Modify: `plants/urls.py` + +- [ ] **Step 1: Add failing tests** + +Append to `plants/tests/test_views.py`: + +```python +@pytest.mark.django_db +class TestSpeciesSearch: + def test_search_page_returns_200(self, client): + resp = client.get(reverse('plant_add')) + assert resp.status_code == 200 + + def test_htmx_search_returns_partial(self, client, settings): + settings.PERENUAL_API_KEY = '' + resp = client.get( + reverse('species_search') + '?q=rose', + HTTP_HX_REQUEST='true', + ) + assert resp.status_code == 200 + assert resp.templates[0].name == 'plants/partials/species_results.html' + + def test_species_select_creates_species_and_redirects(self, client): + resp = client.post(reverse('species_select'), { + 'perenual_id': '42', + 'common_name': 'Test Rose', + 'scientific_name': 'Rosa testii', + 'watering': 'Weekly', + 'sunlight': 'Full sun', + 'pruning_months': 'February, August', + 'api_image_url': '', + }) + assert resp.status_code == 302 + from plants.models import Species + assert Species.objects.filter(perenual_id=42).exists() + + def test_species_select_deduplicates(self, client): + from plants.models import Species + Species.objects.create(perenual_id=99, common_name='Existing') + client.post(reverse('species_select'), { + 'perenual_id': '99', + 'common_name': 'Existing', + 'scientific_name': '', + 'watering': '', + 'sunlight': '', + 'pruning_months': '', + 'api_image_url': '', + }) + assert Species.objects.filter(perenual_id=99).count() == 1 +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestSpeciesSearch -v +``` + +Expected: `NoReverseMatch`. + +- [ ] **Step 3: Write `plants/views/species.py`** + +```python +from django.shortcuts import render, redirect +from django.urls import reverse +from plants.models import Species +from plants.services.perenual import search_species + +_MONTH_MAP = { + 'January': 1, 'February': 2, 'March': 3, 'April': 4, + 'May': 5, 'June': 6, 'July': 7, 'August': 8, + 'September': 9, 'October': 10, 'November': 11, 'December': 12, +} + + +def species_search(request): + """HTMX endpoint: search Perenual and return results partial.""" + q = request.GET.get('q', '').strip() + results = search_species(q) if len(q) >= 2 else [] + return render(request, 'plants/partials/species_results.html', { + 'results': results, 'q': q, + }) + + +def species_select(request): + """POST: cache selected species from search results data, redirect to plant form.""" + perenual_id = int(request.POST['perenual_id']) + if not Species.objects.filter(perenual_id=perenual_id).exists(): + pruning_str = request.POST.get('pruning_months', '') + pruning_months = [ + _MONTH_MAP[m.strip()] + for m in pruning_str.split(',') + if m.strip() in _MONTH_MAP + ] + Species.objects.create( + perenual_id=perenual_id, + common_name=request.POST.get('common_name', ''), + scientific_name=request.POST.get('scientific_name', ''), + watering=request.POST.get('watering', ''), + sunlight=request.POST.get('sunlight', ''), + pruning_months=pruning_months, + api_image_url=request.POST.get('api_image_url', ''), + ) + species = Species.objects.get(perenual_id=perenual_id) + return redirect(f"{reverse('plant_add')}?species_id={species.pk}") +``` + +- [ ] **Step 4: Update `plants/urls.py`** + +```python +from django.urls import path +from plants.views import dashboard, plants, species, pruning + +urlpatterns = [ + path('', dashboard.dashboard, name='dashboard'), + path('plants/', plants.plant_list, name='plant_list'), + path('plants/add/', plants.plant_add, name='plant_add'), + path('plants//', plants.plant_detail, name='plant_detail'), + path('plants//edit/', plants.plant_edit, name='plant_edit'), + path('plants//delete/', plants.plant_delete, name='plant_delete'), + path('plants//log-pruning/', plants.log_pruning, name='log_pruning'), + path('pruning/', pruning.pruning_calendar, name='pruning_calendar'), + path('species/search/', species.species_search, name='species_search'), + path('species/select/', species.species_select, name='species_select'), +] +``` + +Also add a stub for `pruning_calendar` in `plants/views/pruning.py`: + +```python +from django.shortcuts import render + +def pruning_calendar(request): + return render(request, 'plants/pruning_calendar.html', {}) +``` + +And a stub template `plants/templates/plants/pruning_calendar.html`: + +```html +{% extends "plants/base.html" %} +{% block content %}

Pruning calendar coming soon.

{% endblock %} +``` + +- [ ] **Step 5: Write `plants/templates/plants/species_search.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}Add Plant — PlantDB{% endblock %} +{% block content %} +
Add a plant
+

Search for the species to auto-fill care info, or skip to enter details manually.

+ + + +
+ + +{% endblock %} +``` + +- [ ] **Step 6: Write `plants/templates/plants/partials/species_results.html`** + +```html +{% for result in results %} +
+
+ {% if result.default_image and result.default_image.thumbnail %} + + {% else %} +
🌿
+ {% endif %} +
+
{{ result.common_name }}
+ {{ result.scientific_name|join:", " }} +
+
+
+ {% csrf_token %} + + + + + + + + +
+
+{% empty %} +{% if q %}

No results for "{{ q }}"

{% endif %} +{% endfor %} +``` + +- [ ] **Step 7: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestSpeciesSearch -v +``` + +Expected: 4 tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add plants/views/species.py plants/views/pruning.py plants/templates/plants/species_search.html plants/templates/plants/partials/species_results.html plants/templates/plants/pruning_calendar.html plants/urls.py +git commit -m "feat: species search with HTMX and Perenual caching on select" +``` + +--- + +## Task 11: Add Plant Form + +**Files:** +- Modify: `plants/views/plants.py` (replace plant_add stub) +- Create: `plants/templates/plants/plant_form.html` + +- [ ] **Step 1: Add failing tests** + +Append to `plants/tests/test_views.py`: + +```python +@pytest.mark.django_db +class TestPlantAdd: + def test_add_with_species_shows_prefilled_form(self, client): + from plants.models import Species + s = Species.objects.create(common_name='Rose', pruning_months=[2, 8]) + resp = client.get(reverse('plant_add') + f'?species_id={s.pk}') + assert resp.status_code == 200 + assert resp.context['species'] == s + + def test_post_creates_plant_and_redirects(self, client): + resp = client.post(reverse('plant_add'), { + 'name': 'New Fern', + 'location': 'Office', + 'is_indoor': 'on', + 'notes': '', + 'pruning_months': [], + }) + assert Plant.objects.filter(name='New Fern').exists() + plant = Plant.objects.get(name='New Fern') + assert resp.status_code == 302 + assert resp['Location'] == f'/plants/{plant.pk}/' + + def test_post_with_species_links_species(self, client): + from plants.models import Species + s = Species.objects.create(common_name='Rose', pruning_months=[2]) + client.post( + reverse('plant_add') + f'?species_id={s.pk}', + {'name': 'Garden Rose', 'location': 'Garden', 'notes': '', 'pruning_months': ['2']}, + ) + plant = Plant.objects.get(name='Garden Rose') + assert plant.species == s +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestPlantAdd -v +``` + +Expected: failures (stub plant_add returns 200 but no form context). + +- [ ] **Step 3: Replace `plant_add` stub in `plants/views/plants.py`** + +```python +def plant_add(request): + species_id = request.GET.get('species_id') or request.POST.get('species_id') + species = None + + if species_id and species_id != 'skip': + species = get_object_or_404(Species, pk=species_id) + + if species_id is None: + return render(request, 'plants/species_search.html') + + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES) + if form.is_valid(): + plant = form.save(commit=False) + plant.species = species + plant.save() + return redirect('plant_detail', pk=plant.pk) + else: + initial = {'pruning_months': species.pruning_months} if species else {} + form = PlantForm(initial=initial) + + return render(request, 'plants/plant_form.html', { + 'form': form, + 'species': species, + 'species_id': species_id, + 'title': 'Add Plant', + }) +``` + +- [ ] **Step 4: Write `plants/templates/plants/plant_form.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}{{ title }} — PlantDB{% endblock %} +{% block content %} +
+ +
{{ title }}
+
+ +{% if species %} +
+ {% if species.api_image_url %} + + {% endif %} +
+ {{ species.common_name }} + {% if species.scientific_name %}{{ species.scientific_name }}{% endif %} +
Care info pre-filled from species database.
+
+
+{% endif %} + +
+ {% csrf_token %} + + +
+ + {{ form.name }} + {% if form.name.errors %}
{{ form.name.errors }}
{% endif %} +
+ +
+ + {{ form.location }} + {% if form.location.errors %}
{{ form.location.errors }}
{% endif %} +
+ +
+ {{ form.is_indoor }} + +
+ +
+ +
+ {% for widget in form.pruning_months %} +
+ {{ widget.tag }} + +
+ {% endfor %} +
+
+ +
+ + {{ form.photo }} +
Take a photo with your camera or upload one.
+
+ +
+ + {{ form.notes }} +
+ +
+ +
+
+{% endblock %} +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestPlantAdd -v +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add plants/views/plants.py plants/templates/plants/plant_form.html +git commit -m "feat: add plant form with species pre-fill" +``` + +--- + +## Task 12: Edit + Delete Plant + +**Files:** +- Modify: `plants/views/plants.py` (replace stubs) +- Create: `plants/templates/plants/plant_confirm_delete.html` + +- [ ] **Step 1: Add failing tests** + +Append to `plants/tests/test_views.py`: + +```python +@pytest.mark.django_db +class TestPlantEdit: + def test_edit_form_shows_current_values(self, client): + plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[3]) + resp = client.get(reverse('plant_edit', args=[plant.pk])) + assert resp.status_code == 200 + assert resp.context['plant'] == plant + + def test_post_updates_plant(self, client): + plant = Plant.objects.create(name='Fern', location='Office') + client.post(reverse('plant_edit', args=[plant.pk]), { + 'name': 'Updated Fern', + 'location': 'Bedroom', + 'notes': '', + 'pruning_months': [], + }) + plant.refresh_from_db() + assert plant.name == 'Updated Fern' + + +@pytest.mark.django_db +class TestPlantDelete: + def test_delete_confirm_page(self, client): + plant = Plant.objects.create(name='Fern', location='Office') + resp = client.get(reverse('plant_delete', args=[plant.pk])) + assert resp.status_code == 200 + + def test_post_deletes_plant(self, client): + plant = Plant.objects.create(name='Fern', location='Office') + client.post(reverse('plant_delete', args=[plant.pk])) + assert not Plant.objects.filter(pk=plant.pk).exists() +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v +``` + +Expected: failures (stubs return None). + +- [ ] **Step 3: Replace `plant_edit` and `plant_delete` stubs in `plants/views/plants.py`** + +```python +def plant_edit(request, pk): + plant = get_object_or_404(Plant, pk=pk) + if request.method == 'POST': + form = PlantForm(request.POST, request.FILES, instance=plant) + if form.is_valid(): + form.save() + return redirect('plant_detail', pk=plant.pk) + else: + form = PlantForm(instance=plant) + return render(request, 'plants/plant_form.html', { + 'form': form, + 'plant': plant, + 'title': 'Edit Plant', + }) + + +def plant_delete(request, pk): + plant = get_object_or_404(Plant, pk=pk) + if request.method == 'POST': + plant.delete() + return redirect('plant_list') + return render(request, 'plants/plant_confirm_delete.html', {'plant': plant}) +``` + +- [ ] **Step 4: Write `plants/templates/plants/plant_confirm_delete.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}Delete {{ plant.name }} — PlantDB{% endblock %} +{% block content %} +
+
+
Delete "{{ plant.name }}"?
+

This will also delete all pruning history for this plant. This cannot be undone.

+
+ {% csrf_token %} +
+ + Cancel +
+
+
+
+{% endblock %} +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestPlantEdit plants/tests/test_views.py::TestPlantDelete -v +``` + +Expected: 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add plants/views/plants.py plants/templates/plants/plant_confirm_delete.html +git commit -m "feat: plant edit and delete with confirmation" +``` + +--- + +## Task 13: Pruning Calendar + +**Files:** +- Modify: `plants/views/pruning.py` (replace stub) +- Modify: `plants/templates/plants/pruning_calendar.html` (replace stub) + +- [ ] **Step 1: Add failing tests** + +Append to `plants/tests/test_views.py`: + +```python +@pytest.mark.django_db +class TestPruningCalendar: + def test_returns_200(self, client): + resp = client.get(reverse('pruning_calendar')) + assert resp.status_code == 200 + + def test_overdue_plant_in_context(self, client): + plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) + resp = client.get(reverse('pruning_calendar')) + assert plant in resp.context['overdue'] + + def test_due_this_month_in_context(self, client): + from datetime import date + today = date.today() + plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[today.month]) + resp = client.get(reverse('pruning_calendar')) + assert plant in resp.context['due_this_month'] + + def test_no_schedule_plants_excluded(self, client): + plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[]) + resp = client.get(reverse('pruning_calendar')) + assert plant not in resp.context['overdue'] + assert plant not in resp.context['due_this_month'] +``` + +- [ ] **Step 2: Run new tests — expect failure** + +```bash +pytest plants/tests/test_views.py::TestPruningCalendar -v +``` + +Expected: context keys missing from stub. + +- [ ] **Step 3: Replace pruning_calendar stub in `plants/views/pruning.py`** + +```python +from datetime import date +from django.shortcuts import render +from plants.models import Plant +from plants.utils.pruning import pruning_status, months_before + + +def pruning_calendar(request): + today = date.today() + next_month_num = today.month % 12 + 1 + next_month_year = today.year + (1 if today.month == 12 else 0) + next_month_date = date(next_month_year, next_month_num, 1) + + all_plants = list( + Plant.objects.select_related('species').prefetch_related('pruninglogs') + ) + + overdue, due_this_month, next_month_plants, later = [], [], [], [] + + for plant in all_plants: + if not plant.pruning_months: + continue + status = pruning_status(plant, today) + if status == 'overdue': + overdue.append(plant) + elif status == 'due_this_month': + due_this_month.append(plant) + elif next_month_num in plant.pruning_months: + next_month_plants.append(plant) + else: + later.append(plant) + + return render(request, 'plants/pruning_calendar.html', { + 'overdue': overdue, + 'due_this_month': due_this_month, + 'next_month_plants': next_month_plants, + 'later': later, + 'today': today, + 'month_name': today.strftime('%B'), + 'next_month_name': next_month_date.strftime('%B'), + }) +``` + +- [ ] **Step 4: Replace `plants/templates/plants/pruning_calendar.html`** + +```html +{% extends "plants/base.html" %} +{% block title %}Pruning Calendar — PlantDB{% endblock %} +{% block content %} +
✂️ Pruning Calendar
+ +{% if overdue %} +
⚠ Overdue
+
+ {% for plant in overdue %} + +
+
{{ plant.name }}
+ {{ plant.location }} +
+ Overdue +
+ {% endfor %} +
+{% endif %} + +{% if due_this_month %} +
This month — {{ month_name }}
+
+ {% for plant in due_this_month %} + +
+
{{ plant.name }}
+ {{ plant.location }} +
+ This month +
+ {% endfor %} +
+{% endif %} + +{% if next_month_plants %} +
Next — {{ next_month_name }}
+
+ {% for plant in next_month_plants %} + +
+
{{ plant.name }}
+ {{ plant.location }} +
+ {{ next_month_name }} +
+ {% endfor %} +
+{% endif %} + +{% if later %} +
+ Later this year ({{ later|length }}) + +
+{% endif %} + +{% if not overdue and not due_this_month and not next_month_plants and not later %} +

No pruning schedules set. Add pruning months to your plants.

+{% endif %} +{% endblock %} +``` + +- [ ] **Step 5: Run tests — expect pass** + +```bash +pytest plants/tests/test_views.py::TestPruningCalendar -v +``` + +Expected: 4 tests pass. + +- [ ] **Step 6: Run the full test suite** + +```bash +pytest -v +``` + +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add plants/views/pruning.py plants/templates/plants/pruning_calendar.html +git commit -m "feat: pruning calendar grouped by urgency" +``` + +--- + +## Task 14: Docker Compose Deployment + +**Files:** +- Create: `Dockerfile` +- Create: `docker-compose.yml` +- Create: `nginx/default.conf` + +- [ ] **Step 1: Write `Dockerfile`** + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 +``` + +- [ ] **Step 2: Write `docker-compose.yml`** + +```yaml +services: + web: + build: . + env_file: .env + volumes: + - db:/app/db.sqlite3 + - media:/app/media + - static:/app/staticfiles + expose: + - "8000" + command: > + sh -c "python manage.py migrate --noinput && + python manage.py collectstatic --noinput && + gunicorn plantdb.wsgi:application --bind 0.0.0.0:8000 --workers 2" + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - media:/app/media:ro + - static:/app/staticfiles:ro + depends_on: + - web + restart: unless-stopped + +volumes: + db: + media: + static: +``` + +- [ ] **Step 3: Write `nginx/default.conf`** + +```nginx +upstream django { + server web:8000; +} + +server { + listen 80; + client_max_body_size 20M; + + location /static/ { + alias /app/staticfiles/; + } + + location /media/ { + alias /app/media/; + } + + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +- [ ] **Step 4: Create `.env` from the example** (on the homelab server) + +```bash +cp .env.example .env +# Edit .env: set SECRET_KEY, ALLOWED_HOSTS, PERENUAL_API_KEY +``` + +- [ ] **Step 5: Build and start** + +```bash +docker compose up -d --build +``` + +Expected: containers start, app accessible at `http:///`. + +- [ ] **Step 6: Smoke test** + +Open `http:///` in a browser. Verify: +- Dashboard loads with green plant count tile +- "Add a plant" button works +- Species search returns results (if API key set) +- Pruning calendar loads + +- [ ] **Step 7: Commit** + +```bash +git add Dockerfile docker-compose.yml nginx/ +git commit -m "feat: Docker Compose deployment with Nginx and Gunicorn" +``` + +--- + +## Self-Review Checklist + +| Spec requirement | Task | +|---|---| +| Django 5 + Bootstrap 5 + HTMX | Task 1, 6 | +| SQLite | Task 1 | +| Species model (all care fields) | Task 2 | +| Plant model with personal photo + pruning_months | Task 2 | +| PruningLog model | Task 2 | +| Pruning status logic (12-month rolling window) | Task 3 | +| PlantForm with month picker | Task 4 | +| Perenual search with error fallback | Task 5 | +| Dashboard with tiles and urgency list | Task 7 | +| Plant list with HTMX live search and filter | Task 8 | +| Plant detail — photo hero, care chips, species reference image | Task 9 | +| HTMX inline pruning log | Task 9 | +| Species search HTMX, caching on select | Task 10 | +| Add plant with species pre-fill | Task 11 | +| Edit + delete plant | Task 12 | +| Pruning calendar grouped by urgency | Task 13 | +| Docker Compose homelab deployment | Task 14 | +| No user accounts | ✓ (never added) | +| Mobile-first (Bootstrap, viewport meta) | Task 6 | +| API image + personal photo both shown | Task 9 |