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'