Coverage for cc_modules/cc_taskschedulereports.py: 36%

132 statements  

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

1""" 

2camcops_server/cc_modules/cc_taskschedulereports.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**Server reports on CamCOPS scheduled tasks.** 

27 

28""" 

29 

30from typing import List, Type, TYPE_CHECKING, Union 

31 

32from cardinal_pythonlib.classes import classproperty 

33from cardinal_pythonlib.sqlalchemy.core_query import ( 

34 get_rows_fieldnames_from_select, 

35) 

36from cardinal_pythonlib.sqlalchemy.sqlfunc import extract_month, extract_year 

37from sqlalchemy import cast, Integer 

38from sqlalchemy.sql.elements import ColumnElement 

39from sqlalchemy.sql.expression import ( 

40 FromClause, 

41 Select, 

42 desc, 

43 func, 

44 literal, 

45 select, 

46 union_all, 

47) 

48from sqlalchemy.sql.functions import FunctionElement 

49 

50from camcops_server.cc_modules.cc_device import Device 

51from camcops_server.cc_modules.cc_sqla_coltypes import ( 

52 isotzdatetime_to_utcdatetime, 

53) 

54from camcops_server.cc_modules.cc_email import Email 

55from camcops_server.cc_modules.cc_forms import ReportParamSchema 

56from camcops_server.cc_modules.cc_group import Group 

57from camcops_server.cc_modules.cc_patient import Patient 

58from camcops_server.cc_modules.cc_pyramid import ViewParam 

59from camcops_server.cc_modules.cc_report import Report, PlainReportType 

60from camcops_server.cc_modules.cc_reportschema import ( 

61 ByYearSelector, 

62 ByMonthSelector, 

63 DEFAULT_BY_MONTH, 

64 DEFAULT_BY_YEAR, 

65) 

66from camcops_server.cc_modules.cc_taskschedule import ( 

67 PatientTaskSchedule, 

68 PatientTaskScheduleEmail, 

69 TaskSchedule, 

70 TaskScheduleItem, 

71) 

72 

73if TYPE_CHECKING: 

74 from typing import Any 

75 

76 # noinspection PyProtectedMember 

77 from sqlalchemy.sql.expression import Visitable 

78 from camcops_server.cc_modules.cc_request import CamcopsRequest 

79 

80 

81class TaskAssignmentReportSchema(ReportParamSchema): 

82 by_year = ByYearSelector() # must match ViewParam.BY_YEAR 

83 by_month = ByMonthSelector() # must match ViewParam.BY_MONTH 

84 

85 

86# ============================================================================= 

87# Reports 

88# ============================================================================= 

89 

90 

91class TaskAssignmentReport(Report): 

92 """ 

93 Report to count server-side patients and their assigned tasks. 

94 

95 We don't currently record when a patient was assigned to a task schedule; 

96 we only record when the patient registered themselves on the app, along 

97 with any tasks they completed. This report provides: 

98 

99 - Number of server-side patients created (by month or year) 

100 - Number of tasks assigned to registered patients (by month or year) 

101 - Number of tasks assigned to unregistered patients (all time) 

102 - Number of emails sent to patients (by month or year) 

103 

104 This along with the task count report should give good data on completed 

105 and outstanding tasks. 

106 

107 """ 

108 

109 template_name = "task_assignment_report.mako" 

110 

111 label_year = "year" 

112 label_month = "month" 

113 label_group_id = "group_id" 

114 label_group_name = "group_name" 

115 label_schedule_id = "schedule_id" 

116 label_schedule_name = "schedule_name" 

117 label_tasks = "tasks_assigned" 

118 label_patients_created = "patients_created" 

119 label_emails_sent = "emails_sent" 

120 

121 # noinspection PyMethodParameters 

122 @classproperty 

123 def report_id(cls) -> str: 

124 return "taskassignment" 

125 

126 @classmethod 

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

128 _ = req.gettext 

129 return _( 

130 "(Server) Count server-created patients and their assigned tasks" 

131 ) 

132 

133 # noinspection PyMethodParameters 

134 @classproperty 

135 def superuser_only(cls) -> bool: 

136 return False 

137 

138 @staticmethod 

139 def get_paramform_schema_class() -> Type["ReportParamSchema"]: 

140 return TaskAssignmentReportSchema 

141 

142 @classmethod 

143 def get_specific_http_query_keys(cls) -> List[str]: 

144 return [ 

145 ViewParam.BY_YEAR, 

146 ViewParam.BY_MONTH, 

147 ] 

