Coverage for tasks/edeq.py: 50%

1"""

2 camcops_server/tasks/edeq.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**Eating Disorder Examination Questionnaire (EDE-Q 6.0) task.**

27

28"""

29

30 import statistics

31 from typing import Any, List, Optional, Type

32

33 from cardinal_pythonlib.stringfunc import strnumlist, strseq

34 from sqlalchemy import Column

35 from sqlalchemy.sql.sqltypes import Boolean, Float, Integer

36

37 from camcops_server.cc_modules.cc_constants import CssClass

38 from camcops_server.cc_modules.cc_db import add_multiple_columns

39 from camcops_server.cc_modules.cc_html import tr_qa, tr, answer

40 from camcops_server.cc_modules.cc_request import CamcopsRequest

41 from camcops_server.cc_modules.cc_task import TaskHasPatientMixin, Task

42 from camcops_server.cc_modules.cc_text import SS

43 from camcops_server.cc_modules.cc_trackerhelpers import TrackerInfo

44

45

46 class Edeq( # type: ignore[misc]

47 TaskHasPatientMixin,

48 Task,

49):

50 __tablename__ = "edeq"

51 shortname = "EDE-Q"

52 provides_trackers = True

53

54 N_QUESTIONS = 28

55

56 MEASUREMENT_FIELD_NAMES = ["mass_kg", "height_m"]

57

58 @classmethod

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

60

61 add_multiple_columns(

62 cls,

63 "q",

64 1,

65 12,

66 coltype=Integer,

67 minimum=0,

68 maximum=6,

69 comment_fmt="Q{n} - {s}",

70 comment_strings=[

71 "days limit the amount of food 0-6 (no days - every day)",

72 "days long periods without eating 0-6 (no days - every day)",

73 "days exclude from diet 0-6 (no days - every day)",

74 "days follow rules 0-6 (no days - every day)",

75 "days desire empty stomach 0-6 (no days - every day)",

76 "days desire flat stomach 0-6 (no days - every day)",

77 "days thinking about food 0-6 (no days - every day)",

78 "days thinking about shape 0-6 (no days - every day)",

79 "days fear losing control 0-6 (no days - every day)",

80 "days fear weight gain 0-6 (no days - every day)",

81 "days felt fat 0-6 (no days - every day)",

82 "days desire lose weight 0-6 (no days - every day)",

83 ],

84 )

85

86 add_multiple_columns(

87 cls,

88 "q",

89 13,

90 18,

91 coltype=Integer,

92 comment_fmt="Q{n} - {s}",

93 comment_strings=[

94 "times eaten unusually large amount of food",

95 "times sense lost control",

96 "days episodes of overeating",

97 "times made self sick",

98 "times taken laxatives",

99 "times exercised in driven or compulsive way",

100 ],

101 )

102

103 add_multiple_columns(

104 cls,

105 "q",

106 19,

107 21,

108 coltype=Integer,

109 minimum=0,

110 maximum=6,

111 comment_fmt="Q{n} - {s}",

112 comment_strings=[

113 "days eaten in secret (no days - every day)",

114 "times felt guilty (none of the times - every time)",

115 "concern about people seeing you eat (not at all - markedly)",

116 ],

117 )

118

119 add_multiple_columns(

120 cls,

121 "q",

122 22,

123 28,

124 coltype=Integer,

125 minimum=0,

126 maximum=6,

127 comment_fmt="Q{n} - {s}",

128 comment_strings=[

129 "weight influenced how you judge self (not at all - markedly)",

130 "shape influenced how you judge self (not at all - markedly)",

131 "upset if asked to weigh self (not at all - markedly)",

132 "dissatisfied with weight (not at all - markedly)",

133 "dissatisfied with shape (not at all - markedly)",

134 "uncomfortable seeing body (not at all - markedly)",

135 "uncomfortable others seeing shape (not at all - markedly)",

136 ],

137 )

138

139 setattr(

140 cls,

141 "mass_kg",

142 Column("mass_kg", Float, comment="Mass (kg)"),

143 )

