From b5eee37513b6521ed77fd505199091b159352eb2 Mon Sep 17 00:00:00 2001 From: LunarCapsule127 Date: Sat, 29 Aug 2026 20:56:40 +0100 Subject: [PATCH 1/5] Update case_intervention_plan.py --- spp_case_base/models/case_intervention_plan.py | 1 + 1 file changed, 1 insertion(+) 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 From 00f30e57791e036646dfe0060dfe85635b299d34 Mon Sep 17 00:00:00 2001 From: LunarCapsule127 Date: Sat, 29 Aug 2026 21:03:28 +0100 Subject: [PATCH 2/5] Update test_case_intervention_plan.py --- .../tests/test_case_intervention_plan.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spp_case_base/tests/test_case_intervention_plan.py b/spp_case_base/tests/test_case_intervention_plan.py index 52dd8db02..2f55457b5 100644 --- a/spp_case_base/tests/test_case_intervention_plan.py +++ b/spp_case_base/tests/test_case_intervention_plan.py @@ -67,6 +67,34 @@ def setUpClass(cls): } ) + def test_complete_clears_is_current(self): + """Completing a plan ends its tenure as the case's current plan.""" + plan = self.env["spp.case.intervention.plan"].create( + { + "name": "Plan To Complete", + "case_id": self.case.id, + "goals": "

Reach self-sufficiency

", + } + ) + self.assertTrue(plan.is_current, "New plan should start as current") + self.assertEqual( + self.case.current_plan_id, + plan, + "New 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", + ) + def test_create_plan(self): """Test creating a plan and verify defaults.""" plan = self.env["spp.case.intervention.plan"].create( From 0fb0f0c9eb92bfcde8d4ad315966a518165bed6c Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Fri, 4 Sep 2026 10:07:28 +0700 Subject: [PATCH 3/5] fix(spp_case_base): repair released databases holding a completed current plan The code fix covers future completions only. Databases released at 19.0.2.0.0 still hold rows with state='completed' AND is_current=true, so current_plan_id keeps pointing at finished work and the one-current-plan constraint keeps refusing a successor plan -- the whole of #458 survives the upgrade for existing deployments. Bump to 19.0.2.0.1 and demote those rows in a post-migration. Only 'completed': action_create_revision already writes is_current=False alongside 'revised', so that state has no stale population. The log names the affected cases, because the remedy is per-case (promote the successor plan) and a bare count cannot be acted on. --- spp_case_base/README.rst | 9 + spp_case_base/__manifest__.py | 2 +- .../migrations/19.0.2.0.1/post-migration.py | 56 ++++++ spp_case_base/readme/HISTORY.md | 7 + spp_case_base/static/description/index.html | 10 ++ spp_case_base/tests/__init__.py | 1 + ...st_migration_complete_clears_is_current.py | 159 ++++++++++++++++++ 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 spp_case_base/migrations/19.0.2.0.1/post-migration.py create mode 100644 spp_case_base/tests/test_migration_complete_clears_is_current.py 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/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 @@

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

  • Initial migration to OpenSPP2
  • diff --git a/spp_case_base/tests/__init__.py b/spp_case_base/tests/__init__.py index b35a4ebf2..490604012 100644 --- a/spp_case_base/tests/__init__.py +++ b/spp_case_base/tests/__init__.py @@ -6,3 +6,4 @@ from . import test_case_security from . import test_compliance_generated from . import test_case_models +from . import test_migration_complete_clears_is_current 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", + ) From 17bc21dd6613b59547cdc7fa031229b05f04071e Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Fri, 4 Sep 2026 10:07:41 +0700 Subject: [PATCH 4/5] test(spp_case_base): complete plans through the real lifecycle, not from draft The is_current tests completed a plan straight out of draft, but the Complete button is invisible unless the plan is active, so they covered a transition no user can reach -- and a future state guard on action_complete would break them rather than the production path. Route them through submit -> approve -> activate first, and assert the released flag in test_plan_approval_workflow too, which already walks that path. Also pin the symptom #458 leads with rather than only the flag: a successor plan can be created and made current once the previous one completes. That assertion fails at the one-current-plan constraint if the fix is reverted, which is the level an accidental revert has to break. --- .../tests/test_case_intervention_plan.py | 121 ++++++++++++++---- 1 file changed, 93 insertions(+), 28 deletions(-) diff --git a/spp_case_base/tests/test_case_intervention_plan.py b/spp_case_base/tests/test_case_intervention_plan.py index 2f55457b5..cb609d87e 100644 --- a/spp_case_base/tests/test_case_intervention_plan.py +++ b/spp_case_base/tests/test_case_intervention_plan.py @@ -67,34 +67,6 @@ def setUpClass(cls): } ) - def test_complete_clears_is_current(self): - """Completing a plan ends its tenure as the case's current plan.""" - plan = self.env["spp.case.intervention.plan"].create( - { - "name": "Plan To Complete", - "case_id": self.case.id, - "goals": "

    Reach self-sufficiency

    ", - } - ) - self.assertTrue(plan.is_current, "New plan should start as current") - self.assertEqual( - self.case.current_plan_id, - plan, - "New 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", - ) - def test_create_plan(self): """Test creating a plan and verify defaults.""" plan = self.env["spp.case.intervention.plan"].create( @@ -205,6 +177,99 @@ def test_plan_approval_workflow(self): plan.action_complete() self.assertEqual(plan.state, "completed", "Plan should be completed after completion action") self.assertTrue(plan.actual_end_date, "Completion date should be recorded") + 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") + + def _active_plan(self, name): + """Return a plan taken through the approval cycle to ``active``. + + The Complete button is only offered on an active plan + (``views/case_intervention_views.xml``), so the completion tests below + drive the same path rather than completing a draft. + """ + plan = self.env["spp.case.intervention.plan"].create( + { + "name": name, + "case_id": self.case.id, + "goals": "

    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.""" From 2fcb709be8b3bae0f1748725e5a5dd4e5695c184 Mon Sep 17 00:00:00 2001 From: Ken Lewerentz Date: Fri, 4 Sep 2026 10:19:45 +0700 Subject: [PATCH 5/5] fix(spp_case_demo): stop seeding finished plans as the case's current plan Two generator sites manufactured the state #458 is about. The close_case journey step wrote {"state": "completed"} directly, and _add_random_plan passed "completed" to create() alongside is_current=True -- so every generated demo database held plans that were finished yet still their case's current plan, with no actual_end_date and no way to start a successor plan. Both now complete through action_complete(), which stamps the end date and releases the flag. _add_random_plan keeps the same distribution of final states and adds its interventions before completing, so a completed demo plan still has a delivery record. No migration here: spp_case_base's 19.0.2.0.1 post-migration repairs the rows these sites already seeded, whichever module created them. --- spp_case_demo/README.rst | 9 +++ spp_case_demo/__manifest__.py | 2 +- spp_case_demo/models/generate_cases.py | 15 ++++- spp_case_demo/readme/HISTORY.md | 7 +++ spp_case_demo/static/description/index.html | 10 ++++ spp_case_demo/tests/test_generate_cases.py | 65 +++++++++++++++++++++ 6 files changed, 105 insertions(+), 3 deletions(-) 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 @@

    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

  • Initial migration to OpenSPP2
  • diff --git a/spp_case_demo/tests/test_generate_cases.py b/spp_case_demo/tests/test_generate_cases.py index 37a19065e..48aa739b6 100644 --- a/spp_case_demo/tests/test_generate_cases.py +++ b/spp_case_demo/tests/test_generate_cases.py @@ -2,6 +2,7 @@ """Tests for spp.case.demo.generator model.""" +import random from unittest.mock import patch from odoo.exceptions import UserError @@ -874,6 +875,42 @@ def test_journey_close_case_also_completes_active_plan(self): if plan: self.assertEqual(plan.state, "completed") + def test_journey_close_case_releases_the_current_plan(self): + """close_case must not leave a finished plan as the case's current plan. + + The step used to write ``state`` directly, so the generated plan kept + ``is_current`` and had no ``actual_end_date`` -- the incoherent pair + spp_case_base #458 is about, seeded into every demo database. + """ + fake = self._fake() + case_type = self.env["spp.case.type"].search([("name", "=", "General Support")], limit=1) + stage_intake = self.env["spp.case.stage"].search([("phase", "=", "intake")], limit=1) + test_case = ( + self.env["spp.case"] + .sudo() + .create( + { + "case_type_id": case_type.id, + "stage_id": stage_intake.id, + "partner_id": self.client.id, + "presenting_issue": "Plan currency test", + "case_worker_id": self.env.user.id, + } + ) + ) + journey = [ + {"action": "create_plan", "days_back": 40, "plan_name": "Plan to Release"}, + {"action": "close_case", "days_back": 5}, + ] + self.gen._process_case_journey(test_case, journey, fake) + + plan = self.env["spp.case.intervention.plan"].search([("case_id", "=", test_case.id)], limit=1) + self.assertTrue(plan, "create_plan should have produced a plan to close") + self.assertEqual(plan.state, "completed", "close_case should complete the plan") + self.assertTrue(plan.actual_end_date, "Completing through the action should stamp actual_end_date") + self.assertFalse(plan.is_current, "A completed plan is not the case's current plan") + self.assertFalse(test_case.current_plan_id, "The closed case should report no current plan") + def test_journey_intake_action_is_ignored_gracefully(self): """intake action has no handler; it should be skipped without error.""" fake = self._fake() @@ -1072,3 +1109,31 @@ def test_close_random_case_does_nothing_without_closure_stage(self): self.gen._close_random_case(self.test_case, fake, intake_date) except Exception as exc: self.fail(f"_close_random_case raised unexpectedly: {exc}") + + def test_add_random_plan_completed_plan_is_not_current(self): + """A demo plan that lands on "completed" must not stay the current plan. + + ``_add_random_plan`` used to pass ``state="completed"`` straight to + ``create()`` alongside ``is_current=True``, seeding the exact state + spp_case_base #458 reports. The state is forced here rather than relied + on: the generator picks it at random. + """ + from odoo import fields + + real_choice = random.choice + + def always_completed(seq): + """Force the plan-state draw only; leave every other draw random.""" + if list(seq) == ["draft", "active", "completed"]: + return "completed" + return real_choice(seq) + + with patch.object(random, "choice", side_effect=always_completed): + self.gen._add_random_plan(self.test_case, self._fake(), fields.Date.today()) + + plan = self.env["spp.case.intervention.plan"].search([("case_id", "=", self.test_case.id)], limit=1) + self.assertTrue(plan, "_add_random_plan should have produced a plan") + self.assertEqual(plan.state, "completed", "Test premise: the forced draw lands on completed") + self.assertTrue(plan.actual_end_date, "Completing through the action should stamp actual_end_date") + self.assertFalse(plan.is_current, "A completed plan is not the case's current plan") + self.assertTrue(plan.intervention_ids, "Interventions should be added before the plan completes")