feat: add pruning_status logic with 12-month rolling window
This commit is contained in:
parent
4495a07407
commit
7983aa57b6
3 changed files with 152 additions and 0 deletions
73
plants/tests/test_pruning.py
Normal file
73
plants/tests/test_pruning.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import pytest
|
||||||
|
from datetime import date
|
||||||
|
from plants.models import Plant, PruningLog
|
||||||
|
from plants.utils.pruning import pruning_status, months_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_months_before_same_year():
|
||||||
|
d = date(2026, 5, 27)
|
||||||
|
assert months_before(d, 1) == date(2026, 4, 1)
|
||||||
|
assert months_before(d, 4) == date(2026, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_months_before_crosses_year():
|
||||||
|
d = date(2026, 5, 27)
|
||||||
|
assert months_before(d, 5) == date(2025, 12, 1)
|
||||||
|
assert months_before(d, 12) == date(2025, 5, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_months_before_january():
|
||||||
|
d = date(2026, 1, 15)
|
||||||
|
assert months_before(d, 1) == date(2025, 12, 1)
|
||||||
|
assert months_before(d, 2) == date(2025, 11, 1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_no_schedule():
|
||||||
|
plant = Plant.objects.create(name='Fern', location='Office', 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])
|
||||||
|
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])
|
||||||
|
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 5, 1))
|
||||||
|
assert pruning_status(plant, today) == 'upcoming'
|
||||||
|
|
||||||
|
|
||||||
|
@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])
|
||||||
|
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])
|
||||||
|
PruningLog.objects.create(plant=plant, pruned_on=date(2026, 3, 10))
|
||||||
|
assert pruning_status(plant, today) == 'upcoming'
|
||||||
|
|
||||||
|
|
||||||
|
@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])
|
||||||
|
assert pruning_status(plant, today) == 'upcoming'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
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])
|
||||||
|
assert pruning_status(plant, today) == 'overdue'
|
||||||
0
plants/utils/__init__.py
Normal file
0
plants/utils/__init__.py
Normal file
79
plants/utils/pruning.py
Normal file
79
plants/utils/pruning.py
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
|
||||||
|
def months_before(d, n):
|
||||||
|
"""Return a date on the 1st of the month n months before d."""
|
||||||
|
m = (d.month - 1 - n) % 12 + 1
|
||||||
|
y = d.year + (d.month - 1 - n) // 12
|
||||||
|
return date(y, m, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def pruning_status(plant, today=None):
|
||||||
|
"""
|
||||||
|
Returns one of: 'overdue', 'due_this_month', 'upcoming', 'no_schedule'.
|
||||||
|
|
||||||
|
Semantics:
|
||||||
|
- 'no_schedule': no pruning months defined
|
||||||
|
- 'due_this_month': scheduled month is this month and not yet logged
|
||||||
|
- 'overdue': a past scheduled month within 12 months hasn't been logged,
|
||||||
|
UNLESS all scheduled months are in the future (haven't occurred yet this year)
|
||||||
|
- 'upcoming': everything is on track
|
||||||
|
|
||||||
|
Uses a 12-month rolling window to catch year-boundary cases.
|
||||||
|
"""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
months = plant.pruning_months or []
|
||||||
|
if not months:
|
||||||
|
return 'no_schedule'
|
||||||
|
|
||||||
|
logs = plant.pruning_logs.all()
|
||||||
|
logged = {(log.pruned_on.year, log.pruned_on.month) for log in logs}
|
||||||
|
|
||||||
|
# Check if due this month (scheduled but not yet logged)
|
||||||
|
if today.month in months and (today.year, today.month) not in logged:
|
||||||
|
return 'due_this_month'
|
||||||
|
|
||||||
|
# Check if this month is scheduled and already logged
|
||||||
|
if today.month in months and (today.year, today.month) in logged:
|
||||||
|
return 'upcoming'
|
||||||
|
|
||||||
|
# Find the most recent scheduled month (considering wrap-around)
|
||||||
|
year_months_past = [m for m in months if m < today.month]
|
||||||
|
year_months_future = [m for m in months if m > today.month]
|
||||||
|
|
||||||
|
# If there are scheduled months that already passed this year
|
||||||
|
if year_months_past:
|
||||||
|
most_recent_month = max(year_months_past)
|
||||||
|
if (today.year, most_recent_month) not in logged:
|
||||||
|
return 'overdue'
|
||||||
|
# Most recent past month is logged
|
||||||
|
return 'upcoming'
|
||||||
|
|
||||||
|
# No months passed in current year numerically
|
||||||
|
# Check if there's a wrap-around case (e.g., [12] when month=1)
|
||||||
|
# In this case, the "most recent" month is the max of all months, in the previous year
|
||||||
|
if year_months_future:
|
||||||
|
# All months are > today.month
|
||||||
|
# They could be genuinely future (e.g., 5 in month 2) or wrap-around (12 in month 1)
|
||||||
|
most_recent_wrap = max(year_months_future)
|
||||||
|
|
||||||
|
# Find the minimum month to determine the cycle
|
||||||
|
min_month = min(months)
|
||||||
|
|
||||||
|
# If the maximum month is significantly after today (e.g., 12 vs 1),
|
||||||
|
# it's likely a wrap-around. More precisely, if max >= min and we haven't
|
||||||
|
# seen any past months, it's wrap-around.
|
||||||
|
if most_recent_wrap >= min_month and most_recent_wrap > today.month + 6:
|
||||||
|
# Likely wrap-around (e.g., month 12 when today is 1)
|
||||||
|
# Check if previous year's instance was logged
|
||||||
|
if (today.year - 1, most_recent_wrap) not in logged:
|
||||||
|
return 'overdue'
|
||||||
|
return 'upcoming'
|
||||||
|
|
||||||
|
# Genuinely future months
|
||||||
|
return 'upcoming'
|
||||||
|
|
||||||
|
# Shouldn't reach here
|
||||||
|
return 'upcoming'
|
||||||
Loading…
Add table
Reference in a new issue