Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .tools/error_entry_archived_patients.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- Copyright (c) 2026 NSTDA
--
-- Patients that "Error Entry" may have archived by mistake (nirun-life/nirun#135, F4).
--
-- From b25bb607 (2023-12-20) until the 16.0 fix, marking an encounter as
-- entered-in-error also archived its patient, and through _inherits that hid all
-- of the patient's encounters. The archive wizard always sets state_reason_id, so
-- an archived patient without one that has an entered-in-error encounter is a
-- likely victim.
--
-- Read-only. Nothing is reactivated automatically: support checks each patient
-- and reactivates the confirmed ones from the UI, so the change is tracked in the
-- chatter. Run it before the OpenUpgrade 16 -> 17 step:
--
-- psql -d <database> -f .tools/error_entry_archived_patients.sql
--
-- The output holds patient identifiers. Share it only through support's own
-- channel, never in an issue, a pull request or a log attached to one.

SELECT p.id, p.identifier
FROM ni_patient p
WHERE p.active IS NOT TRUE
AND p.state_reason_id IS NULL
AND EXISTS (
SELECT 1 FROM ni_encounter e
WHERE e.patient_id = p.id AND e.state = 'entered-in-error'
)
ORDER BY p.id;
5 changes: 0 additions & 5 deletions .tools/migration_sample_data_p1.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,23 +188,18 @@ def encounter(patient, klass, start, location=None):
"patient_id": patient.id,
"class_id": klass.id,
"period_start": start,
# ni.period.mixin gives period_start_date a default (today), which wins
# over its compute when a record is created from code.
"period_start_date": start.date(),
"location_id": (location or locations[0]).id,
}
if patient.other_address_id:
vals["other_address_id"] = patient.other_address_id.id
enc = env["ni.encounter"].create(vals)
enc.location_history_ids.write({"period_start_date": start.date()})
enc.participant_ids = [
(
0,
0,
{
"employee_id": employees[0].id,
"period_start": start,
"period_start_date": start.date(),
},
)
]
Expand Down
2 changes: 1 addition & 1 deletion ni_observation/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"name": "Observation",
"summary": "Clinical observations including vital signs, lab results, and measurements",
"version": "16.0.0.6.1",
"version": "16.0.0.6.2",
"development_status": "Alpha",
"category": "Healthcare",
"author": "NSTDA, Piruin P.",
Expand Down
4 changes: 3 additions & 1 deletion ni_observation/models/ni_observation_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ class ObservationType(models.Model):
_inherit = ["ni.coding"]
_parent_store = True

parent_id = fields.Many2one("ni.observation.category", index=True, ondelete="")
parent_id = fields.Many2one(
"ni.observation.category", index=True, ondelete="set null"
)
parent_path = fields.Char(index=True, unaccent=False)

child_ids = fields.One2many("ni.observation.category", "parent_id", index=True)
Expand Down
2 changes: 1 addition & 1 deletion ni_observation/report/ni_observation_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class ObservationReport(models.AbstractModel):

def _compute_observation(self):
for rec in self:
obs = self.env["ni.observation"].browse(self.id)
obs = self.env["ni.observation"].browse(rec.id)
if obs:
rec.update(
{
Expand Down
31 changes: 31 additions & 0 deletions ni_observation/tests/test_latest_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,34 @@ def test_same_occurrence_picks_the_last_record(self):
lambda o: o.type_id == self.type
)
self.assertEqual(rows.mapped("value_int"), [2])

def test_compute_observation_on_many_rows(self):
"""Each latest row computes its own observation and history, also when
several rows are read together."""
other_type = self.type.copy({"name": "Latest Test 2", "code": "test-latest-2"})
first = self._observe(1, "2025-01-01 08:00:00")
last = self._observe(2, "2025-01-02 08:00:00")
other = self.env["ni.observation"].create(
{
"patient_id": self.patient.id,
"encounter_id": self.encounter.id,
"type_id": other_type.id,
"occurrence": "2025-01-02 08:00:00",
"value_int": 3,
}
)
self.env.flush_all()
self.env.invalidate_all()
rows = self.env["ni.patient.observation"].search(
[
("patient_id", "=", self.patient.id),
("type_id", "in", (self.type | other_type).ids),
]
)
self.assertEqual(len(rows), 2)
by_type = {row.type_id: row for row in rows}
self.assertEqual(by_type[self.type].observation_id, last)
self.assertEqual(by_type[other_type].observation_id, other)
self.assertEqual(by_type[self.type].history_ids, last.history_ids)
self.assertIn(first, by_type[self.type].history_ids)
self.assertEqual(by_type[other_type].history_count, len(other.history_ids))
2 changes: 1 addition & 1 deletion ni_patient/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"name": "Patient",
"summary": "Core patient data model, encounters, and encounter management",
"version": "16.0.0.4.0",
"version": "16.0.0.4.1",
"development_status": "Alpha",
"category": "Medical",
"author": "NSTDA, Piruin P.",
Expand Down
19 changes: 15 additions & 4 deletions ni_patient/models/ni_encounter.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,9 +518,20 @@ def _get_state_label(self):
def check_origin_date(self):
for rec in self:
if rec.origin_date and not rec.origin_partner_id:
raise _("Transfer from must not be null when transfer at is present")
if rec.origin_date and rec.origin_date > rec.period_start:
raise _("Transfer date must not be after encounter start date")
raise ValidationError(
_("Transfer from must not be null when transfer at is present")
)
# origin_date is a Date: compare it with the start date as the
# user sees it, not with the UTC datetime.
if (
rec.origin_date
and rec.period_start
and rec.origin_date
> fields.Datetime.context_timestamp(rec, rec.period_start).date()
):
raise ValidationError(
_("Transfer date must not be after encounter start date")
)

@api.constrains(
"discharge_status_id",
Expand Down Expand Up @@ -729,7 +740,7 @@ def action_close(self, vals=None):
)

def action_entered_in_error(self):
self.write({"state": "entered-in-error", "active": False})
self.write({"state": "entered-in-error"})

def action_reset_to_draft(self):
self.write({"state": "draft"})
Expand Down
2 changes: 2 additions & 0 deletions ni_patient/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from . import (
test_ni_encounter_class_auto_close,
test_ni_encounter_class_auto_close_edge_cases,
test_ni_encounter_entered_in_error,
test_ni_encounter_origin_date,
test_ni_location_name_search,
test_ni_patient,
test_ni_patient_archive_wizard,
Expand Down
26 changes: 26 additions & 0 deletions ni_patient/tests/test_ni_encounter_entered_in_error.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copyright (c) 2026 NSTDA

from odoo.tests import common


class TestEncounterEnteredInError(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
partner = cls.env["res.partner"].create({"name": "Error Entry Patient"})
cls.patient = cls.env["ni.patient"].create({"partner_id": partner.id})
cls.enc_class = cls.env["ni.encounter.class"].search([], limit=1)

def _make_encounter(self, **vals):
return self.env["ni.encounter"].create(
dict(
{"patient_id": self.patient.id, "class_id": self.enc_class.id},
**vals,
)
)

def test_entered_in_error_keeps_the_patient_active(self):
encounter = self._make_encounter()
encounter.action_entered_in_error()
self.assertEqual(encounter.state, "entered-in-error")
self.assertTrue(self.patient.active)
68 changes: 68 additions & 0 deletions ni_patient/tests/test_ni_encounter_origin_date.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright (c) 2026 NSTDA

from datetime import date, datetime

from odoo.exceptions import ValidationError
from odoo.tests import common


class TestEncounterOriginDate(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tz="Asia/Bangkok"))
cls.partner = cls.env["res.partner"].create({"name": "Origin Date Patient"})
cls.patient = cls.env["ni.patient"].create({"partner_id": cls.partner.id})
cls.hospital = cls.env["res.partner"].create(
{"name": "Origin Hospital", "is_company": True}
)
cls.enc_class = cls.env["ni.encounter.class"].search([], limit=1)

def _make_encounter(self, **vals):
return self.env["ni.encounter"].create(
dict(
{"patient_id": self.patient.id, "class_id": self.enc_class.id},
**vals,
)
)

def test_origin_date_requires_origin_partner(self):
with self.assertRaises(ValidationError):
self._make_encounter(origin_date=date(2026, 10, 1))

def test_origin_date_after_period_start(self):
with self.assertRaises(ValidationError):
self._make_encounter(
origin_partner_id=self.hospital.id,
origin_date=date(2026, 10, 2),
period_start=datetime(2026, 10, 1, 3, 0),
)

def test_origin_date_on_or_before_period_start(self):
for origin_date in (date(2026, 9, 30), date(2026, 10, 1)):
encounter = self._make_encounter(
origin_partner_id=self.hospital.id,
origin_date=origin_date,
period_start=datetime(2026, 10, 1, 3, 0),
)
self.assertEqual(encounter.origin_date, origin_date)

def test_origin_date_uses_user_timezone(self):
# 2026-10-01 20:00 UTC is 2026-10-02 03:00 in Bangkok.
encounter = self._make_encounter(
origin_partner_id=self.hospital.id,
origin_date=date(2026, 10, 2),
period_start=datetime(2026, 10, 1, 20, 0),
)
self.assertEqual(encounter.origin_date, date(2026, 10, 2))

def test_origin_date_without_period_start(self):
encounter = self._make_encounter(period_start=datetime(2026, 10, 1, 3, 0))
encounter.write(
{
"period_start": False,
"origin_partner_id": self.hospital.id,
"origin_date": date(2026, 10, 2),
}
)
self.assertEqual(encounter.origin_date, date(2026, 10, 2))
2 changes: 1 addition & 1 deletion ni_patient/views/ni_encounter_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
<field name="location_id" />
<separator />
<filter name="period_start" string="Since" date="period_start" />
<filter name="period_end" string="Until" date="period_start" />
<filter name="period_end" string="Until" date="period_end" />
<separator />
<filter
name="encounter"
Expand Down
2 changes: 1 addition & 1 deletion ni_period/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
68 changes: 68 additions & 0 deletions ni_period/migrations/16.0.0.1.1/end-migration.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 0 additions & 1 deletion ni_period/models/ni_period_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions ni_period/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (c) 2026 NSTDA

from . import test_ni_period_start_date
23 changes: 23 additions & 0 deletions ni_period/tests/models.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading