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

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"

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 &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:

{% 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"