# 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 |