From e123e1d7ccc938ddc264eda070d6dd9d02825c73 Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Thu, 28 May 2026 21:31:18 +0200 Subject: [PATCH] docs: add plant card photos implementation plan Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-05-28-plant-card-photos.md | 582 ++++++++++++++++++ 1 file changed, 582 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-plant-card-photos.md diff --git a/docs/superpowers/plans/2026-05-28-plant-card-photos.md b/docs/superpowers/plans/2026-05-28-plant-card-photos.md new file mode 100644 index 0000000..b8b98dd --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-plant-card-photos.md @@ -0,0 +1,582 @@ +# 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//delete/`: + +```python +path('plants//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 %} +
+ ← {{ plant.name }} +
Plant card
+
+ +{% 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 +
+``` + +- [ ] **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//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//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 +
+ {% with card_photos=plant.card_photos.all %} + {% if card_photos %} +
+ {% for photo in card_photos %} +
+ +
+
+ {% csrf_token %} + +
+
+
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if plant.card_photos.count < 3 %} +
+ {% csrf_token %} +
+ + +
+
+ {% endif %} + Up to 3 photos — front & back of the label +
+``` + +- [ ] **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 %} + +
+ ← {{ plant.name }} +
Plant card
+
+ + + +{% 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 %} +🪧 View plant card +{% endif %} +``` + +The end of the file should look like: + +```html + {% if plant.card_photos.exists %} + 🪧 View plant card + {% endif %} + + Delete plant + +{% 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" +```