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:
parent
a60c99ed93
commit
b88ba03884
11 changed files with 278 additions and 78 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
71
plants/migrations/0004_location_plantphoto.py
Normal file
71
plants/migrations/0004_location_plantphoto.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
46
plants/templates/plants/partials/photo_gallery.html
Normal file
46
plants/templates/plants/partials/photo_gallery.html
Normal 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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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']
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue