Coverage for tasks/pbq.py: 65%

1"""

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

28 from typing import Any, cast, List, Type

29

30 from cardinal_pythonlib.classes import classproperty

31 from cardinal_pythonlib.stringfunc import strnumlist, strseq

32 from sqlalchemy.sql.sqltypes import Integer

33

34 from camcops_server.cc_modules.cc_constants import CssClass

35 from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo

36 from camcops_server.cc_modules.cc_html import answer, tr

37 from camcops_server.cc_modules.cc_report import (

38 AverageScoreReport,

39 ScoreDetails,

40)

41 from camcops_server.cc_modules.cc_request import CamcopsRequest

42 from camcops_server.cc_modules.cc_sqla_coltypes import (

43 camcops_column,

44 PermittedValueChecker,

45)

46 from camcops_server.cc_modules.cc_summaryelement import SummaryElement

47 from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin

48 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

49

50

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

52# PBQ

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

54

55

56 class 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

334 class 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 ]