feat: location FK, multi-photo gallery, hide pruning history

- Location model: plants now FK to Location instead of free-text CharField
- Data migration 0004 converts existing location strings to Location objects
- PlantForm uses ModelChoiceField dropdown + inline "add new location" text field
- PlantPhoto model: multiple photos per plant with is_thumbnail flag
- Plant.thumbnail_url property: picks marked thumbnail, falls back to first photo then legacy photo field
- Photo gallery partial: upload, set thumbnail (★), delete (✕) with HTMX swaps on #photo-gallery
- plant_detail: uses thumbnail_url for hero image, pruning history section removed
- plant_list search updated: location__name__icontains instead of location__icontains
- Three new URL routes: upload_photo, set_thumbnail, delete_photo
- All 53 tests updated and passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stephan Kerkman 2026-05-28 18:27:45 +02:00
parent a60c99ed93
commit b88ba03884
11 changed files with 278 additions and 78 deletions

View file

@ -1,5 +1,5 @@
from django import forms from django import forms
from .models import Plant, PruningLog from .models import Plant, PruningLog, Location
MONTH_CHOICES = [ MONTH_CHOICES = [
('1', 'Jan'), ('2', 'Feb'), ('3', 'Mar'), ('4', 'Apr'), ('1', 'Jan'), ('2', 'Feb'), ('3', 'Mar'), ('4', 'Apr'),
@ -15,14 +15,25 @@ class PlantForm(forms.ModelForm):
required=False, required=False,
label='Bloom months', label='Bloom months',
) )
location = forms.ModelChoiceField(
queryset=Location.objects.all(),
required=False,
empty_label='— select location —',
widget=forms.Select(attrs={'class': 'form-select'}),
)
new_location = forms.CharField(
max_length=200,
required=False,
label='Or add new location',
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'e.g. Living room'}),
)
class Meta: class Meta:
model = Plant model = Plant
fields = ['name', 'location', 'is_indoor', 'bloom_months', 'photo', 'notes'] fields = ['name', 'location', 'is_indoor', 'bloom_months', 'notes']
widgets = { widgets = {
'notes': forms.Textarea(attrs={'rows': 3}), 'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
'name': forms.TextInput(attrs={'class': 'form-control'}), 'name': forms.TextInput(attrs={'class': 'form-control'}),
'location': forms.TextInput(attrs={'class': 'form-control'}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -35,6 +46,14 @@ class PlantForm(forms.ModelForm):
def clean_bloom_months(self): def clean_bloom_months(self):
return [int(m) for m in self.cleaned_data.get('bloom_months', [])] return [int(m) for m in self.cleaned_data.get('bloom_months', [])]
def clean(self):
cleaned = super().clean()
new_loc = cleaned.get('new_location', '').strip()
if new_loc:
loc, _ = Location.objects.get_or_create(name=new_loc)
cleaned['location'] = loc
return cleaned
class PruningLogForm(forms.ModelForm): class PruningLogForm(forms.ModelForm):
class Meta: class Meta:

View file

@ -0,0 +1,71 @@
import django.db.models.deletion
from django.db import migrations, models
def migrate_location_strings(apps, schema_editor):
Plant = apps.get_model('plants', 'Plant')
Location = apps.get_model('plants', 'Location')
for plant in Plant.objects.all():
name = (plant.location_old or '').strip()
if name:
loc, _ = Location.objects.get_or_create(name=name)
plant.location_new = loc
plant.save(update_fields=['location_new'])
class Migration(migrations.Migration):
dependencies = [
('plants', '0003_add_bloom_months'),
]
operations = [
migrations.CreateModel(
name='Location',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, unique=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='PlantPhoto',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to='plants/photos/')),
('is_thumbnail', models.BooleanField(default=False)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('plant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='plants.plant')),
],
options={
'ordering': ['uploaded_at'],
},
),
migrations.RenameField(
model_name='plant',
old_name='location',
new_name='location_old',
),
migrations.AddField(
model_name='plant',
name='location_new',
field=models.ForeignKey(
blank=True, null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='plants',
to='plants.location',
),
),
migrations.RunPython(migrate_location_strings, migrations.RunPython.noop),
migrations.RemoveField(
model_name='plant',
name='location_old',
),
migrations.RenameField(
model_name='plant',
old_name='location_new',
new_name='location',
),
]

View file

@ -1,6 +1,16 @@
from django.db import models from django.db import models
class Location(models.Model):
name = models.CharField(max_length=200, unique=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class Species(models.Model): class Species(models.Model):
common_name = models.CharField(max_length=200) common_name = models.CharField(max_length=200)
scientific_name = models.CharField(max_length=200, blank=True) scientific_name = models.CharField(max_length=200, blank=True)
@ -30,7 +40,10 @@ class Plant(models.Model):
Species, null=True, blank=True, Species, null=True, blank=True,
on_delete=models.SET_NULL, related_name='plants', on_delete=models.SET_NULL, related_name='plants',
) )
location = models.CharField(max_length=200) location = models.ForeignKey(
Location, null=True, blank=True,
on_delete=models.SET_NULL, related_name='plants',
)
is_indoor = models.BooleanField(default=False) is_indoor = models.BooleanField(default=False)
pruning_months = models.JSONField(default=list) pruning_months = models.JSONField(default=list)
bloom_months = models.JSONField(default=list) bloom_months = models.JSONField(default=list)
@ -44,6 +57,28 @@ class Plant(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
@property
def thumbnail_url(self):
thumb = self.photos.filter(is_thumbnail=True).first()
if thumb:
return thumb.image.url
first = self.photos.first()
if first:
return first.image.url
if self.photo:
return self.photo.url
return None
class PlantPhoto(models.Model):
plant = models.ForeignKey(Plant, on_delete=models.CASCADE, related_name='photos')
image = models.ImageField(upload_to='plants/photos/')
is_thumbnail = models.BooleanField(default=False)
uploaded_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['uploaded_at']
class PruningLog(models.Model): class PruningLog(models.Model):
plant = models.ForeignKey( plant = models.ForeignKey(

View file

@ -0,0 +1,46 @@
<div class="mb-3">
<h6 class="mb-2">Photos</h6>
{% with photos=plant.photos.all %}
{% if photos %}
<div class="d-flex flex-wrap gap-2 mb-2">
{% for photo in photos %}
<div class="position-relative">
<img src="{{ photo.image.url }}" width="80" height="80"
class="rounded {% if photo.is_thumbnail %}border border-success border-2{% endif %}"
style="object-fit:cover;" alt="">
<div class="d-flex gap-1 mt-1">
{% if not photo.is_thumbnail %}
<form method="post" action="{% url 'set_thumbnail' photo.pk %}"
hx-post="{% url 'set_thumbnail' photo.pk %}"
hx-target="#photo-gallery" hx-swap="outerHTML">
{% csrf_token %}
<button type="submit" class="btn btn-outline-success btn-sm py-0 px-1" style="font-size:.7rem;" title="Set as thumbnail"></button>
</form>
{% else %}
<span class="btn btn-success btn-sm py-0 px-1 disabled" style="font-size:.7rem;" title="Thumbnail"></span>
{% endif %}
<form method="post" action="{% url 'delete_photo' photo.pk %}"
hx-post="{% url 'delete_photo' photo.pk %}"
hx-target="#photo-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 %}
<form method="post" action="{% url 'upload_photo' plant.pk %}" enctype="multipart/form-data"
hx-post="{% url 'upload_photo' plant.pk %}" hx-target="#photo-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>
</div>

View file

@ -8,8 +8,8 @@
<a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a> <a href="{% url 'plant_edit' plant.pk %}" class="btn btn-sm btn-outline-secondary">✏️</a>
</div> </div>
{% if plant.photo %} {% if plant.thumbnail_url %}
<img src="{{ plant.photo.url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}"> <img src="{{ plant.thumbnail_url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% elif plant.species and plant.species.api_image_url %} {% 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 }}"> <img src="{{ plant.species.api_image_url }}" class="plant-hero rounded mb-3" alt="{{ plant.species.common_name }}">
{% else %} {% else %}
@ -26,7 +26,7 @@
</span> </span>
</div> </div>
<p class="text-muted small mb-2">📍 {{ plant.location }}</p> {% if plant.location %}<p class="text-muted small mb-2">📍 {{ plant.location.name }}</p>{% endif %}
{% if plant.species %} {% if plant.species %}
<div class="mb-3"> <div class="mb-3">
@ -43,13 +43,6 @@
<span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span> <span class="care-chip" style="background:#e8f5e9; color:#1b5e20;">🌱 {{ plant.species.growth_rate }}</span>
{% endif %} {% endif %}
</div> </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 %} {% endif %}
{% load plant_extras %} {% load plant_extras %}
@ -68,20 +61,10 @@
</div> </div>
{% endif %} {% endif %}
{% if plant.pruning_logs.all %} <div id="photo-gallery">
<div class="mb-3"> {% include "plants/partials/photo_gallery.html" %}
<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> </div>
{% endif %}
<a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm">Delete plant</a> <a href="{% url 'plant_delete' plant.pk %}" class="btn btn-outline-danger btn-sm mt-2">Delete plant</a>
{% endblock %} {% endblock %}

View file

@ -33,6 +33,11 @@
<label class="form-label fw-semibold">Location</label> <label class="form-label fw-semibold">Location</label>
{{ form.location }} {{ form.location }}
{% if form.location.errors %}<div class="text-danger small">{{ form.location.errors }}</div>{% endif %} {% if form.location.errors %}<div class="text-danger small">{{ form.location.errors }}</div>{% endif %}
<div class="mt-2">
<label class="form-label small text-muted mb-1">{{ form.new_location.label }}</label>
{{ form.new_location }}
<div class="form-text">Leave empty if selecting from the list above.</div>
</div>
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check">
@ -52,12 +57,6 @@
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label fw-semibold">Photo</label>
{{ form.photo }}
<div class="form-text">Take a photo with your camera or upload one.</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Notes</label> <label class="form-label fw-semibold">Notes</label>
{{ form.notes }} {{ form.notes }}

View file

@ -1,8 +1,13 @@
import pytest import pytest
from plants.models import Species, Plant, PruningLog from plants.models import Species, Plant, PruningLog, Location
from datetime import date from datetime import date
def make_location(name):
loc, _ = Location.objects.get_or_create(name=name)
return loc
@pytest.mark.django_db @pytest.mark.django_db
def test_species_str(): def test_species_str():
s = Species.objects.create(common_name='Rose') s = Species.objects.create(common_name='Rose')
@ -11,19 +16,19 @@ def test_species_str():
@pytest.mark.django_db @pytest.mark.django_db
def test_plant_str(): def test_plant_str():
p = Plant.objects.create(name='Kitchen Fern', location='Kitchen') p = Plant.objects.create(name='Kitchen Fern', location=make_location('Kitchen'))
assert str(p) == 'Kitchen Fern' assert str(p) == 'Kitchen Fern'
@pytest.mark.django_db @pytest.mark.django_db
def test_plant_species_nullable(): def test_plant_species_nullable():
p = Plant.objects.create(name='Unknown plant', location='Hallway') p = Plant.objects.create(name='Unknown plant')
assert p.species is None assert p.species is None
@pytest.mark.django_db @pytest.mark.django_db
def test_pruning_log_str(): def test_pruning_log_str():
p = Plant.objects.create(name='Rose', location='Garden') p = Plant.objects.create(name='Rose', location=make_location('Garden'))
log = PruningLog.objects.create(plant=p, pruned_on=date(2026, 5, 1)) log = PruningLog.objects.create(plant=p, pruned_on=date(2026, 5, 1))
assert 'Rose' in str(log) assert 'Rose' in str(log)
assert '2026-05-01' in str(log) assert '2026-05-01' in str(log)
@ -31,7 +36,7 @@ def test_pruning_log_str():
@pytest.mark.django_db @pytest.mark.django_db
def test_plant_pruning_months_default(): def test_plant_pruning_months_default():
p = Plant.objects.create(name='Fern', location='Office') p = Plant.objects.create(name='Fern', location=make_location('Office'))
assert p.pruning_months == [] assert p.pruning_months == []
@ -44,7 +49,7 @@ def test_species_pruning_months_default():
@pytest.mark.django_db @pytest.mark.django_db
def test_delete_species_nulls_plant_species(): def test_delete_species_nulls_plant_species():
s = Species.objects.create(common_name='Rose') s = Species.objects.create(common_name='Rose')
p = Plant.objects.create(name='Garden rose', location='Garden', species=s) p = Plant.objects.create(name='Garden rose', location=make_location('Garden'), species=s)
s.delete() s.delete()
p.refresh_from_db() p.refresh_from_db()
assert p.species is None assert p.species is None

View file

@ -1,9 +1,14 @@
import pytest import pytest
from datetime import date from datetime import date
from plants.models import Plant, PruningLog from plants.models import Plant, PruningLog, Location
from plants.utils.pruning import pruning_status, months_before from plants.utils.pruning import pruning_status, months_before
def make_location(name):
loc, _ = Location.objects.get_or_create(name=name)
return loc
def test_months_before_same_year(): def test_months_before_same_year():
d = date(2026, 5, 27) d = date(2026, 5, 27)
assert months_before(d, 1) == date(2026, 4, 1) assert months_before(d, 1) == date(2026, 4, 1)
@ -24,21 +29,21 @@ def test_months_before_january():
@pytest.mark.django_db @pytest.mark.django_db
def test_no_schedule(): def test_no_schedule():
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[]) plant = Plant.objects.create(name='Fern', pruning_months=[])
assert pruning_status(plant) == 'no_schedule' assert pruning_status(plant) == 'no_schedule'
@pytest.mark.django_db @pytest.mark.django_db
def test_due_this_month(): def test_due_this_month():
today = date(2026, 5, 15) today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5]) plant = Plant.objects.create(name='Rose', pruning_months=[5])
assert pruning_status(plant, today) == 'due_this_month' assert pruning_status(plant, today) == 'due_this_month'
@pytest.mark.django_db @pytest.mark.django_db
def test_due_this_month_already_logged(): def test_due_this_month_already_logged():
today = date(2026, 5, 15) today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[5]) plant = Plant.objects.create(name='Rose', pruning_months=[5])
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 1)) PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 1))
assert pruning_status(plant, today) == 'upcoming' assert pruning_status(plant, today) == 'upcoming'
@ -46,14 +51,14 @@ def test_due_this_month_already_logged():
@pytest.mark.django_db @pytest.mark.django_db
def test_overdue_when_past_month_not_logged(): def test_overdue_when_past_month_not_logged():
today = date(2026, 5, 15) today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) plant = Plant.objects.create(name='Rose', pruning_months=[3])
assert pruning_status(plant, today) == 'overdue' assert pruning_status(plant, today) == 'overdue'
@pytest.mark.django_db @pytest.mark.django_db
def test_not_overdue_when_past_month_logged(): def test_not_overdue_when_past_month_logged():
today = date(2026, 5, 15) today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) plant = Plant.objects.create(name='Rose', pruning_months=[3])
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 3, 10)) PruningLog.objects.create(plant=plant, pruned_on=date(2026, 3, 10))
assert pruning_status(plant, today) == 'upcoming' assert pruning_status(plant, today) == 'upcoming'
@ -62,7 +67,7 @@ def test_not_overdue_when_past_month_logged():
def test_not_overdue_when_logged_after_scheduled_month(): def test_not_overdue_when_logged_after_scheduled_month():
# Pruned in May clears a March schedule — the common real-world case # Pruned in May clears a March schedule — the common real-world case
today = date(2026, 5, 28) today = date(2026, 5, 28)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) plant = Plant.objects.create(name='Rose', pruning_months=[3])
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 28)) PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 28))
assert pruning_status(plant, today) == 'upcoming' assert pruning_status(plant, today) == 'upcoming'
@ -70,7 +75,7 @@ def test_not_overdue_when_logged_after_scheduled_month():
@pytest.mark.django_db @pytest.mark.django_db
def test_upcoming_future_month(): def test_upcoming_future_month():
today = date(2026, 5, 15) today = date(2026, 5, 15)
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[8]) plant = Plant.objects.create(name='Rose', pruning_months=[8])
assert pruning_status(plant, today) == 'upcoming' assert pruning_status(plant, today) == 'upcoming'
@ -78,7 +83,7 @@ def test_upcoming_future_month():
def test_overdue_crosses_year_boundary(): def test_overdue_crosses_year_boundary():
# Pruning month December, checked in January # Pruning month December, checked in January
today = date(2026, 1, 10) today = date(2026, 1, 10)
plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[12]) plant = Plant.objects.create(name='Apple', pruning_months=[12])
assert pruning_status(plant, today) == 'overdue' assert pruning_status(plant, today) == 'overdue'
@ -86,5 +91,5 @@ def test_overdue_crosses_year_boundary():
def test_overdue_several_months_before_year_boundary(): def test_overdue_several_months_before_year_boundary():
# Plant scheduled in August, checked in January — should be overdue # Plant scheduled in August, checked in January — should be overdue
today = date(2026, 1, 10) today = date(2026, 1, 10)
plant = Plant.objects.create(name='Oak', location='Garden', pruning_months=[8]) plant = Plant.objects.create(name='Oak', pruning_months=[8])
assert pruning_status(plant, today) == 'overdue' assert pruning_status(plant, today) == 'overdue'

