OpenSPP Case Management: Completing a Plan Clears the Current Flag

Platform: OpenSPP (OpenSPP2) · Module: spp_case_base · Type: Backend / State-machine correctness · Status: Pull request open, awaiting CI and review · Issue: #458 · PR: #478

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

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.