Coverage for tasks/lynall_iam_medical.py: 52%
127 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 14:23 +0100
1"""
2camcops_server/tasks/lynall_iam_medical.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26"""
28from typing import Any, Dict, List, Optional, Union
30from sqlalchemy.orm import Mapped, mapped_column
31from sqlalchemy.sql.sqltypes import Integer, UnicodeText
33from camcops_server.cc_modules.cc_constants import CssClass
34from camcops_server.cc_modules.cc_html import (
35 get_yes_no,
36 get_yes_no_none,
37 tr_qa,
38)
39from camcops_server.cc_modules.cc_request import CamcopsRequest
40from camcops_server.cc_modules.cc_sqla_coltypes import (
41 mapped_bool_column,
42 mapped_camcops_column,
43 PermittedValueChecker,
44)
45from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
46from camcops_server.cc_modules.cc_text import SS
49# =============================================================================
50# Lynall1MedicalHistory
51# =============================================================================
54class LynallIamMedicalHistory(TaskHasPatientMixin, Task): # type: ignore[misc]
55 """
56 Server implementation of the Lynall1IamMedicalHistory task.
57 """
59 __tablename__ = "lynall_1_iam_medical" # historically fixed
60 shortname = "Lynall_IAM_Medical"
61 extrastring_taskname = "lynall_iam_medical"
62 info_filename_stem = extrastring_taskname
64 Q2_N_OPTIONS = 6
65 Q3_N_OPTIONS = 11
66 Q4_N_OPTIONS = 5
67 Q4_OPTION_PSYCH_BEFORE_PHYSICAL = 1
68 Q4_OPTION_PSYCH_AFTER_PHYSICAL = 2
69 Q8_N_OPTIONS = 2
70 Q7B_MIN = 1
71 Q7B_MAX = 10
73 q1_age_first_inflammatory_sx: Mapped[Optional[int]] = mapped_column(
74 comment="Age (y) at onset of first symptoms of inflammatory disease",
75 )
76 q2_when_psych_sx_started: Mapped[Optional[int]] = mapped_camcops_column(
77 permitted_value_checker=PermittedValueChecker(
78 minimum=1, maximum=Q2_N_OPTIONS
79 ),
80 comment="Timing of onset of psych symptoms (1 = NA, 2 = before "
81 "physical symptoms [Sx], 3 = same time as physical Sx but "
82 "before diagnosis [Dx], 4 = around time of Dx, 5 = weeks or "
83 "months after Dx, 6 = years after Dx)",
84 )
85 q3_worst_symptom_last_month: Mapped[Optional[int]] = mapped_camcops_column(
86 permitted_value_checker=PermittedValueChecker(
87 minimum=1, maximum=Q3_N_OPTIONS
88 ),
89 comment="Worst symptom in last month (1 = fatigue, 2 = low mood, 3 = "
90 "irritable, 4 = anxiety, 5 = brain fog/confused, 6 = pain, "
91 "7 = bowel Sx, 8 = mobility, 9 = skin, 10 = other, 11 = no Sx "
92 "in past month)",
93 )
94 q4a_symptom_timing: Mapped[Optional[int]] = mapped_camcops_column(
95 permitted_value_checker=PermittedValueChecker(
96 minimum=1, maximum=Q4_N_OPTIONS
97 ),
98 comment="Timing of brain/psych Sx relative to physical Sx (1 = brain "
99 "before physical, 2 = brain after physical, 3 = same time, "
100 "4 = no relationship, 5 = none of the above)",
101 )
102 q4b_days_psych_before_phys: Mapped[Optional[int]] = mapped_column(
103 comment="If Q4a == 1, number of days that brain Sx typically begin "
104 "before physical Sx",
105 )
106 q4c_days_psych_after_phys: Mapped[Optional[int]] = mapped_column(
107 comment="If Q4a == 2, number of days that brain Sx typically begin "
108 "after physical Sx",
109 )
110 q5_antibiotics: Mapped[Optional[bool]] = mapped_bool_column(
111 "q5_antibiotics",
112 comment="Medication for infection (e.g. antibiotics) in past 3 months?"
113 " (0 = no, 1 = yes)",
114 )
115 q6a_inpatient_last_y: Mapped[Optional[bool]] = mapped_bool_column(
116 "q6a_inpatient_last_y",
117 comment="Inpatient in the last year? (0 = no, 1 = yes)",
118 )
119 q6b_inpatient_weeks: Mapped[Optional[int]] = mapped_column(
120 comment="If Q6a is true, approximate number of weeks spent as an "
121 "inpatient in the past year",
122 )
123 q7a_sx_last_2y: Mapped[Optional[bool]] = mapped_bool_column(
124 "q7a_sx_last_2y",
125 comment="Symptoms within the last 2 years? (0 = no, 1 = yes)",
126 )
127 q7b_variability: Mapped[Optional[int]] = mapped_column(
128 comment="If Q7a is true, degree of variability of symptoms (1-10 "
129 "where 1 = highly variable [from none to severe], 10 = "
130 "there all the time)",
131 )
132 q8_smoking: Mapped[Optional[int]] = mapped_column(
133 comment="Current smoking status (0 = no, 1 = yes but not every day, "
134 "2 = every day)",
135 )
136 q9_pregnant: Mapped[Optional[bool]] = mapped_bool_column(
137 "q9_pregnant", comment="Currently pregnant (0 = no or N/A, 1 = yes)"
138 )
139 q10a_effective_rx_physical: Mapped[Optional[str]] = mapped_column(
140 UnicodeText,
141 comment="Most effective treatments for physical Sx",
142 )
143 q10b_effective_rx_psych: Mapped[Optional[str]] = mapped_column(
144 UnicodeText,
145 comment="Most effective treatments for brain/psychiatric Sx",
146 )
147 q11a_ph_depression: Mapped[Optional[bool]] = mapped_bool_column(
148 "q11a_ph_depression", comment="Personal history of depression?"
149 )
150 q11b_ph_bipolar: Mapped[Optional[bool]] = mapped_bool_column(
151 "q11b_ph_bipolar", comment="Personal history of bipolar disorder?"
152 )
153 q11c_ph_schizophrenia: Mapped[Optional[bool]] = mapped_bool_column(
154 "q11c_ph_schizophrenia", comment="Personal history of schizophrenia?"
155 )
156 q11d_ph_autistic_spectrum: Mapped[Optional[bool]] = mapped_bool_column(
157 "q11d_ph_autistic_spectrum",
158 comment="Personal history of autism/Asperger's?",
159 )
160 q11e_ph_ptsd: Mapped[Optional[bool]] = mapped_bool_column(
161 "q11e_ph_ptsd", comment="Personal history of PTSD?"
162 )
163 q11f_ph_other_anxiety: Mapped[Optional[bool]] = mapped_bool_column(
164 "q11f_ph_other_anxiety",
165 comment="Personal history of other anxiety disorders?",
166 )
167 q11g_ph_personality_disorder: Mapped[Optional[bool]] = mapped_bool_column(
168 "q11g_ph_personality_disorder",
169 comment="Personal history of personality disorder?",
170 )
171 q11h_ph_other_psych: Mapped[Optional[bool]] = mapped_bool_column(
172 "q11h_ph_other_psych",
173 comment="Personal history of other psychiatric disorder(s)?",
174 )
175 q11h_ph_other_detail: Mapped[Optional[str]] = mapped_column(
176 UnicodeText,
177 comment="If q11h_ph_other_psych is true, this is the free-text "
178 "details field",
179 )
180 q12a_fh_depression: Mapped[Optional[bool]] = mapped_bool_column(
181 "q12a_fh_depression", comment="Family history of depression?"
182 )
183 q12b_fh_bipolar: Mapped[Optional[bool]] = mapped_bool_column(
184 "q12b_fh_bipolar", comment="Family history of bipolar disorder?"
185 )
186 q12c_fh_schizophrenia: Mapped[Optional[bool]] = mapped_bool_column(
187 "q12c_fh_schizophrenia", comment="Family history of schizophrenia?"
188 )
189 q12d_fh_autistic_spectrum: Mapped[Optional[bool]] = mapped_bool_column(
190 "q12d_fh_autistic_spectrum",
191 comment="Family history of autism/Asperger's?",
192 )
193 q12e_fh_ptsd: Mapped[Optional[bool]] = mapped_bool_column(
194 "q12e_fh_ptsd", comment="Family history of PTSD?"
195 )
196 q12f_fh_other_anxiety: Mapped[Optional[bool]] = mapped_bool_column(
197 "q12f_fh_other_anxiety",
198 comment="Family history of other anxiety disorders?",
199 )
200 q12g_fh_personality_disorder: Mapped[Optional[bool]] = mapped_bool_column(
201 "q12g_fh_personality_disorder",
202 comment="Family history of personality disorder?",
203 )
204 q12h_fh_other_psych: Mapped[Optional[bool]] = mapped_bool_column(
205 "q12h_fh_other_psych",
206 comment="Family history of other psychiatric disorder(s)?",
207 )
208 q12h_fh_other_detail: Mapped[Optional[str]] = mapped_column(
209 UnicodeText,
210 comment="If q12h_fh_other_psych is true, this is the free-text "
211 "details field",
212 )
213 q13a_behcet: Mapped[Optional[bool]] = mapped_bool_column(
214 "q13a_behcet", comment="Behçet’s syndrome? (0 = no, 1 = yes)"
215 )
216 q13b_oral_ulcers: Mapped[Optional[bool]] = mapped_bool_column(
217 "q13b_oral_ulcers",
218 comment="(If Behçet’s) Oral ulcers? (0 = no, 1 = yes)",
219 )
220 q13c_oral_age_first: Mapped[Optional[int]] = mapped_column(
221 comment="(If Behçet’s + oral) Age (y) at first oral ulcers",
222 )
223 q13d_oral_scarring: Mapped[Optional[bool]] = mapped_bool_column(
224 "q13d_oral_scarring",
225 comment="(If Behçet’s + oral) Oral scarring? (0 = no, 1 = yes)",
226 )
227 q13e_genital_ulcers: Mapped[Optional[bool]] = mapped_bool_column(
228 "q13e_genital_ulcers",
229 comment="(If Behçet’s) Genital ulcers? (0 = no, 1 = yes)",
230 )
231 q13f_genital_age_first: Mapped[Optional[int]] = mapped_column(
232 Integer,
233 comment="(If Behçet’s + genital) Age (y) at first genital ulcers",
234 )
235 q13g_genital_scarring: Mapped[Optional[bool]] = mapped_bool_column(
236 "q13g_genital_scarring",
237 comment="(If Behçet’s + genital) Genital scarring? (0 = no, 1 = yes)",
238 )
240 @staticmethod
241 def longname(req: "CamcopsRequest") -> str:
242 _ = req.gettext
243 return _("Lynall M-E — 1 — IAM — Medical history")
245 def is_complete(self) -> bool:
246 if self.any_fields_none(
247 [
248 "q1_age_first_inflammatory_sx",
249 "q2_when_psych_sx_started",
250 "q3_worst_symptom_last_month",
251 "q4a_symptom_timing",
252 "q5_antibiotics",
253 "q6a_inpatient_last_y",
254 "q7a_sx_last_2y",
255 "q8_smoking",
256 "q9_pregnant",
257 "q10a_effective_rx_physical",
258 "q10b_effective_rx_psych",
259 "q13a_behcet",
260 ]
261 ):
262 return False
263 if self.any_fields_null_or_empty_str(
264 ["q10a_effective_rx_physical", "q10b_effective_rx_psych"]
265 ):
266 return False
267 q4a = self.q4a_symptom_timing
268 if (
269 q4a == self.Q4_OPTION_PSYCH_BEFORE_PHYSICAL
270 and self.q4b_days_psych_before_phys is None
271 ):
272 return False
273 if (
274 q4a == self.Q4_OPTION_PSYCH_AFTER_PHYSICAL
275 and self.q4c_days_psych_after_phys is None
276 ):
277 return False
278 if self.q6a_inpatient_last_y and self.q6b_inpatient_weeks is None:
279 return False
280 if self.q7a_sx_last_2y and self.q7b_variability is None:
281 return False
282 if self.q11h_ph_other_psych and not self.q11h_ph_other_detail:
283 return False
284 if self.q12h_fh_other_psych and not self.q12h_fh_other_detail:
285 return False
286 if self.q13a_behcet:
287 if self.any_fields_none(
288 ["q13b_oral_ulcers", "q13e_genital_ulcers"]
289 ):
290 return False
291 if self.q13b_oral_ulcers:
292 if self.any_fields_none(
293 ["q13c_oral_age_first", "q13d_oral_scarring"]
294 ):
295 return False
296 if self.q13e_genital_ulcers:
297 if self.any_fields_none(
298 ["q13f_genital_age_first", "q13g_genital_scarring"]
299 ):
300 return False
301 return True
303 def get_task_html(self, req: CamcopsRequest) -> str:
304 def plainrow(
305 qname: str,
306 xstring_name: str,
307 value: Any,
308 if_applicable: bool = False,
309 qsuffix: str = "",
310 ) -> str:
311 ia_str = (
312 f"<i>[{req.wsstring(SS.IF_APPLICABLE)}]</i> "
313 if if_applicable
314 else ""
315 )
316 q = f"{ia_str}{qname}. {self.wxstring(req, xstring_name)}{qsuffix}"
317 return tr_qa(q, value)
319 def lookuprow(
320 qname: str,
321 xstring_name: str,
322 key: Optional[int],
323 lookup: Dict[int, str],
324 if_applicable: bool = False,
325 qsuffix: str = "",
326 ) -> str:
327 description = lookup.get(key, None)
328 value = None if description is None else f"{key}: {description}"
329 return plainrow(
330 qname,
331 xstring_name,
332 value,
333 if_applicable=if_applicable,
334 qsuffix=qsuffix,
335 )
337 def boolrow(
338 qname: str,
339 xstring_name: str,
340 value: Optional[bool],
341 lookup: Dict[int, str],
342 if_applicable: bool = False,
343 qsuffix: str = "",
344 ) -> str:
345 v = int(value) if value is not None else None
346 return lookuprow(
347 qname,
348 xstring_name,
349 v,
350 lookup,
351 if_applicable=if_applicable,
352 qsuffix=qsuffix,
353 )
355 def ynrow(
356 qname: str, xstring_name: str, value: Optional[Union[int, bool]]
357 ) -> str:
358 return plainrow(qname, xstring_name, get_yes_no(req, value))
360 def ynnrow(
361 qname: str,
362 xstring_name: str,
363 value: Optional[Union[int, bool]],
364 if_applicable: bool = False,
365 ) -> str:
366 return plainrow(
367 qname,
368 xstring_name,
369 get_yes_no_none(req, value),
370 if_applicable=if_applicable,
371 )
373 q2_options = self.make_options_from_xstrings(
374 req, "q2_option", 1, self.Q2_N_OPTIONS
375 )
376 q3_options = self.make_options_from_xstrings(
377 req, "q3_option", 1, self.Q3_N_OPTIONS
378 )
379 q4a_options = self.make_options_from_xstrings(
380 req, "q4a_option", 1, self.Q4_N_OPTIONS
381 )
382 q7a_options = self.make_options_from_xstrings(req, "q7a_option", 0, 1)
383 _q7b_anchors = [] # type: List[str]
384 for _o in (1, 10):
385 _wxstring = self.wxstring(req, f"q7b_anchor_{_o}")
386 _q7b_anchors.append(f"{_o}: {_wxstring}")
387 q7b_explanation = f" <i>(Anchors: {' // '.join(_q7b_anchors)})</i>"
388 q8_options = self.make_options_from_xstrings(
389 req, "q8_option", 1, self.Q8_N_OPTIONS
390 )
391 q9_options = self.make_options_from_xstrings(req, "q9_option", 0, 1)
393 rows_1_to_9 = "".join(
394 [
395 plainrow(
396 "1", "q1_question", self.q1_age_first_inflammatory_sx
397 ),
398 lookuprow(
399 "2",
400 "q2_question",
401 self.q2_when_psych_sx_started,
402 q2_options,
403 ),
404 lookuprow(
405 "3",
406 "q3_question",
407 self.q3_worst_symptom_last_month,
408 q3_options,
409 ),
410 lookuprow(
411 "4a", "q4a_question", self.q4a_symptom_timing, q4a_options
412 ),
413 plainrow(
414 "4b", "q4b_question", self.q4b_days_psych_before_phys, True
415 ),
416 plainrow(
417 "4c", "q4c_question", self.q4c_days_psych_after_phys, True
418 ),
419 ynnrow("5", "q5_question", self.q5_antibiotics),
420 ynnrow("6a", "q6a_question", self.q6a_inpatient_last_y),
421 plainrow("6b", "q6b_question", self.q6b_inpatient_weeks, True),
422 boolrow(
423 "7a", "q7a_question", self.q7a_sx_last_2y, q7a_options
424 ),
425 plainrow(
426 "7b",
427 "q7b_question",
428 self.q7b_variability,
429 True,
430 qsuffix=q7b_explanation,
431 ),
432 lookuprow("8", "q8_question", self.q8_smoking, q8_options),
433 boolrow("9", "q9_question", self.q9_pregnant, q9_options),
434 ]
435 )
437 rows_10a_and_10b = "".join(
438 [
439 plainrow(
440 "10a", "q10a_question", self.q10a_effective_rx_physical
441 ),
442 plainrow("10b", "q10b_question", self.q10b_effective_rx_psych),
443 ]
444 )
446 rows_11a_to_11h = "".join(
447 [
448 ynrow("11a", "depression", self.q11a_ph_depression),
449 ynrow("11b", "bipolar", self.q11b_ph_bipolar),
450 ynrow("11c", "schizophrenia", self.q11c_ph_schizophrenia),
451 ynrow(
452 "11d", "autistic_spectrum", self.q11d_ph_autistic_spectrum
453 ),
454 ynrow("11e", "ptsd", self.q11e_ph_ptsd),
455 ynrow("11f", "other_anxiety", self.q11f_ph_other_anxiety),
456 ynrow(
457 "11g",
458 "personality_disorder",
459 self.q11g_ph_personality_disorder,
460 ),
461 ynrow("11h", "other_psych", self.q11h_ph_other_psych),
462 plainrow(
463 "11h", "other_psych", self.q11h_ph_other_detail, True
464 ),
465 ]
466 )
468 rows_12a_to_12h = "".join(
469 [
470 ynrow("12a", "depression", self.q12a_fh_depression),
471 ynrow("12b", "bipolar", self.q12b_fh_bipolar),
472 ynrow("12c", "schizophrenia", self.q12c_fh_schizophrenia),
473 ynrow(
474 "12d", "autistic_spectrum", self.q12d_fh_autistic_spectrum
475 ),
476 ynrow("12e", "ptsd", self.q12e_fh_ptsd),
477 ynrow("12f", "other_anxiety", self.q12f_fh_other_anxiety),
478 ynrow(
479 "12g",
480 "personality_disorder",
481 self.q12g_fh_personality_disorder,
482 ),
483 ynrow("12h", "other_psych", self.q12h_fh_other_psych),
484 plainrow(
485 "12h", "other_psych", self.q12h_fh_other_detail, True
486 ),
487 ]
488 )
490 rows_13a_to_13g = "".join(
491 [
492 ynnrow("13a", "q13a_question", self.q13a_behcet),
493 ynnrow("13b", "q13b_question", self.q13b_oral_ulcers, True),
494 plainrow(
495 "13c", "q13c_question", self.q13c_oral_age_first, True
496 ),
497 ynnrow("13d", "q13d_question", self.q13d_oral_scarring, True),
498 ynnrow("13e", "q13e_question", self.q13e_genital_ulcers, True),
499 plainrow(
500 "13f", "q13f_question", self.q13f_genital_age_first, True
501 ),
502 ynnrow(
503 "13g", "q13g_question", self.q13g_genital_scarring, True
504 ),
505 ]
506 )
508 return f"""
509 <div class="{CssClass.SUMMARY}">
510 <table class="{CssClass.SUMMARY}">
511 {self.get_is_complete_tr(req)}
512 </table>
513 </div>
514 <table class="{CssClass.TASKDETAIL}">
515 <tr>
516 <th width="60%">{req.sstring(SS.QUESTION)}</th>
517 <th width="40%">{req.sstring(SS.ANSWER)}</th>
518 </tr>
519 {rows_1_to_9}
520 <tr class="subheading">
521 <td><i>{self.wxstring(req, "q10_stem")}</i></td>
522 <td></td>
523 </tr>
524 {rows_10a_and_10b}
525 <tr class="subheading">
526 <td><i>{self.wxstring(req, "q11_title")}</i></td>
527 <td></td>
528 </tr>
529 {rows_11a_to_11h}
530 <tr class="subheading">
531 <td><i>{self.wxstring(req, "q12_title")}</i></td>
532 <td></td>
533 </tr>
534 {rows_12a_to_12h}
535 <tr class="subheading">
536 <td><i>{self.wxstring(req, "q13_title")}</i></td>
537 <td></td>
538 </tr>
539 {rows_13a_to_13g}
540 </table>
541 """