# 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 %} {% 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 %} {% 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" ```