DEV Community

Doby Baxter
Doby Baxter

Posted on

Enforcing Data Invariants in Odoo: Model Constraints, Migrations, and Tests That Actually Cover the Fix

This post collects a few things I ran into while contributing fixes to OpenSPP, a social-protection platform built on Odoo. They are all variations on one question: when a value has to satisfy a rule, where does that rule need to live so it holds on every path the value can arrive through.

Write paths in Odoo

A stored model field can be written through several paths:

  • The form UI.
  • ORM create() and write() from other code.
  • CSV and Excel import (load()).
  • Remote writes over XML-RPC, the API, and DCI.

These do not share the same hooks. Where a guard is placed determines which of these paths it covers.

The relevant decorators

Three decorators come up, and the difference between them is the whole point:

Decorator Fires when Covers import / RPC / ORM
@api.onchange("field") In the form UI only, as the user edits No
@api.constrains("field") On every create/write touching the field Yes
@api.depends("field") When a computed field recomputes Yes, for stored computes

onchange runs client-side in the form. constrains runs server-side on the stored field. If a rule needs to hold everywhere, it has to be a constrains (or a database constraint), not an onchange.

Example: a future birthdate

res.partner.birthdate had a single guard, an onchange that reset the field if the date was in the future. That guard only runs in the form, so ORM writes, imports, and API writes all stored future dates. The non-stored age compute then rendered those as negative values.

The fix was to add a stored-field @api.constrains("birthdate"). Because the field is stored and writeable, the constraint fires on create, write, and import, so the rule no longer depends on which path the value came through. The onchange can stay as immediate form feedback, but it is no longer the only check.

Invalid state propagates to other fields

A second case shows why the location of the check matters beyond the field itself. A plan model had an is_current flag meaning "the current plan for this case." Completing a plan set its state to completed but never cleared is_current.

Downstream, several things read that pair:

  • A derived current_plan_id (based on is_current) kept pointing at the completed plan.
  • A "has an active plan" check read false.
  • A one-current-plan-per-case constraint then refused to let a new plan be marked current, because the completed plan still held the flag.

The incoherent value was written in one place and surfaced as a failure elsewhere: a user could not mark a new plan as current. The fix folded is_current = False into the same write() that sets the completed state, so every path that completes a plan also releases the flag.

A code fix only covers future writes

A write-time constraint or a corrected write() does not touch rows that are already in the bad state. Existing deployments still held completed plans flagged as current, and those cases stayed blocked after the fix shipped.

That needs a separate data migration. When the manifest version increases, Odoo runs matching migration scripts:

module/
  migrations/
    19.0.2.0.1/
      post-migration.py
Enter fullscreen mode Exit fullscreen mode
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"
    )
Enter fullscreen mode Exit fullscreen mode

Notes on the migration:

  • The signature is always def migrate(cr, version):, and cr is the raw cursor.
  • Keep the SQL literal. No f-strings, .format(), or composed identifiers, or the security linters (Semgrep, pylint-odoo) flag it.
  • The if not version: guard skips fresh installs, where there is nothing to repair.
  • Repairing existing rows and preventing new bad rows are two separate jobs. Both are needed.

Fixture and demo data can re-seed the bad state

The same module's demo data generator was creating the bad state directly (writing state = "completed" without going through the completion path, and passing is_current: True alongside a completed state). A migration does not help here, because a fresh install recreates the rows after the migration would have run.

The fix was to route both generator sites through the real completion action, so generated demo data satisfies the same invariant as production data. Worth checking the code that seeds data, not only the code that validates it.

Tests that pass without the fix

A test can go green for a reason unrelated to the code under test. Odoo applies field defaults before running validation, and _validate_fields fires constraints on the defaulted fields too.

In this model, registration_date defaults to today, and a pre-existing constraint requires registration date to be after birthdate. So creating a record with a future birthdate raised a ValidationError from that older constraint, before the new birthdate constraint ran. The test asserted only the exception type, so it passed with the new constraint removed.

Two ways to make such a test actually cover the fix:

  • Assert on the message, for example assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"), so you know which constraint fired.
  • Or pass explicit values for the defaulted fields so only the constraint under test can raise.

A useful check either way: revert the fix locally and confirm the test fails. If it stays green, it is not covering the change.

It also helps to assert the reported symptom rather than the internal flag. If the bug was "a completed plan blocks marking a new one current," the test should complete the plan and then create a second plan and assert it does not raise, which is the behavior a revert has to break.

Date comparisons and timezone

fields.Date.today() returns the server date, which is effectively UTC. fields.Date.context_today(record) returns the date in the acting user's timezone.

For a "not in the future" check, the server date can reject a valid date for users ahead of UTC. A user in Pacific/Auckland at 10:00 local is already on the next calendar day while the server is still on the previous one, so a same-day birthdate compares as "in the future" against the server date. Using context_today(record) compares against the user's date instead.

Summary

For a rule that must always hold:

  • Enforce it with @api.constrains or a database constraint, on the stored field, not with @api.onchange.
  • Add a migration to repair rows already in the invalid state.
  • Check fixture and demo data so they do not re-seed the invalid state.
  • Write the test to fail without the fix, and assert the reported symptom.
  • Use context_today for date comparisons that depend on the user's calendar day.

Top comments (0)