144

145 setattr(

146 cls,

147 "height_m",

148 Column("height_m", Float, comment="Height (m)"),

149 )

150

151 setattr(

152 cls,

153 "num_periods_missed",

154 Column(

155 "num_periods_missed",

156 Integer,

157 comment="Number of periods missed",

158 ),

159 )

160

161 setattr(

162 cls,

163 "pill",

164 Column(

165 "pill", Boolean, comment="Taking the (oral contraceptive) pill"

166 ),

167 )

168

169 COMMON_FIELD_NAMES = strseq("q", 1, N_QUESTIONS) + MEASUREMENT_FIELD_NAMES

170

171 FEMALE_FIELD_NAMES = ["num_periods_missed", "pill"]

172

173 RESTRAINT_Q_NUMS = [1, 2, 3, 4, 5]

174 RESTRAINT_Q_STR = ", ".join(str(q) for q in RESTRAINT_Q_NUMS)

175 RESTRAINT_FIELD_NAMES = strnumlist("q", RESTRAINT_Q_NUMS)

176

177 EATING_CONCERN_Q_NUMS = [7, 9, 19, 20, 21]

178 EATING_CONCERN_Q_STR = ", ".join(str(q) for q in EATING_CONCERN_Q_NUMS)

179 EATING_CONCERN_FIELD_NAMES = strnumlist("q", EATING_CONCERN_Q_NUMS)

180

181 SHAPE_CONCERN_Q_NUMS = [6, 8, 10, 11, 23, 26, 27, 28]

182 SHAPE_CONCERN_Q_STR = ", ".join(str(q) for q in SHAPE_CONCERN_Q_NUMS)

183 SHAPE_CONCERN_FIELD_NAMES = strnumlist("q", SHAPE_CONCERN_Q_NUMS)

184

185 WEIGHT_CONCERN_Q_NUMS = [8, 12, 22, 24, 25]

186 WEIGHT_CONCERN_Q_STR = ", ".join(str(q) for q in WEIGHT_CONCERN_Q_NUMS)

187 WEIGHT_CONCERN_FIELD_NAMES = strnumlist("q", WEIGHT_CONCERN_Q_NUMS)

188

189 @staticmethod

190 def longname(req: CamcopsRequest) -> str:

191 _ = req.gettext

192 return _("Eating Disorder Examination Questionnaire")

193

194 def is_complete(self) -> bool:

195 if self.any_fields_none(self.COMMON_FIELD_NAMES):

196 return False

197

198 if self.patient.sex == "F" and self.any_fields_none(

199 self.FEMALE_FIELD_NAMES

200 ):

201 return False

202

203 return True

204

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

206 return [

207 TrackerInfo(

208 value=self.global_score(),

209 plot_label="EDE-Q global score",

210 axis_label="Global score (0–6)",

211 axis_min=-0.5,

212 axis_max=6.5,

213 ),

214 ]

215

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

217 score_range = "[0–6]"

218

219 rows = ""

220 for q_num in range(1, self.N_QUESTIONS + 1):

221 field = "q" + str(q_num)

222 question_cell = self.xstring(req, field)

223

224 rows += tr_qa(question_cell, self.get_answer_cell(req, q_num))

225

226 mass = getattr(self, "mass_kg")

227 if mass is not None:

228 mass = f"{mass} kg"

229 height = getattr(self, "height_m")

230 if height is not None:

231 height = f"{height} m"

232

233 rows += tr_qa(self.xstring(req, "mass_kg"), mass)

234 rows += tr_qa(self.xstring(req, "height_m"), height)

235

236 if self.patient.is_female():

237 for field in self.FEMALE_FIELD_NAMES:

238 rows += tr_qa(self.xstring(req, field), getattr(self, field))

239

