diff --git a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py index ee56328..79b9430 100644 --- a/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py +++ b/bwh_hive/bwh_hive/doctype/hive_task/hive_task.py @@ -115,7 +115,9 @@ def _maybe_spawn_recurrence(self): """When a recurring task is marked Done, spawn the next instance. Skips if the frequency is unset, the status didn't just change to Done, - or the computed next due date is past `recurrence_end_date`. + an open instance of the series already exists, or the computed next due + date is past `recurrence_end_date`. An overdue series rolls forward to + the first due date on or after today. """ if not self.recurrence_frequency or self.status != "Done": return @@ -124,9 +126,18 @@ def _maybe_spawn_recurrence(self): if self.flags.get("recurrence_spawned"): return + parent_name = self.recurring_parent or self.name + if frappe.db.exists( + "Hive Task", + {"recurring_parent": parent_name, "status": ("!=", "Done"), "is_archived": 0}, + ): + return + next_due = _add_period(self.due_date or today(), self.recurrence_frequency) if not next_due: return + while getdate(next_due) < getdate(today()): + next_due = _add_period(next_due, self.recurrence_frequency) if self.recurrence_end_date and getdate(next_due) > getdate(self.recurrence_end_date): return @@ -135,7 +146,6 @@ def _maybe_spawn_recurrence(self): interval_days = (getdate(self.due_date) - getdate(self.start_date)).days new_start = add_days(next_due, -interval_days) - parent_name = self.recurring_parent or self.name new_task = frappe.new_doc("Hive Task") new_task.update( { diff --git a/bwh_hive/bwh_hive/doctype/hive_task/test_hive_task.py b/bwh_hive/bwh_hive/doctype/hive_task/test_hive_task.py index affe9e0..9ac3cc7 100644 --- a/bwh_hive/bwh_hive/doctype/hive_task/test_hive_task.py +++ b/bwh_hive/bwh_hive/doctype/hive_task/test_hive_task.py @@ -5,6 +5,7 @@ import frappe from frappe.tests import IntegrationTestCase +from frappe.utils import add_days, getdate, today EXTRA_TEST_RECORD_DEPENDENCIES = [] IGNORE_TEST_RECORD_DEPENDENCIES = [] @@ -192,3 +193,46 @@ def test_multiple_tasks_only_archived_one_hidden(self): visible_names = {t.name for t in visible} self.assertNotIn(self.task.name, visible_names) self.assertIn(task2.name, visible_names) + + +class TestHiveTaskRecurrence(IntegrationTestCase): + def setUp(self): + self.project = _make_project("Recurrence Test Project") + + def tearDown(self): + frappe.db.rollback() + + def _open_instances(self, parent): + return frappe.get_all( + "Hive Task", + filters={"recurring_parent": parent.name, "status": ("!=", "Done")}, + pluck="due_date", + ) + + def test_overdue_task_spawns_next_instance_from_today(self): + task = _make_task( + self.project, + "Daily Standup Notes", + recurrence_frequency="Daily", + due_date=add_days(today(), -3), + ) + task.status = "Done" + task.save() + + self.assertEqual(self._open_instances(task), [getdate(today())]) + + def test_reopening_and_completing_does_not_duplicate_next_instance(self): + task = _make_task( + self.project, + "Weekly Report", + recurrence_frequency="Weekly", + due_date=today(), + ) + task.status = "Done" + task.save() + task.status = "To Do" + task.save() + task.status = "Done" + task.save() + + self.assertEqual(self._open_instances(task), [getdate(add_days(today(), 7))])