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
19 changes: 19 additions & 0 deletions ni_observation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ fields.
- `wizard/ni_observation_wizard_views.xml` provides guided observation entry.
- `report/ni_patient_observation_view.xml`, `report/ni_encounter_observation_view.xml`,
and `report/summary_report.xml` provide patient and encounter reporting.
- `ni.patient.observation` and `ni.encounter.observation` (`report/`) are SQL views of
the latest valued observation per `(patient_id, type_id)` and per
`(encounter_id, type_id)`: highest `occurrence`, ties broken by highest `id`. Rows
without `type_id` or `occurrence` are left out, and the encounter view leaves out
observations without an encounter.
- `static/src/component/list_renderer/*` and `static/src/scss/observation.scss`
customize the backend list presentation.

Expand Down Expand Up @@ -62,10 +67,24 @@ patching it.
Laboratory pages. Only `web_notebook_advance` shows it as a counter; without that
module the views still load and the badge is not shown.

## Pitfalls

- The latest-observation views use `NOT EXISTS` instead of `DISTINCT ON`, so PostgreSQL
can apply the ORM's filter on `id` or `survey_response_id` before it picks the latest
row. Do not switch back to `DISTINCT ON`: every read then rebuilds the view from the
whole table.
- The anti-joins need the indexes `ni_observation__patient__ob_type__idx` and
`ni_observation__encounter__ob_type__idx` on `(<key>, type_id, occurrence DESC, id)`,
declared in `ni.observation.init()`. `tools.create_index()` skips a name that already
exists, so a database with an older definition under the same name needs a manual
rebuild.

## Verification

