diff --git a/spp_registry/README.rst b/spp_registry/README.rst index 4eb0e993..691b3913 100644 --- a/spp_registry/README.rst +++ b/spp_registry/README.rst @@ -139,6 +139,30 @@ Dependencies Changelog ========= +19.0.2.2.6 +~~~~~~~~~~ + +- fix(registry): compare the registration date and the form's birthdate + check against the user's today, not the server's UTC date, finishing + what 19.0.2.2.4 started for ``_check_birthdate_not_future``. The + ``registration_date`` default was ``fields.Date.today()`` and + ``_check_registration_date`` bounded it by ``date.today()``, so a + registrar east of UTC creating a registrant born earlier that local + day was refused twice over: the defaulted registration date was the + server's yesterday ("must be later than the birth date") with no way + to correct it in the form, where the field is read-only once + defaulted, and setting the correct date through the API or an import + was refused as future. ``_birthdate_onchange`` used the same server + date and silently reset that valid birthdate in the form, while west + of UTC it kept the user's tomorrow only for the constraint to refuse + it on save. All three now use ``fields.Date.context_today`` (#520) +- behaviour note for teams spread across timezones: "today" is now each + user's own, so a registration date stored by a user east of UTC can + read as tomorrow to a colleague further west until their local + midnight, and an API or import write of that value by the second user + is refused as future until then. The form is unaffected because + ``registration_date`` is read-only there once set. + 19.0.2.2.4 ~~~~~~~~~~ diff --git a/spp_registry/__manifest__.py b/spp_registry/__manifest__.py index 2ed500e3..c0b3345c 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.4", + "version": "19.0.2.2.6", "sequence": 1, "author": "OpenSPP.org", "website": "https://github.com/OpenSPP/OpenSPP2", diff --git a/spp_registry/models/individual.py b/spp_registry/models/individual.py index 0bce94b4..3a2a8ab2 100644 --- a/spp_registry/models/individual.py +++ b/spp_registry/models/individual.py @@ -92,9 +92,13 @@ def _birthdate_onchange(self): This function is used to validate and reset birthdate in case the birthdate date is being set greater than the date today. Resets to previous birthdate value if available, otherwise None. + + "Today" is the user's (``context_today``), the same rule as + ``_check_birthdate_not_future``, so the form never resets a date + the constraint accepts or keeps one it will refuse on save. """ for rec in self: - if rec.birthdate and rec.birthdate > fields.Date.today(): + if rec.birthdate and rec.birthdate > fields.Date.context_today(rec): # Restore previous birthdate or set to None if new record rec.birthdate = rec._origin.birthdate if rec._origin.id else None return { diff --git a/spp_registry/models/registrant.py b/spp_registry/models/registrant.py index a2cb246b..014c2e04 100644 --- a/spp_registry/models/registrant.py +++ b/spp_registry/models/registrant.py @@ -1,6 +1,5 @@ # Part of OpenSPP. See LICENSE file for full copyright and licensing details. import logging -from datetime import date from odoo import _, api, fields, models from odoo.exceptions import AccessError, ValidationError @@ -44,7 +43,7 @@ class SPPRegistrant(models.Model): phone_number_ids = fields.One2many("spp.phone.number", "partner_id", "Phone Numbers") company_id = fields.Many2one("res.company", required=True, default=lambda self: self.env.company) - registration_date = fields.Date(default=lambda self: fields.Date.today(), index=True) + registration_date = fields.Date(default=lambda self: fields.Date.context_today(self), index=True) tags_ids = fields.Many2many( "spp.vocabulary.code", relation="res_partner_registrant_tag_rel", @@ -126,9 +125,17 @@ def _onchange_negative_restrict(self): @api.constrains("registration_date") def _check_registration_date(self): + """Registration date is bounded by the user's today and the birthdate. + + The upper bound is 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 must not be refused a registration + made that morning. Same rule as ``_check_birthdate_not_future`` and + the field's default. + """ for record in self: if record.registration_date: - if record.registration_date > date.today(): + if record.registration_date > fields.Date.context_today(record): error_message = "Registration date must be less than the current date." raise ValidationError(error_message) elif "birthdate" in record and record.birthdate and record.registration_date < record.birthdate: diff --git a/spp_registry/readme/HISTORY.md b/spp_registry/readme/HISTORY.md index d2f80ecd..afa064a6 100644 --- a/spp_registry/readme/HISTORY.md +++ b/spp_registry/readme/HISTORY.md @@ -1,3 +1,8 @@ +### 19.0.2.2.6 + +- fix(registry): compare the registration date and the form's birthdate check against the user's today, not the server's UTC date, finishing what 19.0.2.2.4 started for `_check_birthdate_not_future`. The `registration_date` default was `fields.Date.today()` and `_check_registration_date` bounded it by `date.today()`, so a registrar east of UTC creating a registrant born earlier that local day was refused twice over: the defaulted registration date was the server's yesterday ("must be later than the birth date") with no way to correct it in the form, where the field is read-only once defaulted, and setting the correct date through the API or an import was refused as future. `_birthdate_onchange` used the same server date and silently reset that valid birthdate in the form, while west of UTC it kept the user's tomorrow only for the constraint to refuse it on save. All three now use `fields.Date.context_today` (#520) +- behaviour note for teams spread across timezones: "today" is now each user's own, so a registration date stored by a user east of UTC can read as tomorrow to a colleague further west until their local midnight, and an API or import write of that value by the second user is refused as future until then. The form is unaffected because `registration_date` is read-only there once set. + ### 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) diff --git a/spp_registry/static/description/index.html b/spp_registry/static/description/index.html index 48cfcf7c..3dde87e8 100644 --- a/spp_registry/static/description/index.html +++ b/spp_registry/static/description/index.html @@ -518,6 +518,31 @@

