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 .models import Plant, PruningLog
from .models import Plant, PruningLog, Location
MONTH_CHOICES = [
('1', 'Jan'), ('2', 'Feb'), ('3', 'Mar'), ('4', 'Apr'),
@ -15,14 +15,25 @@ class PlantForm(forms.ModelForm):
required=False,
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:
model = Plant
fields = ['name', 'location', 'is_indoor', 'bloom_months', 'photo', 'notes']
fields = ['name', 'location', 'is_indoor', 'bloom_months', 'notes']
widgets = {
'notes': forms.Textarea(attrs={'rows': 3}),
'notes': forms.Textarea(attrs={'rows': 3, 'class': 'form-control'}),
'name': forms.TextInput(attrs={'class': 'form-control'}),
'location': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
@ -35,6 +46,14 @@ class PlantForm(forms.ModelForm):
def clean_bloom_months(self):
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 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
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):
common_name = models.CharField(max_length=200)
scientific_name = models.CharField(max_length=200, blank=True)
@ -30,7 +40,10 @@ class Plant(models.Model):
Species, null=True, blank=True,
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)
pruning_months = models.JSONField(default=list)
bloom_months = models.JSONField(default=list)
@ -44,6 +57,28 @@ class Plant(models.Model):
def __str__(self):
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):
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>
</div>
{% if plant.photo %}
<img src="{{ plant.photo.url }}" class="plant-hero rounded mb-3" alt="{{ plant.name }}">
{% if plant.thumbnail_url %}
<img src="{{ plant.thumbnail_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 %}
@ -26,7 +26,7 @@
</span>
</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 %}
<div class="mb-3">
@ -43,13 +43,6 @@
<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 %}
{% load plant_extras %}
@ -68,20 +61,10 @@
</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 id="photo-gallery">
{% include "plants/partials/photo_gallery.html" %}
</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 %}

View file

@ -33,6 +33,11 @@
<label class="form-label fw-semibold">Location</label>
{{ form.location }}
{% 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 class="mb-3 form-check">
@ -52,12 +57,6 @@
</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">
<label class="form-label fw-semibold">Notes</label>
{{ form.notes }}

View file

@ -1,8 +1,13 @@
import pytest
from plants.models import Species, Plant, PruningLog
from plants.models import Species, Plant, PruningLog, Location
from datetime import date
def make_location(name):
loc, _ = Location.objects.get_or_create(name=name)
return loc
@pytest.mark.django_db
def test_species_str():
s = Species.objects.create(common_name='Rose')
@ -11,19 +16,19 @@ def test_species_str():
@pytest.mark.django_db
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'
@pytest.mark.django_db
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
@pytest.mark.django_db
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))
assert 'Rose' in str(log)
assert '2026-05-01' in str(log)
@ -31,7 +36,7 @@ def test_pruning_log_str():
@pytest.mark.django_db
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 == []
@ -44,7 +49,7 @@ def test_species_pruning_months_default():
@pytest.mark.django_db
def test_delete_species_nulls_plant_species():
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()
p.refresh_from_db()
assert p.species is None

View file

@ -1,9 +1,14 @@
import pytest
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
def make_location(name):
loc, _ = Location.objects.get_or_create(name=name)
return loc
def test_months_before_same_year():
d = date(2026, 5, 27)
assert months_before(d, 1) == date(2026, 4, 1)
@ -24,21 +29,21 @@ def test_months_before_january():
@pytest.mark.django_db
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'
@pytest.mark.django_db
def test_due_this_month():
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'
@pytest.mark.django_db
def test_due_this_month_already_logged():
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))
assert pruning_status(plant, today) == 'upcoming'
@ -46,14 +51,14 @@ def test_due_this_month_already_logged():
@pytest.mark.django_db
def test_overdue_when_past_month_not_logged():
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'
@pytest.mark.django_db
def test_not_overdue_when_past_month_logged():
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))
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():
# Pruned in May clears a March schedule — the common real-world case
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))
assert pruning_status(plant, today) == 'upcoming'
@ -70,7 +75,7 @@ def test_not_overdue_when_logged_after_scheduled_month():
@pytest.mark.django_db
def test_upcoming_future_month():
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'
@ -78,7 +83,7 @@ def test_upcoming_future_month():
def test_overdue_crosses_year_boundary():
# Pruning month December, checked in January
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'
@ -86,5 +91,5 @@ def test_overdue_crosses_year_boundary():
def test_overdue_several_months_before_year_boundary():
# Plant scheduled in August, checked in January — should be overdue
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'

View file

