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

1""" 

2camcops_server/tasks/lynall_iam_medical.py 

3 

4=============================================================================== 

5 

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

8 

9 This file is part of CamCOPS. 

10 

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. 

15 

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. 

20 

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/>. 

23 

24=============================================================================== 

25 

26""" 

27 

28from typing import Any, Dict, List, Optional, Union 

29 

30from sqlalchemy.orm import Mapped, mapped_column 

31from sqlalchemy.sql.sqltypes import Integer, UnicodeText 

32 

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 

47 

48 

49# ============================================================================= 

50# Lynall1MedicalHistory 

51# ============================================================================= 

52 

53 

54class LynallIamMedicalHistory(TaskHasPatientMixin, Task): # type: ignore[misc] 

55 """ 

56 Server implementation of the Lynall1IamMedicalHistory task. 

57 """ 

58 

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 

63 

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 

72 

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 ) 

239 

240 @staticmethod 

241 def longname(req: "CamcopsRequest") -> str: 

242 _ = req.gettext 

243 return _("Lynall M-E — 1 — IAM — Medical history") 

244 

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 

302 

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) 

318 

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 ) 

336 

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 ) 

354 

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)) 

359 

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 ) 

372 

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) 

392 

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 ) 

436 

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 ) 

445 

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 ) 

467 

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 ) 

489 

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 ) 

507 

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 """