Coverage for tasks/lynall_iam_medical.py: 52%

1"""

2 camcops_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

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

29

30 from sqlalchemy.orm import Mapped, mapped_column

31 from sqlalchemy.sql.sqltypes import Integer, UnicodeText

32

33 from camcops_server.cc_modules.cc_constants import CssClass

34 from camcops_server.cc_modules.cc_html import (

35 get_yes_no,

36 get_yes_no_none,

37 tr_qa,

38)

39 from camcops_server.cc_modules.cc_request import CamcopsRequest

40 from camcops_server.cc_modules.cc_sqla_coltypes import (

41 mapped_bool_column,

42 mapped_camcops_column,

43 PermittedValueChecker,

44)

45 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

46 from camcops_server.cc_modules.cc_text import SS

47

48

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

50# Lynall1MedicalHistory

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

52

53

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