@ -1,7 +1,12 @@
import pytest
from django.urls import reverse
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
@ -11,21 +16,21 @@ class TestDashboard:
assert resp.status_code == 200
def test_shows_total_plant_count(self, client):
Plant.objects.create(name='Fern', location='Office')
Plant.objects.create(name='Rose', location='Garden')
Plant.objects.create(name='Fern', location=make_location('Office'))
Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.get(reverse('dashboard'))
assert resp.context['total_plants'] == 2
def test_blooming_now_plant_appears_in_context(self, client):
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'))
assert plant in resp.context['blooming_now']
def test_non_blooming_plant_not_in_blooming_now(self, client):
today = date.today()
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'))
assert plant not in resp.context['blooming_now']
@ -37,13 +42,13 @@ class TestPlantList:
assert resp.status_code == 200
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'))
assert 'Monstera' in resp.content.decode()
def test_search_filters_by_name(self, client):
Plant.objects.create(name='Monstera', location='Living room')
Plant.objects.create(name='Rose', location='Garden')
Plant.objects.create(name='Monstera', location=make_location('Living room'))
Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.get(reverse('plant_list') + '?q=Rose')
content = resp.content.decode()
assert 'Rose' in content
@ -58,8 +63,8 @@ class TestPlantList:
assert resp.templates[0].name == 'plants/partials/plant_list_results.html'
def test_indoor_filter(self, client):
Plant.objects.create(name='Fern', location='Office', is_indoor=True)
Plant.objects.create(name='Rose', location='Garden', is_indoor=False)
Plant.objects.create(name='Fern', location=make_location('Office'), is_indoor=True)
Plant.objects.create(name='Rose', location=make_location('Garden'), is_indoor=False)
resp = client.get(reverse('plant_list') + '?filter=indoor')
content = resp.content.decode()
assert 'Fern' in content
@ -69,7 +74,7 @@ class TestPlantList:
@pytest.mark.django_db
class TestPlantDetail:
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]))
assert resp.status_code == 200
@ -78,7 +83,7 @@ class TestPlantDetail:
assert resp.status_code == 404
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]))
assert resp.context['plant'] == plant
assert resp.context['pruning_status'] == 'no_schedule'
@ -87,7 +92,7 @@ class TestPlantDetail:
@pytest.mark.django_db
class TestLogPruning:
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(
reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''},
@ -96,7 +101,7 @@ class TestLogPruning:
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')
plant = Plant.objects.create(name='Rose', location=make_location('Garden'))
resp = client.post(
reverse('log_pruning', args=[plant.pk]),
{'pruned_on': '2026-05-01', 'notes': ''},
@ -160,7 +165,7 @@ class TestPlantAdd:
def test_post_creates_plant_and_redirects(self, client):
resp = client.post(reverse('plant_add'), {
'name': 'New Fern',
'location': 'Office',
'new_location': 'Office',
'is_indoor': 'on',
'notes': '',
'pruning_months': [],
@ -175,7 +180,7 @@ class TestPlantAdd:
s = Species.objects.create(common_name='Rose', pruning_months=[2])
client.post(
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')
assert plant.species == s
@ -184,16 +189,16 @@ class TestPlantAdd:
@pytest.mark.django_db
class TestPlantEdit:
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]))
assert resp.status_code == 200
assert resp.context['plant'] == plant
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]), {
'name': 'Updated Fern',
'location': 'Bedroom',
'new_location': 'Bedroom',
'notes': '',
'pruning_months': [],
})
@ -204,12 +209,12 @@ class TestPlantEdit:
@pytest.mark.django_db
class TestPlantDelete:
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]))
assert resp.status_code == 200
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]))
assert not Plant.objects.filter(pk=plant.pk).exists()
@ -221,19 +226,19 @@ class TestPruningCalendar:
assert resp.status_code == 200
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'))
assert plant in resp.context['overdue']
def test_due_this_month_in_context(self, client):
from datetime import date
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'))
assert plant in resp.context['due_this_month']
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'))
assert plant not in resp.context['overdue']
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>/delete/', plants.plant_delete, name='plant_delete'),
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('species/search/', species.species_search, name='species_search'),
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.views.decorators.csrf import ensure_csrf_cookie
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.utils.pruning import pruning_status
def plant_list(request):
qs = Plant.objects.select_related('species').all()
qs = Plant.objects.select_related('species', 'location').all()
q = request.GET.get('q', '')
indoor_filter = request.GET.get('filter', '')
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':
qs = qs.filter(is_indoor=True)
elif indoor_filter == 'outdoor':
@ -29,7 +29,7 @@ def plant_list(request):
def plant_detail(request, pk):
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,
)
today = date.today()
@ -111,3 +111,32 @@ def plant_delete(request, pk):
plant.delete()
return redirect('plant_list')
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})