18 KiB
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:
@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
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):
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
python manage.py makemigrations plants --name plantcardphoto
Expected: Migrations for 'plants': plants/migrations/0007_plantcardphoto.py
- Step 5: Apply the migration
python manage.py migrate
Expected: Applying plants.0007_plantcardphoto... OK
- Step 6: Run tests to verify they pass
pytest plants/tests/test_models.py -k "card_photo" -v
Expected: 3 passed
- Step 7: Commit
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):
@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
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:
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:
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/:
path('plants/<int:pk>/card/', plants.plant_card, name='plant_card'),
- Step 6: Create the minimal template plants/templates/plants/plant_card.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
pytest plants/tests/test_views.py::TestPlantCard -v
Expected: 4 passed
- Step 8: Commit
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):
@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
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:
<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.
@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
path('plants/<int:pk>/card-photos/upload/', plants.upload_card_photo, name='upload_card_photo'),
- Step 6: Run tests to verify they pass
pytest plants/tests/test_views.py::TestUploadCardPhoto -v
Expected: 3 passed
- Step 7: Commit
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):
@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
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:
@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
path('card-photos/<int:card_photo_pk>/delete/', plants.delete_card_photo, name='delete_card_photo'),
- Step 5: Run tests to verify they pass
pytest plants/tests/test_views.py::TestDeleteCardPhoto -v
Expected: 3 passed
- Step 6: Commit
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:
<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 & 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:
{% 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
pytest plants/ -v
Expected: all tests pass
- Step 4: Commit
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:
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
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:
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 %}:
{% 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:
{% 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
pytest plants/tests/test_views.py::TestPlantDetail -v
Expected: all tests pass including the 2 new ones
- Step 6: Run the full test suite
pytest plants/ -v
Expected: all tests pass
- Step 7: Commit
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"