240 html = """

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

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

243 {tr_is_complete}

244 {global_score}

245 {restraint_score}

246 {eating_concern_score}

247 {shape_concern_score}

248 {weight_concern_score}

249 </table>

250 </div>

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

252 <tr>

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

254 <th width="40%">Score</th>

255 </tr>

256 {rows}

257 </table>

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

259 [1] Mean of four subscales.

260 [2] Mean of questions {restraint_q_nums}.

261 [3] Mean of questions {eating_concern_q_nums}.

262 [4] Mean of questions {shape_concern_q_nums}.

263 [5] Mean of questions {weight_concern_q_nums}.

264 </div>

265 """.format(

266 CssClass=CssClass,

267 tr_is_complete=self.get_is_complete_tr(req),

268 global_score=tr(

269 req.sstring(SS.GLOBAL_SCORE) + " <sup>[1]</sup>",

270 f"{answer(self.global_score())} {score_range}",

271 ),

272 restraint_score=tr(

273 self.wxstring(req, "restraint") + " <sup>[2]</sup>",

274 f"{answer(self.restraint())} {score_range}",

275 ),

276 eating_concern_score=tr(

277 self.wxstring(req, "eating_concern") + " <sup>[3]</sup>",

278 f"{answer(self.eating_concern())} {score_range}",

279 ),

280 shape_concern_score=tr(

281 self.wxstring(req, "shape_concern") + " <sup>[4]</sup>",

282 f"{answer(self.shape_concern())} {score_range}",

283 ),

284 weight_concern_score=tr(

285 self.wxstring(req, "weight_concern") + " <sup>[5]</sup>",

286 f"{answer(self.weight_concern())} {score_range}",

287 ),

288 rows=rows,

289 restraint_q_nums=self.RESTRAINT_Q_STR,

290 eating_concern_q_nums=self.EATING_CONCERN_Q_STR,

291 shape_concern_q_nums=self.SHAPE_CONCERN_Q_STR,

292 weight_concern_q_nums=self.WEIGHT_CONCERN_Q_STR,

293 )

294 return html

295

296 def get_answer_cell(

297 self, req: CamcopsRequest, q_num: int

298 ) -> Optional[str]:

299 q_field = "q" + str(q_num)

300

301 score = getattr(self, q_field)

302 if score is None or (13 <= q_num <= 18):

303 return score

304

305 meaning = self.get_score_meaning(req, q_num, score)

306

307 answer_cell = f"{score} [{meaning}]"

308

309 return answer_cell

310

311 def get_score_meaning(

312 self, req: CamcopsRequest, q_num: int, score: int

313 ) -> str:

314 if q_num <= 12 or q_num == 19:

315 return self.wxstring(req, f"days_option_{score}")

316

317 if q_num == 20:

318 return self.wxstring(req, f"freq_option_{score}")

319

320 if score % 2 == 1:

321 previous = self.wxstring(req, f"how_much_option_{score-1}")

322 next_ = self.wxstring(req, f"how_much_option_{score+1}")

323 return f"{previous}—{next_}"

324

325 return self.wxstring(req, f"how_much_option_{score}")

326

327 def restraint(self) -> Optional[float]:

328 return self.subscale(self.RESTRAINT_FIELD_NAMES)

329

330 def eating_concern(self) -> Optional[float]:

331 return self.subscale(self.EATING_CONCERN_FIELD_NAMES)

332

333 def shape_concern(self) -> Optional[float]:

334 return self.subscale(self.SHAPE_CONCERN_FIELD_NAMES)

335

336 def weight_concern(self) -> Optional[float]:

337 return self.subscale(self.WEIGHT_CONCERN_FIELD_NAMES)

338

339 def subscale(self, field_names: List[str]) -> Optional[float]:

340 if self.any_fields_none(field_names):

341 return None

342

343 return self.mean_fields(field_names)

344

345 def global_score(self) -> Optional[float]:

346 subscales = [

347 self.restraint(),

348 self.eating_concern(),

349 self.shape_concern(),

350 self.weight_concern(),

351 ]

352

353 if None in subscales:

354 return None

355

356 return statistics.mean(subscales)