Coverage for tasks/pbq.py: 65%

104 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 14:23 +0100

1""" 

2camcops_server/tasks/pbq.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, cast, List, Type 

29 

30from cardinal_pythonlib.classes import classproperty 

31from cardinal_pythonlib.stringfunc import strnumlist, strseq 

32from sqlalchemy.sql.sqltypes import Integer 

33 

34from camcops_server.cc_modules.cc_constants import CssClass 

35from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

36from camcops_server.cc_modules.cc_html import answer, tr 

37from camcops_server.cc_modules.cc_report import ( 

38 AverageScoreReport, 

39 ScoreDetails, 

40) 

41from camcops_server.cc_modules.cc_request import CamcopsRequest 

42from camcops_server.cc_modules.cc_sqla_coltypes import ( 

43 camcops_column, 

44 PermittedValueChecker, 

45) 

46from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

47from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin 

48from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo 

49 

50 

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

52# PBQ 

53# ============================================================================= 

54 

55 

56class Pbq( # type: ignore[misc] 

57 TaskHasPatientMixin, 

58 Task, 

59): 

60 """ 

61 Server implementation of the PBQ task. 

62 """ 

63 

64 __tablename__ = "pbq" 

65 shortname = "PBQ" 

66 provides_trackers = True 

67 

68 MIN_PER_Q = 0 

69 MAX_PER_Q = 5 

70 NQUESTIONS = 25 

71 

72 @classmethod 

73 def extend_columns(cls: Type["Pbq"], **kwargs: Any) -> None: 

74 comment_strings = [ 

75 # This is the Brockington 2006 order; see XML for notes. 

76 # 1-5 

77 "I feel close to my baby", 

78 "I wish the old days when I had no baby would come back", 

79 "I feel distant from my baby", 

80 "I love to cuddle my baby", 

81 "I regret having this baby", 

82 # 6-10 

83 "The baby does not seem to be mine", 

84 "My baby winds me up", 

85 "I love my baby to bits", 

86 "I feel happy when my baby smiles or laughs", 

87 "My baby irritates me", 

88 # 11-15 

89 "I enjoy playing with my baby", 

90 "My baby cries too much", 

91 "I feel trapped as a mother", 

92 "I feel angry with my baby", 

93 "I resent my baby", 

94 # 16-20 

95 "My baby is the most beautiful baby in the world", 

96 "I wish my baby would somehow go away", 

97 "I have done harmful things to my baby", 

98 "My baby makes me anxious", 

99 "I am afraid of my baby", 

100 # 21-25 

101 "My baby annoys me", 

102 "I feel confident when changing my baby", 

103 "I feel the only solution is for someone else to look after my baby", # noqa 

104 "I feel like hurting my baby", 

105 "My baby is easily comforted", 

106 ] 

107 pvc = PermittedValueChecker( 

108 minimum=cls.MIN_PER_Q, maximum=cls.MAX_PER_Q 

109 ) 

110 for n in range(1, cls.NQUESTIONS + 1): 

111 i = n - 1 

112 colname = f"q{n}" 

113 if n in cls.SCORED_A0N5_Q: 

114 explan = "always 0 - never 5" 

115 else: 

116 explan = "always 5 - never 0" 

117 comment = f"Q{n}, {comment_strings[i]} ({explan}, higher worse)" 

118 setattr( 

119 cls, 

120 colname, 

121 camcops_column( 

122 colname, 

123 Integer, 

124 comment=comment, 

125 permitted_value_checker=pvc, 

126 ), 

127 ) 

128 

129 QUESTION_FIELDS = strseq("q", 1, NQUESTIONS) 

130 MAX_TOTAL = MAX_PER_Q * NQUESTIONS 

131 SCORED_A0N5_Q = [1, 4, 8, 9, 11, 16, 22, 25] # rest scored A5N0 

132 FACTOR_1_Q = [ 

133 1, 

134 2, 

135 6, 

136 7, 

137 8, 

138 9, 

139 10, 

140 12, 

141 13, 

142 15, 

143 16, 

144 17, 

145 ] # 12 questions 

146 FACTOR_2_Q = [3, 4, 5, 11, 14, 21, 23] # 7 questions 

147 FACTOR_3_Q = [19, 20, 22, 25] # 4 questions 

148 FACTOR_4_Q = [18, 24] # 2 questions 

149 FACTOR_1_F = strnumlist("q", FACTOR_1_Q) 

150 FACTOR_2_F = strnumlist("q", FACTOR_2_Q) 

151 FACTOR_3_F = strnumlist("q", FACTOR_3_Q) 

152 FACTOR_4_F = strnumlist("q", FACTOR_4_Q) 

153 FACTOR_1_MAX = len(FACTOR_1_Q) * MAX_PER_Q 

154 FACTOR_2_MAX = len(FACTOR_2_Q) * MAX_PER_Q 

155 FACTOR_3_MAX = len(FACTOR_3_Q) * MAX_PER_Q 

156 FACTOR_4_MAX = len(FACTOR_4_Q) * MAX_PER_Q 

157 

158 @staticmethod 

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

160 _ = req.gettext 

161 return _("Postpartum Bonding Questionnaire") 

162 

163 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]: 

164 return [ 

165 TrackerInfo( 

166 value=self.total_score(), 

167 plot_label="PBQ total score (lower is better)", 

168 axis_label=f"Total score (out of {self.MAX_TOTAL})", 

169 axis_min=-0.5, 

170 axis_max=self.MAX_TOTAL + 0.5, 

171 ) 

172 ] 

173 

174 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]: 

175 return self.standard_task_summary_fields() + [ 

176 SummaryElement( 

177 name="total_score", 

178 coltype=Integer(), 

179 value=self.total_score(), 

180 comment=f"Total score (/ {self.MAX_TOTAL})", 

181 ), 

182 SummaryElement( 

183 name="factor_1_score", 

184 coltype=Integer(), 

185 value=self.factor_1_score(), 

186 comment=f"Factor 1 score (/ {self.FACTOR_1_MAX})", 

187 ), 

188 SummaryElement( 

189 name="factor_2_score", 

190 coltype=Integer(), 

191 value=self.factor_2_score(), 

192 comment=f"Factor 2 score (/ {self.FACTOR_2_MAX})", 

193 ), 

194 SummaryElement( 

195 name="factor_3_score", 

196 coltype=Integer(), 

197 value=self.factor_3_score(), 

198 comment=f"Factor 3 score (/ {self.FACTOR_3_MAX})", 

199 ), 

200 SummaryElement( 

201 name="factor_4_score", 

202 coltype=Integer(), 

203 value=self.factor_4_score(), 

204 comment=f"Factor 4 score (/ {self.FACTOR_4_MAX})", 

205 ), 

206 ] 

207 

208 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]: 

209 if not self.is_complete(): 

210 return CTV_INCOMPLETE 

211 return [ 

212 CtvInfo( 

213 content=( 

214 f"PBQ total score " 

215 f"{self.total_score()}/{self.MAX_TOTAL}. " 

216 f"Factor 1 score " 

217 f"{self.factor_1_score()}/{self.FACTOR_1_MAX}. " 

218 f"Factor 2 score " 

219 f"{self.factor_2_score()}/{self.FACTOR_2_MAX}. " 

220 f"Factor 3 score " 

221 f"{self.factor_3_score()}/{self.FACTOR_3_MAX}. " 

222 f"Factor 4 score " 

223 f"{self.factor_4_score()}/{self.FACTOR_4_MAX}." 

224 ) 

225 ) 

226 ] 

227 

228 def total_score(self) -> int: 

229 return cast(int, self.sum_fields(self.QUESTION_FIELDS)) 

230 

231 def factor_1_score(self) -> int: 

232 return cast(int, self.sum_fields(self.FACTOR_1_F)) 

233 

234 def factor_2_score(self) -> int: 

235 return cast(int, self.sum_fields(self.FACTOR_2_F)) 

236 

237 def factor_3_score(self) -> int: 

238 return cast(int, self.sum_fields(self.FACTOR_3_F)) 

239 

240 def factor_4_score(self) -> int: 

241 return cast(int, self.sum_fields(self.FACTOR_4_F)) 

242 

243 def is_complete(self) -> bool: 

244 return self.field_contents_valid() and self.all_fields_not_none( 

245 self.QUESTION_FIELDS 

246 ) 

247 

248 def get_task_html(self, req: CamcopsRequest) -> str: 

249 always = self.xstring(req, "always") 

250 very_often = self.xstring(req, "very_often") 

251 quite_often = self.xstring(req, "quite_often") 

252 sometimes = self.xstring(req, "sometimes") 

253 rarely = self.xstring(req, "rarely") 

254 never = self.xstring(req, "never") 

255 a0n5 = { 

256 0: always, 

257 1: very_often, 

258 2: quite_often, 

259 3: sometimes, 

260 4: rarely, 

261 5: never, 

262 } 

263 a5n0 = { 

264 5: always, 

265 4: very_often, 

266 3: quite_often, 

267 2: sometimes, 

268 1: rarely, 

269 0: never, 

270 } 

271 h = f""" 