148 

149 def get_rows_colnames(self, req: "CamcopsRequest") -> PlainReportType: 

150 by_year = req.get_bool_param(ViewParam.BY_YEAR, DEFAULT_BY_YEAR) 

151 by_month = req.get_bool_param(ViewParam.BY_MONTH, DEFAULT_BY_MONTH) 

152 

153 tasks_query = self._get_tasks_query(req, by_year, by_month) 

154 tasks_query.alias("tasks_data") 

155 patients_query = self._get_created_patients_query( 

156 req, by_year, by_month 

157 ) 

158 patients_query.alias("patients_data") 

159 emails_query = self._get_emails_sent_query(req, by_year, by_month) 

160 emails_query.alias("emails_data") 

161 

162 selectors = ( 

163 [] 

164 ) # type: List[Union[ColumnElement[Any], FromClause, int]] 

165 sorters = [] # type: List[Union[str, bool, Visitable, None]] 

166 groupers = [ 

167 self.label_group_id, 

168 self.label_schedule_id, 

169 self.label_group_name, 

170 self.label_schedule_name, 

171 ] # type: List[Union[str, bool, Visitable, None]] 

172 

173 # Merge the three queries. 

174 all_data = union_all(tasks_query, patients_query, emails_query).alias( 

175 "all_data" 

176 ) 

177 

178 if by_year: 

179 selectors.append(all_data.c.year) 

180 groupers.append(all_data.c.year) 

181 sorters.append(desc(all_data.c.year)) 

182 

183 if by_month: 

184 selectors.append(all_data.c.month) 

185 groupers.append(all_data.c.month) 

186 sorters.append(desc(all_data.c.month)) 

187 

188 sorters += [all_data.c.group_id, all_data.c.schedule_id] 

189 selectors += [ 

190 all_data.c.group_name, 

191 all_data.c.schedule_name, 

192 func.sum(all_data.c.patients_created).label( 

193 self.label_patients_created 

194 ), 

195 func.sum(all_data.c.tasks_assigned).label(self.label_tasks), 

196 func.sum(all_data.c.emails_sent).label(self.label_emails_sent), 

197 ] 

198 query = ( 

199 select(*selectors) # type: ignore[arg-type] 

200 .select_from(all_data) 

201 .group_by(*groupers) # type: ignore[arg-type] 

202 .order_by(*sorters) # type: ignore[arg-type] 

203 ) 

204 

205 rows, colnames = get_rows_fieldnames_from_select(req.dbsession, query) 

206 

207 return PlainReportType(rows=rows, column_names=colnames) 

208 

209 def _get_tasks_query( 

210 self, req: "CamcopsRequest", by_year: bool, by_month: bool 

211 ) -> Select: 

212 """ 

213 Returns a query of the number of tasks assigned to (scheduled for) 

214 patients created on the server (in a way compatible with being merged 

215 with other queries in this report). 

216 """ 

217 

218 pts = PatientTaskSchedule.__table__ 

219 ts = TaskSchedule.__table__ 

220 tsi = TaskScheduleItem.__table__ 

221 group = Group.__table__ 

222 

223 tables = ( 

224 pts.join(ts, pts.c.schedule_id == ts.c.id) 

225 .join(tsi, tsi.c.schedule_id == ts.c.id) 

226 .join(group, ts.c.group_id == group.c.id) 

227 ) 

228 

229 date_column = isotzdatetime_to_utcdatetime(pts.c.start_datetime) 

230 # Order must be consistent across queries 

231 count_selectors = [ 

232 literal(0).label(self.label_patients_created), 

233 func.count().label(self.label_tasks), 

234 literal(0).label(self.label_emails_sent), 

235 ] 

236 

237 query = self._build_query( 

238 req, tables, by_year, by_month, date_column, count_selectors # type: ignore[arg-type] # noqa: E501 

239 ) 

240 

241 return query 

242 

243 def _get_created_patients_query( 

244 self, req: "CamcopsRequest", by_year: bool, by_month: bool 

245 ) -> Select: 

246 """ 

247 Returns a query of the number of patients created on the server (in a 

248 way compatible with being merged with other queries in this report). 

249 """ 

250 server_device = Device.get_server_device(req.dbsession) 

251 

252 pts = PatientTaskSchedule.__table__ 

253 ts = TaskSchedule.__table__ 

254 group = Group.__table__ 

255 patient = Patient.__table__ 

256 

257 # noinspection PyProtectedMember 

