feat: plant detail with photo hero and inline HTMX pruning log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c7a9699acc
commit
ef84af433e
4 changed files with 184 additions and 7 deletions
35
plants/templates/plants/partials/pruning_strip.html
Normal file
35
plants/templates/plants/partials/pruning_strip.html
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<div id="pruning-strip" class="pruning-strip {% if pruning_status == 'overdue' %}overdue{% endif %} mb-3 rounded-end">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>✂️ Pruning</strong>
|
||||||
|
{% if plant.pruning_months %}
|
||||||
|
<div class="small">
|
||||||
|
Scheduled: {{ plant.pruning_months|join:", " }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% with plant.pruning_logs.first as last_log %}
|
||||||
|
{% if last_log %}
|
||||||
|
<div class="small text-muted">Last pruned: {{ last_log.pruned_on|date:"j M Y" }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% if pruning_status == 'overdue' %}
|
||||||
|
<span class="badge bg-danger">Overdue</span>
|
||||||
|
{% elif pruning_status == 'due_this_month' %}
|
||||||
|
<span class="badge bg-warning text-dark">Due this month</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="{% url 'log_pruning' plant.pk %}"
|
||||||
|
hx-target="#pruning-strip"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-flex gap-2 align-items-end">
|
||||||
|
{{ log_form.pruned_on }}
|
||||||
|
<button type="submit" class="btn btn-sm btn-success">Log pruning</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
81
plants/templates/plants/plant_detail.html
Normal file
81
plants/templates/plants/plant_detail.html
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
{% extends "plants/base.html" %}
|
||||||
|
{% block title %}{{ plant.name }} — PlantDB{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-2">
|
||||||
|
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-secondary">←</a>
|
||||||
|
<h5 class="mb-0 flex-grow-1">{{ plant.name }}</h5>
|
||||||
|
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plant.photo %}
|
||||||
|
<img src="{{ plant.photo.url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
|
||||||
|
{% elif plant.species and plant.species.api_image_url %}
|
||||||
|
<img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="plant-hero-placeholder rounded mb-3">🌿</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0">{{ plant.name }}</h6>
|
||||||
|
{% if plant.species %}<small class="text-muted fst-italic">{{ plant.species.scientific_name }}</small>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="badge {% if plant.is_indoor %}bg-info{% else %}bg-success{% endif %}">
|
||||||
|
{% if plant.is_indoor %}Indoor{% else %}Outdoor{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted small mb-2">📍 {{ plant.location }}</p>
|
||||||
|
|
||||||
|
{% if plant.species %}
|
||||||
|
<div class="mb-3">
|
||||||
|
{% if plant.species.watering %}
|
||||||
|
<span class="care-chip" style="background:#e3f2fd; color:#1565c0;">💧 {{ plant.species.watering }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if plant.species.sunlight %}
|
||||||
|
<span class="care-chip" style="background:#fff9c4; color:#7d4a00;">☀️ {{ plant.species.sunlight }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if plant.species.max_height_cm %}
|
||||||
|
<span class="care-chip" style="background:#f3e5f5; color:#4a148c;">📏 up to {{ plant.species.max_height_cm }} cm</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if plant.species.growth_rate %}
|
||||||
|
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if plant.species.api_image_url and plant.photo %}
|
||||||
|
<div class="mb-3 d-flex align-items-center gap-2">
|
||||||
|
<img src="{{ plant.species.api_image_url }}" width="60" height="60" class="rounded" style="object-fit:cover;">
|
||||||
|
<small class="text-muted">Species reference: {{ plant.species.common_name }}</small>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% include "plants/partials/pruning_strip.html" %}
|
||||||
|
|
||||||
|
{% if plant.notes %}
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<small class="text-muted">{{ plant.notes|linebreaksbr }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if plant.pruning_logs.all %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>Pruning history</h6>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
{% for log in plant.pruning_logs.all %}
|
||||||
|
<li class="list-group-item py-1">
|
||||||
|
<strong>{{ log.pruned_on|date:"j M Y" }}</strong>
|
||||||
|
{% if log.notes %}<small class="text-muted ms-2">{{ log.notes }}</small>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm">Delete plant</a>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -67,3 +67,41 @@ class TestPlantList:
|
||||||
content = resp.content.decode()
|
content = resp.content.decode()
|
||||||
assert 'Fern' in content
|
assert 'Fern' in content
|
||||||
assert 'Rose' not in content
|
assert 'Rose' not in content
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestPlantDetail:
|
||||||
|
def test_returns_200(self, client):
|
||||||
|
plant = Plant.objects.create(name='Fern', location='Office')
|
||||||
|
resp = client.get(reverse('plant_detail', args=[plant.pk]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_404_for_missing_plant(self, client):
|
||||||
|
resp = client.get(reverse('plant_detail', args=[9999]))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_context_contains_plant_and_status(self, client):
|
||||||
|
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[])
|
||||||
|
resp = client.get(reverse('plant_detail', args=[plant.pk]))
|
||||||
|
assert resp.context['plant'] == plant
|
||||||
|
assert resp.context['pruning_status'] == 'no_schedule'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestLogPruning:
|
||||||
|
def test_creates_pruning_log(self, client):
|
||||||
|
plant = Plant.objects.create(name='Rose', location='Garden')
|
||||||
|
resp = client.post(
|
||||||
|
reverse('log_pruning', args=[plant.pk]),
|
||||||
|
{'pruned_on': '2026-05-01', 'notes': ''},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists()
|
||||||
|
|
||||||
|
def test_returns_pruning_strip_partial(self, client):
|
||||||
|
plant = Plant.objects.create(name='Rose', location='Garden')
|
||||||
|
resp = client.post(
|
||||||
|
reverse('log_pruning', args=[plant.pk]),
|
||||||
|
{'pruned_on': '2026-05-01', 'notes': ''},
|
||||||
|
)
|
||||||
|
assert resp.templates[0].name == 'plants/partials/pruning_strip.html'
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,39 @@ def plant_list(request):
|
||||||
|
|
||||||
|
|
||||||
def plant_detail(request, pk):
|
def plant_detail(request, pk):
|
||||||
pass
|
plant = get_object_or_404(
|
||||||
|
Plant.objects.select_related('species').prefetch_related('pruning_logs'),
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@require_POST
|
||||||
|
def log_pruning(request, pk):
|
||||||
|
plant = get_object_or_404(
|
||||||
|
Plant.objects.prefetch_related('pruning_logs'), pk=pk
|
||||||
|
)
|
||||||
|
form = PruningLogForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
log = form.save(commit=False)
|
||||||
|
log.plant = plant
|
||||||
|
log.save()
|
||||||
|
today = date.today()
|
||||||
|
return render(request, 'plants/partials/pruning_strip.html', {
|
||||||
|
'plant': plant,
|
||||||
|
'pruning_status': pruning_status(plant, today),
|
||||||
|
'log_form': PruningLogForm(initial={'pruned_on': today}),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def plant_add(request):
|
def plant_add(request):
|
||||||
pass
|
return render(request, 'plants/species_search.html')
|
||||||
|
|
||||||
|
|
||||||
def plant_edit(request, pk):
|
def plant_edit(request, pk):
|
||||||
|
|
@ -40,8 +68,3 @@ def plant_edit(request, pk):
|
||||||
|
|
||||||
def plant_delete(request, pk):
|
def plant_delete(request, pk):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
|
||||||
def log_pruning(request, pk):
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue