feat: replace pruning UI with bloom months

- Add bloom_months to Plant and Species models (migration 0003)
- Swap pruning_months form field for bloom_months throughout
- Plant detail shows green bloom strip instead of pruning strip
- Dashboard shows 'Blooming now' count and list instead of overdue pruning
- Remove ✂️ pruning calendar from nav (feature hidden, backend intact)
- Update dashboard tests to reflect new context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stephan Kerkman 2026-05-28 17:03:25 +02:00
parent eb28f7453b
commit a60c99ed93
10 changed files with 68 additions and 64 deletions

View file

@ -9,16 +9,16 @@ MONTH_CHOICES = [
class PlantForm(forms.ModelForm): class PlantForm(forms.ModelForm):
pruning_months = forms.MultipleChoiceField( bloom_months = forms.MultipleChoiceField(
choices=MONTH_CHOICES, choices=MONTH_CHOICES,
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
required=False, required=False,
label='Pruning months', label='Bloom months',
) )
class Meta: class Meta:
model = Plant model = Plant
fields = ['name', 'location', 'is_indoor', 'pruning_months', 'photo', 'notes'] fields = ['name', 'location', 'is_indoor', 'bloom_months', 'photo', 'notes']
widgets = { widgets = {
'notes': forms.Textarea(attrs={'rows': 3}), 'notes': forms.Textarea(attrs={'rows': 3}),
'name': forms.TextInput(attrs={'class': 'form-control'}), 'name': forms.TextInput(attrs={'class': 'form-control'}),
@ -27,14 +27,13 @@ class PlantForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# JSONField stores ints; CheckboxSelectMultiple expects strings matching choice values if self.instance.pk and self.instance.bloom_months:
if self.instance.pk and self.instance.pruning_months: self.initial['bloom_months'] = [str(m) for m in self.instance.bloom_months]
self.initial['pruning_months'] = [str(m) for m in self.instance.pruning_months] elif not self.instance.pk and self.initial.get('bloom_months'):
elif not self.instance.pk and self.initial.get('pruning_months'): self.initial['bloom_months'] = [str(m) for m in self.initial['bloom_months']]
self.initial['pruning_months'] = [str(m) for m in self.initial['pruning_months']]
def clean_pruning_months(self): def clean_bloom_months(self):
return [int(m) for m in self.cleaned_data.get('pruning_months', [])] return [int(m) for m in self.cleaned_data.get('bloom_months', [])]
class PruningLogForm(forms.ModelForm): class PruningLogForm(forms.ModelForm):

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.1 on 2026-05-28 15:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('plants', '0002_alter_pruninglog_plant_alter_species_max_height_cm'),
]
operations = [
migrations.AddField(
model_name='plant',
name='bloom_months',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='species',
name='bloom_months',
field=models.JSONField(default=list),
),
]

View file

@ -12,6 +12,7 @@ class Species(models.Model):
growth_rate = models.CharField(max_length=100, blank=True) growth_rate = models.CharField(max_length=100, blank=True)
growth_season = models.CharField(max_length=100, blank=True) growth_season = models.CharField(max_length=100, blank=True)
pruning_months = models.JSONField(default=list) pruning_months = models.JSONField(default=list)
bloom_months = models.JSONField(default=list)
api_image_url = models.URLField(blank=True) api_image_url = models.URLField(blank=True)
perenual_id = models.IntegerField(unique=True, null=True, blank=True) perenual_id = models.IntegerField(unique=True, null=True, blank=True)
@ -32,6 +33,7 @@ class Plant(models.Model):
location = models.CharField(max_length=200) location = models.CharField(max_length=200)
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)
photo = models.ImageField(upload_to='plants/', blank=True) photo = models.ImageField(upload_to='plants/', blank=True)
notes = models.TextField(blank=True) notes = models.TextField(blank=True)
date_added = models.DateField(auto_now_add=True) date_added = models.DateField(auto_now_add=True)

View file

@ -30,7 +30,6 @@
<a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a> <a class="navbar-brand fw-bold" href="{% url 'dashboard' %}">🌿 PlantDB</a>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a> <a href="{% url 'plant_list' %}" class="btn btn-sm btn-outline-light">Plants</a>
<a href="{% url 'pruning_calendar' %}" class="btn btn-sm btn-outline-light">✂️</a>
<a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a> <a href="{% url 'plant_add' %}" class="btn btn-sm btn-light fw-bold">+</a>
</div> </div>
</nav> </nav>

View file

@ -1,4 +1,5 @@
{% extends "plants/base.html" %} {% extends "plants/base.html" %}
{% load plant_extras %}
{% block title %}Dashboard — PlantDB{% endblock %} {% block title %}Dashboard — PlantDB{% endblock %}
{% block content %} {% block content %}
<h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6> <h6 class="text-muted mb-3">{{ today|date:"F Y" }}</h6>
@ -15,39 +16,26 @@
</a> </a>
</div> </div>
<div class="col-6"> <div class="col-6">
<a href="{% url 'pruning_calendar' %}" class="text-decoration-none"> <div class="card text-center h-100" style="background:#d8f3dc;">
<div class="card text-center h-100" style="background:#ffe8cc;">
<div class="card-body py-3"> <div class="card-body py-3">
<div class="display-5 fw-bold" style="color:#7d4a00;"> <div class="display-5 fw-bold" style="color:#1b4332;">{{ blooming_now_count }}</div>
{{ needs_pruning_count }} <div class="small" style="color:#2d6a4f;">Blooming now</div>
</div>
<div class="small" style="color:#7d4a00;">Need pruning</div>
</div> </div>
</div> </div>
</a>
</div> </div>
</div> </div>
{% if overdue or due_this_month %} {% if blooming_now %}
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header fw-semibold">✂️ Needs attention</div> <div class="card-header fw-semibold">🌸 Blooming this month</div>
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
{% for plant in overdue %} {% for plant in blooming_now %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"> <a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div> <div>
<div class="fw-semibold">{{ plant.name }}</div> <div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small> <small class="text-muted">{{ plant.location }}</small>
</div> </div>
<span class="badge bg-danger">overdue</span> <div>{% month_strip plant.bloom_months %}</div>
</a>
{% endfor %}
{% for plant in due_this_month %}
<a href="{% url 'plant_detail' plant.pk %}" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
<div>
<div class="fw-semibold">{{ plant.name }}</div>
<small class="text-muted">{{ plant.location }}</small>
</div>
<span class="badge bg-warning text-dark">this month</span>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View file

@ -52,7 +52,13 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% include "plants/partials/pruning_strip.html" %} {% load plant_extras %}
{% if plant.bloom_months %}
<div class="mb-3 p-2 rounded" style="background:#d8f3dc; border-left:4px solid #52b788;">
<strong>🌸 Bloom</strong>
<div class="mt-1">{% month_strip plant.bloom_months %}</div>
</div>
{% endif %}
{% if plant.notes %} {% if plant.notes %}
<div class="card mb-3"> <div class="card mb-3">

View file

@ -41,9 +41,9 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Pruning months</label> <label class="form-label fw-semibold">Bloom months</label>
<div class="month-toggle-group"> <div class="month-toggle-group">
{% for widget in form.pruning_months %} {% for widget in form.bloom_months %}
<div class="month-toggle"> <div class="month-toggle">
{{ widget.tag }} {{ widget.tag }}
<label for="{{ widget.id_for_label }}">{{ widget.choice_label|slice:":1" }}</label> <label for="{{ widget.id_for_label }}">{{ widget.choice_label|slice:":1" }}</label>

View file

@ -16,21 +16,18 @@ class TestDashboard:
resp = client.get(reverse('dashboard')) resp = client.get(reverse('dashboard'))
assert resp.context['total_plants'] == 2 assert resp.context['total_plants'] == 2
def test_overdue_plant_appears_in_context(self, client): def test_blooming_now_plant_appears_in_context(self, client):
plant = Plant.objects.create(name='Rose', location='Garden', pruning_months=[3])
# No log — overdue relative to current date (month 3 is more than 6 months ago only if checked after September)
# Use a month that's definitely in the recent past: use today's month - 1
resp = client.get(reverse('dashboard'))
# Just check the view works; pruning logic is tested separately
assert 'overdue' in resp.context
def test_due_this_month_plant_appears_in_context(self, client):
today = date.today() today = date.today()
plant = Plant.objects.create( plant = Plant.objects.create(name='Rose', location='Garden', bloom_months=[today.month])
name='Apple', location='Garden', pruning_months=[today.month]
)
resp = client.get(reverse('dashboard')) resp = client.get(reverse('dashboard'))
assert plant in resp.context['due_this_month'] 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])
resp = client.get(reverse('dashboard'))
assert plant not in resp.context['blooming_now']
@pytest.mark.django_db @pytest.mark.django_db