- Review `ni_observation/tests/test_compute_code.py` and
`ni_observation/tests/test_inverse_value.py` after changing computed fields, inverse
methods, or unit logic.
- Run `ni_observation/tests/test_latest_observation.py` after changing the
latest-observation views.
- Re-check observation entry, sheet views, and patient or encounter reports after
changes to reference data or UI components.
2 changes: 1 addition & 1 deletion ni_observation/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"name": "Observation",
"summary": "Clinical observations including vital signs, lab results, "
"and measurements",
"version": "17.0.1.0.1",
"version": "17.0.1.0.2",
"development_status": "Alpha",
"category": "Healthcare",
"author": "NSTDA, Piruin P.",
Expand Down
4 changes: 2 additions & 2 deletions ni_observation/models/ni_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ def _compute_history(self):
for rec in self:
rec.history_ids = self.env["ni.observation"].search(
[
("patient_id", "=", self.patient_id.id),
("type_id", "=", self.type_id.get_display_type().ids),
("patient_id", "=", rec.patient_id.id),
("type_id", "in", rec.type_id.get_display_type().ids),
("value", "!=", False),
],
order="occurrence desc",
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
22 changes: 18 additions & 4 deletions ni_observation/report/ni_encounter_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,27 @@ class EncounterObservationLatest(models.Model):
_inherit = ["ni.observation.report"]

def init(self):
# NOT EXISTS instead of DISTINCT ON: PostgreSQL cannot push the ORM's
# filter on id or survey_response_id into a DISTINCT ON subquery, so
# each read rebuilt the view from the whole table.
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(
"""CREATE OR REPLACE VIEW %s AS (
SELECT DISTINCT ON (encounter_id, type_id) *
FROM ni_observation
WHERE value IS NOT NULL
ORDER BY encounter_id, type_id, occurrence DESC, id DESC
SELECT o.*
FROM ni_observation o
WHERE o.value IS NOT NULL
AND o.encounter_id IS NOT NULL
AND o.type_id IS NOT NULL
AND o.occurrence IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM ni_observation n
WHERE n.encounter_id = o.encounter_id
AND n.type_id = o.type_id
AND n.value IS NOT NULL
AND (n.occurrence > o.occurrence
OR (n.occurrence = o.occurrence AND n.id > o.id))
)
)
"""
% (self._table)
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
21 changes: 17 additions & 4 deletions ni_observation/report/ni_patient_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@ class EncounterObservationLatest(models.Model):
_inherit = ["ni.observation.report"]

def init(self):
# NOT EXISTS instead of DISTINCT ON: PostgreSQL cannot push the ORM's
# filter on id or survey_response_id into a DISTINCT ON subquery, so
# each read rebuilt the view from the whole table.
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute(
"""CREATE OR REPLACE VIEW %s AS (
SELECT DISTINCT ON (patient_id, type_id) *
FROM ni_observation
WHERE value IS NOT NULL
ORDER BY patient_id, type_id, occurrence DESC, id DESC
SELECT o.*
FROM ni_observation o
WHERE o.value IS NOT NULL
AND o.type_id IS NOT NULL
AND o.occurrence IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM ni_observation n
WHERE n.patient_id = o.patient_id
AND n.type_id = o.type_id
AND n.value IS NOT NULL
AND (n.occurrence > o.occurrence
OR (n.occurrence = o.occurrence AND n.id > o.id))
)
)
"""
% (self._table)
Expand Down
1 change: 1 addition & 0 deletions ni_observation/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
from . import test_compute_code
from . import test_inverse_value
from . import test_latest_observation
from . import test_observation_history
from . import test_observation_display_order
105 changes: 95 additions & 10 deletions ni_observation/tests/test_latest_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,7 @@ def setUpClass(cls):
super().setUpClass()
partner = cls.env["res.partner"].create({"name": "Latest Test Patient"})
cls.patient = cls.env["ni.patient"].create({"partner_id": partner.id})
cls.encounter = cls.env["ni.encounter"].create(
{
"patient_id": cls.patient.id,
"class_id": cls.env.ref("ni_patient.class_IMP").id,
"period_start": fields.Datetime.now(),
}
)
cls.encounter = cls._create_encounter(cls.patient)
cls.type = cls.env["ni.observation.type"].create(
{
"name": "Latest Test",
Expand All @@ -28,11 +22,24 @@ def setUpClass(cls):
}
)

def _observe(self, value, occurrence):
@classmethod
def _create_encounter(cls, patient):
return cls.env["ni.encounter"].create(
{
"patient_id": patient.id,
"class_id": cls.env.ref("ni_patient.class_IMP").id,
"period_start": fields.Datetime.now(),
}
)

def _observe(self, value, occurrence, patient=None, encounter=None):
patient = patient or self.patient
if encounter is None:
encounter = self.encounter
return self.env["ni.observation"].create(
{
"patient_id": self.patient.id,
"encounter_id": self.encounter.id,
"patient_id": patient.id,
"encounter_id": encounter.id,
"type_id": self.type.id,
"occurrence": occurrence,
"value_int": value,
Expand Down Expand Up @@ -73,3 +80,81 @@ def test_same_occurrence_picks_the_last_record(self):
lambda o: o.type_id == self.type
)
self.assertEqual(rows.mapped("value_int"), [2])

def _views(self):
return (
("ni.patient.observation", "patient_id", self.patient),
("ni.encounter.observation", "encounter_id", self.encounter),
)

def test_later_occurrence_wins_over_higher_id(self):
last = self._observe(1, "2025-01-02 08:00:00")
self._observe(2, "2025-01-01 08:00:00")
for model, field, record in self._views():
self.assertEqual(self._latest(model, field, record).ids, [last.id])

def test_newer_observation_without_value_is_ignored(self):
last = self._observe(1, "2025-01-01 08:00:00")
self._observe(0, "2025-01-02 08:00:00")
for model, field, record in self._views():
self.assertEqual(self._latest(model, field, record).ids, [last.id])

def test_other_patient_does_not_hide_the_latest(self):
partner = self.env["res.partner"].create({"name": "Latest Other Patient"})
other_patient = self.env["ni.patient"].create({"partner_id": partner.id})
other_encounter = self._create_encounter(other_patient)
last = self._observe(1, "2025-01-01 08:00:00")
self._observe(2, "2025-01-02 08:00:00", other_patient, other_encounter)
for model, field, record in self._views():
self.assertEqual(self._latest(model, field, record).ids, [last.id])

def test_search_by_id_returns_only_the_latest(self):
"""The ORM reads these views by id, so a filter on id alone must
still drop the rows that are not the latest of their group."""
first = self._observe(1, "2025-01-01 08:00:00")
last = self._observe(2, "2025-01-02 08:00:00")
self.env.flush_all()
for model, _field, _record in self._views():
rows = self.env[model].search([("id", "in", [first.id, last.id])])
self.assertEqual(rows.ids, [last.id])
self.assertEqual(rows.read(["value_int"])[0]["value_int"], 2)

def test_encounter_view_skips_observation_without_encounter(self):
no_encounter = self._observe(
1, "2025-01-01 08:00:00", encounter=self.env["ni.encounter"]
)
self.env.flush_all()
domain = [("id", "=", no_encounter.id)]
self.assertFalse(self.env["ni.encounter.observation"].search(domain))
self.assertTrue(self.env["ni.patient.observation"].search(domain))

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))
56 changes: 56 additions & 0 deletions ni_observation/tests/test_observation_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Copyright (c) 2026 NSTDA

from odoo import fields
from odoo.tests import common


class TestObservationHistory(common.TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
partner = cls.env["res.partner"].create({"name": "History Test Patient"})
cls.patient = cls.env["ni.patient"].create({"partner_id": partner.id})
cls.encounter = cls.env["ni.encounter"].create(
{
"patient_id": cls.patient.id,
"class_id": cls.env.ref("ni_patient.class_IMP").id,
"period_start": fields.Datetime.now(),
}
)
cls.type_a, cls.type_b = cls.env["ni.observation.type"].create(
[
{
"name": f"History Test {code}",
"code": f"test-history-{code}",
"value_type": "int",
"min": 0,
"max": 100,
}
for code in ("a", "b")
]
)

def _observe(self, obs_type, value, occurrence):
return self.env["ni.observation"].create(
{
"patient_id": self.patient.id,
"encounter_id": self.encounter.id,
"type_id": obs_type.id,
"occurrence": occurrence,
"value_int": value,
}
)

def test_history_of_several_observations(self):
"""Each observation's history holds its own type only, also when
several observations are read together."""
a1 = self._observe(self.type_a, 1, "2025-01-01 08:00:00")
a2 = self._observe(self.type_a, 2, "2025-01-02 08:00:00")
b1 = self._observe(self.type_b, 3, "2025-01-02 08:00:00")
self.env.invalidate_all()
observations = a2 | b1
observations.mapped("history_ids")
self.assertEqual(a2.history_ids, a2 | a1)
self.assertEqual(a2.history_count, 2)
self.assertEqual(b1.history_ids, b1)
self.assertEqual(b1.history_count, 1)
11 changes: 11 additions & 0 deletions ni_patient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ and requests into a single patient history.
`wizard/ni_patient_archive_wizard_views.xml` support guided encounter and archive
actions.

## Database Indexes

`original_id` on `ir.attachment` (`models/ir_attachment.py`) uses
`index="btree_not_null"` so deleting an attachment can check that `ON DELETE SET NULL`
foreign key without a table scan. The generated name (`<table>_<column>_index`) matches
the index already on production; do not rename it.

Since 17.0, `message_main_attachment_id` belongs to `mail.thread.main.attachment`, which
`ni.patient` and `res.partner` do not inherit, so the 16.0 indexes on that field are not
declared here.

## Dependencies

- `ni_coding`
Expand Down
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": "17.0.1.0.1",
"version": "17.0.1.0.2",
"development_status": "Alpha",
"category": "Medical",
"author": "NSTDA, Piruin P.",
Expand Down
1 change: 1 addition & 0 deletions ni_patient/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from . import ni_location
from . import ni_location_type
from . import res_partner
from . import ir_attachment
from . import res_company
from . import ni_patient_res
from . import ni_workflow
Expand Down
10 changes: 10 additions & 0 deletions ni_patient/models/ir_attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copyright (c) 2026 NSTDA

from odoo import fields, models


class IrAttachment(models.Model):
_inherit = "ir.attachment"

# Deleting an attachment checks this ON DELETE SET NULL foreign key.
original_id = fields.Many2one(index="btree_not_null")
Loading
Loading