docs: add plant card photos implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
90c209aef0
commit
e123e1d7cc
1 changed files with 582 additions and 0 deletions
582
docs/superpowers/plans/2026-05-28-plant-card-photos.md
Normal file
582
docs/superpowers/plans/2026-05-28-plant-card-photos.md
Normal file
|
|
@ -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/<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 & 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"
|
||||||
|
```
|
||||||
Loading…
Add table
Reference in a new issue