Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions spp_registry/models/individual.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ def _birthdate_onchange(self):
}
}

@api.constrains("birthdate")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constraint closes the partner-side hole, but spp_change_request_v2 stores a proposed birthdate on its own detail models with no matching guard, so the failure now lands at the worst moment.

spp_change_request_v2/details/create_group.py:369 (birthdate = fields.Date(...)), details/edit_individual.py:54 and wizards/create_group_member_wizard.py:179 all accept a future date; details/create_group.py:426-434 _compute_age even clamps the result to 0, so the UI shows nothing wrong. The value only reaches res.partner at apply time (strategies/add_member.py:35,50 and strategies/create_group.py:321), which is called unguarded from change_request.py:1521 (strategy.apply(sudo_self)).

Concrete scenario: a CR with a future DOB is submitted, reviewed and approved; on the final approve the res.partner.create raises ValidationError, the whole approval transaction rolls back, and the approver sees "Date of birth cannot be in the future." with no indication which CR field caused it. Before this PR the CR applied (badly, but successfully). Worth mirroring the check on the CR detail/wizard models so it is caught at data entry.

def _check_birthdate_not_future(self):
"""Server-side backstop for future dates of birth.

``_birthdate_onchange`` only runs in the form UI, so ORM
``create`` / ``write``, CSV/Excel import, and API writes
(XML-RPC, API v2, DCI) bypass it and a future birthdate persists.
``birthdate`` is a stored, writeable field, so this constraint
fires on every write path and keeps the non-stored ``age``
compute from ever rendering a negative string.
"""
today = fields.Date.today()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fields.Date.today() is the server date (UTC in a normal deployment), not the users date. fields.Date.context_today(record) is the timezone-aware form Odoo uses for "not in the future" checks.

Concrete scenario: a registrar in Pacific/Auckland (UTC+13 during DST) at 10:00 local on 2 January is at 21:00 UTC on 1 January. Recording a newborn born that morning, birthdate = 2026-01-02 compares > the server today of 2026-01-01 and the write is refused with "Date of birth cannot be in the future." — for a date that is unambiguously in the past for that user. Any deployment east of UTC hits this for part of each day.

(The pre-existing _birthdate_onchange has the same flaw, but there it silently resets a field; here it is a hard failure on every write path including imports.)

for record in self:
if record.birthdate and record.birthdate > today:
raise ValidationError(_("Date of birth cannot be in the future."))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message names neither the record nor the offending value, and _validate_fields does not decorate constraint errors with record info (odoo/models.py:1358-1367 just calls check(self)).

