diff --git a/spp_change_request_v2/README.rst b/spp_change_request_v2/README.rst index 59e61398f..297815919 100644 --- a/spp_change_request_v2/README.rst +++ b/spp_change_request_v2/README.rst @@ -853,6 +853,40 @@ Before declaring a new CR type complete: Changelog ========= +19.0.3.1.16 +~~~~~~~~~~~ + +- fix(change_request): an Edit Individual change request can be opened + again on a registrant that already holds a future date of birth. + ``spp.change.request.create`` prefills the detail from the registrant + and that prefill is a write, so the guard added in 19.0.3.1.15 refused + the copied value and the request could not be created at all — closing + the very path field staff use to correct the date. A birthdate the + guard would refuse is now dropped from the prefill mapping rather than + offered, so the field arrives empty and a valid date has to be + entered. The rule itself lives in one place (``_is_future_birthdate`` + on the mixin), so what prefill declines to offer and what the + constraint refuses cannot drift apart. +- fix(change_request): the guard's message names the person when the + record carries one, matching the registry constraint's wording. A + Create Group member line reported only the date, which did not say + which of several lines to fix. + +19.0.3.1.15 +~~~~~~~~~~~ + +- fix(change_request): refuse a date of birth in the future while the + change request is being filled in, rather than at apply time. The Add + Member, Edit Individual and Create Group detail models and the Create + Group member wizard each store a proposed ``birthdate`` with no guard + of their own, so 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 registry's rule at data entry, comparing against the + user's own today so a registrar east of UTC is not refused a birth + recorded earlier that local day (#362) + 19.0.3.1.14 ~~~~~~~~~~~ diff --git a/spp_change_request_v2/__manifest__.py b/spp_change_request_v2/__manifest__.py index a6484a53b..b09a537a5 100644 --- a/spp_change_request_v2/__manifest__.py +++ b/spp_change_request_v2/__manifest__.py @@ -1,6 +1,6 @@ { "name": "OpenSPP Change Request V2", - "version": "19.0.3.1.14", + "version": "19.0.3.1.16", "sequence": 50, "category": "OpenSPP", "summary": "Configuration-driven change request system with UX improvements, conflict detection and duplicate prevention", diff --git a/spp_change_request_v2/details/add_member.py b/spp_change_request_v2/details/add_member.py index 5c41c39f8..dd19956b8 100644 --- a/spp_change_request_v2/details/add_member.py +++ b/spp_change_request_v2/details/add_member.py @@ -6,7 +6,7 @@ class SPPCRDetailAddMember(models.Model): _name = "spp.cr.detail.add_member" _description = "CR Detail: Add Group Member" - _inherit = ["spp.cr.detail.base", "mail.thread"] + _inherit = ["spp.cr.detail.base", "mail.thread", "spp.cr.birthdate.mixin"] # ══════════════════════════════════════════════════════════════════════════ # MEMBER INFORMATION - Real Odoo fields with full features diff --git a/spp_change_request_v2/details/create_group.py b/spp_change_request_v2/details/create_group.py index 9065b2094..9ed29b9a6 100644 --- a/spp_change_request_v2/details/create_group.py +++ b/spp_change_request_v2/details/create_group.py @@ -346,6 +346,7 @@ class SPPCRDetailCreateGroupMemberNew(models.Model): _name = "spp.cr.detail.create_group.member_new" _description = "CR Detail: Create Group — New Member" + _inherit = ["spp.cr.birthdate.mixin"] detail_id = fields.Many2one( "spp.cr.detail.create_group", diff --git a/spp_change_request_v2/details/edit_individual.py b/spp_change_request_v2/details/edit_individual.py index f66668ea9..b04c86e73 100644 --- a/spp_change_request_v2/details/edit_individual.py +++ b/spp_change_request_v2/details/edit_individual.py @@ -6,7 +6,7 @@ class SPPCRDetailEditIndividual(models.Model): _name = "spp.cr.detail.edit_individual" _description = "CR Detail: Edit Individual" - _inherit = ["spp.cr.detail.base", "mail.thread"] + _inherit = ["spp.cr.detail.base", "mail.thread", "spp.cr.birthdate.mixin"] # ══════════════════════════════════════════════════════════════════════════ # PERSONAL INFORMATION @@ -45,10 +45,13 @@ class SPPCRDetailEditIndividual(models.Model): def _get_prefill_mapping(self): """Return the field mapping for pre-filling from registrant. + The mapping is per-record: a birthdate the guard would refuse is + dropped rather than offered. + Returns: dict: Mapping of detail field names to registrant field names """ - return { + mapping = { "given_name": "given_name", "family_name": "family_name", "birthdate": "birthdate", @@ -60,6 +63,15 @@ def _get_prefill_mapping(self): "city": "city", "postal_code": "zip", } + # A registrant saved before the future-birthdate guard existed can + # still hold one, and prefilling is a write: copying it back raises + # the mixin's constraint while the change request is being created, + # so the request could not be opened at all — and an Edit Individual + # request is how field staff correct the date. Leave the field empty + # instead, so a valid one has to be entered. + if self._is_future_birthdate(self.registrant_id.birthdate): + del mapping["birthdate"] + return mapping # ══════════════════════════════════════════════════════════════════════════ # ONCHANGE - Pre-fill from registrant diff --git a/spp_change_request_v2/i18n/fr.po b/spp_change_request_v2/i18n/fr.po index 4604d033b..798d8de2b 100644 --- a/spp_change_request_v2/i18n/fr.po +++ b/spp_change_request_v2/i18n/fr.po @@ -1419,6 +1419,11 @@ msgstr "Demande de modification Appliquer Mapping" msgid "Change Request Audit" msgstr "Demande de modification Audit" +#. module: spp_change_request_v2 +#: model:ir.model,name:spp_change_request_v2.model_spp_cr_birthdate_mixin +msgid "Change Request Birthdate Guard Mixin" +msgstr "" + #. module: spp_change_request_v2 #: model:spp.event.type,name:spp_change_request_v2.event_type_cr_conflict msgid "Change Request Conflict" @@ -2163,6 +2168,18 @@ msgstr "Date of Birth" msgid "Date of Death" msgstr "Date of Death" +#. module: spp_change_request_v2 +#. odoo-python +#: code:addons/spp_change_request_v2/models/birthdate_mixin.py:0 +msgid "Date of birth cannot be in the future: %(date)s." +msgstr "" + +#. module: spp_change_request_v2 +#. odoo-python +#: code:addons/spp_change_request_v2/models/birthdate_mixin.py:0 +msgid "Date of birth cannot be in the future: %(name)s has %(date)s." +msgstr "" + #. module: spp_change_request_v2 #: model:ir.model.fields,field_description:spp_change_request_v2.field_spp_cr_detail_merge_registrants__deactivate_duplicates msgid "Deactivate Duplicates" diff --git a/spp_change_request_v2/i18n/spp_change_request_v2.pot b/spp_change_request_v2/i18n/spp_change_request_v2.pot index 9e81ce3fb..623e67619 100644 --- a/spp_change_request_v2/i18n/spp_change_request_v2.pot +++ b/spp_change_request_v2/i18n/spp_change_request_v2.pot @@ -1315,6 +1315,11 @@ msgstr "" msgid "Change Request Audit" msgstr "" +#. module: spp_change_request_v2 +#: model:ir.model,name:spp_change_request_v2.model_spp_cr_birthdate_mixin +msgid "Change Request Birthdate Guard Mixin" +msgstr "" + #. module: spp_change_request_v2 #: model:spp.event.type,name:spp_change_request_v2.event_type_cr_conflict msgid "Change Request Conflict" @@ -2044,6 +2049,18 @@ msgstr "" msgid "Date of Death" msgstr "" +#. module: spp_change_request_v2 +#. odoo-python +#: code:addons/spp_change_request_v2/models/birthdate_mixin.py:0 +msgid "Date of birth cannot be in the future: %(date)s." +msgstr "" + +#. module: spp_change_request_v2 +#. odoo-python +#: code:addons/spp_change_request_v2/models/birthdate_mixin.py:0 +msgid "Date of birth cannot be in the future: %(name)s has %(date)s." +msgstr "" + #. module: spp_change_request_v2 #: model:ir.model.fields,field_description:spp_change_request_v2.field_spp_cr_detail_merge_registrants__deactivate_duplicates msgid "Deactivate Duplicates" diff --git a/spp_change_request_v2/models/__init__.py b/spp_change_request_v2/models/__init__.py index 934504e51..d2e12e191 100644 --- a/spp_change_request_v2/models/__init__.py +++ b/spp_change_request_v2/models/__init__.py @@ -1,5 +1,6 @@ from . import conflict_rule from . import conflict_mixin +from . import birthdate_mixin from . import change_request_type from . import change_request_type_mapping from . import change_request diff --git a/spp_change_request_v2/models/birthdate_mixin.py b/spp_change_request_v2/models/birthdate_mixin.py new file mode 100644 index 000000000..501c30f69 --- /dev/null +++ b/spp_change_request_v2/models/birthdate_mixin.py @@ -0,0 +1,60 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""Shared guard against a proposed date of birth in the future. + +``res.partner`` refuses a future ``birthdate`` on every write path +(``spp_registry/models/individual.py::_check_birthdate_not_future``), but a +change request stores its proposed birthdate on its own detail and wizard +models first. Without the same guard there the value is only refused when a +strategy writes it to ``res.partner`` at apply time — which rolls back the +whole approval and reports an error the approver cannot trace back to a +field. Mixing this in catches it at data entry instead. + +Inheriting models must define a ``birthdate`` date field; the mixin +deliberately does not, so it stays a pure behaviour mixin with no schema +footprint of its own. +""" + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class SPPCRBirthdateMixin(models.AbstractModel): + _name = "spp.cr.birthdate.mixin" + _description = "Change Request Birthdate Guard Mixin" + + def _is_future_birthdate(self, birthdate): + """Return whether ``birthdate`` is later than this user's today. + + Shared with the callers that must avoid *offering* a value the + constraint would refuse — prefilling a change request from a + registrant that predates the guard — so the two cannot disagree. + """ + return bool(birthdate) and birthdate > fields.Date.context_today(self) + + def _birthdate_record_name(self): + """Return the person's name for the error message, when there is one. + + The inheriting models are all half-filled forms: a new group member + line has no database identity, so ``display_name`` would name the + model rather than the person. The given/family names all four of them + carry are what tells the user which line to fix. + """ + self.ensure_one() + parts = [self[field] for field in ("given_name", "family_name") if field in self._fields and self[field]] + return " ".join(parts) + + @api.constrains("birthdate") + def _check_birthdate_not_future(self): + for record in self: + if not record._is_future_birthdate(record.birthdate): + continue + name = record._birthdate_record_name() + if name: + raise ValidationError( + _( + "Date of birth cannot be in the future: %(name)s has %(date)s.", + name=name, + date=record.birthdate, + ) + ) + raise ValidationError(_("Date of birth cannot be in the future: %(date)s.", date=record.birthdate)) diff --git a/spp_change_request_v2/readme/HISTORY.md b/spp_change_request_v2/readme/HISTORY.md index 5cf9dd00a..4b8652f35 100644 --- a/spp_change_request_v2/readme/HISTORY.md +++ b/spp_change_request_v2/readme/HISTORY.md @@ -1,3 +1,12 @@ +### 19.0.3.1.16 + +- fix(change_request): an Edit Individual change request can be opened again on a registrant that already holds a future date of birth. `spp.change.request.create` prefills the detail from the registrant and that prefill is a write, so the guard added in 19.0.3.1.15 refused the copied value and the request could not be created at all — closing the very path field staff use to correct the date. A birthdate the guard would refuse is now dropped from the prefill mapping rather than offered, so the field arrives empty and a valid date has to be entered. The rule itself lives in one place (`_is_future_birthdate` on the mixin), so what prefill declines to offer and what the constraint refuses cannot drift apart. +- fix(change_request): the guard's message names the person when the record carries one, matching the registry constraint's wording. A Create Group member line reported only the date, which did not say which of several lines to fix. + +### 19.0.3.1.15 + +- fix(change_request): refuse a date of birth in the future while the change request is being filled in, rather than at apply time. The Add Member, Edit Individual and Create Group detail models and the Create Group member wizard each store a proposed `birthdate` with no guard of their own, so 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 registry's rule at data entry, comparing against the user's own today so a registrar east of UTC is not refused a birth recorded earlier that local day (#362) + ### 19.0.3.1.14 - fix(change_request): group-scope conflict rules work again. `_get_group_member_ids` traversed `spp.group.membership` records through `individual_id` and `group_id`, but that model names its many2ones `individual` and `group` — so resolving a household's members raised `KeyError`/`AttributeError` instead of returning them. A change request whose type carried an active group-scope conflict rule crashed on creation for any group registrant, and for any individual registrant with a live membership — exactly the registrants the rule exists to check. The one existing test called the method with a member-less individual, the single shape that happened to work; group-scope detection is now tested with real memberships in both directions, including that ended memberships are excluded. diff --git a/spp_change_request_v2/static/description/index.html b/spp_change_request_v2/static/description/index.html index cd7c7753b..90a552098 100644 --- a/spp_change_request_v2/static/description/index.html +++ b/spp_change_request_v2/static/description/index.html @@ -1339,6 +1339,42 @@

Changelog

+

19.0.3.1.16

+ +
+
+

19.0.3.1.15

+ +
+

19.0.3.1.14

-
+

19.0.3.1.13

-
+

19.0.3.1.12

  • fix(change_request): auto-apply-on-approve runs through the public @@ -1387,7 +1423,7 @@

    19.0.3.1.12

    the applying user is still recorded as the approver.
-
+

19.0.3.1.11

  • fix(change_request): field-mapping transform expressions are evaluated @@ -1426,7 +1462,7 @@

    19.0.3.1.11

    the full traceback is logged only at DEBUG.
-
+

19.0.3.1.10

  • fix(security): conflict and duplicate detection now decide whether a @@ -1466,7 +1502,7 @@

    19.0.3.1.10

    configured mapping.
-
+

19.0.3.1.9

  • fix(security): duplicate detection now scores the fields both change @@ -1483,7 +1519,7 @@

    19.0.3.1.9

    requester-writable selected_field_name / field_to_modify.
-
+

19.0.3.1.8

  • fix(security): scope the Create-Group member wizards to the parent @@ -1501,7 +1537,7 @@

    19.0.3.1.8

    access-control entry grants.
-
+

19.0.3.1.7

  • fix(security): require change-request manager rights to apply a change @@ -1516,7 +1552,7 @@

    19.0.3.1.7

    endpoint.
-
+

19.0.3.1.6

  • fix(security): derive conflict and duplicate detection from the change @@ -1530,7 +1566,7 @@

    19.0.3.1.6

    an empty one, so detection cannot silently disable itself.
-
+

19.0.3.1.5

  • fix(security): scope the CR Requestor, Local Validator and HQ @@ -1542,7 +1578,7 @@

    19.0.3.1.5

    are noupdate.
-
+

19.0.3.1.4

  • fix(security): add ownership and area record rules to every concrete @@ -1559,7 +1595,7 @@

    19.0.3.1.4

    unrestricted delete their access-control entries grant.
-
+

19.0.3.1.3

  • fix(security): route and apply the same single field for @@ -1572,7 +1608,7 @@

    19.0.3.1.3

    the routing selector.
-
+

19.0.3.1.2

  • fix(change_request_v2): adding an ID now looks for a live one of that @@ -1581,7 +1617,7 @@

    19.0.3.1.2

    (#1136)
-
+

19.0.3.1.1

  • fix(change_request): enforce the (cr_type_id, reason) uniqueness @@ -1595,7 +1631,7 @@

    19.0.3.1.1

    applied) so the constraint applies cleanly on upgrade.
-
+

19.0.3.1.0

  • revert(change_request): restore the create-a-new-individual Add @@ -1613,7 +1649,7 @@

    19.0.3.1.0

    not restored here; reinstate separately if needed.
-
+

19.0.3.0.0

  • feat(change_request): redesign the group/membership CR flows (#242) — @@ -1635,7 +1671,7 @@

    19.0.3.0.0

    must adapt (see #1133).
-
+

19.0.2.0.8

  • fix(views): disable inline creation of CR document types on the Change @@ -1646,7 +1682,7 @@

    19.0.2.0.8

    Documents” modal (missing Name field) that blocked saving (#1125)
-
+

19.0.2.0.7

  • fix(security): align CR Requestor / CR Local Validator / CR HQ @@ -1658,7 +1694,7 @@

    19.0.2.0.7

    dependencies.
-
+

19.0.2.0.6

  • fix(views): route post-submit CRs (pending / approved / applied / @@ -1673,7 +1709,7 @@

    19.0.2.0.6

    list so row-click goes through the stage router.
-
+

19.0.2.0.5

  • fix(security): add a global ir.rule on spp.change.request that @@ -1686,27 +1722,27 @@

    19.0.2.0.5

    roles).
-
+

19.0.2.0.3

  • fix: add HTML escaping to all computed Html fields with sanitize=False to prevent stored XSS (#50)
-
+

19.0.2.0.2

  • fix: fix batch approval wizard line deletion (#130)
-
+

19.0.2.0.1

  • fix: skip field types before getattr and isolate detail prefetch (#129)
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_change_request_v2/tests/__init__.py b/spp_change_request_v2/tests/__init__.py index 9d1cb083c..0a0e4a967 100644 --- a/spp_change_request_v2/tests/__init__.py +++ b/spp_change_request_v2/tests/__init__.py @@ -37,3 +37,4 @@ from . import test_detection_matches_apply from . import test_field_mapping_transform from . import test_routing_field +from . import test_birthdate_mixin diff --git a/spp_change_request_v2/tests/test_birthdate_mixin.py b/spp_change_request_v2/tests/test_birthdate_mixin.py new file mode 100644 index 000000000..89b4d8727 --- /dev/null +++ b/spp_change_request_v2/tests/test_birthdate_mixin.py @@ -0,0 +1,112 @@ +# Part of OpenSPP. See LICENSE file for full copyright and licensing details. +"""The shared birthdate guard on change request detail models. + +``spp.cr.birthdate.mixin`` mirrors the registry's +``_check_birthdate_not_future`` so a future date of birth is refused while +the change request is being filled in, rather than at apply time — where it +would roll back the whole approval and report an error the approver cannot +trace back to a field. +""" + +from datetime import timedelta + +from odoo import fields +from odoo.exceptions import ValidationError +from odoo.tests import TransactionCase, tagged + +from .common import get_or_create_cr_type + + +@tagged("post_install", "-at_install") +class TestBirthdateMixin(TransactionCase): + """spp_change_request_v2/models/birthdate_mixin.py""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.Partner = cls.env["res.partner"] + cls.CR = cls.env["spp.change.request"] + cls.individual = cls.Partner.create({"name": "Mixin Subject", "is_registrant": True, "is_group": False}) + cls.group = cls.Partner.create({"name": "Mixin Household", "is_registrant": True, "is_group": True}) + + def setUp(self): + super().setUp() + # Mirror the constraint, which compares against the user's today. + self.today = fields.Date.context_today(self.individual) + self.future = self.today + timedelta(days=1) + + def _detail(self, code, registrant): + cr = self.CR.create( + { + "request_type_id": get_or_create_cr_type(self.env, code).id, + "registrant_id": registrant.id, + } + ) + return cr.get_detail() + + def test_edit_individual_future_birthdate_rejected(self): + """The guard fires at data entry, not at apply time.""" + detail = self._detail("edit_individual", self.individual) + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + detail.write({"birthdate": self.future}) + + def test_edit_individual_today_allowed(self): + """birthdate == today is the boundary that must pass.""" + detail = self._detail("edit_individual", self.individual) + detail.write({"birthdate": self.today}) + self.assertEqual(detail.birthdate, self.today) + + def test_add_member_future_birthdate_rejected(self): + detail = self._detail("add_member", self.group) + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + detail.write({"birthdate": self.future}) + + def test_edit_individual_prefill_skips_future_birthdate(self): + """A change request can still be opened on a registrant holding one. + + ``spp.change.request.create`` prefills the detail from the registrant, + and that prefill is a write: offering the birthdate back would raise + the mixin's constraint while the request is being created, so the + request could not be opened at all — closing the very path field staff + use to correct the date. + """ + subject = self.Partner.create( + { + "name": "Legacy Record", + "given_name": "Legacy", + "is_registrant": True, + "is_group": False, + } + ) + + self.env.cr.execute( + "UPDATE res_partner SET birthdate = %s WHERE id = %s", + (self.future, subject.id), + ) + subject.invalidate_recordset(["birthdate"]) + self.assertEqual(subject.birthdate, self.future) + + detail = self._detail("edit_individual", subject) + + self.assertFalse(detail.birthdate) + self.assertEqual(detail.given_name, "Legacy") + + def test_create_group_new_member_future_birthdate_rejected(self): + """The sub-model reached through the wizard is guarded too.""" + detail = self._detail("create_group", self.group) + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + detail.write( + { + "member_new_ids": [ + ( + 0, + 0, + { + "given_name": "Ada", + "family_name": "Lovelace", + "birthdate": self.future, + }, + ) + ] + } + ) diff --git a/spp_change_request_v2/wizards/create_group_member_wizard.py b/spp_change_request_v2/wizards/create_group_member_wizard.py index d17055554..64cbeda5a 100644 --- a/spp_change_request_v2/wizards/create_group_member_wizard.py +++ b/spp_change_request_v2/wizards/create_group_member_wizard.py @@ -18,6 +18,7 @@ class SPPCRCreateGroupMemberWizard(models.TransientModel): _name = "spp.cr.detail.create_group.member.wizard" _description = "Create Group — Add Member Wizard" + _inherit = ["spp.cr.birthdate.mixin"] detail_id = fields.Many2one( "spp.cr.detail.create_group", diff --git a/spp_mis_demo_v2/README.rst b/spp_mis_demo_v2/README.rst index f44d84104..f03d51c67 100644 --- a/spp_mis_demo_v2/README.rst +++ b/spp_mis_demo_v2/README.rst @@ -161,6 +161,21 @@ Dependencies Changelog ========= +19.0.2.1.5 +~~~~~~~~~~ + +- fix(mis_demo): the Add Newborn demo change request resolves its + birthdate against the user's today instead of the server's. + ``STORY_CHANGE_REQUESTS`` is a class attribute, so the + ``fields.Date.today()`` literal it carried was evaluated once at + import — and it was the server's UTC date. The birthdate guard added + for #362 refuses a date the user has not reached yet, so an admin west + of UTC running the generator between 00:00 UTC and their local + midnight got a ``ValidationError`` and demo generation aborted. The + definition now declares ``birthdate_days_back`` and + ``_build_detail_changes`` resolves it with + ``fields.Date.context_today`` at build time. + 19.0.2.1.4 ~~~~~~~~~~ diff --git a/spp_mis_demo_v2/__manifest__.py b/spp_mis_demo_v2/__manifest__.py index f2e6dac82..91c7e9ed3 100644 --- a/spp_mis_demo_v2/__manifest__.py +++ b/spp_mis_demo_v2/__manifest__.py @@ -4,7 +4,7 @@ "name": "OpenSPP MIS Demo V2", "summary": "Demo Generator V2 for SP-MIS programs with fixed stories and volume generation", "category": "OpenSPP", - "version": "19.0.2.1.4", + "version": "19.0.2.1.5", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_mis_demo_v2/models/mis_demo_generator.py b/spp_mis_demo_v2/models/mis_demo_generator.py index f25d3fc95..98972b693 100644 --- a/spp_mis_demo_v2/models/mis_demo_generator.py +++ b/spp_mis_demo_v2/models/mis_demo_generator.py @@ -2741,7 +2741,7 @@ def _get_demo_user(self, role): "proposed_changes": { "given_name": "Baby Morales", "family_name": "Morales", - "birthdate": fields.Date.today(), + "birthdate_days_back": 0, "relationship_xmlid": "spp_mis_demo_v2.code_membership_type_child", }, }, @@ -3164,6 +3164,15 @@ def _build_detail_changes(self, detail_model, registrant, proposed_changes, cr_d if key in self.env[detail_model]._fields: vals[key] = new_val + # Resolved here, not in STORY_CHANGE_REQUESTS: that is a class + # attribute, so a date literal there is evaluated once at import and + # frozen. It also has to be the *user's* today — spp.cr.birthdate.mixin + # refuses a date the user has not reached yet, which is the server's + # date for an admin west of UTC. + days_back = proposed_changes.get("birthdate_days_back") + if days_back is not None and "birthdate" in self.env[detail_model]._fields: + vals["birthdate"] = fields.Date.context_today(self) - datetime.timedelta(days=days_back) + # If nothing mapped, generate simple edits to show a change if not vals: if detail_model == "spp.cr.detail.edit_individual": diff --git a/spp_mis_demo_v2/readme/HISTORY.md b/spp_mis_demo_v2/readme/HISTORY.md index e9601d6ab..222b31755 100644 --- a/spp_mis_demo_v2/readme/HISTORY.md +++ b/spp_mis_demo_v2/readme/HISTORY.md @@ -1,3 +1,7 @@ +### 19.0.2.1.5 + +- fix(mis_demo): the Add Newborn demo change request resolves its birthdate against the user's today instead of the server's. `STORY_CHANGE_REQUESTS` is a class attribute, so the `fields.Date.today()` literal it carried was evaluated once at import — and it was the server's UTC date. The birthdate guard added for #362 refuses a date the user has not reached yet, so an admin west of UTC running the generator between 00:00 UTC and their local midnight got a `ValidationError` and demo generation aborted. The definition now declares `birthdate_days_back` and `_build_detail_changes` resolves it with `fields.Date.context_today` at build time. + ### 19.0.2.1.4 - revert(mis_demo): the Add Member and Change Head of Household demo CRs match the reinstated old flows (see `spp_change_request_v2` #871/#873 revert) — Add Member builds a new individual (given/family name, birthdate, relationship) and Change HoH sets `new_head_id` from the named new head, instead of the redesigned `individual_id` / per-member role lines. diff --git a/spp_mis_demo_v2/static/description/index.html b/spp_mis_demo_v2/static/description/index.html index 173d51028..e2c53396a 100644 --- a/spp_mis_demo_v2/static/description/index.html +++ b/spp_mis_demo_v2/static/description/index.html @@ -531,6 +531,22 @@

    Changelog

+

19.0.2.1.5

+
    +
  • fix(mis_demo): the Add Newborn demo change request resolves its +birthdate against the user’s today instead of the server’s. +STORY_CHANGE_REQUESTS is a class attribute, so the +fields.Date.today() literal it carried was evaluated once at +import — and it was the server’s UTC date. The birthdate guard added +for #362 refuses a date the user has not reached yet, so an admin west +of UTC running the generator between 00:00 UTC and their local +midnight got a ValidationError and demo generation aborted. The +definition now declares birthdate_days_back and +_build_detail_changes resolves it with +fields.Date.context_today at build time.
  • +
+
+

19.0.2.1.4

  • revert(mis_demo): the Add Member and Change Head of Household demo CRs @@ -541,7 +557,7 @@

    19.0.2.1.4

    per-member role lines.
-
+

19.0.2.1.3

  • fix: PHL story registrants map to the curated PSGC p-code area @@ -550,7 +566,7 @@

    19.0.2.1.3

    dropping area assignments).
-
+

19.0.2.1.2

  • fix: demo GIS reports use dimension_ids + member_expansion @@ -559,7 +575,7 @@

    19.0.2.1.2

    spp_gis_report dimension change).
-
+

19.0.2.1.1

  • feat(demo): adapt the change-request demo generator to the redesigned @@ -567,7 +583,7 @@

    19.0.2.1.1

    Head of Household uses per-member role lines (#873) (#242)
-
+

19.0.2.1.0

  • feat(demo): seed country-appropriate CR document types (≥5 per country @@ -576,7 +592,7 @@

    19.0.2.1.0

    files to a change request without defining them manually (#1102)
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_registry/README.rst b/spp_registry/README.rst index da0eeb46a..4eb0e993a 100644 --- a/spp_registry/README.rst +++ b/spp_registry/README.rst @@ -139,6 +139,26 @@ Dependencies Changelog ========= +19.0.2.2.4 +~~~~~~~~~~ + +- fix(registry): refuse a date of birth in the future on every write + path. ``_birthdate_onchange`` only runs in the form UI, so ORM + ``create``/``write``, CSV/Excel import and API writes (XML-RPC, API + v2, DCI) all persisted a future ``birthdate`` — which the non-stored + ``age`` compute then rendered as a negative number in views, exports + and API reads. A stored-field constraint now enforces it server-side, + comparing against the user's own today so a registrar east of UTC is + not refused a birth recorded earlier that local day, and naming the + record and the offending value so a bad row in a bulk import can be + found. The onchange is kept as the friendlier silent-reset UX in the + form (#362) +- upgrade note: the constraint validates on write, so registrants + already holding a future birthdate are left as they are until + something writes that field, and that write then fails until the date + is corrected. Find them with: + ``SELECT id, display_name, birthdate FROM res_partner WHERE birthdate > CURRENT_DATE;`` + 19.0.2.2.3 ~~~~~~~~~~ diff --git a/spp_registry/__manifest__.py b/spp_registry/__manifest__.py index c8e43552f..2ed500e32 100644 --- a/spp_registry/__manifest__.py +++ b/spp_registry/__manifest__.py @@ -3,7 +3,7 @@ { "name": "OpenSPP Registry", "category": "OpenSPP/Core", - "version": "19.0.2.2.3", + "version": "19.0.2.2.4", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_registry/i18n/es.po b/spp_registry/i18n/es.po index c63cd042f..04300400f 100644 --- a/spp_registry/i18n/es.po +++ b/spp_registry/i18n/es.po @@ -359,6 +359,12 @@ msgstr "Fecha de nacimiento" msgid "Date of Birth - mm/dd/yyyy" msgstr "Fecha de nacimiento - mm/dd/aaaa" +#. module: spp_registry +#. odoo-python +#: code:addons/spp_registry/models/individual.py:0 +msgid "Date of birth cannot be in the future: %(name)s has %(date)s." +msgstr "La fecha de nacimiento no puede estar en el futuro: %(name)s tiene %(date)s." + #. module: spp_registry #: model_terms:ir.ui.view,arch_db:spp_registry.view_individuals_form msgid "Date when the group was registered" diff --git a/spp_registry/i18n/fr.po b/spp_registry/i18n/fr.po index c89ad0459..098f1760a 100644 --- a/spp_registry/i18n/fr.po +++ b/spp_registry/i18n/fr.po @@ -360,6 +360,12 @@ msgstr "Date de naissance" msgid "Date of Birth - mm/dd/yyyy" msgstr "Date de naissance - mm/jj/aaaa" +#. module: spp_registry +#. odoo-python +#: code:addons/spp_registry/models/individual.py:0 +msgid "Date of birth cannot be in the future: %(name)s has %(date)s." +msgstr "La date de naissance ne peut pas être dans le futur : %(name)s a %(date)s." + #. module: spp_registry #: model_terms:ir.ui.view,arch_db:spp_registry.view_individuals_form msgid "Date when the group was registered" diff --git a/spp_registry/i18n/spp_registry.pot b/spp_registry/i18n/spp_registry.pot index d9fc6d2c7..2c70d8880 100644 --- a/spp_registry/i18n/spp_registry.pot +++ b/spp_registry/i18n/spp_registry.pot @@ -341,6 +341,12 @@ msgstr "" msgid "Date of Birth - mm/dd/yyyy" msgstr "" +#. module: spp_registry +#. odoo-python +#: code:addons/spp_registry/models/individual.py:0 +msgid "Date of birth cannot be in the future: %(name)s has %(date)s." +msgstr "" + #. module: spp_registry #: model_terms:ir.ui.view,arch_db:spp_registry.view_individuals_form msgid "Date when the group was registered" diff --git a/spp_registry/models/individual.py b/spp_registry/models/individual.py index 87a0681e3..0bce94b42 100644 --- a/spp_registry/models/individual.py +++ b/spp_registry/models/individual.py @@ -104,6 +104,32 @@ def _birthdate_onchange(self): } } + @api.constrains("birthdate") + 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. + + The comparison is against the *user's* today + (``fields.Date.context_today``), not the server's UTC date: a + registrar east of UTC is on tomorrow's date for part of each day, + and a birth recorded that morning is not a future date for them. + """ + 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, + ) + ) + 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 diff --git a/spp_registry/readme/HISTORY.md b/spp_registry/readme/HISTORY.md index f75e01fb1..d2f80ecda 100644 --- a/spp_registry/readme/HISTORY.md +++ b/spp_registry/readme/HISTORY.md @@ -1,3 +1,8 @@ +### 19.0.2.2.4 + +- fix(registry): refuse a date of birth in the future on every write path. `_birthdate_onchange` only runs in the form UI, so ORM `create`/`write`, CSV/Excel import and API writes (XML-RPC, API v2, DCI) all persisted a future `birthdate` — which the non-stored `age` compute then rendered as a negative number in views, exports and API reads. A stored-field constraint now enforces it server-side, comparing against the user's own today so a registrar east of UTC is not refused a birth recorded earlier that local day, and naming the record and the offending value so a bad row in a bulk import can be found. The onchange is kept as the friendlier silent-reset UX in the form (#362) +- upgrade note: the constraint validates on write, so registrants already holding a future birthdate are left as they are until something writes that field, and that write then fails until the date is corrected. Find them with: `SELECT id, display_name, birthdate FROM res_partner WHERE birthdate > CURRENT_DATE;` + ### 19.0.2.2.3 - fix(registry): repair the stored `status`/`is_ended` computes on `spp.group.membership` once the clock crosses `ended_date`. Both fields depend only on `ended_date` compared against *now*, so a future-dated departure never took effect once the clock crossed it — rosters, metrics, API search and downstream gates kept treating the member as active indefinitely. Writing a future `ended_date` now schedules a lightweight, index-served repair cron in the minute after that moment (staleness window ≈1–2 minutes), and a daily sweep self-heals everything else: rows already stale in existing databases (drained in committed batches, resuming across runs until the backlog is gone) and rows written behind the ORM, including `is_ended = NULL` rows that raw-SQL consumers treated as ended (#417) diff --git a/spp_registry/static/description/index.html b/spp_registry/static/description/index.html index f65bf0c23..48cfcf7c2 100644 --- a/spp_registry/static/description/index.html +++ b/spp_registry/static/description/index.html @@ -518,6 +518,27 @@

    Changelog

+

19.0.2.2.4

+
    +
  • fix(registry): refuse a date of birth in the future on every write +path. _birthdate_onchange only runs in the form UI, so ORM +create/write, CSV/Excel import and API writes (XML-RPC, API +v2, DCI) all persisted a future birthdate — which the non-stored +age compute then rendered as a negative number in views, exports +and API reads. A stored-field constraint now enforces it server-side, +comparing against the user’s own today so a registrar east of UTC is +not refused a birth recorded earlier that local day, and naming the +record and the offending value so a bad row in a bulk import can be +found. The onchange is kept as the friendlier silent-reset UX in the +form (#362)
  • +
  • upgrade note: the constraint validates on write, so registrants +already holding a future birthdate are left as they are until +something writes that field, and that write then fails until the date +is corrected. Find them with: +SELECT id, display_name, birthdate FROM res_partner WHERE birthdate > CURRENT_DATE;
  • +
+
+

19.0.2.2.3

  • fix(registry): repair the stored status/is_ended computes on @@ -541,7 +562,7 @@

    19.0.2.2.3

    CREATE INDEX CONCURRENTLY IF NOT EXISTS spp_group_membership__ended_date_index ON spp_group_membership (ended_date) WHERE ended_date IS NOT NULL;
-
+

19.0.2.2.2

  • fix(registry): let an ID type be used again after its ID was removed. @@ -553,7 +574,7 @@

    19.0.2.2.2

    surfacing a database error (#1136)
-
+

19.0.2.2.1

  • feat(registry): registry configuration is consolidated into one @@ -564,7 +585,7 @@

    19.0.2.2.1

    framework refuses a settings save from anyone else (#1009)
-
+

19.0.2.1.4

  • fix(registry): remove the dead @api.constrains("age") @@ -576,7 +597,7 @@

    19.0.2.1.4

    dropped
-
+

19.0.2.1.3

  • fix(registry): show an ID Status column on the group form @@ -587,7 +608,7 @@

    19.0.2.1.3

    (#1110)
-
+

19.0.2.1.1

  • fix(views): add reusable x2many_no_padding JS widget that @@ -597,7 +618,7 @@

    19.0.2.1.1

    don’t bloat the layout (#943).
-
+

19.0.2.0.0

  • Initial migration to OpenSPP2
  • diff --git a/spp_registry/tests/test_constraints.py b/spp_registry/tests/test_constraints.py index 0182731fa..4d8809dc2 100644 --- a/spp_registry/tests/test_constraints.py +++ b/spp_registry/tests/test_constraints.py @@ -8,10 +8,13 @@ date cannot be in the future, and cannot precede the birthdate. - spp.id.type ``_check_namespace_uri_format`` (ADR-007) plus the lowercase-normalisation behaviour of ``create`` / ``write``. +- res.partner (individual) ``_check_birthdate_not_future`` — a future + date of birth is refused on ``create``, ``write`` and ``load``. """ from datetime import date, timedelta +from odoo import fields from odoo.exceptions import ValidationError from odoo.tests import tagged @@ -209,3 +212,64 @@ 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") +class TestBirthdateNotFutureConstraint(RegistryCommon): + """spp_registry/models/individual.py::_check_birthdate_not_future + + ``registration_date`` is passed as ``False`` wherever a future + birthdate must be refused. Its default is today and + ``_check_registration_date`` refuses a ``registration_date`` earlier + than the birthdate — which is true of every valid date once the + birthdate is in the future. With the default left in place that + constraint fires on each of these writes and the tests would pass + without ``_check_birthdate_not_future`` existing at all. + """ + + def setUp(self): + super().setUp() + self.today = fields.Date.context_today(self.individual_a) + self.future = self.today + timedelta(days=1) + + def test_future_birthdate_rejected_on_write(self): + """A future birthdate set via write() raises ValidationError.""" + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + self.individual_a.write({"birthdate": self.future}) + + def test_future_birthdate_rejected_on_create(self): + """A future birthdate passed to create() raises ValidationError.""" + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + self.Partner.create( + { + "name": "Time Traveller", + "is_registrant": True, + "is_group": False, + "birthdate": self.future, + "registration_date": False, + } + ) + + def test_future_birthdate_rejected_on_import(self): + """load() (CSV/Excel import) is refused by the same constraint.""" + result = self.Partner.load( + ["name", "is_registrant", "is_group", "birthdate", "registration_date"], + [["Imported Person", "1", "0", str(self.future), ""]], + ) + self.assertIn("Date of birth cannot be in the future", str(result["messages"])) + 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": self.today}) + self.assertEqual(self.individual_a.birthdate, self.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.""" + with self.assertRaisesRegex(ValidationError, "Date of birth cannot be in the future"): + self.individual_a.write({"birthdate": self.future, "birthdate_not_exact": True})