OpenSPP Case Management: Completing a Plan Clears the Current Flag
Context
In OpenSPP's case management, a case can have a series of
intervention plans over time. Exactly one of them is meant to be
the case's current plan at any moment. That is tracked by a stored
boolean, is_current, on
spp.case.intervention.plan (in
spp_case_base/models/case_intervention_plan.py), with
a constraint, _check_single_current_plan, enforcing
that only one plan per case can carry the flag.
The case model reads that flag directly. Its
current_plan_id is computed as
intervention_plan_ids.filtered(lambda p: p.is_current)[:1],
and a separate has_active_plan derivation looks at
both is_current and the plan's
state.
Problem
A plan moves through a small lifecycle: draft, pending approval, approved, active, and finally completed. The completion action looked like this:
def action_complete(self):
"""Mark plan as completed."""
for plan in self:
plan.write({
"state": "completed",
"actual_end_date": fields.Date.context_today(self),
})
return True
It set the state and stamped an end date, but it never released
is_current. The only method that ever wrote
is_current = False was the revision path. So a plan
that finished normally stayed the case's current plan
indefinitely.
That left two consumers reading an incoherent state at once:
current_plan_id kept pointing at completed work,
while has_active_plan read False for the same case.
And because the single-current constraint still counted the
finished plan, no one could mark a fresh plan as current without
first clearing the stale flag by hand.
Background
The fix direction was not really in question, because the model
already knew how to end a plan's tenure correctly.
action_create_revision writes
is_current = False on the old plan before promoting
the new version. Completion simply forgot to do the same thing.
The reporter of the issue had run into this while building a
downstream case-management module on top of
spp_case_base, and was carrying a local
action_complete override to clear the flag after
super(), marked for deletion once the base fixed it.
My Approach
I folded the missing tear-down into the existing write, so completing a plan ends its tenure the same way a revision does:
def action_complete(self):
"""Mark plan as completed."""
for plan in self:
plan.write({
"state": "completed",
"actual_end_date": fields.Date.context_today(self),
"is_current": False,
})
return True
Keeping it inside the same write() call matters: any
downstream model that overrides write observes the
change, which is exactly what lets the reporter delete their local
patch. After completion, current_plan_id resolves to
an empty recordset, which is the correct reading. A finished plan
is not the case's current plan, and
has_active_plan already excluded completed states, so
the two derivations finally agree.
Tests
I added test_complete_clears_is_current to
spp_case_base/tests/test_case_intervention_plan.py. It
creates a plan, confirms it starts as the case's
current_plan_id, completes it, and then asserts three
things: the state is completed,
is_current is False, and the case now reports no
current plan. The existing lifecycle test stays green unchanged,
since it only checked state and
actual_end_date.
Considerations
-
Same write, not a second one: I deliberately
folded
is_current: Falseinto the existing write rather than adding a separatefiltered("is_current").write(...). One write is simpler, atomic, and guarantees overrides see the full picture. - No new current plan is chosen: completing a plan leaves the case with no current plan, which is intentional. Promoting a successor is a separate decision a case worker makes, not something completion should guess at.
- Blast radius: the change touches one method and affects a single case at a time. Every consumer that keys on "the case's current plan" simply stops seeing finished work.
Fix Process
I traced the flag from the model that owns it out to the two case computes that read it, confirmed the revision path as the reference behavior, then made the one-line change and covered it with a focused test that pins both halves of the bug (the flag and the derived pointer).
Current Status
Pull request
#478
is open against the 19.0 branch and merges cleanly,
awaiting CI and maintainer review. I could not run the full Odoo
test suite on my machine, so I am relying on the project's CI to
execute the spp_case_base tests on the pull request.
Reflection
This is a small change with a tidy lesson behind it. When a state transition owns a flag, the transition is responsible for tearing that flag down, not just for setting the next state. The bug was not a wrong value anywhere; it was a value nobody remembered to clear, which is a very ordinary way for a workflow to drift out of sync with itself.
For a social protection system, "which plan is this case following right now" is a question that feeds real casework. Making sure a completed plan quietly steps aside keeps that answer honest.