Changelog

+

19.0.2.2.6

+ +
+

19.0.2.2.4

-
+

19.0.2.2.3

-
+

19.0.2.2.2

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

    19.0.2.2.2

    surfacing a database error (#1136)
-
+

19.0.2.2.1

  • feat(registry): registry configuration is consolidated into one @@ -585,7 +610,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") @@ -597,7 +622,7 @@

    19.0.2.1.4

    dropped
-
+

19.0.2.1.3

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

    19.0.2.1.3

    (#1110)
-
+

19.0.2.1.1

  • fix(views): add reusable x2many_no_padding JS widget that @@ -618,7 +643,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 4d8809dc..d207c1b8 100644 --- a/spp_registry/tests/test_constraints.py +++ b/spp_registry/tests/test_constraints.py @@ -10,9 +10,14 @@ 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``. +- registration-date default, ``_check_registration_date`` and + ``_birthdate_onchange`` all use the user's today, not the server's + (#520), exercised east and west of UTC on a frozen clock. """ -from datetime import date, timedelta +from datetime import date, datetime, time, timedelta + +from freezegun import freeze_time from odoo import fields from odoo.exceptions import ValidationError @@ -112,18 +117,27 @@ def test_no_head_code_in_vocabulary_short_circuits(self): @tagged("post_install", "-at_install") class TestRegistrationDateConstraint(RegistryCommon): - """spp_registry/models/registrant.py::_check_registration_date""" + """spp_registry/models/registrant.py::_check_registration_date + + Dates are based on the user's today (``context_today``) to match the + constraint; anchoring on the server date would make these depend on the + test user's timezone. + """ + + def setUp(self): + super().setUp() + self.today = fields.Date.context_today(self.individual_a) + self.future = self.today + timedelta(days=1) def test_future_registration_date_rejected(self): """Registration date in the future raises ValidationError.""" - future = date.today() + timedelta(days=1) - with self.assertRaises(ValidationError): - self.individual_a.write({"registration_date": future}) + with self.assertRaisesRegex(ValidationError, "Registration date must be less than the current date"): + self.individual_a.write({"registration_date": self.future}) def test_today_is_allowed(self): """Registration date == today is the boundary that must pass.""" - self.individual_a.write({"registration_date": date.today()}) - self.assertEqual(self.individual_a.registration_date, date.today()) + self.individual_a.write({"registration_date": self.today}) + self.assertEqual(self.individual_a.registration_date, self.today) def test_registration_before_birthdate_rejected(self): """Registration date < birthdate raises ValidationError. @@ -273,3 +287,134 @@ 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}) + + +@tagged("post_install", "-at_install") +class TestUserTodayAcrossTimezones(RegistryCommon): + """The registration-date default, ``_check_registration_date`` and + ``_birthdate_onchange`` compare against the user's today + (``fields.Date.context_today``), not the server's UTC date — the + follow-up to ``_check_birthdate_not_future`` (#397). + + The clock is frozen so these pass at any wall-clock hour: at 23:00 UTC a + registrar in Asia/Manila (UTC+8) is already on the next calendar day; at + 05:00 UTC one in America/Los_Angeles (UTC-7/-8) is still on the previous + one. The frozen instant is derived from the real date, so nothing ages. + """ + + EAST_TZ = "Asia/Manila" + WEST_TZ = "America/Los_Angeles" + + FUTURE_MESSAGE = "Registration date must be less than the current date" + + def _frozen_utc(self, hour): + return freeze_time(datetime.combine(date.today(), time(hour, 0))) + + def _user_today(self, record, shift): + """The user's today, asserting the frozen clock and tz context really + shift it by ``shift`` days from the server's; without this every test + would still pass if freezegun or the tz context silently stopped + applying, with ``user_today`` collapsing onto the server date.""" + user_today = fields.Date.context_today(record) + self.assertEqual( + user_today, + date.today() + timedelta(days=shift), + "precondition: frozen clock / tz context not in effect", + ) + return user_today + + def _new_registrant_vals(self, name, birthdate): + return {"name": name, "is_registrant": True, "is_group": False, "birthdate": birthdate} + + def test_east_of_utc_default_registration_date_is_users_today(self): + """A registrant born on the user's today is accepted with the defaulted + registration date, which is the user's today as well (it used to be the + server's date — the user's yesterday — and failed "later than birth").""" + with self._frozen_utc(23): + Partner = self.Partner.with_context(tz=self.EAST_TZ) + user_today = self._user_today(Partner, +1) + + newborn = Partner.create(self._new_registrant_vals("Newborn East", user_today)) + + self.assertEqual(newborn.registration_date, user_today) + + def test_east_of_utc_registration_on_users_today_accepted(self): + """registration_date == the user's today is the boundary that must pass.""" + with self._frozen_utc(23): + individual = self.individual_a.with_context(tz=self.EAST_TZ) + user_today = self._user_today(individual, +1) + + individual.write({"registration_date": user_today}) + + self.assertEqual(individual.registration_date, user_today) + + def test_registration_after_users_today_still_rejected(self): + """The upper bound still holds under a timezone context.""" + with self._frozen_utc(23): + individual = self.individual_a.with_context(tz=self.EAST_TZ) + future = self._user_today(individual, +1) + timedelta(days=1) + with self.assertRaisesRegex(ValidationError, self.FUTURE_MESSAGE): + individual.write({"registration_date": future}) + + def test_west_of_utc_default_registration_date_is_users_today(self): + """West of UTC the default follows the user too: the stored date is the + user's today, a day behind the server's, and coexists with a past + birthdate without tripping the registration-before-birth branch.""" + with self._frozen_utc(5): + Partner = self.Partner.with_context(tz=self.WEST_TZ) + user_today = self._user_today(Partner, -1) + + registrant = Partner.create(self._new_registrant_vals("Registrant West", date(1990, 1, 1))) + + self.assertEqual(registrant.registration_date, user_today) + + def test_west_of_utc_registration_on_servers_today_rejected(self): + """The bound moved a day earlier for users west of UTC, by design: the + server's today is their tomorrow and is refused as future. This is the + one direction in which a value accepted before is refused now.""" + with self._frozen_utc(5): + individual = self.individual_a.with_context(tz=self.WEST_TZ) + self._user_today(individual, -1) + with self.assertRaisesRegex(ValidationError, self.FUTURE_MESSAGE): + individual.write({"registration_date": date.today()}) + + def test_east_of_utc_birthdate_onchange_keeps_users_today(self): + """The form must not reset a birthdate the user has already reached.""" + with self._frozen_utc(23): + Partner = self.Partner.with_context(tz=self.EAST_TZ) + user_today = self._user_today(Partner, +1) + form_record = Partner.new(self._new_registrant_vals("Newborn East", user_today)) + + result = form_record._birthdate_onchange() + + self.assertIsNone(result, "no warning for a date the user has reached") + self.assertEqual(form_record.birthdate, user_today) + + def test_west_of_utc_birthdate_onchange_resets_users_tomorrow(self): + """West of UTC the server's today is the user's tomorrow: the form must + reset it, as ``_check_birthdate_not_future`` would refuse it on save.""" + with self._frozen_utc(5): + Partner = self.Partner.with_context(tz=self.WEST_TZ) + server_today = date.today() + self._user_today(Partner, -1) + form_record = Partner.new(self._new_registrant_vals("Newborn West", server_today)) + + result = form_record._birthdate_onchange() + + self.assertIn("warning", result or {}, "the user's tomorrow must be refused in the form") + self.assertFalse(form_record.birthdate) + + def test_birthdate_onchange_restores_saved_value_on_existing_record(self): + """Editing a saved registrant to the user's tomorrow restores the saved + birthdate rather than clearing it (the ``_origin`` branch).""" + saved_birthdate = date(1990, 1, 1) + self.individual_a.write({"birthdate": saved_birthdate}) + with self._frozen_utc(5): + Partner = self.Partner.with_context(tz=self.WEST_TZ) + self._user_today(Partner, -1) + form_record = Partner.new({"birthdate": date.today()}, origin=self.individual_a) + + result = form_record._birthdate_onchange() + + self.assertIn("warning", result or {}) + self.assertEqual(form_record.birthdate, saved_birthdate)