Skip to content

[17.0][MIG] ni_questionnaire: Migration to 17.0 - #159

Merged
piruin merged 33 commits into
nirun-life:17.0from
wasud:17.0-mig-p1-questionnaire
Oct 21, 2026
Merged

piruin merged 33 commits into
nirun-life:17.0from
wasud:17.0-mig-p1-questionnaire

Conversation

@wasud

@wasud wasud commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Decisions to review

What changed

  • ni_questionnaire history replayed, then [IMP] pre-commit and [MIG] ni_questionnaire: Migration to 17.0.
  • Migrator: attrs → expressions (including the <attribute name="attrs"> override on the response form), version.
  • Manual work:
    • Conditional questions: the tests now use triggering_answer_ids (17 removed is_conditional, triggering_question_id, triggering_answer_id).
    • Encounter compute assigns instead of write().
    • Hidden list fields use column_invisible.
    • Thai text the migrator encoded as entities restored.
  • Reports:
    • The two "latest survey" SQL views select an explicit column list from survey_user_input (SQL() with identifiers), instead of SELECT … *.
    • The monthly line report keeps only its _table_query (its init() created the same view a second time).
  • New tests tests/test_encounter_survey.py: start creates and reuses the encounter's response (with patient, encounter, current user); print uses it; the latest response follows completion.
  • Lint: named translation placeholders, init() returns super(), redundant labels.
  • th.po regenerated (new msgids filled).

Why

Part of #140 (P1-5, epic #131).

Migration notes

  • Effort: L.
  • What the migrator did: attrs in 9 view files.
  • Manual work:
    • conditional tests;
    • the encounter flow (4 pre-existing bugs);
    • report SQL;
    • selection_add;
    • lint backlog.
  • Surprises:
    • The encounter start/print flow never worked as designed on 16.0. survey_token was never an Odoo API, and the patient partner blocks the clinician.
    • Stored computes over SQL views read stale data.

How tested

  • Unit/integration tests: 32 tests pass on 17 (29 existing + 3 new), fresh DB.
  • Manual check (browser, Odoo 17 with demo data). Questionnaire with a conditional question, a score → observation mapping and two grades.
    • Encounter "Start Questionnaire" opens the encounter's response.
    • Answering "Yes" shows the conditional question; submit.
    • Score 10, grade "High", grading reference shown.
    • Observation "Smoke Score = 10" created on the encounter.
    • "Print Questionnaire" appears and prints the answers.
    • Encounter Questionnaire tab (latest survey: grade, score), monthly pivot report.
    • No JS console errors.
  • pre-commit clean on ni_questionnaire.

Documentation

  • Module README.md updated (17.0, subject types, encounter flow, report views, tests)

Notes for reviewer

@piruin piruin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of ni_questionnaire only (the patient/observation/survey commits belong to the stacked PRs).

Blocking — clinicians can't start the questionnaire from the encounter on 17.0 (models/ni_encounter.py, action_start_survey)

self.survey_id._create_answer(...) runs as the logged-in user without sudo(). In 17.0 core, survey_user_input_rule_survey_user_cw only allows a survey officer to create a survey.user_input for a survey they created (survey_id.create_uid = user). Clinicians only get survey.group_survey_user (implied by ni_patient.group_viewer), so anyone who did not create the survey gets an AccessError. The survey_subject wizard works because it calls self.sudo().survey_id._create_answer(...). The new tests run as superuser (record rules skipped) and the manual check used admin, so neither covers this. Since the PR states the start-from-encounter flow now works, this is in scope even though 16.0 has the same bug.