272 <div class="{CssClass.SUMMARY}"> 

273 <table class="{CssClass.SUMMARY}"> 

274 {self.get_is_complete_tr(req)} 

275 <tr> 

276 <td>Total score</td> 

277 <td>{answer(self.total_score())} / {self.MAX_TOTAL}</td> 

278 </td> 

279 <tr> 

280 <td>Factor 1 score <sup>[1]</sup></td> 

281 <td>{answer(self.factor_1_score())} / {self.FACTOR_1_MAX}</td> 

282 </td> 

283 <tr> 

284 <td>Factor 2 score <sup>[2]</sup></td> 

285 <td>{answer(self.factor_2_score())} / {self.FACTOR_2_MAX}</td> 

286 </td> 

287 <tr> 

288 <td>Factor 3 score <sup>[3]</sup></td> 

289 <td>{answer(self.factor_3_score())} / {self.FACTOR_3_MAX}</td> 

290 </td> 

291 <tr> 

292 <td>Factor 4 score <sup>[4]</sup></td> 

293 <td>{answer(self.factor_4_score())} / {self.FACTOR_4_MAX}</td> 

294 </td> 

295 </table> 

296 </div> 

297 <table class="{CssClass.TASKDETAIL}"> 

298 <tr> 

299 <th width="60%">Question</th> 

300 <th width="40%">Answer ({self.MIN_PER_Q}–{self.MAX_PER_Q})</th> 

301 </tr> 

302 """ # noqa 

303 for q in range(1, self.NQUESTIONS + 1): 

304 qtext = f"{q}. " + self.wxstring(req, f"q{q}") 

305 a = getattr(self, f"q{q}") 

306 option = a0n5.get(a) if q in self.SCORED_A0N5_Q else a5n0.get(a) 

307 atext = f"{a}: {option}" 

308 h += tr(qtext, answer(atext)) 

309 h += f""" 

310 </table> 

311 <div class="{CssClass.FOOTNOTES}"> 

312 Factors and cut-off scores are from Brockington et al. (2006, 

313 PMID 16673041), as follows. 

314 [1] General factor; ≤11 normal, ≥12 high; based on questions 

315 {", ".join(str(x) for x in self.FACTOR_1_Q)}. 

316 [2] Factor examining severe mother–infant relationship 

317 disorders; ≤12 normal, ≥13 high (cf. original 2001 study 

318 with ≤16 normal, ≥17 high); based on questions 

319 {", ".join(str(x) for x in self.FACTOR_2_Q)}. 

320 [3] Factor relating to infant-focused anxiety; ≤9 normal, ≥10 

321 high; based on questions 

322 {", ".join(str(x) for x in self.FACTOR_3_Q)}. 

323 [4] Factor relating to thoughts of harm to infant; ≤1 normal, 

324 ≥2 high (cf. original 2001 study with ≤2 normal, ≥3 high); 

325 known low sensitivity; based on questions 

326 {", ".join(str(x) for x in self.FACTOR_4_Q)}. 

327 </div> 

328 """ 

329 return h 

330 

331 # No SNOMED codes for the PBQ (checked 2019-04-01). 

332 

333 

334class PBQReport(AverageScoreReport): 

335 # noinspection PyMethodParameters 

336 @classproperty 

337 def report_id(cls) -> str: 

338 return "PBQ" 

339 

340 @classmethod 

341 def title(cls, req: "CamcopsRequest") -> str: 

342 _ = req.gettext 

343 return _("PBQ — Average scores") 

344 

345 # noinspection PyMethodParameters 

346 @classproperty 

347 def task_class(cls) -> Type[Task]: 

348 return Pbq 

349 

350 @classmethod 

351 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]: 

352 _ = req.gettext 

353 return [ 

354 ScoreDetails( 

355 name=_("Total score"), 

356 scorefunc=Pbq.total_score, # type: ignore[arg-type] 

357 minimum=0, 

358 maximum=Pbq.MAX_TOTAL, 

359 higher_score_is_better=False, 

360 ), 

361 ScoreDetails( 

362 name=_("Factor 1 score"), 

363 scorefunc=Pbq.factor_1_score, # type: ignore[arg-type] 

364 minimum=0, 

365 maximum=Pbq.FACTOR_1_MAX, 

366 higher_score_is_better=False, 

367 ), 

368 ScoreDetails( 

369 name=_("Factor 2 score"), 

370 scorefunc=Pbq.factor_2_score, # type: ignore[arg-type] 

371 minimum=0, 

372 maximum=Pbq.FACTOR_2_MAX, 

373 higher_score_is_better=False, 

374 ), 

375 ScoreDetails( 

376 name=_("Factor 3 score"), 

377 scorefunc=Pbq.factor_3_score, # type: ignore[arg-type] 

378 minimum=0, 

379 maximum=Pbq.FACTOR_3_MAX, 

380 higher_score_is_better=False, 

381 ), 

382 ScoreDetails( 

383 name=_("Factor 4 score"), 

384 scorefunc=Pbq.factor_4_score, # type: ignore[arg-type] 

385 minimum=0, 

386 maximum=Pbq.FACTOR_4_MAX, 

387 higher_score_is_better=False, 

388 ), 

389 ]