View file

@ -1,26 +1,16 @@
from datetime import date from datetime import date
from django.shortcuts import render from django.shortcuts import render
from plants.models import Plant from plants.models import Plant
from plants.utils.pruning import pruning_status
def dashboard(request): def dashboard(request):
today = date.today() today = date.today()
all_plants = list( all_plants = list(Plant.objects.select_related('species').all())
Plant.objects.select_related('species').prefetch_related('pruning_logs') blooming_now = [p for p in all_plants if today.month in (p.bloom_months or [])]
)
overdue, due_this_month = [], []
for plant in all_plants:
status = pruning_status(plant, today)
if status == 'overdue':
overdue.append(plant)
elif status == 'due_this_month':
due_this_month.append(plant)
return render(request, 'plants/dashboard.html', { return render(request, 'plants/dashboard.html', {
'total_plants': len(all_plants), 'total_plants': len(all_plants),
'overdue': overdue, 'blooming_now': blooming_now,
'due_this_month': due_this_month, 'blooming_now_count': len(blooming_now),
'needs_pruning_count': len(overdue) + len(due_this_month),
'today': today, 'today': today,
}) })

View file

@ -78,7 +78,7 @@ def plant_add(request):
plant.save() plant.save()
return redirect('plant_detail', pk=plant.pk) return redirect('plant_detail', pk=plant.pk)
else: else:
initial = {'pruning_months': species.pruning_months} if species else {} initial = {'bloom_months': species.bloom_months} if species else {}
form = PlantForm(initial=initial) form = PlantForm(initial=initial)
return render(request, 'plants/plant_form.html', { return render(request, 'plants/plant_form.html', {