Concrete scenario: a 5,000-row CSV import of registrants, one of which has a typod birthdate. The whole batch aborts with the bare string "Date of birth cannot be in the future." and the operator has no way to find the bad row. Including the display name and the value — e.g. _("Date of birth cannot be in the future: %(name)s has %(date)s", name=record.display_name, date=record.birthdate)` — makes this actionable.

Separately, this new translatable string is not in spp_registry/i18n/spp_registry.pot (nor es.po/fr.po), which do carry the sibling onchange message ("You cant select a date of birth greater than today", pot line 1483) — the catalogs need regenerating.


def _recompute_parent_groups(self, records):
field = self.env["res.partner"]._fields["force_recompute_canary"]
# Get the 'head' vocabulary code - this is a unique membership type
Expand Down
4 changes: 4 additions & 0 deletions spp_registry/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### 19.0.2.1.5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version regression: the module is already at 19.0.2.2.2 on the base branch (19.0.2.2.3 on 19.0 now), so a 19.0.2.1.5 heading inserted above 19.0.2.2.2 both breaks the descending order of this file and names a version older than what ships.

Also, spp_registry/__manifest__.py is not bumped at all, so this behaviour change ships with no version increment and Odoo will not run a module upgrade for it — sites that only upgrade on a version change will keep the old code.

Two follow-ups: bump the manifest and use the next version above the current head (e.g. 19.0.2.2.4), and regenerate spp_registry/README.rst — its Changelog section is generated from this file and currently has no entry for this change.


- fix(registry): reject future dates of birth on every write path. `_birthdate_onchange` only guards the form UI, so ORM `create`/`write`, CSV/Excel import, and API writes (XML-RPC, API v2, DCI) could persist a future `birthdate` — which the non-stored `age` compute then rendered as a negative string. A stored-field `@api.constrains("birthdate")` (`_check_birthdate_not_future`) now enforces this server-side; the onchange is kept as the friendlier silent-reset UX in the form (#362)

### 19.0.2.2.2

- fix(registry): let an ID type be used again after its ID was removed. Removing an ID through a change request keeps the row and marks it Invalid, and the old uniqueness rule counted those dead rows — so the registrant was left with an Invalid ID and no way to add a valid one of the same type. Uniqueness now applies to live IDs only, and is refused before the write so the message names the ID type rather than surfacing a database error (#1136)
Expand Down
47 changes: 47 additions & 0 deletions spp_registry/tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,50 @@ def test_duplicate_name_rejected(self):
def test_empty_name_rejected(self):
with self.assertRaises(ValidationError):
self.IDType.create({"name": False})

@tagged("post_install", "-at_install")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only one blank line before the class decorator; PEP 8 / ruff-format want two. .pre-commit-config.yaml:131-136 runs ruff with --exit-non-zero-on-fix plus ruff-format, so the pre-commit CI job will fail on this hunk until the extra blank line is added.

Suggested change
@tagged("post_install", "-at_install")
@tagged("post_install", "-at_install")

class TestBirthdateNotFutureConstraint(RegistryCommon):

def test_future_birthdate_rejected_on_write(self):
"""A future birthdate set via write() raises ValidationError."""
future = date.today() + timedelta(days=1)
with self.assertRaises(ValidationError):
self.individual_a.write({"birthdate": future})

def test_future_birthdate_rejected_on_create(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes on the base branch without the new constraint, so it does not actually cover the create path.

registration_date is fields.Date(default=lambda self: fields.Date.today()) (spp_registry/models/registrant.py:47), and Odoo applies defaults before validating: _create runs records._validate_fields(name for data in data_list for name in data["stored"]), and data["stored"] includes defaulted fields. So _check_registration_date (registrant.py:127-136) fires on this create and raises "Registration date must be later than the birth date." because registration_date (today) < birthdate (tomorrow).

assertRaises(ValidationError) therefore succeeds for the wrong reason — remove _check_birthdate_not_future and the test still passes. Assert on the message, e.g. assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"), or pass an explicit past registration_date so only the new constraint can fire.

"""A future birthdate passed to create() raises ValidationError."""
future = date.today() + timedelta(days=1)
with self.assertRaises(ValidationError):
self.Partner.create(
{
"name": "Time Traveller",
"is_registrant": True,
"is_group": False,
"birthdate": future,
}
)

def test_future_birthdate_rejected_on_import(self):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same vacuity as the create test: load() applies the registration_date default (today), so the pre-existing _check_registration_date raises "Registration date must be later than the birth date." for a tomorrow birthdate. result["messages"] is truthy and result["ids"] is falsy on the base branch too, so this test does not prove the import path is covered by the new constraint.

Assert the message text instead, e.g. self.assertIn("Date of birth cannot be in the future", str(result["messages"])), or include a past registration_date column in the load.

future = date.today() + timedelta(days=1)
result = self.Partner.load(
["name", "is_registrant", "is_group", "birthdate"],
[["Imported Person", "1", "0", str(future)]],
)
self.assertTrue(result["messages"], "expected a constraint message from load()")
self.assertFalse(result["ids"], "the future-birthdate row must not be created")

def test_today_is_allowed(self):
"""birthdate == today is the boundary that must pass."""
self.individual_a.write({"birthdate": date.today()})
self.assertEqual(self.individual_a.birthdate, date.today())

def test_past_birthdate_allowed(self):
"""An ordinary past birthdate writes without error."""
self.individual_a.write({"birthdate": date(1990, 1, 1)})
self.assertEqual(self.individual_a.birthdate, date(1990, 1, 1))

def test_approximate_future_birthdate_rejected(self):
"""An approximate DOB (birthdate_not_exact) still can't be future."""
future = date.today() + timedelta(days=1)
with self.assertRaises(ValidationError):
self.individual_a.write({"birthdate": future, "birthdate_not_exact": True})