258 tables = ( 

259 pts.join(ts, pts.c.schedule_id == ts.c.id) 

260 .join(group, ts.c.group_id == group.c.id) 

261 .join(patient, pts.c.patient_pk == patient.c._pk) 

262 ) 

263 

264 # noinspection PyProtectedMember 

265 date_column = isotzdatetime_to_utcdatetime(patient.c._when_added_exact) 

266 # Order must be consistent across queries 

267 count_selectors = [ 

268 func.count().label(self.label_patients_created), 

269 literal(0).label(self.label_tasks), 

270 literal(0).label(self.label_emails_sent), 

271 ] 

272 

273 # noinspection PyProtectedMember,PyTypeChecker 

274 return self._build_query( 

275 req, tables, by_year, by_month, date_column, count_selectors # type: ignore[arg-type] # noqa: E501 

276 ).where(patient.c._device_id == server_device.id) 

277 

278 def _get_emails_sent_query( 

279 self, req: "CamcopsRequest", by_year: bool, by_month: bool 

280 ) -> Select: 

281 """ 

282 Returns a query of the number of e-mails sent to patients created on 

283 the server (in a way compatible with being merged with other queries in 

284 this report). 

285 """ 

286 

287 pts = PatientTaskSchedule.__table__ 

288 ts = TaskSchedule.__table__ 

289 group = Group.__table__ 

290 patient = Patient.__table__ 

291 ptse = PatientTaskScheduleEmail.__table__ 

292 email = Email.__table__ 

293 

294 # noinspection PyProtectedMember 

295 tables = ( 

296 ptse.join(pts, ptse.c.patient_task_schedule_id == pts.c.id) 

297 .join(ts, pts.c.schedule_id == ts.c.id) 

298 .join(group, ts.c.group_id == group.c.id) 

299 .join(patient, pts.c.patient_pk == patient.c._pk) 

300 .join(email, ptse.c.email_id == email.c.id) 

301 ) 

302 

303 date_column = email.c.sent_at_utc 

304 # Order must be consistent across queries 

305 count_selectors = [ 

306 literal(0).label(self.label_patients_created), 

307 literal(0).label(self.label_tasks), 

308 func.count().label(self.label_emails_sent), 

309 ] 

310 

311 # noinspection PyTypeChecker 

312 return self._build_query( 

313 req, tables, by_year, by_month, date_column, count_selectors # type: ignore[arg-type] # noqa: E501 

314 ).where( 

315 email.c.sent == True # noqa: E712 

316 ) 

317 

318 def _build_query( 

319 self, 

320 req: "CamcopsRequest", 

321 tables: FromClause, 

322 by_year: bool, 

323 by_month: bool, 

324 date_column: FunctionElement, 

325 count_selectors: List[FunctionElement], 

326 ) -> Select: 

327 assert req.user is not None # For type checker 

328 

329 group_ids = req.user.ids_of_groups_user_may_report_on 

330 superuser = req.user.superuser 

331 

332 ts = TaskSchedule.__table__ 

333 group = Group.__table__ 

334 

335 groupers = [ 

336 group.c.id, 

337 ts.c.id, 

338 ] # type: List[Union[str, bool, Visitable, None]] 

339 

340 selectors = ( 

341 [] 

342 ) # type: List[Union[ColumnElement[Any], FromClause, int]] 

343 

344 if by_year: 

345 selectors.append( 

346 cast( # Necessary for SQLite tests 

347 extract_year(date_column), 

348 Integer(), 

349 ).label(self.label_year) 

350 ) 

351 groupers.append(self.label_year) 

352 

353 if by_month: 

354 selectors.append( 

355 cast( # Necessary for SQLite tests 

356 extract_month(date_column), 

357 Integer(), 

358 ).label(self.label_month) 

359 ) 

360 groupers.append(self.label_month) 

361 # Regardless: 

362 selectors.append(group.c.id.label(self.label_group_id)) 

363 selectors.append(group.c.name.label(self.label_group_name)) 

364 selectors.append(ts.c.id.label(self.label_schedule_id)) 

365 selectors.append(ts.c.name.label(self.label_schedule_name)) 

366 selectors += count_selectors 

367 # noinspection PyUnresolvedReferences 

368 query = select(*selectors).select_from(tables).group_by(*groupers) # type: ignore[arg-type] # noqa: E501 

369 if not superuser: 

370 # Restrict to accessible groups 

371 # noinspection PyProtectedMember 

372 query = query.where(group.c.id.in_(group_ids)) 

373 

374 return query