2312 lines
70 KiB
Markdown
2312 lines
70 KiB
Markdown
# 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
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||
<title>{% block title %}PlantDB{% endblock %}</title>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||
<script src="https://unpkg.com/htmx.org@1.9.12" defer></script>
|
||
<style>
|
||
body { padding-bottom: 1rem; }
|
||
.plant-hero { width: 100%; height: 220px; object-fit: cover; }
|
||
.plant-hero-placeholder { width: 100%; height: 220px; background: #d8f3dc; display: flex; align-items: center; justify-content: center; color: #52b788; font-size: 3rem; }
|
||
.care-chip { display: inline-block; padding: .2rem .55rem; border-radius: 1rem; font-size: .8rem; margin: .15rem; }
|
||
.pruning-strip { border-left: 4px solid #ffc107; background: #fff9c4; padding: .5rem .75rem; border-radius: 0 .375rem .375rem 0; }
|
||
.pruning-strip.overdue { border-color: #dc3545; background: #fff5f5; }
|
||
.month-checkboxes .form-check { display: inline-flex; margin-right: .5rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="navbar navbar-dark px-3" style="background-color: #2d6a4f;">
|
||
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a>
|
||
<div class="d-flex gap-2">
|
||
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
|
||
<a href="{% url 'pruning_calendar' %}" class="btn btn-sm btn-outline-light">✂️</a>
|
||
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
|
||
</div>
|
||
</nav>
|
||
|
||
<div class="container-fluid px-3 pt-3">
|
||
{% block content %}{% endblock %}
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 5: Verify template directory is found**
|
||
|
||
```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 %}
|
||
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
|
||
|
||
<div class="row g-2 mb-3">
|
||
<div class="col-6">
|
||
<a href="{% url 'plant_list' %}" class="text-decoration-none">
|
||
<div class="card text-center h-100" style="background:#d8f3dc;">
|
||
<div class="card-body py-3">
|
||
<div class="display-5 fw-bold" style="color:#1b4332;">{{ total_plants }}</div>
|
||
<div class="small" style="color:#2d6a4f;">Plants</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
<div class="col-6">
|
||
<a href="{% url 'pruning_calendar' %}" class="text-decoration-none">
|
||
<div class="card text-center h-100" style="background:#ffe8cc;">
|
||
<div class="card-body py-3">
|
||
<div class="display-5 fw-bold" style="color:#7d4a00;">
|
||
{{ overdue|length|add:due_this_month|length }}
|
||
</div>
|
||
<div class="small" style="color:#7d4a00;">Need pruning</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
{% if overdue or due_this_month %}
|
||
<div class="card mb-3">
|
||
<div class="card-header fw-semibold">✂️ Needs attention</div>
|
||
<div class="list-group list-group-flush">
|
||
{% for plant in overdue %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small class="text-muted">{{ plant.location }}</small>
|
||
</div>
|
||
<span class="badge bg-danger">overdue</span>
|
||
</a>
|
||
{% endfor %}
|
||
{% for plant in due_this_month %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small class="text-muted">{{ plant.location }}</small>
|
||
</div>
|
||
<span class="badge bg-warning text-dark">this month</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="d-grid">
|
||
<a href="{% url 'plant_add' %}" class="btn btn-success">+ Add a plant</a>
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests — expect pass**
|
||
|
||
```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 %}
|
||
<div class="mb-3">
|
||
<input
|
||
type="search"
|
||
name="q"
|
||
value="{{ q }}"
|
||
class="form-control"
|
||
placeholder="🔍 Search plants..."
|
||
hx-get="{% url 'plant_list' %}"
|
||
hx-trigger="input changed delay:300ms"
|
||
hx-target="#plant-list"
|
||
hx-swap="innerHTML"
|
||
>
|
||
</div>
|
||
|
||
<div class="btn-group w-100 mb-3" role="group">
|
||
<a href="{% url 'plant_list' %}" class="btn btn-sm {% if not filter %}btn-success{% else %}btn-outline-success{% endif %}">All</a>
|
||
<a href="{% url 'plant_list' %}?filter=indoor" class="btn btn-sm {% if filter == 'indoor' %}btn-success{% else %}btn-outline-success{% endif %}">Indoor</a>
|
||
<a href="{% url 'plant_list' %}?filter=outdoor" class="btn btn-sm {% if filter == 'outdoor' %}btn-success{% else %}btn-outline-success{% endif %}">Outdoor</a>
|
||
</div>
|
||
|
||
<div id="plant-list">
|
||
{% include "plants/partials/plant_list_results.html" %}
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Write `plants/templates/plants/partials/plant_list_results.html`**
|
||
|
||
```html
|
||
{% for plant in plants %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="text-decoration-none">
|
||
<div class="card mb-2">
|
||
<div class="card-body d-flex align-items-center gap-3 py-2">
|
||
{% if plant.photo %}
|
||
<img src="{{ plant.photo.url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||
{% elif plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" width="48" height="48" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||
{% else %}
|
||
<div style="width:48px; height:48px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
|
||
{% endif %}
|
||
<div>
|
||
<div class="fw-semibold" style="color:#1b4332;">{{ plant.name }}</div>
|
||
<small class="text-muted">
|
||
{{ plant.location }}
|
||
· {% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</a>
|
||
{% empty %}
|
||
<p class="text-muted text-center mt-4">No plants found.</p>
|
||
{% endfor %}
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests — expect pass**
|
||
|
||
```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/<int:pk>/', plants.plant_detail, name='plant_detail'),
|
||
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
|
||
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
|
||
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
|
||
]
|
||
```
|
||
|
||
(plant_add, plant_edit, plant_delete will be added in Tasks 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
|
||
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end">
|
||
<div class="d-flex justify-content-between align-items-start">
|
||
<div>
|
||
<strong>✂️ Pruning</strong>
|
||
{% if plant.pruning_months %}
|
||
<div class="small">
|
||
Scheduled:
|
||
{% for m in plant.pruning_months %}
|
||
{{ m|date:"N" }}{% if not forloop.last %}, {% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
{% with plant.pruninglogs.first as last_log %}
|
||
{% if last_log %}
|
||
<div class="small text-muted">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
|
||
{% endif %}
|
||
{% endwith %}
|
||
</div>
|
||
{% if pruning_status == 'overdue' %}
|
||
<span class="badge bg-danger">Overdue</span>
|
||
{% elif pruning_status == 'due_this_month' %}
|
||
<span class="badge bg-warning text-dark">Due this month</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<form
|
||
hx-post="{% url 'log_pruning' plant.pk %}"
|
||
hx-target="#pruning-strip"
|
||
hx-swap="outerHTML"
|
||
class="mt-2"
|
||
>
|
||
{% csrf_token %}
|
||
<div class="d-flex gap-2 align-items-end">
|
||
{{ log_form.pruned_on }}
|
||
<button type="submit" class="btn btn-sm btn-success">Log pruning</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
```
|
||
|
||
Note: Django's `{{ m|date:"N" }}` won't work on plain integers. Use a template filter or hardcode a month lookup. Instead, replace the pruning months display with:
|
||
|
||
```html
|
||
<div class="small">
|
||
Scheduled: {{ plant.pruning_months|join:", " }}
|
||
</div>
|
||
```
|
||
|
||
(A nicer month-name display can be added later; the data is correct.)
|
||
|
||
- [ ] **Step 6: Write `plants/templates/plants/plant_detail.html`**
|
||
|
||
```html
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}{{ plant.name }} — PlantDB{% endblock %}
|
||
{% block content %}
|
||
|
||
<div class="d-flex align-items-center gap-2 mb-2">
|
||
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
||
<h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5>
|
||
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
|
||
</div>
|
||
|
||
{% if plant.photo %}
|
||
<img src="{{ plant.photo.url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
|
||
{% elif plant.species and plant.species.api_image_url %}
|
||
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
|
||
{% else %}
|
||
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
|
||
{% endif %}
|
||
|
||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||
<div>
|
||
<h6 class="mb-0">{{ plant.name }}</h6>
|
||
{% if plant.species %}<small class="text-muted fst-italic">{{ plant.species.scientific_name }}</small>{% endif %}
|
||
</div>
|
||
<span class="badge {% if plant.is_indoor %}bg-info{% else %}bg-success{% endif %}">
|
||
{% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
|
||
</span>
|
||
</div>
|
||
|
||
<p class="text-muted small mb-2">📍 {{ plant.location }}</p>
|
||
|
||
{% if plant.species %}
|
||
<div class="mb-3">
|
||
{% if plant.species.watering %}
|
||
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</span>
|
||
{% endif %}
|
||
{% if plant.species.sunlight %}
|
||
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</span>
|
||
{% endif %}
|
||
{% if plant.species.max_height_cm %}
|
||
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
|
||
{% endif %}
|
||
{% if plant.species.growth_rate %}
|
||
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{% if plant.species.api_image_url and plant.photo %}
|
||
<div class="mb-3 d-flex align-items-center gap-2">
|
||
<img src="{{ plant.species.api_image_url }}" width="60" height="60" class="rounded" style="object-fit:cover;">
|
||
<small class="text-muted">Species reference: {{ plant.species.common_name }}</small>
|
||
</div>
|
||
{% endif %}
|
||
{% endif %}
|
||
|
||
{% include "plants/partials/pruning_strip.html" %}
|
||
|
||
{% if plant.notes %}
|
||
<div class="card mb-3">
|
||
<div class="card-body py-2">
|
||
<small class="text-muted">{{ plant.notes|linebreaksbr }}</small>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if plant.pruninglogs.all %}
|
||
<div class="mb-3">
|
||
<h6>Pruning history</h6>
|
||
<ul class="list-group list-group-flush">
|
||
{% for log in plant.pruninglogs.all %}
|
||
<li class="list-group-item py-1">
|
||
<strong>{{ log.pruned_on|date:"j M Y" }}</strong>
|
||
{% if log.notes %}<small class="text-muted ms-2">{{ log.notes }}</small>{% endif %}
|
||
</li>
|
||
{% endfor %}
|
||
</ul>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm">Delete plant</a>
|
||
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests — expect pass**
|
||
|
||
```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/<int:pk>/', plants.plant_detail, name='plant_detail'),
|
||
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
|
||
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
|
||
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
|
||
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
|
||
path('species/search/', species.species_search, name='species_search'),
|
||
path('species/select/', species.species_select, name='species_select'),
|
||
]
|
||
```
|
||
|
||
Also add a stub for `pruning_calendar` in `plants/views/pruning.py`:
|
||
|
||
```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 %}<p>Pruning calendar coming soon.</p>{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Write `plants/templates/plants/species_search.html`**
|
||
|
||
```html
|
||
{% extends "plants/base.html" %}
|
||
{% block title %}Add Plant — PlantDB{% endblock %}
|
||
{% block content %}
|
||
<h5 class="mb-3">Add a plant</h5>
|
||
<p class="text-muted small">Search for the species to auto-fill care info, or skip to enter details manually.</p>
|
||
|
||
<input
|
||
type="search"
|
||
class="form-control mb-2"
|
||
placeholder="🔍 Search species, e.g. 'climbing rose'"
|
||
hx-get="{% url 'species_search' %}"
|
||
hx-trigger="input changed delay:400ms"
|
||
hx-target="#species-results"
|
||
hx-swap="innerHTML"
|
||
name="q"
|
||
autocomplete="off"
|
||
>
|
||
|
||
<div id="species-results" class="list-group mb-3"></div>
|
||
|
||
<div class="text-center">
|
||
<a href="{% url 'plant_add' %}?species_id=skip" class="text-muted small">
|
||
Skip — enter details manually
|
||
</a>
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 6: Write `plants/templates/plants/partials/species_results.html`**
|
||
|
||
```html
|
||
{% for result in results %}
|
||
<div class="list-group-item">
|
||
<div class="d-flex align-items-center gap-2 mb-1">
|
||
{% if result.default_image and result.default_image.thumbnail %}
|
||
<img src="{{ result.default_image.thumbnail }}" width="40" height="40" class="rounded" style="object-fit:cover; flex-shrink:0;">
|
||
{% else %}
|
||
<div style="width:40px; height:40px; background:#d8f3dc; border-radius:.375rem; flex-shrink:0; display:flex; align-items:center; justify-content:center;">🌿</div>
|
||
{% endif %}
|
||
<div>
|
||
<div class="fw-semibold">{{ result.common_name }}</div>
|
||
<small class="text-muted fst-italic">{{ result.scientific_name|join:", " }}</small>
|
||
</div>
|
||
</div>
|
||
<form method="POST" action="{% url 'species_select' %}">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="perenual_id" value="{{ result.id }}">
|
||
<input type="hidden" name="common_name" value="{{ result.common_name }}">
|
||
<input type="hidden" name="scientific_name" value="{{ result.scientific_name|join:', ' }}">
|
||
<input type="hidden" name="watering" value="{{ result.watering|default:'' }}">
|
||
<input type="hidden" name="sunlight" value="{{ result.sunlight|join:', ' }}">
|
||
<input type="hidden" name="pruning_months" value="{{ result.pruning_month|join:', ' }}">
|
||
<input type="hidden" name="api_image_url" value="{{ result.default_image.regular_url|default:'' }}">
|
||
<button type="submit" class="btn btn-sm btn-success">Select →</button>
|
||
</form>
|
||
</div>
|
||
{% empty %}
|
||
{% if q %}<p class="list-group-item text-muted">No results for "{{ q }}"</p>{% endif %}
|
||
{% endfor %}
|
||
```
|
||
|
||
- [ ] **Step 7: Run tests — expect pass**
|
||
|
||
```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 %}
|
||
<div class="d-flex align-items-center gap-2 mb-3">
|
||
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
||
<h5 class="mb-0">{{ title }}</h5>
|
||
</div>
|
||
|
||
{% if species %}
|
||
<div class="alert alert-success py-2 d-flex align-items-center gap-2">
|
||
{% if species.api_image_url %}
|
||
<img src="{{ species.api_image_url }}" width="36" height="36" class="rounded" style="object-fit:cover;">
|
||
{% endif %}
|
||
<div>
|
||
<strong>{{ species.common_name }}</strong>
|
||
{% if species.scientific_name %}<small class="text-muted fst-italic ms-1">{{ species.scientific_name }}</small>{% endif %}
|
||
<div class="small">Care info pre-filled from species database.</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<form method="POST" enctype="multipart/form-data">
|
||
{% csrf_token %}
|
||
<input type="hidden" name="species_id" value="{{ species_id|default:'' }}">
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Your name for this plant *</label>
|
||
{{ form.name }}
|
||
{% if form.name.errors %}<div class="text-danger small">{{ form.name.errors }}</div>{% endif %}
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Location</label>
|
||
{{ form.location }}
|
||
{% if form.location.errors %}<div class="text-danger small">{{ form.location.errors }}</div>{% endif %}
|
||
</div>
|
||
|
||
<div class="mb-3 form-check">
|
||
{{ form.is_indoor }}
|
||
<label class="form-check-label" for="{{ form.is_indoor.id_for_label }}">Indoor plant</label>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Pruning months</label>
|
||
<div class="month-checkboxes d-flex flex-wrap gap-1">
|
||
{% for widget in form.pruning_months %}
|
||
<div class="form-check form-check-inline border rounded px-2 py-1">
|
||
{{ widget.tag }}
|
||
<label class="form-check-label" for="{{ widget.id_for_label }}">{{ widget.choice_label }}</label>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Photo</label>
|
||
{{ form.photo }}
|
||
<div class="form-text">Take a photo with your camera or upload one.</div>
|
||
</div>
|
||
|
||
<div class="mb-3">
|
||
<label class="form-label fw-semibold">Notes</label>
|
||
{{ form.notes }}
|
||
</div>
|
||
|
||
<div class="d-grid">
|
||
<button type="submit" class="btn btn-success">Save plant</button>
|
||
</div>
|
||
</form>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect pass**
|
||
|
||
```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 %}
|
||
<div class="card mt-3">
|
||
<div class="card-body">
|
||
<h5 class="card-title">Delete "{{ plant.name }}"?</h5>
|
||
<p class="card-text text-muted">This will also delete all pruning history for this plant. This cannot be undone.</p>
|
||
<form method="POST">
|
||
{% csrf_token %}
|
||
<div class="d-flex gap-2">
|
||
<button type="submit" class="btn btn-danger">Yes, delete</button>
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="btn btn-outline-secondary">Cancel</a>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect pass**
|
||
|
||
```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 %}
|
||
<h5 class="mb-3">✂️ Pruning Calendar</h5>
|
||
|
||
{% if overdue %}
|
||
<h6 class="text-danger">⚠ Overdue</h6>
|
||
<div class="list-group mb-3">
|
||
{% for plant in overdue %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action list-group-item-danger d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small>{{ plant.location }}</small>
|
||
</div>
|
||
<span class="badge bg-danger">Overdue</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if due_this_month %}
|
||
<h6 style="color:#7d4a00;">This month — {{ month_name }}</h6>
|
||
<div class="list-group mb-3">
|
||
{% for plant in due_this_month %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action list-group-item-warning d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small>{{ plant.location }}</small>
|
||
</div>
|
||
<span class="badge bg-warning text-dark">This month</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if next_month_plants %}
|
||
<h6 class="text-muted">Next — {{ next_month_name }}</h6>
|
||
<div class="list-group mb-3">
|
||
{% for plant in next_month_plants %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
|
||
<div>
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small>{{ plant.location }}</small>
|
||
</div>
|
||
<span class="badge bg-secondary">{{ next_month_name }}</span>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if later %}
|
||
<details>
|
||
<summary class="text-muted mb-2">Later this year ({{ later|length }})</summary>
|
||
<div class="list-group mb-3">
|
||
{% for plant in later %}
|
||
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action">
|
||
<div class="fw-semibold">{{ plant.name }}</div>
|
||
<small class="text-muted">{{ plant.location }} · Months: {{ plant.pruning_months|join:", " }}</small>
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
</details>
|
||
{% endif %}
|
||
|
||
{% if not overdue and not due_this_month and not next_month_plants and not later %}
|
||
<p class="text-muted text-center mt-4">No pruning schedules set. Add pruning months to your plants.</p>
|
||
{% endif %}
|
||
{% endblock %}
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests — expect pass**
|
||
|
||
```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://<homelab-ip>/`.
|
||
|
||
- [ ] **Step 6: Smoke test**
|
||
|
||
Open `http://<homelab-ip>/` in a browser. Verify:
|
||
- Dashboard loads with green plant count tile
|
||
- "Add a plant" button works
|
||
- Species search returns results (if API key set)
|
||
- Pruning calendar loads
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```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 |
|