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 + + +
+ + +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/📍 {{ plant.location }}
+ +{% if plant.species %} +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 %} +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 %} +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 %} +This will also delete all pruning history for this plant. This cannot be undone.
+ +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://