bloombase/docs/superpowers/plans/2026-05-28-plant-card-photos.md
Stephan Kerkman e123e1d7cc docs: add plant card photos implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 21:31:18 +02:00

582 lines
18 KiB
Markdown

# Plant Card Photos 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:** Add a `PlantCardPhoto` model so users can store up to 3 photos of a plant's physical care card, accessible via a dedicated page linked from plant detail.
**Architecture:** New `PlantCardPhoto` model parallel to `PlantPhoto` (no thumbnail logic). Three new views (`plant_card` GET, `upload_card_photo` POST, `delete_card_photo` POST) following the existing htmx partial-swap pattern. A small button on plant detail links to the card page — only rendered when card photos exist.
**Tech Stack:** Django 4.x, pytest-django, htmx, Bootstrap 5
---
## File Map
| File | Action |
|---|---|
| `plants/models.py` | Add `PlantCardPhoto` model |
| `plants/migrations/0007_plantcardphoto.py` | New migration (auto-generated) |
| `plants/views/plants.py` | Add 3 views, update `plant_detail` prefetch, import `PlantCardPhoto` |
| `plants/urls.py` | Add 3 URLs |
| `plants/templates/plants/plant_card.html` | New full page template |
| `plants/templates/plants/partials/card_gallery.html` | New partial (photos + upload form) |
| `plants/templates/plants/plant_detail.html` | Add card button before delete link |
| `plants/tests/test_models.py` | Add 3 model tests |
| `plants/tests/test_views.py` | Add `TestPlantCard`, `TestUploadCardPhoto`, `TestDeleteCardPhoto`; extend `TestPlantDetail` |
---
### Task 1: PlantCardPhoto model + migration
**Files:**
- Modify: `plants/models.py`
- Create: `plants/migrations/0007_plantcardphoto.py` (auto-generated)
- Modify: `plants/tests/test_models.py`
- [ ] **Step 1: Write the failing model tests**
Add to the bottom of `plants/tests/test_models.py`:
```python
@pytest.mark.django_db
def test_card_photo_belongs_to_plant():
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Test')
photo = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/test.jpg')
assert photo.plant == plant
@pytest.mark.django_db
def test_card_photos_ordered_by_uploaded_at():
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Test')
p1 = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/a.jpg')
p2 = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/b.jpg')
qs = list(PlantCardPhoto.objects.filter(plant=plant))
assert qs[0] == p1
assert qs[1] == p2
@pytest.mark.django_db
def test_card_photos_deleted_with_plant():
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Test')
PlantCardPhoto.objects.create(plant=plant, image='plants/cards/test.jpg')
plant.delete()
assert PlantCardPhoto.objects.count() == 0
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest plants/tests/test_models.py -k "card_photo" -v
```
Expected: `ImportError: cannot import name 'PlantCardPhoto' from 'plants.models'`
- [ ] **Step 3: Add PlantCardPhoto to plants/models.py**
Add after the `PlantPhoto` class (before `PruningLog`):
```python
class PlantCardPhoto(models.Model):
plant = models.ForeignKey(Plant, on_delete=models.CASCADE, related_name='card_photos')
image = models.ImageField(upload_to='plants/cards/')
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['uploaded_at']
```
- [ ] **Step 4: Generate the migration**
```bash
python manage.py makemigrations plants --name plantcardphoto
```
Expected: `Migrations for 'plants': plants/migrations/0007_plantcardphoto.py`
- [ ] **Step 5: Apply the migration**
```bash
python manage.py migrate
```
Expected: `Applying plants.0007_plantcardphoto... OK`
- [ ] **Step 6: Run tests to verify they pass**
```bash
pytest plants/tests/test_models.py -k "card_photo" -v
```
Expected: 3 passed
- [ ] **Step 7: Commit**
```bash
git add plants/models.py plants/migrations/0007_plantcardphoto.py plants/tests/test_models.py
git commit -m "feat: add PlantCardPhoto model"
```
---
### Task 2: plant_card view + URL
**Files:**
- Modify: `plants/views/plants.py`
- Modify: `plants/urls.py`
- Create: `plants/templates/plants/plant_card.html` (minimal stub)
- Modify: `plants/tests/test_views.py`
- [ ] **Step 1: Write the failing tests**
Add to `plants/tests/test_views.py` (after the existing `TestPlantDelete` class):
```python
@pytest.mark.django_db
class TestPlantCard:
def test_returns_200(self, client):
plant = Plant.objects.create(name='Fern')
resp = client.get(reverse('plant_card', args=[plant.pk]))
assert resp.status_code == 200
def test_404_for_missing_plant(self, client):
resp = client.get(reverse('plant_card', args=[9999]))
assert resp.status_code == 404
def test_context_contains_plant(self, client):
plant = Plant.objects.create(name='Fern')
resp = client.get(reverse('plant_card', args=[plant.pk]))
assert resp.context['plant'] == plant
def test_uses_plant_card_template(self, client):
plant = Plant.objects.create(name='Fern')
resp = client.get(reverse('plant_card', args=[plant.pk]))
assert 'plants/plant_card.html' in [t.name for t in resp.templates]
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest plants/tests/test_views.py::TestPlantCard -v
```
Expected: `NoReverseMatch: Reverse for 'plant_card' not found`
- [ ] **Step 3: Add PlantCardPhoto to the import in plants/views/plants.py**
Change the existing import line:
```python
from plants.models import Plant, Species, PruningLog, PlantPhoto, PlantCardPhoto
```
- [ ] **Step 4: Add the plant_card view to plants/views/plants.py**
Add after `plant_delete`:
```python
def plant_card(request, pk):
plant = get_object_or_404(
Plant.objects.prefetch_related('card_photos'), pk=pk
)
return render(request, 'plants/plant_card.html', {'plant': plant})
```
- [ ] **Step 5: Add the URL to plants/urls.py**
Add after `plants/<int:pk>/delete/`:
```python
path('plants/<int:pk>/card/', plants.plant_card, name='plant_card'),
```
- [ ] **Step 6: Create the minimal template plants/templates/plants/plant_card.html**
```html
{% extends "plants/base.html" %}
{% block title %}Plant card — {{ plant.name }} — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_detail' plant.pk %}" class="btn btn-sm btn-outline-secondary">← {{ plant.name }}</a>
<h5 class="mb-0">Plant card</h5>
</div>
<div id="card-gallery"></div>
{% endblock %}
```
- [ ] **Step 7: Run tests to verify they pass**
```bash
pytest plants/tests/test_views.py::TestPlantCard -v
```
Expected: 4 passed
- [ ] **Step 8: Commit**
```bash
git add plants/views/plants.py plants/urls.py plants/templates/plants/plant_card.html plants/tests/test_views.py
git commit -m "feat: add plant_card view and URL"
```
---
### Task 3: upload_card_photo view + URL
**Files:**
- Modify: `plants/views/plants.py`
- Modify: `plants/urls.py`
- Create: `plants/templates/plants/partials/card_gallery.html` (minimal stub)
- Modify: `plants/tests/test_views.py`
- [ ] **Step 1: Write the failing tests**
Add to `plants/tests/test_views.py` (after `TestPlantCard`):
```python
@pytest.mark.django_db
class TestUploadCardPhoto:
def test_upload_creates_card_photo(self, client):
from django.core.files.uploadedfile import SimpleUploadedFile
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Fern')
image = SimpleUploadedFile('card.jpg', b'\xff\xd8\xff\xe0' + b'\x00' * 100, content_type='image/jpeg')
resp = client.post(reverse('upload_card_photo', args=[plant.pk]), {'image': image})
assert resp.status_code == 200
assert PlantCardPhoto.objects.filter(plant=plant).count() == 1
def test_upload_blocked_at_max_3(self, client):
from django.core.files.uploadedfile import SimpleUploadedFile
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Fern')
for i in range(3):
PlantCardPhoto.objects.create(plant=plant, image=f'plants/cards/card{i}.jpg')
image = SimpleUploadedFile('card_extra.jpg', b'\xff\xd8\xff\xe0' + b'\x00' * 100, content_type='image/jpeg')
client.post(reverse('upload_card_photo', args=[plant.pk]), {'image': image})
assert PlantCardPhoto.objects.filter(plant=plant).count() == 3
def test_returns_card_gallery_partial(self, client):
plant = Plant.objects.create(name='Fern')
resp = client.post(reverse('upload_card_photo', args=[plant.pk]), {})
assert resp.templates[0].name == 'plants/partials/card_gallery.html'
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest plants/tests/test_views.py::TestUploadCardPhoto -v
```
Expected: `NoReverseMatch: Reverse for 'upload_card_photo' not found`
- [ ] **Step 3: Create the minimal partial plants/templates/plants/partials/card_gallery.html**
The partial must exist before the view renders it:
```html
<div class="mb-3"></div>
```
- [ ] **Step 4: Add the upload_card_photo view to plants/views/plants.py**
Add after `plant_card`. Note: re-fetch the plant after creation so the prefetch cache reflects the new photo.
```python
@require_POST
def upload_card_photo(request, pk):
plant = get_object_or_404(Plant, pk=pk)
if request.FILES.get('image') and plant.card_photos.count() < 3:
PlantCardPhoto.objects.create(plant=plant, image=request.FILES['image'])
plant = Plant.objects.prefetch_related('card_photos').get(pk=pk)
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
```
- [ ] **Step 5: Add the URL to plants/urls.py**
```python
path('plants/<int:pk>/card-photos/upload/', plants.upload_card_photo, name='upload_card_photo'),
```
- [ ] **Step 6: Run tests to verify they pass**
```bash
pytest plants/tests/test_views.py::TestUploadCardPhoto -v
```
Expected: 3 passed
- [ ] **Step 7: Commit**
```bash
git add plants/views/plants.py plants/urls.py plants/templates/plants/partials/card_gallery.html plants/tests/test_views.py
git commit -m "feat: add upload_card_photo view and URL"
```
---
### Task 4: delete_card_photo view + URL
**Files:**
- Modify: `plants/views/plants.py`
- Modify: `plants/urls.py`
- Modify: `plants/tests/test_views.py`
- [ ] **Step 1: Write the failing tests**
Add to `plants/tests/test_views.py` (after `TestUploadCardPhoto`):
```python
@pytest.mark.django_db
class TestDeleteCardPhoto:
def test_delete_removes_record(self, client):
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Fern')
photo = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg')
resp = client.post(reverse('delete_card_photo', args=[photo.pk]))
assert resp.status_code == 200
assert not PlantCardPhoto.objects.filter(pk=photo.pk).exists()
def test_returns_card_gallery_partial(self, client):
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Fern')
photo = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg')
resp = client.post(reverse('delete_card_photo', args=[photo.pk]))
assert resp.templates[0].name == 'plants/partials/card_gallery.html'
def test_404_for_missing_photo(self, client):
resp = client.post(reverse('delete_card_photo', args=[9999]))
assert resp.status_code == 404
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest plants/tests/test_views.py::TestDeleteCardPhoto -v
```
Expected: `NoReverseMatch: Reverse for 'delete_card_photo' not found`
- [ ] **Step 3: Add the delete_card_photo view to plants/views/plants.py**
Add after `upload_card_photo`:
```python
@require_POST
def delete_card_photo(request, card_photo_pk):
card_photo = get_object_or_404(PlantCardPhoto, pk=card_photo_pk)
plant_pk = card_photo.plant_id
card_photo.image.delete(save=False)
card_photo.delete()
plant = Plant.objects.prefetch_related('card_photos').get(pk=plant_pk)
return render(request, 'plants/partials/card_gallery.html', {'plant': plant})
```
- [ ] **Step 4: Add the URL to plants/urls.py**
```python
path('card-photos/<int:card_photo_pk>/delete/', plants.delete_card_photo, name='delete_card_photo'),
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest plants/tests/test_views.py::TestDeleteCardPhoto -v
```
Expected: 3 passed
- [ ] **Step 6: Commit**
```bash
git add plants/views/plants.py plants/urls.py plants/tests/test_views.py
git commit -m "feat: add delete_card_photo view and URL"
```
---
### Task 5: Full templates — card gallery partial + plant card page
**Files:**
- Modify: `plants/templates/plants/partials/card_gallery.html` (replace stub)
- Modify: `plants/templates/plants/plant_card.html` (replace stub)
- [ ] **Step 1: Replace the stub card_gallery.html with the full partial**
Write the full content of `plants/templates/plants/partials/card_gallery.html`:
```html
<div class="mb-3">
{% with card_photos=plant.card_photos.all %}
{% if card_photos %}
<div class="d-flex flex-wrap gap-2 mb-2">
{% for photo in card_photos %}
<div>
<img src="{{ photo.image.url }}" width="80" height="80"
class="rounded" style="object-fit:cover;" alt="">
<div class="d-flex gap-1 mt-1">
<form method="post" action="{% url 'delete_card_photo' photo.pk %}"
hx-post="{% url 'delete_card_photo' photo.pk %}"
hx-target="#card-gallery" hx-swap="outerHTML">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger btn-sm py-0 px-1"
style="font-size:.7rem;" title="Delete"></button>
</form>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% if plant.card_photos.count < 3 %}
<form method="post" action="{% url 'upload_card_photo' plant.pk %}" enctype="multipart/form-data"
hx-post="{% url 'upload_card_photo' plant.pk %}" hx-target="#card-gallery" hx-swap="outerHTML"
hx-encoding="multipart/form-data">
{% csrf_token %}
<div class="d-flex gap-2 align-items-center">
<input type="file" name="image" accept="image/*" capture="environment"
class="form-control form-control-sm" style="max-width:220px;">
<button type="submit" class="btn btn-sm btn-outline-secondary">Upload</button>
</div>
</form>
{% endif %}
<small class="text-muted">Up to 3 photos — front &amp; back of the label</small>
</div>
```
- [ ] **Step 2: Replace the stub plant_card.html with the full template**
Write the full content of `plants/templates/plants/plant_card.html`:
```html
{% extends "plants/base.html" %}
{% block title %}Plant card — {{ plant.name }} — PlantDB{% endblock %}
{% block content %}
<div class="d-flex align-items-center gap-2 mb-3">
<a href="{% url 'plant_detail' plant.pk %}" class="btn btn-sm btn-outline-secondary">← {{ plant.name }}</a>
<h5 class="mb-0">Plant card</h5>
</div>
<div id="card-gallery">
{% include "plants/partials/card_gallery.html" %}
</div>
{% endblock %}
```
- [ ] **Step 3: Run the full test suite to confirm nothing broke**
```bash
pytest plants/ -v
```
Expected: all tests pass
- [ ] **Step 4: Commit**
```bash
git add plants/templates/plants/partials/card_gallery.html plants/templates/plants/plant_card.html
git commit -m "feat: add card gallery partial and plant card page templates"
```
---
### Task 6: Plant detail — card button + prefetch update
**Files:**
- Modify: `plants/views/plants.py`
- Modify: `plants/templates/plants/plant_detail.html`
- Modify: `plants/tests/test_views.py`
- [ ] **Step 1: Write the failing tests**
Add two methods to the existing `TestPlantDetail` class in `plants/tests/test_views.py`:
```python
def test_card_button_shown_when_card_photos_exist(self, client):
from plants.models import PlantCardPhoto
plant = Plant.objects.create(name='Fern')
PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg')
resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert 'View plant card' in resp.content.decode()
def test_card_button_hidden_when_no_card_photos(self, client):
plant = Plant.objects.create(name='Fern')
resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert 'View plant card' not in resp.content.decode()
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
pytest plants/tests/test_views.py::TestPlantDetail -v
```
Expected: the 2 new tests FAIL (button not yet in template)
- [ ] **Step 3: Update plant_detail view to prefetch card_photos**
In `plants/views/plants.py`, change the `plant_detail` view's `prefetch_related` call:
```python
def plant_detail(request, pk):
plant = get_object_or_404(
Plant.objects.select_related('species', 'location').prefetch_related('pruning_logs', 'photos', 'card_photos'),
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,
})
```
- [ ] **Step 4: Add the button to plant_detail.html**
In `plants/templates/plants/plant_detail.html`, add just before the delete link at the bottom of the `{% block content %}`:
```html
{% if plant.card_photos.exists %}
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 View plant card</a>
{% endif %}
```
The end of the file should look like:
```html
{% if plant.card_photos.exists %}
<a href="{% url 'plant_card' plant.pk %}" class="btn btn-sm btn-outline-secondary mb-2">🪧 View plant card</a>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a>
{% endblock %}
```
- [ ] **Step 5: Run tests to verify they pass**
```bash
pytest plants/tests/test_views.py::TestPlantDetail -v
```
Expected: all tests pass including the 2 new ones
- [ ] **Step 6: Run the full test suite**
```bash
pytest plants/ -v
```
Expected: all tests pass
- [ ] **Step 7: Commit**
```bash
git add plants/views/plants.py plants/templates/plants/plant_detail.html plants/tests/test_views.py
git commit -m "feat: show plant card button on detail page when card photos exist"
```