From 7983aa57b695f72481c6c2478bad2ddb99c89876 Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Wed, 27 May 2026 20:06:07 +0200 Subject: [PATCH] feat: add pruning_status logic with 12-month rolling window --- plants/tests/test_pruning.py | 73 +++++++++++++++++++++++++++++++++ plants/utils/__init__.py | 0 plants/utils/pruning.py | 79 ++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 plants/tests/test_pruning.py create mode 100644 plants/utils/__init__.py create mode 100644 plants/utils/pruning.py diff --git a/plants/tests/test_pruning.py b/plants/tests/test_pruning.py new file mode 100644 index 0000000..21a6bea --- /dev/null +++ b/plants/tests/test_pruning.py @@ -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' diff --git a/plants/utils/__init__.py b/plants/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plants/utils/pruning.py b/plants/utils/pruning.py new file mode 100644 index 0000000..4f25c45 --- /dev/null +++ b/plants/utils/pruning.py @@ -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'