Coverage for tasks/pbq.py : 66%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1#!/usr/bin/env python
3"""
4camcops_server/tasks/pbq.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
12 CamCOPS is free software: you can redistribute it and/or modify
13 it under the terms of the GNU General Public License as published by
14 the Free Software Foundation, either version 3 of the License, or
15 (at your option) any later version.
17 CamCOPS is distributed in the hope that it will be useful,
18 but WITHOUT ANY WARRANTY; without even the implied warranty of
19 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20 GNU General Public License for more details.
22 You should have received a copy of the GNU General Public License
23 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
25===============================================================================
27"""
29from typing import Any, Dict, List, Tuple, Type
31from cardinal_pythonlib.classes import classproperty
32from cardinal_pythonlib.stringfunc import strnumlist, strseq
33from sqlalchemy.ext.declarative import DeclarativeMeta
34from sqlalchemy.sql.sqltypes import Integer
36from camcops_server.cc_modules.cc_constants import CssClass
37from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo
38from camcops_server.cc_modules.cc_html import answer, tr
39from camcops_server.cc_modules.cc_report import (
40 AverageScoreReport,
41 ScoreDetails,
42)
43from camcops_server.cc_modules.cc_request import CamcopsRequest
44from camcops_server.cc_modules.cc_sqla_coltypes import (
45 CamcopsColumn,
46 PermittedValueChecker,
47)
48from camcops_server.cc_modules.cc_summaryelement import SummaryElement
49from camcops_server.cc_modules.cc_task import Task, TaskHasPatientMixin
50from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo
53# =============================================================================
54# PBQ
55# =============================================================================
57class PbqMetaclass(DeclarativeMeta):
58 # noinspection PyInitNewSignature
59 def __init__(cls: Type['Pbq'],
60 name: str,
61 bases: Tuple[Type, ...],
62 classdict: Dict[str, Any]) -> None:
63 comment_strings = [
64 # This is the Brockington 2006 order; see XML for notes.
65 # 1-5
66 "I feel close to my baby",
67 "I wish the old days when I had no baby would come back",
68 "I feel distant from my baby",
69 "I love to cuddle my baby",
70 "I regret having this baby",
71 # 6-10
72 "The baby does not seem to be mine",
73 "My baby winds me up",
74 "I love my baby to bits",
75 "I feel happy when my baby smiles or laughs",
76 "My baby irritates me",
77 # 11-15
78 "I enjoy playing with my baby",
79 "My baby cries too much",
80 "I feel trapped as a mother",
81 "I feel angry with my baby",
82 "I resent my baby",
83 # 16-20
84 "My baby is the most beautiful baby in the world",
85 "I wish my baby would somehow go away",
86 "I have done harmful things to my baby",
87 "My baby makes me anxious",
88 "I am afraid of my baby",
89 # 21-25
90 "My baby annoys me",
91 "I feel confident when changing my baby",
92 "I feel the only solution is for someone else to look after my baby", # noqa
93 "I feel like hurting my baby",
94 "My baby is easily comforted",
95 ]
96 pvc = PermittedValueChecker(minimum=cls.MIN_PER_Q,
97 maximum=cls.MAX_PER_Q)
98 for n in range(1, cls.NQUESTIONS + 1):
99 i = n - 1
100 colname = f"q{n}"
101 if n in cls.SCORED_A0N5_Q:
102 explan = "always 0 - never 5"
103 else:
104 explan = "always 5 - never 0"
105 comment = f"Q{n}, {comment_strings[i]} ({explan}, higher worse)"
106 setattr(
107 cls,
108 colname,
109 CamcopsColumn(colname, Integer,
110 comment=comment, permitted_value_checker=pvc)
111 )
112 super().__init__(name, bases, classdict)
115class Pbq(TaskHasPatientMixin, Task,
116 metaclass=PbqMetaclass):
117 """
118 Server implementation of the PBQ task.
119 """
120 __tablename__ = "pbq"
121 shortname = "PBQ"
122 provides_trackers = True
124 MIN_PER_Q = 0
125 MAX_PER_Q = 5
126 NQUESTIONS = 25
127 QUESTION_FIELDS = strseq("q", 1, NQUESTIONS)
128 MAX_TOTAL = MAX_PER_Q * NQUESTIONS
129 SCORED_A0N5_Q = [1, 4, 8, 9, 11, 16, 22, 25] # rest scored A5N0
130 FACTOR_1_Q = [1, 2, 6, 7, 8, 9, 10, 12, 13, 15, 16, 17] # 12 questions # noqa
131 FACTOR_2_Q = [3, 4, 5, 11, 14, 21, 23] # 7 questions
132 FACTOR_3_Q = [19, 20, 22, 25] # 4 questions
133 FACTOR_4_Q = [18, 24] # 2 questions
134 FACTOR_1_F = strnumlist("q", FACTOR_1_Q)
135 FACTOR_2_F = strnumlist("q", FACTOR_2_Q)
136 FACTOR_3_F = strnumlist("q", FACTOR_3_Q)
137 FACTOR_4_F = strnumlist("q", FACTOR_4_Q)
138 FACTOR_1_MAX = len(FACTOR_1_Q) * MAX_PER_Q
139 FACTOR_2_MAX = len(FACTOR_2_Q) * MAX_PER_Q
140 FACTOR_3_MAX = len(FACTOR_3_Q) * MAX_PER_Q
141 FACTOR_4_MAX = len(FACTOR_4_Q) * MAX_PER_Q
143 @staticmethod
144 def longname(req: "CamcopsRequest") -> str:
145 _ = req.gettext
146 return _("Postpartum Bonding Questionnaire")
148 def get_trackers(self, req: CamcopsRequest) -> List[TrackerInfo]:
149 return [TrackerInfo(
150 value=self.total_score(),
151 plot_label="PBQ total score (lower is better)",
152 axis_label=f"Total score (out of {self.MAX_TOTAL})",
153 axis_min=-0.5,
154 axis_max=self.MAX_TOTAL + 0.5
155 )]
157 def get_summaries(self, req: CamcopsRequest) -> List[SummaryElement]:
158 return self.standard_task_summary_fields() + [
159 SummaryElement(
160 name="total_score", coltype=Integer(),
161 value=self.total_score(),
162 comment=f"Total score (/ {self.MAX_TOTAL})"
163 ),
164 SummaryElement(
165 name="factor_1_score", coltype=Integer(),
166 value=self.factor_1_score(),
167 comment=f"Factor 1 score (/ {self.FACTOR_1_MAX})"
168 ),
169 SummaryElement(
170 name="factor_2_score", coltype=Integer(),
171 value=self.factor_2_score(),
172 comment=f"Factor 2 score (/ {self.FACTOR_2_MAX})"
173 ),
174 SummaryElement(
175 name="factor_3_score", coltype=Integer(),
176 value=self.factor_3_score(),
177 comment=f"Factor 3 score (/ {self.FACTOR_3_MAX})"
178 ),
179 SummaryElement(
180 name="factor_4_score", coltype=Integer(),
181 value=self.factor_4_score(),
182 comment=f"Factor 4 score (/ {self.FACTOR_4_MAX})"
183 ),
184 ]
186 def get_clinical_text(self, req: CamcopsRequest) -> List[CtvInfo]:
187 if not self.is_complete():
188 return CTV_INCOMPLETE
189 return [CtvInfo(content=(
190 f"PBQ total score {self.total_score()}/{self.MAX_TOTAL}. "
191 f"Factor 1 score {self.factor_1_score()}/{self.FACTOR_1_MAX}. "
192 f"Factor 2 score {self.factor_2_score()}/{self.FACTOR_2_MAX}. "
193 f"Factor 3 score {self.factor_3_score()}/{self.FACTOR_3_MAX}. "
194 f"Factor 4 score {self.factor_4_score()}/{self.FACTOR_4_MAX}."
195 ))]
197 def total_score(self) -> int:
198 return self.sum_fields(self.QUESTION_FIELDS)
200 def factor_1_score(self) -> int:
201 return self.sum_fields(self.FACTOR_1_F)
203 def factor_2_score(self) -> int:
204 return self.sum_fields(self.FACTOR_2_F)
206 def factor_3_score(self) -> int:
207 return self.sum_fields(self.FACTOR_3_F)
209 def factor_4_score(self) -> int:
210 return self.sum_fields(self.FACTOR_4_F)
212 def is_complete(self) -> bool:
213 return (
214 self.field_contents_valid() and
215 self.all_fields_not_none(self.QUESTION_FIELDS)
216 )
218 def get_task_html(self, req: CamcopsRequest) -> str:
219 always = self.xstring(req, "always")
220 very_often = self.xstring(req, "very_often")
221 quite_often = self.xstring(req, "quite_often")
222 sometimes = self.xstring(req, "sometimes")
223 rarely = self.xstring(req, "rarely")
224 never = self.xstring(req, "never")
225 a0n5 = {
226 0: always,
227 1: very_often,
228 2: quite_often,
229 3: sometimes,
230 4: rarely,
231 5: never,
232 }
233 a5n0 = {
234 5: always,
235 4: very_often,
236 3: quite_often,
237 2: sometimes,
238 1: rarely,
239 0: never,
240 }
241 h = f"""
242 <div class="{CssClass.SUMMARY}">
243 <table class="{CssClass.SUMMARY}">
244 {self.get_is_complete_tr(req)}
245 <tr>
246 <td>Total score</td>
247 <td>{answer(self.total_score())} / {self.MAX_TOTAL}</td>
248 </td>
249 <tr>
250 <td>Factor 1 score <sup>[1]</sup></td>
251 <td>{answer(self.factor_1_score())} / {self.FACTOR_1_MAX}</td>
252 </td>
253 <tr>
254 <td>Factor 2 score <sup>[2]</sup></td>
255 <td>{answer(self.factor_2_score())} / {self.FACTOR_2_MAX}</td>
256 </td>
257 <tr>
258 <td>Factor 3 score <sup>[3]</sup></td>
259 <td>{answer(self.factor_3_score())} / {self.FACTOR_3_MAX}</td>
260 </td>
261 <tr>
262 <td>Factor 4 score <sup>[4]</sup></td>
263 <td>{answer(self.factor_4_score())} / {self.FACTOR_4_MAX}</td>
264 </td>
265 </table>
266 </div>
267 <table class="{CssClass.TASKDETAIL}">
268 <tr>
269 <th width="60%">Question</th>
270 <th width="40%">Answer ({self.MIN_PER_Q}–{self.MAX_PER_Q})</th>
271 </tr>
272 """ # noqa
273 for q in range(1, self.NQUESTIONS + 1):
274 qtext = f"{q}. " + self.wxstring(req, f"q{q}")
275 a = getattr(self, f"q{q}")
276 option = a0n5.get(a) if q in self.SCORED_A0N5_Q else a5n0.get(a)
277 atext = f"{a}: {option}"
278 h += tr(qtext, answer(atext))
279 h += f"""
280 </table>
281 <div class="{CssClass.FOOTNOTES}">
282 Factors and cut-off scores are from Brockington et al. (2006,
283 PMID 16673041), as follows.
284 [1] General factor; ≤11 normal, ≥12 high; based on questions
285 {", ".join(str(x) for x in self.FACTOR_1_Q)}.
286 [2] Factor examining severe mother–infant relationship
287 disorders; ≤12 normal, ≥13 high (cf. original 2001 study
288 with ≤16 normal, ≥17 high); based on questions
289 {", ".join(str(x) for x in self.FACTOR_2_Q)}.
290 [3] Factor relating to infant-focused anxiety; ≤9 normal, ≥10
291 high; based on questions
292 {", ".join(str(x) for x in self.FACTOR_3_Q)}.
293 [4] Factor relating to thoughts of harm to infant; ≤1 normal,
294 ≥2 high (cf. original 2001 study with ≤2 normal, ≥3 high);
295 known low sensitivity; based on questions
296 {", ".join(str(x) for x in self.FACTOR_4_Q)}.
297 </div>
298 """
299 return h
301 # No SNOMED codes for the PBQ (checked 2019-04-01).
304class PBQReport(AverageScoreReport):
305 # noinspection PyMethodParameters
306 @classproperty
307 def report_id(cls) -> str:
308 return "PBQ"
310 @classmethod
311 def title(cls, req: "CamcopsRequest") -> str:
312 _ = req.gettext
313 return _("PBQ — Average scores")
315 # noinspection PyMethodParameters
316 @classproperty
317 def task_class(cls) -> Type[Task]:
318 return Pbq
320 @classmethod
321 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]:
322 _ = req.gettext
323 return [
324 ScoreDetails(
325 name=_("Total score"),
326 scorefunc=Pbq.total_score,
327 minimum=0,
328 maximum=Pbq.MAX_TOTAL,
329 higher_score_is_better=False
330 ),
331 ScoreDetails(
332 name=_("Factor 1 score"),
333 scorefunc=Pbq.factor_1_score,
334 minimum=0,
335 maximum=Pbq.FACTOR_1_MAX,
336 higher_score_is_better=False
337 ),
338 ScoreDetails(
339 name=_("Factor 2 score"),
340 scorefunc=Pbq.factor_2_score,
341 minimum=0,
342 maximum=Pbq.FACTOR_2_MAX,
343 higher_score_is_better=False
344 ),
345 ScoreDetails(
346 name=_("Factor 3 score"),
347 scorefunc=Pbq.factor_3_score,
348 minimum=0,
349 maximum=Pbq.FACTOR_3_MAX,
350 higher_score_is_better=False
351 ),
352 ScoreDetails(
353 name=_("Factor 4 score"),
354 scorefunc=Pbq.factor_4_score,
355 minimum=0,
356 maximum=Pbq.FACTOR_4_MAX,
357 higher_score_is_better=False
358 ),
359 ]