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
24 changes: 24 additions & 0 deletions spp_registry/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
~~~~~~~~~~

Expand Down
2 changes: 1 addition & 1 deletion spp_registry/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion spp_registry/models/individual.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions spp_registry/models/registrant.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions spp_registry/readme/HISTORY.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
39 changes: 32 additions & 7 deletions spp_registry/static/description/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,31 @@ <h2><a class="toc-backref" href="#toc-entry-1">Changelog</a></h2>
</div>
</div>
<div class="section" id="section-1">
<h1>19.0.2.2.6</h1>
<ul class="simple">
<li>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 <tt class="docutils literal">_check_birthdate_not_future</tt>. The
<tt class="docutils literal">registration_date</tt> default was <tt class="docutils literal">fields.Date.today()</tt> and
<tt class="docutils literal">_check_registration_date</tt> bounded it by <tt class="docutils literal">date.today()</tt>, 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. <tt class="docutils literal">_birthdate_onchange</tt> 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 <tt class="docutils literal">fields.Date.context_today</tt> (#520)</li>
<li>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
<tt class="docutils literal">registration_date</tt> is read-only there once set.</li>
</ul>
</div>
<div class="section" id="section-2">
<h1>19.0.2.2.4</h1>
<ul class="simple">
<li>fix(registry): refuse a date of birth in the future on every write
Expand All @@ -538,7 +563,7 @@ <h1>19.0.2.2.4</h1>
<tt class="docutils literal">SELECT id, display_name, birthdate FROM res_partner WHERE birthdate &gt; CURRENT_DATE;</tt></li>
</ul>
</div>
<div class="section" id="section-2">
<div class="section" id="section-3">
<h1>19.0.2.2.3</h1>
<ul class="simple">
<li>fix(registry): repair the stored <tt class="docutils literal">status</tt>/<tt class="docutils literal">is_ended</tt> computes on
Expand All @@ -562,7 +587,7 @@ <h1>19.0.2.2.3</h1>
<tt class="docutils literal">CREATE INDEX CONCURRENTLY IF NOT EXISTS spp_group_membership__ended_date_index ON spp_group_membership (ended_date) WHERE ended_date IS NOT NULL;</tt></li>
</ul>
</div>
<div class="section" id="section-3">
<div class="section" id="section-4">
<h1>19.0.2.2.2</h1>
<ul class="simple">
<li>fix(registry): let an ID type be used again after its ID was removed.
Expand All @@ -574,7 +599,7 @@ <h1>19.0.2.2.2</h1>
surfacing a database error (#1136)</li>
</ul>
</div>
<div class="section" id="section-4">
<div class="section" id="section-5">
<h1>19.0.2.2.1</h1>
<ul class="simple">
<li>feat(registry): registry configuration is consolidated into one
Expand All @@ -585,7 +610,7 @@ <h1>19.0.2.2.1</h1>
framework refuses a settings save from anyone else (#1009)</li>
</ul>
</div>
<div class="section" id="section-5">
<div class="section" id="section-6">
<h1>19.0.2.1.4</h1>
<ul class="simple">
<li>fix(registry): remove the dead <tt class="docutils literal"><span class="pre">&#64;api.constrains(&quot;age&quot;)</span></tt>
Expand All @@ -597,7 +622,7 @@ <h1>19.0.2.1.4</h1>
dropped</li>
</ul>
</div>
<div class="section" id="section-6">
<div class="section" id="section-7">
<h1>19.0.2.1.3</h1>
<ul class="simple">
<li>fix(registry): show an ID <strong>Status</strong> column on the group form
Expand All @@ -608,7 +633,7 @@ <h1>19.0.2.1.3</h1>
(#1110)</li>
</ul>
</div>
<div class="section" id="section-7">
<div class="section" id="section-8">
<h1>19.0.2.1.1</h1>
<ul class="simple">
<li>fix(views): add reusable <tt class="docutils literal">x2many_no_padding</tt> JS widget that
Expand All @@ -618,7 +643,7 @@ <h1>19.0.2.1.1</h1>
don’t bloat the layout (#943).</li>
</ul>
</div>
<div class="section" id="section-8">
<div class="section" id="section-9">
<h1>19.0.2.0.0</h1>
<ul class="simple">
<li>Initial migration to OpenSPP2</li>
Expand Down
159 changes: 152 additions & 7 deletions spp_registry/tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Loading