View file

@ -1,7 +1,12 @@
import pytest import pytest
from django.urls import reverse from django.urls import reverse
from datetime import date from datetime import date
from plants.models import Plant, PruningLog from plants.models import Plant, PruningLog, Location
def make_location(name):
loc, _ = Location.objects.get_or_create(name=name)
return loc
@pytest.mark.django_db @pytest.mark.django_db
@ -11,21 +16,21 @@ class TestDashboard:
assert resp.status_code == 200 assert resp.status_code == 200
def test_shows_total_plant_count(self, client): def test_shows_total_plant_count(self, client):
Plant.objects.create(name='Fern', location='Office') Plant.objects.create(name='Fern', location=make_location('Office'))
Plant.objects.create(name='Rose', location='Garden') Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.get(reverse('dashboard')) resp = client.get(reverse('dashboard'))
assert resp.context['total_plants'] == 2 assert resp.context['total_plants'] == 2
def test_blooming_now_plant_appears_in_context(self, client): def test_blooming_now_plant_appears_in_context(self, client):
today = date.today() today = date.today()
plant = Plant.objects.create(name='Rose', location='Garden', bloom_months=[today.month]) plant = Plant.objects.create(name='Rose', bloom_months=[today.month])
resp = client.get(reverse('dashboard')) resp = client.get(reverse('dashboard'))
assert plant in resp.context['blooming_now'] assert plant in resp.context['blooming_now']
def test_non_blooming_plant_not_in_blooming_now(self, client): def test_non_blooming_plant_not_in_blooming_now(self, client):
today = date.today() today = date.today()
other_month = today.month % 12 + 1 other_month = today.month % 12 + 1
plant = Plant.objects.create(name='Apple', location='Garden', bloom_months=[other_month]) plant = Plant.objects.create(name='Apple', bloom_months=[other_month])
resp = client.get(reverse('dashboard')) resp = client.get(reverse('dashboard'))
assert plant not in resp.context['blooming_now'] assert plant not in resp.context['blooming_now']
@ -37,13 +42,13 @@ class TestPlantList:
assert resp.status_code == 200 assert resp.status_code == 200
def test_lists_plants(self, client): def test_lists_plants(self, client):
Plant.objects.create(name='Monstera', location='Living room') Plant.objects.create(name='Monstera', location=make_location('Living room'))
resp = client.get(reverse('plant_list')) resp = client.get(reverse('plant_list'))
assert 'Monstera' in resp.content.decode() assert 'Monstera' in resp.content.decode()
def test_search_filters_by_name(self, client): def test_search_filters_by_name(self, client):
Plant.objects.create(name='Monstera', location='Living room') Plant.objects.create(name='Monstera', location=make_location('Living room'))
Plant.objects.create(name='Rose', location='Garden') Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.get(reverse('plant_list') + '?q=Rose') resp = client.get(reverse('plant_list') + '?q=Rose')
content = resp.content.decode() content = resp.content.decode()
assert 'Rose' in content assert 'Rose' in content
@ -58,8 +63,8 @@ class TestPlantList:
assert resp.templates[0].name == 'plants/partials/plant_list_results.html' assert resp.templates[0].name == 'plants/partials/plant_list_results.html'
def test_indoor_filter(self, client): def test_indoor_filter(self, client):
Plant.objects.create(name='Fern', location='Office', is_indoor=True) Plant.objects.create(name='Fern', location=make_location('Office'), is_indoor=True)
Plant.objects.create(name='Rose', location='Garden', is_indoor=False) Plant.objects.create(name='Rose', location=make_location('Garden'), is_indoor=False)
resp = client.get(reverse('plant_list') + '?filter=indoor') resp = client.get(reverse('plant_list') + '?filter=indoor')
content = resp.content.decode() content = resp.content.decode()
assert 'Fern' in content assert 'Fern' in content
@ -69,7 +74,7 @@ class TestPlantList:
@pytest.mark.django_db @pytest.mark.django_db
class TestPlantDetail: class TestPlantDetail:
def test_returns_200(self, client): def test_returns_200(self, client):
plant = Plant.objects.create(name='Fern', location='Office') plant = Plant.objects.create(name='Fern', location=make_location('Office'))
resp = client.get(reverse('plant_detail', args=[plant.pk])) resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert resp.status_code == 200 assert resp.status_code == 200
@ -78,7 +83,7 @@ class TestPlantDetail:
assert resp.status_code == 404 assert resp.status_code == 404
def test_context_contains_plant_and_status(self, client): def test_context_contains_plant_and_status(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[]) plant = Plant.objects.create(name='Rose', pruning_months=[])
resp = client.get(reverse('plant_detail', args=[plant.pk])) resp = client.get(reverse('plant_detail', args=[plant.pk]))
assert resp.context['plant'] == plant assert resp.context['plant'] == plant
assert resp.context['pruning_status'] == 'no_schedule' assert resp.context['pruning_status'] == 'no_schedule'
@ -87,7 +92,7 @@ class TestPlantDetail:
@pytest.mark.django_db @pytest.mark.django_db
class TestLogPruning: class TestLogPruning:
def test_creates_pruning_log(self, client): def test_creates_pruning_log(self, client):
plant = Plant.objects.create(name='Rose', location='Garden') plant = Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.post( resp = client.post(
reverse('log_pruning', args=[plant.pk]), reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''}, {'pruned_on': '2026-05-01', 'notes': ''},
@ -96,7 +101,7 @@ class TestLogPruning:
assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists() assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists()
def test_returns_pruning_strip_partial(self, client): def test_returns_pruning_strip_partial(self, client):
plant = Plant.objects.create(name='Rose', location='Garden') plant = Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.post( resp = client.post(
reverse('log_pruning', args=[plant.pk]), reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''}, {'pruned_on': '2026-05-01', 'notes': ''},
@ -160,7 +165,7 @@ class TestPlantAdd:
def test_post_creates_plant_and_redirects(self, client): def test_post_creates_plant_and_redirects(self, client):
resp = client.post(reverse('plant_add'), { resp = client.post(reverse('plant_add'), {
'name': 'New Fern', 'name': 'New Fern',
'location': 'Office', 'new_location': 'Office',
'is_indoor': 'on', 'is_indoor': 'on',
'notes': '', 'notes': '',
'pruning_months': [], 'pruning_months': [],
@ -175,7 +180,7 @@ class TestPlantAdd:
s = Species.objects.create(common_name='Rose', pruning_months=[2]) s = Species.objects.create(common_name='Rose', pruning_months=[2])
client.post( client.post(
reverse('plant_add') + f'?species_id={s.pk}', reverse('plant_add') + f'?species_id={s.pk}',
{'name': 'Garden Rose', 'location': 'Garden', 'notes': '', 'pruning_months': ['2']}, {'name': 'Garden Rose', 'new_location': 'Garden', 'notes': '', 'pruning_months': ['2']},
) )
plant = Plant.objects.get(name='Garden Rose') plant = Plant.objects.get(name='Garden Rose')
assert plant.species == s assert plant.species == s
@ -184,16 +189,16 @@ class TestPlantAdd:
@pytest.mark.django_db @pytest.mark.django_db
class TestPlantEdit: class TestPlantEdit:
def test_edit_form_shows_current_values(self, client): def test_edit_form_shows_current_values(self, client):
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[3]) plant = Plant.objects.create(name='Fern', location=make_location('Office'), pruning_months=[3])
resp = client.get(reverse('plant_edit', args=[plant.pk])) resp = client.get(reverse('plant_edit', args=[plant.pk]))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.context['plant'] == plant assert resp.context['plant'] == plant
def test_post_updates_plant(self, client): def test_post_updates_plant(self, client):
plant = Plant.objects.create(name='Fern', location='Office') plant = Plant.objects.create(name='Fern', location=make_location('Office'))
client.post(reverse('plant_edit', args=[plant.pk]), { client.post(reverse('plant_edit', args=[plant.pk]), {
'name': 'Updated Fern', 'name': 'Updated Fern',
'location': 'Bedroom', 'new_location': 'Bedroom',
'notes': '', 'notes': '',
'pruning_months': [], 'pruning_months': [],
}) })
@ -204,12 +209,12 @@ class TestPlantEdit:
@pytest.mark.django_db @pytest.mark.django_db
class TestPlantDelete: class TestPlantDelete:
def test_delete_confirm_page(self, client): def test_delete_confirm_page(self, client):
plant = Plant.objects.create(name='Fern', location='Office') plant = Plant.objects.create(name='Fern', location=make_location('Office'))
resp = client.get(reverse('plant_delete', args=[plant.pk])) resp = client.get(reverse('plant_delete', args=[plant.pk]))
assert resp.status_code == 200 assert resp.status_code == 200
def test_post_deletes_plant(self, client): def test_post_deletes_plant(self, client):
plant = Plant.objects.create(name='Fern', location='Office') plant = Plant.objects.create(name='Fern', location=make_location('Office'))
client.post(reverse('plant_delete', args=[plant.pk])) client.post(reverse('plant_delete', args=[plant.pk]))
assert not Plant.objects.filter(pk=plant.pk).exists() assert not Plant.objects.filter(pk=plant.pk).exists()
@ -221,19 +226,19 @@ class TestPruningCalendar:
assert resp.status_code == 200 assert resp.status_code == 200
def test_overdue_plant_in_context(self, client): def test_overdue_plant_in_context(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3]) plant = Plant.objects.create(name='Rose', location=make_location('Garden'), pruning_months=[3])
resp = client.get(reverse('pruning_calendar')) resp = client.get(reverse('pruning_calendar'))
assert plant in resp.context['overdue'] assert plant in resp.context['overdue']
def test_due_this_month_in_context(self, client): def test_due_this_month_in_context(self, client):
from datetime import date from datetime import date
today = date.today() today = date.today()
plant = Plant.objects.create(name='Apple', location='Garden', pruning_months=[today.month]) plant = Plant.objects.create(name='Apple', location=make_location('Garden'), pruning_months=[today.month])
resp = client.get(reverse('pruning_calendar')) resp = client.get(reverse('pruning_calendar'))
assert plant in resp.context['due_this_month'] assert plant in resp.context['due_this_month']
def test_no_schedule_plants_excluded(self, client): def test_no_schedule_plants_excluded(self, client):
plant = Plant.objects.create(name='Fern', location='Office', pruning_months=[]) plant = Plant.objects.create(name='Fern', location=make_location('Office'), pruning_months=[])
resp = client.get(reverse('pruning_calendar')) resp = client.get(reverse('pruning_calendar'))
assert plant not in resp.context['overdue'] assert plant not in resp.context['overdue']
assert plant not in resp.context['due_this_month'] assert plant not in resp.context['due_this_month']

View file

@ -9,6 +9,9 @@ urlpatterns = [
path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'), path('plants/<int:pk>/edit/', plants.plant_edit, name='plant_edit'),
path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'), path('plants/<int:pk>/delete/', plants.plant_delete, name='plant_delete'),
path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'), path('plants/<int:pk>/log-pruning/', plants.log_pruning, name='log_pruning'),
path('plants/<int:pk>/upload-photo/', plants.upload_photo, name='upload_photo'),
path('photos/<int:photo_pk>/set-thumbnail/', plants.set_thumbnail, name='set_thumbnail'),
path('photos/<int:photo_pk>/delete/', plants.delete_photo, name='delete_photo'),
path('pruning/', pruning.pruning_calendar, name='pruning_calendar'), path('pruning/', pruning.pruning_calendar, name='pruning_calendar'),
path('species/search/', species.species_search, name='species_search'), path('species/search/', species.species_search, name='species_search'),
path('species/select/', species.species_select, name='species_select'), path('species/select/', species.species_select, name='species_select'),

View file

@ -2,18 +2,18 @@ from datetime import date
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from plants.models import Plant, Species, PruningLog from plants.models import Plant, Species, PruningLog, PlantPhoto
from plants.forms import PlantForm, PruningLogForm from plants.forms import PlantForm, PruningLogForm
from plants.utils.pruning import pruning_status from plants.utils.pruning import pruning_status
def plant_list(request): def plant_list(request):
qs = Plant.objects.select_related('species').all() qs = Plant.objects.select_related('species', 'location').all()
q = request.GET.get('q', '') q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '') indoor_filter = request.GET.get('filter', '')
if q: if q:
qs = qs.filter(name__icontains=q) | qs.filter(location__icontains=q) qs = qs.filter(name__icontains=q) | qs.filter(location__name__icontains=q)
if indoor_filter == 'indoor': if indoor_filter == 'indoor':
qs = qs.filter(is_indoor=True) qs = qs.filter(is_indoor=True)
elif indoor_filter == 'outdoor': elif indoor_filter == 'outdoor':
@ -29,7 +29,7 @@ def plant_list(request):
def plant_detail(request, pk): def plant_detail(request, pk):
plant = get_object_or_404( plant = get_object_or_404(
Plant.objects.select_related('species').prefetch_related('pruning_logs'), Plant.objects.select_related('species', 'location').prefetch_related('pruning_logs', 'photos'),
pk=pk, pk=pk,
) )
today = date.today() today = date.today()
@ -111,3 +111,32 @@ def plant_delete(request, pk):
plant.delete() plant.delete()
return redirect('plant_list') return redirect('plant_list')
return render(request, 'plants/plant_confirm_delete.html', {'plant': plant}) return render(request, 'plants/plant_confirm_delete.html', {'plant': plant})
@require_POST
def upload_photo(request, pk):
plant = get_object_or_404(Plant.objects.prefetch_related('photos'), pk=pk)
if request.FILES.get('image'):
PlantPhoto.objects.create(plant=plant, image=request.FILES['image'])
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
@require_POST
def set_thumbnail(request, photo_pk):
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
PlantPhoto.objects.filter(plant=photo.plant).update(is_thumbnail=False)
photo.is_thumbnail = True
photo.save(update_fields=['is_thumbnail'])
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})
@require_POST
def delete_photo(request, photo_pk):
photo = get_object_or_404(PlantPhoto, pk=photo_pk)
plant = Plant.objects.prefetch_related('photos').get(pk=photo.plant_id)
photo.image.delete(save=False)
photo.delete()
plant.refresh_from_db()
plant = Plant.objects.prefetch_related('photos').get(pk=plant.pk)
return render(request, 'plants/partials/photo_gallery.html', {'plant': plant})