diff --git a/spp_case_base/README.rst b/spp_case_base/README.rst index 3f27621dc..237fc7a28 100644 --- a/spp_case_base/README.rst +++ b/spp_case_base/README.rst @@ -151,6 +151,15 @@ Dependencies Changelog ========= +19.0.2.0.1 +~~~~~~~~~~ + +- fix(case): completing an intervention plan now clears ``is_current``, + so a finished plan stops being the case's current plan. Previously + only the revision path released the flag, leaving ``current_plan_id`` + pointing at completed work while ``has_active_plan`` read False, and + blocking any new plan from being marked current. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_case_base/__manifest__.py b/spp_case_base/__manifest__.py index 1ce233749..b49f65259 100644 --- a/spp_case_base/__manifest__.py +++ b/spp_case_base/__manifest__.py @@ -1,7 +1,7 @@ # pylint: disable=pointless-statement { "name": "OpenSPP Case Management Base", - "version": "19.0.2.0.0", + "version": "19.0.2.0.1", "category": "OpenSPP/Monitoring", "summary": "Core case management functionality for OpenSPP", "author": "OpenSPP.org", diff --git a/spp_case_base/migrations/19.0.2.0.1/post-migration.py b/spp_case_base/migrations/19.0.2.0.1/post-migration.py new file mode 100644 index 000000000..042483691 --- /dev/null +++ b/spp_case_base/migrations/19.0.2.0.1/post-migration.py @@ -0,0 +1,56 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Release ``is_current`` on intervention plans that already finished. + +``action_complete`` never cleared ``is_current`` (#458), and the only other +writer of ``is_current = False`` is the revision path, so a plan that finished +NORMALLY stayed its case's current plan forever. The code fix covers future +completions only; the rows already in a released database keep the incoherent +pair — ``case.current_plan_id`` pointing at completed work while +``has_active_plan`` reads False — and keep tripping the one-current-plan-per-case +constraint, so they are cleared here. + +Deliberately narrow: only ``completed``. ``action_create_revision`` writes +``is_current = False`` together with ``revised``, so that state has no +equivalent stale population, and widening the predicate would demote plans this +fix makes no claim about. + +Affected cases are left with NO current plan, which is the intended end state — +the plan is finished, and marking a fresh plan current is now possible again +(that constraint failure is the user-facing half of #458). The plan's own record +is untouched: ``state``, ``actual_end_date`` and its interventions all stay. + +Raw SQL with no ORM invalidation, matching +``spp_grm_cel/migrations/19.0.2.0.2/post-migration.py``: nothing reads +``is_current`` through the ORM later in this transaction, and a flush-and- +invalidate would risk writing a cached value back over the UPDATE. Callers that +DO read back in the same transaction — the test for this script, for one — must +invalidate on their side. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + + cr.execute( + "UPDATE spp_case_intervention_plan SET is_current = false " + "WHERE state = 'completed' AND is_current = true " + "RETURNING case_id" + ) + # One row per demoted plan; the one-current-plan constraint means a case + # cannot appear twice, but sort and de-duplicate so the log is stable. + case_ids = sorted({row[0] for row in cr.fetchall()}) + + if case_ids: + _logger.warning( + "Released is_current on %s completed intervention plan(s) that were still " + "flagged as their case's current plan. Cases affected (spp.case ids): %s. " + "Those cases now report no current plan; where case work is continuing, " + "mark the successor plan current on the case's Intervention Plans tab.", + len(case_ids), + ", ".join(str(cid) for cid in case_ids), + ) diff --git a/spp_case_base/models/case_intervention_plan.py b/spp_case_base/models/case_intervention_plan.py index ef90f67c7..fe7330af1 100644 --- a/spp_case_base/models/case_intervention_plan.py +++ b/spp_case_base/models/case_intervention_plan.py @@ -195,6 +195,7 @@ def action_complete(self): { "state": "completed", "actual_end_date": fields.Date.context_today(self), + "is_current": False, } ) return True diff --git a/spp_case_base/readme/HISTORY.md b/spp_case_base/readme/HISTORY.md index 4aaf9afef..743623c07 100644 --- a/spp_case_base/readme/HISTORY.md +++ b/spp_case_base/readme/HISTORY.md @@ -1,3 +1,10 @@ +### 19.0.2.0.1 + +- fix(case): completing an intervention plan now clears `is_current`, so a finished plan + stops being the case's current plan. Previously only the revision path released the + flag, leaving `current_plan_id` pointing at completed work while `has_active_plan` read + False, and blocking any new plan from being marked current. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_case_base/static/description/index.html b/spp_case_base/static/description/index.html index 4ecf78438..44399ec34 100644 --- a/spp_case_base/static/description/index.html +++ b/spp_case_base/static/description/index.html @@ -540,6 +540,16 @@
Reach self-sufficiency
", + } + ) + self.env["spp.case.intervention"].create( + { + "name": f"{name} Intervention", + "plan_id": plan.id, + } + ) + plan.action_submit_for_approval() + plan.with_user(self.supervisor).action_approve() + plan.action_activate() + self.assertEqual(plan.state, "active", "Plan should be active before completion") + return plan + + def test_complete_clears_is_current(self): + """Test that completing a plan ends its tenure as the case's current plan.""" + plan = self._active_plan("Plan To Complete") + + self.assertTrue(plan.is_current, "Active plan should still be current") + self.assertEqual( + self.case.current_plan_id, + plan, + "Active plan should be the case's current plan", + ) + + plan.action_complete() + + self.assertEqual(plan.state, "completed", "Plan should be completed") + self.assertFalse( + plan.is_current, + "Completed plan should no longer be marked current", + ) + self.assertFalse( + self.case.current_plan_id, + "Case should have no current plan once the plan is completed", + ) + self.assertFalse( + self.case.has_active_plan, + "Case should not report an active plan once the plan is completed", + ) + + def test_complete_frees_the_current_plan_slot(self): + """Test that a fresh plan can be made current once the previous one completes. + + This is the user-facing half of the fix: while a finished plan kept + ``is_current``, the one-current-plan-per-case constraint refused every + attempt to start the next plan. + """ + self._active_plan("Finished Plan").action_complete() + + # Defaults to is_current=True, so this create is what used to raise. + successor = self.env["spp.case.intervention.plan"].create( + { + "name": "Successor Plan", + "case_id": self.case.id, + "goals": "Second cycle goals
", + } + ) + + self.assertTrue(successor.is_current, "Successor plan should be current") + self.assertEqual( + self.case.current_plan_id, + successor, + "Case should point at the successor plan", + ) + + def test_complete_by_case_worker(self): + """Test that the assigned case worker may complete a plan and release the flag. + + ``action_complete`` writes ``is_current`` through ``write()``, so the + worker record rule (own cases only) has to permit it. + """ + plan = self._active_plan("Worker Completed Plan") + + plan.with_user(self.case_worker).action_complete() + + self.assertEqual(plan.state, "completed", "Plan should be completed") + self.assertFalse(plan.is_current, "Completed plan should no longer be marked current") def test_submit_without_interventions(self): """Test that plan cannot be submitted without interventions.""" diff --git a/spp_case_base/tests/test_migration_complete_clears_is_current.py b/spp_case_base/tests/test_migration_complete_clears_is_current.py new file mode 100644 index 000000000..e78918503 --- /dev/null +++ b/spp_case_base/tests/test_migration_complete_clears_is_current.py @@ -0,0 +1,159 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Exercise the 19.0.2.0.1 post-migration that demotes finished plans. + +The code fix in ``action_complete`` only covers future completions. Databases +released before it keep rows with ``state = 'completed'`` and +``is_current = true``, which is what #458 reports: ``current_plan_id`` points at +finished work and the one-current-plan constraint refuses a successor plan. This +pins that the script clears exactly those rows and nothing else. + +``migrations/`` is not a package, so the script is loaded through ``importlib`` +— same pattern as ``spp_gis/tests/test_migration_geofence_tags.py`` and +``spp_hide_menus_base/tests/test_migration_dedup_hide_menu.py``. +""" + +import importlib.util +from pathlib import Path + +from odoo import Command +from odoo.tests import TransactionCase, tagged + +MIGRATION_PATH = Path(__file__).parent.parent / "migrations" / "19.0.2.0.1" / "post-migration.py" + + +def _load_migrate(): + spec = importlib.util.spec_from_file_location("spp_case_base_post_migration_19_0_2_0_1", MIGRATION_PATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.migrate + + +migrate = _load_migrate() + + +@tagged("post_install", "-at_install") +class TestCompleteClearsIsCurrentMigration(TransactionCase): + """A released database's stale 'completed but still current' rows.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.case_worker = cls.env["res.users"].create( + { + "name": "Migration Case Worker", + "login": "test_worker_plan_migration", + "email": "worker_plan_migration@test.com", + "group_ids": [ + Command.link(cls.env.ref("base.group_user").id), + Command.link(cls.env.ref("spp_case_base.group_case_worker").id), + ], + } + ) + cls.client = cls.env["res.partner"].create({"name": "Migration Client"}) + cls.case_type = cls.env["spp.case.type"].create( + { + "name": "Migration Case Type", + "code": "MIGR01", + "domain": "social_protection", + } + ) + cls.Plan = cls.env["spp.case.intervention.plan"] + + def _case(self, issue): + return self.env["spp.case"].create( + { + "case_type_id": self.case_type.id, + "partner_id": self.client.id, + "case_worker_id": self.case_worker.id, + "presenting_issue": f"{issue}
", + } + ) + + def _stale_plan(self, name, issue): + """A plan in the shape pre-fix ``action_complete`` left behind. + + The state is forced with SQL on purpose: writing ``state`` through the + ORM is fine, but going through ``action_complete`` would apply the fix + and there would be nothing left to migrate. + """ + plan = self.Plan.create( + { + "name": name, + "case_id": self._case(issue).id, + "goals": "Goals
", + } + ) + self.env.cr.execute( + "UPDATE spp_case_intervention_plan SET state = 'completed', actual_end_date = CURRENT_DATE WHERE id = %s", + (plan.id,), + ) + plan.invalidate_recordset(["state", "actual_end_date"]) + self.assertEqual(plan.state, "completed") + self.assertTrue(plan.is_current, "Test premise: the finished plan is still flagged current") + return plan + + def test_migration_demotes_completed_plans(self): + """Test that the script releases is_current on completed plans.""" + stale = self._stale_plan("Stale Completed Plan", "Stale case") + + migrate(self.env.cr, "19.0.2.0.0") + + # The script writes with raw SQL and deliberately does not invalidate, + # so the read-back has to. + stale.invalidate_recordset(["is_current"]) + self.assertFalse(stale.is_current, "Migration should release is_current") + self.assertEqual(stale.state, "completed", "Migration should not touch state") + self.assertTrue(stale.actual_end_date, "Migration should not touch actual_end_date") + self.assertFalse( + stale.case_id.current_plan_id, + "Case should report no current plan after the migration", + ) + + def test_migration_frees_the_current_plan_slot(self): + """Test that a successor plan can be created once the migration has run.""" + stale = self._stale_plan("Stale Completed Plan", "Blocked case") + case = stale.case_id + + migrate(self.env.cr, "19.0.2.0.0") + stale.invalidate_recordset(["is_current"]) + + successor = self.Plan.create( + { + "name": "Successor Plan", + "case_id": case.id, + "goals": "Next cycle
", + } + ) + + self.assertTrue(successor.is_current, "Successor plan should be current") + self.assertEqual(case.current_plan_id, successor, "Case should point at the successor") + + def test_migration_leaves_unfinished_plans_alone(self): + """Test that plans that have not completed keep is_current.""" + keep = self.Plan.create( + { + "name": "Active Current Plan", + "case_id": self._case("Live case").id, + "goals": "Goals
", + "state": "active", + } + ) + self.assertTrue(keep.is_current, "Test premise: the active plan is current") + + migrate(self.env.cr, "19.0.2.0.0") + + keep.invalidate_recordset(["is_current"]) + self.assertTrue(keep.is_current, "An active plan must stay its case's current plan") + + def test_migration_skips_fresh_install(self): + """Test that the script is a no-op when there is no installed version.""" + stale = self._stale_plan("Stale Completed Plan", "Fresh install case") + + migrate(self.env.cr, None) + + stale.invalidate_recordset(["is_current"]) + self.assertTrue( + stale.is_current, + "A fresh install has no legacy rows to repair, so the script must return early", + ) diff --git a/spp_case_demo/README.rst b/spp_case_demo/README.rst index e121b47b8..f0c334da8 100644 --- a/spp_case_demo/README.rst +++ b/spp_case_demo/README.rst @@ -135,6 +135,15 @@ Dependencies Changelog ========= +19.0.2.0.1 +~~~~~~~~~~ + +- fix(case): the generator no longer seeds intervention plans that are + both ``completed`` and their case's current plan. The ``close_case`` + journey step and the random-plan helper now complete plans through + ``action_complete()``, so a finished demo plan gets an + ``actual_end_date`` and releases ``is_current``. + 19.0.2.0.0 ~~~~~~~~~~ diff --git a/spp_case_demo/__manifest__.py b/spp_case_demo/__manifest__.py index 5920b2165..faf7307c0 100644 --- a/spp_case_demo/__manifest__.py +++ b/spp_case_demo/__manifest__.py @@ -3,7 +3,7 @@ { "name": "OpenSPP Case Management Demo Data", - "version": "19.0.2.0.0", + "version": "19.0.2.0.1", "category": "OpenSPP", "summary": "Demo data generator for Case Management", "author": "OpenSPP.org", diff --git a/spp_case_demo/models/generate_cases.py b/spp_case_demo/models/generate_cases.py index 64b1317c7..1342a0937 100644 --- a/spp_case_demo/models/generate_cases.py +++ b/spp_case_demo/models/generate_cases.py @@ -312,7 +312,9 @@ def _process_case_journey(self, case, journey, fake): } ) if current_plan: - current_plan.sudo().write({"state": "completed"}) + # Through the action, not a bare state write: completing a + # plan also stamps actual_end_date and releases is_current. + current_plan.sudo().action_complete() def _create_random_case(self, fake, beneficiaries): """Create a random case with realistic data.""" @@ -378,12 +380,18 @@ def _add_random_plan(self, case, fake, intake_date): Plan = self.env["spp.case.intervention.plan"] Intervention = self.env["spp.case.intervention"] + # A plan reaches "completed" by being completed, not by being created + # that way: action_complete stamps actual_end_date and releases + # is_current, so a finished demo plan is not left as its case's current + # plan. The interventions are added first so the completed plan has a + # delivery record. + final_state = random.choice(["draft", "active", "completed"]) plan = Plan.sudo().create( { "case_id": case.id, "name": f"Support Plan - {case.partner_id.name or 'Client'}", "is_current": True, - "state": random.choice(["draft", "active", "completed"]), + "state": "active" if final_state == "completed" else final_state, "start_date": intake_date + timedelta(days=random.randint(1, 7)), "goals": fake.paragraph(), } @@ -410,6 +418,9 @@ def _add_random_plan(self, case, fake, intake_date): } ) + if final_state == "completed": + plan.sudo().action_complete() + def _add_random_visits(self, case, fake, intake_date): """Add random visits to case.""" Visit = self.env["spp.case.visit"] diff --git a/spp_case_demo/readme/HISTORY.md b/spp_case_demo/readme/HISTORY.md index 4aaf9afef..086bdb38d 100644 --- a/spp_case_demo/readme/HISTORY.md +++ b/spp_case_demo/readme/HISTORY.md @@ -1,3 +1,10 @@ +### 19.0.2.0.1 + +- fix(case): the generator no longer seeds intervention plans that are both + `completed` and their case's current plan. The `close_case` journey step and the + random-plan helper now complete plans through `action_complete()`, so a finished + demo plan gets an `actual_end_date` and releases `is_current`. + ### 19.0.2.0.0 - Initial migration to OpenSPP2 diff --git a/spp_case_demo/static/description/index.html b/spp_case_demo/static/description/index.html index 343bfc4ca..cba308d50 100644 --- a/spp_case_demo/static/description/index.html +++ b/spp_case_demo/static/description/index.html @@ -506,6 +506,16 @@