Suggested: .sudo() like the wizard, plus a test that calls encounter.with_user(<user in ni_patient.group_user only>).action_start_survey(). (18.0/19.0 core lets officers create on unrestricted surveys, so #160/#161 are not affected.)

Medium — subject wizard now shows the patient/encounter pickers that 16.0 hid (wizard/survey_subject_views.xml)

On 16.0, subject_ni_patient / subject_ni_encounter had invisible="context.get('default_subject_ni_…')" next to attrs. A truthy context value overrode the attrs domain (16.0 core ir_ui_view.py, context-dependent modifiers). The 17.0 conversion kept only type not in [...], so opening the wizard from an encounter or observation ("Start Survey") now shows editable patient/encounter fields. Suggested:

invisible="context.get('default_subject_ni_patient') or type not in ['ni.patient', 'ni.encounter']"

(and the same with default_subject_ni_encounter for subject_ni_encounter). This is the only element in the module that had both.

Low — the latest-response view and compute break ties differently (report/ni_patient_survey_latest.py)

The survey_latest views order by create_date DESC with no tie-breaker, while ni.encounter._compute_response sorts by (create_date, id). If two done responses share a create_date (e.g. created in one transaction), response_latest_id can point to a row the view's DISTINCT ON dropped. Adding , id DESC to the view's ORDER BY aligns them (and matches the index, which already includes id).

Nit — the error branch of _answer_group_observation raises AttributeError (models/survey_user_input.py)

code is group.observation_code_id, already an ni.observation.type, which has no observation_type_id. So code.observation_type_id.name / .value_type crash instead of raising the ValidationError. Mostly unreachable thanks to the field domain, but these lines were just rewritten; use code.name / code.value_type.

Note (non-blocking) — creating the answer with user=env.user counts attempts against the clinician's partner, not the patient's. On a survey with limited attempts, a clinician runs out across patients. The survey_subject wizard behaves the same way.

Checked and fine: attrs conversions (apart from the wizard), selection_add with ondelete, action_start_survey(answer=) / action_print_survey(answer=), compute dependencies, the post-migration recompute, and the explicit view column list (covers every stored field of the view model).

@wasud
wasud force-pushed the 17.0-mig-p1-questionnaire branch 2 times, most recently from bd39466 to d4997b7 Compare October 21, 2026 01:59
@wasud

wasud commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Pushed (head d4997b7e); the fixes are in [MIG] ni_questionnaire: Migration to 17.0, and #160/#161 replay them.

  • Blocking, start from the encounter: action_start_survey now calls self.survey_id.sudo()._create_answer(...), like the survey_subject wizard. New test test_start_as_clinician runs encounter.with_user(clinician).action_start_survey() with a user in ni_patient.group_user only, who did not create the survey. Before the fix it raised the AccessError; now it passes. The response is still created for the clinician's partner, so core lets them in.
  • Subject wizard pickers: subject_ni_patient / subject_ni_encounter are hidden again when default_subject_ni_patient / default_subject_ni_encounter is in the context, as you suggested.
  • Tie-breaker: the survey_latest views now order by create_date DESC, id DESC. New test test_latest_response_ties_on_create_date: two done responses created in one transaction share their create_date, and the view and response_latest_id both pick the last one. It failed before the fix.
  • Nit: the error branch of _answer_group_observation uses code.name / code.value_type. New test test_group_needs_a_numeric_observation_type (it raised AttributeError before, ValidationError now).
  • Note on attempts: left as is. The encounter flow and the survey_subject wizard both count attempts against the clinician's partner, so they still behave the same. If limited attempts per patient are needed, that is a separate change for both flows.
  • The same sudo() bug exists on 16.0. I can add it to the P0-4 fixes ([16.0][FIX] Pre-existing bugs found during the 19.0 migration scan #166) if you want it there too.

Tests: 93 pass on a fresh 17.0 database (ni_patient, ni_observation, ni_questionnaire, ni_partner_*); pre-commit passes.

poplacto and others added 15 commits October 21, 2026 10:56
Clinical instruments such as MMSE are not scored out of a fixed total: a
respondent with no schooling is not given three of the items, so the test
is scored out of 23 instead of 30 and the normal/abnormal cutoff moves
with education level, at 14, 17 or 22 points.

Core already shrinks the maximum, since _mark_done() drops the inactive
conditional questions from predefined_question_ids. What was missing is
everything downstream, so survey.grade gains two things: a points basis,
because a fixed points cutoff stops being a fixed percentage once the
total moves, and triggering_answer_ids, so one survey can hold several
sets of ranges keyed on the answer that decided which items were asked.
Those ranges have to overlap numerically, so the unique and overlap rules
move from _sql_constraints to Python keyed on _scope_key() - a Many2many
cannot take part in a SQL unique index.

Observations are built in _mark_done() after super(). The old write()
hook ran before core trimmed the denominator and so recorded the pre-trim
percentage.

survey.grade.interpretation_id lets the observation follow the grade
rather than ni.observation.reference.range, which scopes only by age and
gender and therefore cannot read a total whose maximum varies.

grading_basis, observation_answer_type and observation_score_type are
deliberately not required: a NOT NULL column on survey.survey or
survey.question breaks the tests of every module upstream of these two,
which run before either is in the registry.

Also corrects the value-mapped answer branch, which dispatched on
truthiness: a numerical answer of 0 was taken for "no answer given" and
datetime had no branch at all, both of which blocked survey completion.

The tests are modelled on MMSE-Thai 2002 itself so the shrinking maximum
and the three cutoffs are checked against a real instrument. Stock Odoo
16 gates a conditional question on a single answer, so the skipped items
hang off a binary "Attended school?" question while the cutoff bands hang
off a separate three-level "Education level" question.

Grade UI follows: triggering answers read "Question: Answer" since many
surveys reuse "Yes"/"No" across questions; a scoped grade is flagged in
the tree with the fa-code-fork icon the question tree already uses, with
the answers themselves on a "Conditional" tab of the grade form; and the
patient scoping (gender, age, interpretation) is reachable from the grade
form too, gender drawn as a mars/venus icon in the tree.

Both modules are committed together because the change is not separable -
ni_questionnaire overrides the very constraint and grade lookup that
survey_grading reworks, so neither half passes its own tests alone.

Took 3 minutes

Took 8 minutes
Core gates the entire score block of the completion page on
scoring_success_min, but a graded questionnaire has no meaningful passing
percentage - a points-based instrument such as ADL leaves it at 0. The
badge sat inside that block and so never reached the page.

Widening the gate to `scoring_success_min or grade_ids` is preferred over
moving the badge out of the block, because survey_subject rewrites the
same column through a positional t[1]/div xpath: inserting any element
ahead of that target breaks it with "cannot be located in parent view".
It also brings back survey_subject's own score line, which shared the
gate.

Passed/failed stays tied to scoring_success_min. Without one,
scoring_success is 0 >= 0 and every respondent would be congratulated,
and since a bare t-else always fires, the failure branch has to become a
t-elif on the same condition.

Both verdict specs address direct children of the scoring block rather
than matching t-else anywhere in the template. survey_subject carries a
t-else of its own and the apply order between the two modules follows
install order, so an unscoped match rewrites the wrong node - silently,
leaving a broken score line and an ungated verdict.

The guard for that combination lives in ni_questionnaire, the only module
that depends on both.

Took 49 minutes
grade_for() narrows the bands by the patient's gender and age, which is
invisible to whoever reads the result: two patients with the same score
can hold different grades with nothing on screen to explain it. Extend
grade_conditions() so the grading reference says which traits applied.

Only traits the survey actually grades differently by are named - a
gender when any band carries one, an age when the bands fall into more
than one window. Printing them unconditionally would put an age and a
gender on every result page that had no bearing on the grade.

Took 48 minutes
…upgrade

16.0 computed the encounter's latest response from a SQL view before the
response reached the database, so the stored response_latest_id, its grade and
date are empty or stale in 16.0 databases. 17.0 computes them from the
responses, but only when a response changes. Recompute them once in the 17.0
post-migration script, so that migrated encounters show their latest grade and
the "Print Questionnaire" button.

Found by the Phase 1 data migration pilot (nirun-life#141).
@wasud
wasud force-pushed the 17.0-mig-p1-questionnaire branch from d4997b7 to e0b294d Compare October 21, 2026 03:57
@piruin
piruin merged commit d623bbf into nirun-life:17.0 Oct 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants