Coverage for tasks/npiq.py: 43%

87 statements  

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

1""" 

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

29 

30from cardinal_pythonlib.stringfunc import strseq 

31from sqlalchemy.sql.sqltypes import Boolean, Integer 

32 

33from camcops_server.cc_modules.cc_constants import ( 

34 CssClass, 

35 DATA_COLLECTION_UNLESS_UPGRADED_DIV, 

36 PV, 

37) 

38from camcops_server.cc_modules.cc_ctvinfo import CTV_INCOMPLETE, CtvInfo 

39from camcops_server.cc_modules.cc_db import add_multiple_columns 

40from camcops_server.cc_modules.cc_html import answer, get_yes_no_unknown, tr 

41from camcops_server.cc_modules.cc_request import CamcopsRequest 

42from camcops_server.cc_modules.cc_summaryelement import SummaryElement 

43from camcops_server.cc_modules.cc_task import ( 

44 Task, 

45 TaskHasPatientMixin, 

46 TaskHasRespondentMixin, 

47) 

48 

49 

50# ============================================================================= 

51# NPI-Q 

52# ============================================================================= 

53 

54ENDORSED = "endorsed" 

55SEVERITY = "severity" 

56DISTRESS = "distress" 

57 

58 

59class NpiQ( # type: ignore[misc] 

60 TaskHasPatientMixin, 

61 TaskHasRespondentMixin, 

62 Task, 

63): 

64 """ 

65 Server implementation of the NPI-Q task. 

66 """ 

67 

68 __tablename__ = "npiq" 

69 shortname = "NPI-Q" 

70 

71 NQUESTIONS = 12 

72 

73 @classmethod 

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

75 question_snippets = [ 

76 "delusions", # 1 

77 "hallucinations", 

78 "agitation/aggression", 

79 "depression/dysphoria", 

80 "anxiety", # 5 

81 "elation/euphoria", 

82 "apathy/indifference", 

83 "disinhibition", 

84 "irritability/lability", 

85 "motor disturbance", # 10 

86 "night-time behaviour", 

87 "appetite/eating", 

88 ] 

89 add_multiple_columns( 

90 cls, 

91 ENDORSED, 

92 1, 

93 cls.NQUESTIONS, 

94 Boolean, 

95 pv=PV.BIT, 

96 comment_fmt="Q{n}, {s}, endorsed?", 

97 comment_strings=question_snippets, 

98 ) 

99 add_multiple_columns( 

100 cls, 

101 SEVERITY, 

102 1, 

103 cls.NQUESTIONS, 

104 pv=list(range(1, 3 + 1)), 

105 comment_fmt="Q{n}, {s}, severity (1-3), if endorsed", 

106 comment_strings=question_snippets, 

107 ) 

108 add_multiple_columns( 

109 cls, 

110 DISTRESS, 

111 1, 

112 cls.NQUESTIONS, 

113 pv=list(range(0, 5 + 1)), 

114 comment_fmt="Q{n}, {s}, distress (0-5), if endorsed", 

115 comment_strings=question_snippets, 

116 ) 

117 

118 ENDORSED_FIELDS = strseq(ENDORSED, 1, NQUESTIONS) 

119 MAX_SEVERITY = 3 * NQUESTIONS 

120 MAX_DISTRESS = 5 * NQUESTIONS 

121 

122 @staticmethod 

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

124 _ = req.gettext 

125 return _("Neuropsychiatric Inventory Questionnaire") 

126 

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

128 return self.standard_task_summary_fields() + [ 

129 SummaryElement( 

130 name="n_endorsed", 

131 coltype=Integer(), 

132 value=self.n_endorsed(), 

133 comment=f"Number endorsed (/ {self.NQUESTIONS})", 

134 ), 

135 SummaryElement( 

136 name="severity_score", 

137 coltype=Integer(), 

138 value=self.severity_score(), 

139 comment=f"Severity score (/ {self.MAX_SEVERITY})", 

140 ), 

141 SummaryElement( 

142 name="distress_score", 

143 coltype=Integer(), 

144 value=self.distress_score(), 

145 comment=f"Distress score (/ {self.MAX_DISTRESS})", 

146 ), 

147 ] 

148 

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

150 if not self.is_complete(): 

151 return CTV_INCOMPLETE 

152 return [ 

153 CtvInfo( 

154 content=( 

155 "Endorsed: {e}/{me}; severity {s}/{ms}; " 

156 "distress {d}/{md}".format( 

157 e=self.n_endorsed(), 

158 me=self.NQUESTIONS, 

159 s=self.severity_score(), 

160 ms=self.MAX_SEVERITY, 

161 d=self.distress_score(), 

162 md=self.MAX_DISTRESS, 

163 ) 

164 ) 

165 ) 

166 ] 

167 

168 def q_endorsed(self, q: int) -> bool: 

169 return bool(getattr(self, ENDORSED + str(q))) 

170 

171 def n_endorsed(self) -> int: 

172 return self.count_booleans(self.ENDORSED_FIELDS) 

173 

174 def severity_score(self) -> int: 

175 total = 0 

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

177 if self.q_endorsed(q): 

178 s = getattr(self, SEVERITY + str(q)) 

179 if s is not None: 

180 total += s 

181 return total 

182 

183 def distress_score(self) -> int: 

184 total = 0 

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

186 if self.q_endorsed(q): 

187 d = getattr(self, DISTRESS + str(q)) 

188 if d is not None: 

189 total += d 

190 return total 

191 

192 def q_complete(self, q: int) -> bool: 

193 qstr = str(q) 

194 endorsed = getattr(self, ENDORSED + qstr) 

195 if endorsed is None: 

196 return False 

197 if not endorsed: 

198 return True 

199 if getattr(self, SEVERITY + qstr) is None: 

200 return False 

201 if getattr(self, DISTRESS + qstr) is None: 

202 return False 

203 return True 

204 

205 def is_complete(self) -> bool: 

206 return ( 

207 self.is_respondent_complete() 

208 and all(self.q_complete(q) for q in range(1, self.NQUESTIONS + 1)) 

209 and self.field_contents_valid() 

210 ) 

211 

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

213 h = f""" 

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

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

216 {self.get_is_complete_tr(req)} 

217 <tr> 

218 <td>Endorsed</td> 

219 <td>{self.n_endorsed()} / 12</td> 

220 </td> 

221 <tr> 

222 <td>Severity score</td> 

223 <td>{self.severity_score()} / 36</td> 

224 </td> 

225 <tr> 

226 <td>Distress score</td> 

227 <td>{self.distress_score()} / 60</td> 

228 </td> 

229 </table> 

230 </div> 

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

232 <tr> 

233 <th width="40%">Question</th> 

234 <th width="20%">Endorsed</th> 

235 <th width="20%">Severity (patient)</th> 

236 <th width="20%">Distress (carer)</th> 

237 </tr> 

238 """ 

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

240 qstr = str(q) 

241 e = getattr(self, ENDORSED + qstr) 

242 s = getattr(self, SEVERITY + qstr) 

243 d = getattr(self, DISTRESS + qstr) 

244 qtext = "<b>{}:</b> {}".format( 

245 self.wxstring(req, "t" + qstr), self.wxstring(req, "q" + qstr) 

246 ) 

247 etext = get_yes_no_unknown(req, e) 

248 if e: 

249 stext = self.wxstring( 

250 req, f"severity_{s}", s, provide_default_if_none=False 

251 ) 

252 dtext = self.wxstring( 

253 req, f"distress_{d}", d, provide_default_if_none=False 

254 ) 

255 else: 

256 stext = "" 

257 dtext = "" 

258 h += tr(qtext, answer(etext), answer(stext), answer(dtext)) 

259 h += f""" 

260 </table> 

261 {DATA_COLLECTION_UNLESS_UPGRADED_DIV} 

262 """ 

263 return h