From ea3315c937b960fc49e3effbb7497e29f5fa2442 Mon Sep 17 00:00:00 2001 From: wachi Date: Mon, 19 Oct 2026 12:37:20 +0700 Subject: [PATCH 1/7] [FIX] ni_period: compute the start date from period_start on create period_start_date is a stored computed field with a default of today. On create, a default counts as a given value, so the compute never runs: records created from code (import, RPC, bulk wizards) with only period_start got today as their start date, and an encounter could then fail the end-date check when it was closed. Drop the default. period_start already defaults to now, so a new record in the form still starts today. Existing databases still hold the wrong dates, so the version bump ships an end-migration script that repairs them. It runs at end- time because ni_period has no dependencies, and goes through every stored model on the mixin, skipping SQL views and models that declare their own period_start_date (ni.condition already had default=None). It only fixes datetime-mode rows whose start date differs from period_start and equals their creation day (+/- 1 day for the creating user's timezone); date-mode rows are left alone, since there a mismatch can be intended. Each row gets period_start::date, as the compute does, and the number of fixed rows is logged per model. The tests use a fake model on the mixin: the create case, and the script on a fixed row, a date-mode row, a correct row and a model with its own field. Production must run `-u ni_period` on 16.0 with this version before the OpenUpgrade 16 -> 17 step: OCA keeps only the current version's scripts. Refs: nirun-life/nirun#135 --- ni_period/__manifest__.py | 2 +- .../migrations/16.0.0.1.1/end-migration.py | 68 ++++++++++++ ni_period/models/ni_period_mixin.py | 1 - ni_period/tests/__init__.py | 3 + ni_period/tests/models.py | 23 ++++ ni_period/tests/test_ni_period_start_date.py | 103 ++++++++++++++++++ 6 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 ni_period/migrations/16.0.0.1.1/end-migration.py create mode 100644 ni_period/tests/__init__.py create mode 100644 ni_period/tests/models.py create mode 100644 ni_period/tests/test_ni_period_start_date.py diff --git a/ni_period/__manifest__.py b/ni_period/__manifest__.py index d57ebb4e4..203c926f4 100644 --- a/ni_period/__manifest__.py +++ b/ni_period/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Nirun - Period", "summary": "Time period fields (start/end datetime ranges) for clinical resources", - "version": "16.0.0.1.0", + "version": "16.0.0.1.1", "development_status": "Alpha", "category": "Medical", "author": "NSTDA, Piruin P.", diff --git a/ni_period/migrations/16.0.0.1.1/end-migration.py b/ni_period/migrations/16.0.0.1.1/end-migration.py new file mode 100644 index 000000000..ef0593120 --- /dev/null +++ b/ni_period/migrations/16.0.0.1.1/end-migration.py @@ -0,0 +1,68 @@ +# Copyright (c) 2026 NSTDA +"""Repair the start dates written by the old ``period_start_date`` default. + +Up to 16.0.0.1.0, ``period_start_date`` defaulted to today. On create a default +counts as a given value, so the compute never ran: every record created from +code (import, RPC, bulk wizards) stored its creation day instead of the date of +its ``period_start``. + +This runs as an ``end-`` script because ``ni_period`` has no dependencies: at +``post-`` time none of the models that use the mixin are loaded yet. +""" +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + +MIXIN = "ni.period.mixin" + + +def _defines_own_start_date(model): + """True when the model (or an extension of it) declares its own + ``period_start_date``; ``ni.condition`` did, with ``default=None``, so the + bug never reached it.""" + for cls in type(model).mro()[1:]: + if "period_start_date" in vars(cls) and getattr(cls, "_name", None) != MIXIN: + return True + return False + + +def _models_to_fix(env): + for model_name in sorted(env.registry.descendants([MIXIN], "_inherit")): + model = env[model_name] + if model._abstract or not model._auto or _defines_own_start_date(model): + continue + yield model + + +def fix_period_start_date(env): + for model in _models_to_fix(env): + # Only datetime-mode rows: in date mode the inverse leaves period_start + # alone when the user edits the date, so a mismatch can be intended. + # The old default used the creating user's timezone, hence +/- 1 day. + env.cr.execute( + f""" + UPDATE "{model._table}" + SET period_start_date = period_start::date + WHERE period_mode = 'datetime' + AND period_start IS NOT NULL + AND period_start_date <> period_start::date + AND period_start_date BETWEEN create_date::date - 1 + AND create_date::date + 1 + """ + ) + if env.cr.rowcount: + _logger.info( + "ni_period: fixed period_start_date on %s %s rows", + env.cr.rowcount, + model._name, + ) + model.invalidate_model(["period_start_date"]) + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + fix_period_start_date(env) diff --git a/ni_period/models/ni_period_mixin.py b/ni_period/models/ni_period_mixin.py index 6581511d6..195954ac9 100644 --- a/ni_period/models/ni_period_mixin.py +++ b/ni_period/models/ni_period_mixin.py @@ -28,7 +28,6 @@ class PeriodMixin(models.AbstractModel): store=True, compute="_compute_period_start_date", inverse="_inverse_period_start_date", - default=lambda self: fields.Date.context_today(self), ) period_end = fields.Datetime( "Until", diff --git a/ni_period/tests/__init__.py b/ni_period/tests/__init__.py new file mode 100644 index 000000000..e5f4f7b45 --- /dev/null +++ b/ni_period/tests/__init__.py @@ -0,0 +1,3 @@ +# Copyright (c) 2026 NSTDA + +from . import test_ni_period_start_date diff --git a/ni_period/tests/models.py b/ni_period/tests/models.py new file mode 100644 index 000000000..0c60732f2 --- /dev/null +++ b/ni_period/tests/models.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026 NSTDA + +from odoo import fields, models + + +class PeriodTest(models.Model): + _name = "ni.period.test" + _inherit = "ni.period.mixin" + _description = "Period Test" + + +class PeriodOwnDateTest(models.Model): + _name = "ni.period.own.date.test" + _inherit = "ni.period.mixin" + _description = "Period Test (own start date)" + + period_start_date = fields.Date( + default=None, + compute="_compute_period_start_date", + inverse="_inverse_period_start_date", + store=True, + readonly=False, + ) diff --git a/ni_period/tests/test_ni_period_start_date.py b/ni_period/tests/test_ni_period_start_date.py new file mode 100644 index 000000000..5afd52ec9 --- /dev/null +++ b/ni_period/tests/test_ni_period_start_date.py @@ -0,0 +1,103 @@ +# Copyright (c) 2026 NSTDA + +import importlib.util +from datetime import date, datetime +from pathlib import Path + +from odoo_test_helper import FakeModelLoader + +from odoo.tests import TransactionCase + +SCRIPT = ( + Path(__file__).resolve().parents[1] + / "migrations" + / "16.0.0.1.1" + / "end-migration.py" +) + + +def _load_script(): + spec = importlib.util.spec_from_file_location("ni_period_end_migration", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestPeriodStartDate(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.loader = FakeModelLoader(cls.env, cls.__module__) + cls.loader.backup_registry() + from .models import PeriodOwnDateTest, PeriodTest + + cls.loader.update_registry((PeriodTest, PeriodOwnDateTest)) + cls.Period = cls.env["ni.period.test"] + cls.PeriodOwn = cls.env["ni.period.own.date.test"] + + @classmethod + def tearDownClass(cls): + cls.loader.restore_registry() + super().tearDownClass() + + def _set_start_date_to_creation_day(self, record): + """Write what the old default stored: the day the record was created.""" + self.env.flush_all() + self.env.cr.execute( + f'UPDATE "{record._table}" SET period_start_date = create_date::date ' + "WHERE id = %s", + (record.id,), + ) + record.invalidate_recordset(["period_start_date"]) + + def _start_date(self, record): + self.env.flush_all() + self.env.cr.execute( + f'SELECT period_start_date FROM "{record._table}" WHERE id = %s', + (record.id,), + ) + return self.env.cr.fetchone()[0] + + def test_period_start_date_follows_period_start_on_create(self): + """Records created from code (import, RPC) get the start date of their + period_start, not today.""" + record = self.Period.create({"period_start": datetime(2025, 3, 1, 3, 0)}) + self.assertEqual(record.period_start_date, date(2025, 3, 1)) + + def test_end_migration_repairs_start_dates(self): + start = datetime(2025, 3, 1, 3, 0) + wrong = self.Period.create({"period_start": start}) + self._set_start_date_to_creation_day(wrong) + date_mode = self.Period.create({"period_mode": "date", "period_start": start}) + self._set_start_date_to_creation_day(date_mode) + correct = self.Period.create({"period_start": start}) + own = self.PeriodOwn.create({"period_start": start}) + self._set_start_date_to_creation_day(own) + creation_day = self._start_date(wrong) + self.assertNotEqual(creation_day, date(2025, 3, 1)) + + with self.assertLogs("ni_period_end_migration", "INFO") as logs: + _load_script().migrate(self.env.cr, "16.0.0.1.0") + + # 1. a datetime-mode row holding its creation day is fixed + self.assertEqual(self._start_date(wrong), date(2025, 3, 1)) + self.assertEqual(wrong.period_start_date, date(2025, 3, 1)) + # 2. a date-mode row with a deliberate mismatch is left alone + self.assertEqual(self._start_date(date_mode), creation_day) + # 3. a row that is already correct is left alone + self.assertEqual(self._start_date(correct), date(2025, 3, 1)) + # 4. a model that defines its own field is skipped + self.assertEqual(self._start_date(own), creation_day) + self.assertEqual( + logs.output, + [ + "INFO:ni_period_end_migration:ni_period: fixed period_start_date " + "on 1 ni.period.test rows" + ], + ) + + def test_end_migration_skips_a_fresh_install(self): + wrong = self.Period.create({"period_start": datetime(2025, 3, 1, 3, 0)}) + self._set_start_date_to_creation_day(wrong) + _load_script().migrate(self.env.cr, None) + self.assertNotEqual(self._start_date(wrong), date(2025, 3, 1)) From 095d84bd92b40493b4df36f5e9a65e0583f4730b Mon Sep 17 00:00:00 2001 From: wachi Date: Mon, 19 Oct 2026 12:37:20 +0700 Subject: [PATCH 2/7] [FIX] partner_age: remove a test file that never ran tests/test_res_partner.py was not imported in tests/__init__.py, so it never ran. It is a near copy of test_res_partner_age.py (the same cases, two assertions in a different order), so there is nothing to merge. Refs: nirun-life/nirun#135 --- partner_age/tests/test_res_partner.py | 76 --------------------------- 1 file changed, 76 deletions(-) delete mode 100644 partner_age/tests/test_res_partner.py diff --git a/partner_age/tests/test_res_partner.py b/partner_age/tests/test_res_partner.py deleted file mode 100644 index d34f2f84c..000000000 --- a/partner_age/tests/test_res_partner.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright (c) 2021-2023 NSTDA - -from dateutil.relativedelta import relativedelta - -from odoo import fields -from odoo.exceptions import ValidationError -from odoo.tests import common - - -class TestPartnerAge(common.TransactionCase): - def setUp(self): - super(TestPartnerAge, self).setUp() - self.partners = self.env["res.partner"] - self.today = fields.Date.context_today(self.partners) - - def test_age_from_birthdate(self): - partner = self.partners.create( - {"name": "partner", "birthdate": self.today - relativedelta(years=34)} - ) - - self.assertEqual(partner.display_age, "34 Years") - self.assertEqual(partner.deceased, False) - - def test_age_from_age(self): - partner = self.partners.create({"name": "partner", "age": 34}) - - self.assertEqual(partner.display_age, "34 Years") - self.assertEqual(partner.age_init, 34) - self.assertEqual(partner.age_init_date, self.today) - - def test_update_from_age_to_birthdate(self): - partner = self.partners.create({"name": "partner", "age": 34}) - - partner.update({"birthdate": self.today - relativedelta(years=36)}) - - self.assertEqual(partner.display_age, "36 Years") - self.assertFalse(partner.age_init) - self.assertFalse(partner.age_init_date) - - def test_update_from_birthdate_to_age(self): - partner = self.partners.create( - {"name": "partner", "birthdate": self.today - relativedelta(years=36)} - ) - - partner.update({"age": 34}) - - self.assertFalse(partner.birthdate) - - def test_deceased(self): - partner = self.partners.create( - { - "name": "partner", - "birthdate": self.today - relativedelta(years=62), - "deceased_date": self.today - relativedelta(years=2), - } - ) - - self.assertEqual(partner.display_age, "60 Years") - self.assertEqual(partner.deceased, True) - - def test_check_deceased(self): - with self.assertRaises(ValidationError): - self.partners.create( - { - "name": "partner", - "birthdate": self.today - relativedelta(years=34), - "deceased_date": self.today - relativedelta(years=47), - } - ) - with self.assertRaises(ValidationError): - self.partners.create( - { - "name": "partner", - "deceased_date": self.today + relativedelta(days=47), - } - ) From d24c7ccc9cee546ea7ba30e7f8761094732cfb7a Mon Sep 17 00:00:00 2001 From: wachi Date: Mon, 19 Oct 2026 12:37:20 +0700 Subject: [PATCH 3/7] [FIX] survey_subject: show "Answer Survey" on active surveys only The button's invisible condition compared active with the string 'False', which never matches a boolean, so the button also showed on archived surveys. Compare with False. Refs: nirun-life/nirun#135 --- survey_subject/__manifest__.py | 2 +- survey_subject/wizard/survey_subject_views.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/survey_subject/__manifest__.py b/survey_subject/__manifest__.py index 31e9a2a12..67ee7b750 100644 --- a/survey_subject/__manifest__.py +++ b/survey_subject/__manifest__.py @@ -3,7 +3,7 @@ { "name": "Survey - Subject", "summary": "Survey subjects and respondent management", - "version": "16.0.0.3.1", + "version": "16.0.0.3.2", "development_status": "Alpha", "category": "Marketing/Surveys", "author": "NSTDA, Piruin P.", diff --git a/survey_subject/wizard/survey_subject_views.xml b/survey_subject/wizard/survey_subject_views.xml index 44adc1f57..0229d1637 100644 --- a/survey_subject/wizard/survey_subject_views.xml +++ b/survey_subject/wizard/survey_subject_views.xml @@ -126,7 +126,7 @@