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:
parent
eb28f7453b
commit
a60c99ed93
10 changed files with 68 additions and 64 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
23
plants/migrations/0003_add_bloom_months.py
Normal file
23
plants/migrations/0003_add_bloom_months.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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:#1b4332;">{{ blooming_now_count }}</div>
|
||||||
<div class="display-5 fw-bold" style="color:#7d4a00;">
|
<div class="small" style="color:#2d6a4f;">Blooming now</div>
|
||||||
{{ needs_pruning_count }}
|
|
||||||
</div>
|
|
||||||
<div class="small" style="color:#7d4a00;">Need pruning</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
</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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue