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