OpenSPP Registry: Reject Future Birthdates
Context
OpenSPP stores a beneficiary’s date of birth on
res.partner.birthdate, defined in
spp_registry/models/individual.py. A non-stored
computed field, age, derives years of age from that
date using relativedelta(now, dob).
In the web form, a small guard called
_birthdate_onchange already reacts when someone
types a future date: it clears the field and shows a friendly
warning.
Problem
That onchange guard only runs in the form UI. Every other write path skips it entirely, so a birthdate in the future is accepted through:
-
direct ORM calls —
partner.write({"birthdate": date(2030, 1, 1)}) create()values- CSV / Excel import
- API calls (XML-RPC, API v2, DCI endpoints)
Once a future birthdate is stored, the computed
age goes negative and renders as a string such as
"-3" in views, exports, and anywhere else that reads
the field — a confusing and invalid value for downstream program
logic.
Background
This is pre-existing behavior that was explicitly flagged as out
of scope in PR
#357. That PR removed a dead @api.constrains("age")
guard which looked like it covered this case but never actually
ran — constrains hooks do not fire on non-stored computed fields,
so the removal was behavior-preserving.
No @api.constrains on birthdate existed
anywhere in the repository. The only related check is
registration_date >= birthdate in
spp_registry/models/registrant.py, which does not
reject a future date of birth on its own.
My Approach
The fix is a stored-field constraint that fires on every create/write path, giving a reliable server-side backstop while the friendly form onchange stays in place for a gentle in-UI experience. This is the version that shipped:
@api.constrains("birthdate")
def _check_birthdate_not_future(self):
for record in self:
if record.birthdate and record.birthdate > fields.Date.context_today(record):
raise ValidationError(
_(
"Date of birth cannot be in the future: %(name)s has %(date)s.",
name=record.display_name,
date=record.birthdate,
)
)
Because birthdate is a stored, writeable field, the
constraint runs on create, write, import, and API paths alike —
and, unlike the removed age guard, produces no
registry-load warning. I kept it consistent with the existing
_check_registration_date constraint already living in
registrant.py.
Two details in that snippet came out of review and are worth
naming. The comparison is against
fields.Date.context_today(record), the user’s own
today, rather than the server’s UTC date: a registrar east of UTC
is already on tomorrow’s date for part of each day, and a birth
recorded that morning is not a future date for them. And the
message names the record and the offending value, so a single
mistyped row in a five-thousand-row import can be found — a bare
string would abort the batch with nothing to search on.
Guarding the change-request path
Closing the registry hole moved the failure somewhere worse. A
proposed date of birth is also stored on the change-request
models — Add Member, Edit Individual, the Create Group member
lines and the Create Group member wizard — and none of them had a
guard of their own. A future date survived submission and review,
and was only refused when a strategy wrote it to
res.partner on the final approval, rolling back the
whole approval with an error the approver could not trace back to
a field.
A shared spp.cr.birthdate.mixin now applies the same
rule at data entry, using the same comparison as the registry
constraint so the two cannot disagree and let a value through one
to be refused by the other at apply time.
Tests
Following the project’s test-first workflow, I added a
TestBirthdateNotFutureConstraint class in
spp_registry/tests/test_constraints.py. It exercises
the write paths that skip the form onchange —
create, write, and
load() (the ORM path CSV/Excel import uses under the
hood) — plus the boundary cases that must still pass: a birthdate
of today, and an ordinary past date. A final test asserts that an
approximate date of birth still can’t be set in the future.
The create and import tests needed more care than they first
appeared to. registration_date defaults to today, and
_check_registration_date refuses any registration
date earlier than the birthdate — which is true of every date once
the birthdate is in the future. Both tests therefore passed on the
base branch without the new constraint existing at all, because a
neighbouring constraint was raising instead. They now pass
registration_date explicitly empty, which
short-circuits that check, and assert on the message text, so
removing the constraint fails them. Their dates derive from
context_today so they cannot go flaky on a runner
whose local date differs from UTC.
Two further suites cover the widened scope:
test_birthdate_mixin.py for the shared change-request
guard, and
test_edit_individual_prefill_skips_future_birthdate
for the prefill regression described below — it plants the bad
date with raw SQL, since the ORM now refuses it.
Considerations
-
Existing bad data: a constraint only validates
on write, so records already holding a future birthdate stay as
they are until something writes that field. It ships as an
upgrade note in
HISTORY.mdcarrying a query to find them. I offered a migration that clears the values instead; the maintainer preferred the note, on the grounds that a 2031-for-2013 typo is recoverable by a human while an emptied field is not. -
Approximate birthdates: I assumed the
birthdate_not_exactflag should still not allow a future date, and added a test asserting that — while flagging it in the pull request as a product decision for the maintainer to confirm rather than deciding it silently. Both reviewers agreed; the test stays. - Keep the onchange: the silent-reset UX in the form is friendlier than a raised error, so it stays as the first line of defense with the constraint behind it.
Fix Process
I followed the standard OpenSPP contribution flow so the change landed cleanly:
- wrote failing tests first (TDD), then added the constraint
- covered the create, write, and import paths in the test suite
-
version bump plus a
readme/HISTORY.mdfragment for each module touched - regenerated the module READMEs and static descriptions from those fragments, and the translation catalogues for the new strings
Review
The pull request went through three rounds, and the review is where most of the work happened.
Round one raised seven points. Two of my tests
passed on the base branch without the constraint existing, for the
registration_date reason above. The changelog
fragment named a version below what already shipped, and the
manifest was not bumped at all. The constraint compared against
the server’s UTC date. The error message identified neither the
record nor the value. The new translatable string was missing from
the catalogues. And the largest of them: the change-request models
store their own proposed birthdate, so the failure would land at
final approval rather than at data entry — the point that led to
the shared mixin.
Round two requested changes on a regression the new guard introduced. The change-request detail prefills a registrant’s existing birthdate with a plain write, so an Edit Individual request could no longer be created for a registrant already holding a future date — closing the very path field staff would use to correct one. I fixed that in the prefill mapping rather than at the write, so the onchange path that consumes the same mapping is covered too, and moved the rule onto the mixin so what prefill declines to offer and what the constraint refuses cannot drift apart.
The demo generator in the same round needed more than the one-line
change suggested. fields.Date.context_today(self)
cannot go where the old call sat:
STORY_CHANGE_REQUESTS is a class attribute, so there
is no self in scope, and the
fields.Date.today() literal it carried was evaluated
once at import and then frozen — stale as well as UTC-bound. The
definition now declares birthdate_days_back and
_build_detail_changes resolves it against
fields.Date.context_today at build time, which closes
both faults at once.
Round three approved. Both reviewers ran the branch rather than reading the diff — the approving maintainer neutralised my prefill fix to confirm the regression test actually fails without it, and checked the paths the test does not cover.
Outcome
PR
#397
merged into 19.0 on 16 September 2026, squashed as
1a3c5919 — 28 files, +540/−41, all 35 checks green.
It ships as spp_registry 19.0.2.2.4,
spp_change_request_v2 19.0.3.1.16 and
spp_mis_demo_v2 19.0.2.1.5, and closes issue
#362.
Two non-blocking findings are filed as
#520
— the sibling registration_date default and the form
onchange still compare against the server date, so the
east-of-UTC case the constraint now handles is still refused on
create — and
#521, where manual: true is not a valid pre-commit hook
key, so the README generators run in the default stage and every
changelog-fragment PR fails pre-commit until the generated files
are committed.
One thread stays open by agreement. The 19.0.3.1.16 changelog line says a valid date has to be entered once prefill stops offering a refused one, which is stronger than the code enforces: birthdate is mapped for apply like any other field and empty values are applied deliberately, so a request approved without one clears the registrant’s impossible date rather than carrying it forward. The maintainer raised it as non-blocking, and the wording gets corrected in the next change to that module rather than re-spinning the generated READMEs for prose alone.
Reflection
I like this as a first contribution because it’s small in code but careful in reasoning: the interesting part isn’t the constraint itself, it’s understanding why the old guard never fired, where every write path enters the model, and how to protect existing data without breaking unrelated saves.
The review taught me more than the fix did. Three rounds took this from six lines in one module to a guard that holds at data entry across three, with tests that fail when the constraint is removed rather than passing for a neighbouring reason. Closing one hole opened another twice over — at approval time, then at prefill — and both were only visible to someone running the branch.
For a social protection platform, a clean date of birth is not a cosmetic detail — it feeds eligibility and program logic, so getting the validation right genuinely matters for the people the system serves.