Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/cc_modules/cc_report.py 

5 

6=============================================================================== 

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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/>. 

24 

25=============================================================================== 

26 

27**CamCOPS reports.** 

28 

29""" 

30 

31import logging 

32from abc import ABC 

33from typing import (Any, Callable, Dict, List, Optional, Sequence, 

34 Type, TYPE_CHECKING, Union) 

35 

36from cardinal_pythonlib.classes import all_subclasses, classproperty 

37from cardinal_pythonlib.datetimefunc import format_datetime 

38from cardinal_pythonlib.logs import BraceStyleAdapter 

39from cardinal_pythonlib.pyramid.responses import ( 

40 OdsResponse, TsvResponse, XlsxResponse, 

41) 

42from deform.form import Form 

43from pyramid.httpexceptions import HTTPBadRequest 

44from pyramid.renderers import render_to_response 

45from pyramid.response import Response 

46from sqlalchemy.engine.result import ResultProxy 

47from sqlalchemy.orm.query import Query 

48from sqlalchemy.sql.elements import ColumnElement 

49from sqlalchemy.sql.expression import and_, column, func, select 

50from sqlalchemy.sql.selectable import SelectBase 

51 

52# import as LITTLE AS POSSIBLE; this is used by lots of modules 

53from camcops_server.cc_modules.cc_constants import ( 

54 DateFormat, 

55 DEFAULT_ROWS_PER_PAGE, 

56) 

57from camcops_server.cc_modules.cc_db import FN_CURRENT, TFN_WHEN_CREATED 

58from camcops_server.cc_modules.cc_pyramid import ( 

59 CamcopsPage, 

60 PageUrl, 

61 ViewArg, 

62 ViewParam, 

63) 

64from camcops_server.cc_modules.cc_tsv import TsvCollection, TsvPage 

65 

66if TYPE_CHECKING: 

67 from camcops_server.cc_modules.cc_forms import ( # noqa: F401 

68 ReportParamForm, 

69 ReportParamSchema, 

70 ) 

71 from camcops_server.cc_modules.cc_request import CamcopsRequest # noqa: E501,F401 

72 from camcops_server.cc_modules.cc_task import Task # noqa: F401 

73 

74log = BraceStyleAdapter(logging.getLogger(__name__)) 

75 

76 

77# ============================================================================= 

78# Other constants 

79# ============================================================================= 

80 

81class PlainReportType(object): 

82 """ 

83 Simple class to hold the results of a plain report. 

84 """ 

85 def __init__(self, rows: Sequence[Sequence[Any]], 

86 column_names: Sequence[str]) -> None: 

87 self.rows = rows 

88 self.column_names = column_names 

89 

90 

91# ============================================================================= 

92# Report class 

93# ============================================================================= 

94 

95class Report(object): 

96 """ 

97 Abstract base class representing a report. 

98 

99 If you are writing a report, you must override these attributes: 

100 

101 - :meth:`report_id` 

102 - :meth:`report_title` 

103 - One combination of: 

104 

105 - (simplest) :meth:`get_query` OR :meth:`get_rows_colnames` 

106 - (for multi-page results) :meth:`render_html` and :meth:`get_tsv_pages` 

107 - (manual control) all ``render_*`` functions 

108 

109 See the explanations of each. 

110 """ 

111 

112 template_name = "report.mako" 

113 

114 # ------------------------------------------------------------------------- 

115 # Attributes that must be provided 

116 # ------------------------------------------------------------------------- 

117 # noinspection PyMethodParameters 

118 @classproperty 

119 def report_id(cls) -> str: 

120 """ 

121 Returns a identifying string, unique to this report, used in the HTML 

122 report selector. 

123 """ 

124 raise NotImplementedError("implement in subclass") 

125 

126 @classmethod 

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

128 """ 

129 Descriptive title for display purposes. 

130 """ 

131 raise NotImplementedError("implement in subclass") 

132 

133 # noinspection PyMethodParameters 

134 @classproperty 

135 def superuser_only(cls) -> bool: 

136 """ 

137 If ``True`` (the default), only superusers may run the report. 

138 You must explicitly override this property to permit others. 

139 """ 

140 return True 

141 

142 @classmethod 

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

144 """ 

145 Returns the keys used for the HTTP GET query. They include details of: 

146 

147 - which report? 

148 - how to view it? 

149 - pagination options 

150 - report-specific configuration details from 

151 :func:`get_specific_http_query_keys`. 

152 """ 

153 return [ 

154 ViewParam.REPORT_ID, 

155 ViewParam.VIEWTYPE, 

156 ViewParam.ROWS_PER_PAGE, 

157 ViewParam.PAGE, 

158 ] + cls.get_specific_http_query_keys() 

159 

160 @classmethod 

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

162 """ 

163 Additional HTTP GET query keys used by this report. Override to add 

164 custom ones. 

165 """ 

166 return [] 

167 

168 def get_query(self, req: "CamcopsRequest") \ 

169 -> Union[None, SelectBase, Query]: 

170 """ 

171 Overriding this function is one way of providing a report. (The other 

172 is :func:`get_rows_colnames`.) 

173 

174 To override this function, return the SQLAlchemy Base :class:`Select` 

175 statement or the SQLAlchemy ORM :class:`Query` to execute the report. 

176 

177 Parameters are passed in via the request. 

178 """ 

179 return None 

180 

181 def get_rows_colnames(self, req: "CamcopsRequest") \ 

182 -> Optional[PlainReportType]: 

183 """ 

184 Overriding this function is one way of providing a report. (The other 

185 is :func:`get_query`.) 

186 

187 To override this function, return a :class:`PlainReportType` with 

188 column names and row content. 

189 """ 

190 return None 

191 

192 @staticmethod 

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

194 """ 

195 Returns the class used as the Colander schema for the form that 

196 configures the report. By default, this is a simple form that just 

197 offers a choice of output format, but you can provide a more 

198 extensive one (an example being in 

199 :class:`camcops_server.tasks.diagnosis.DiagnosisFinderReportBase`. 

200 """ 

201 from camcops_server.cc_modules.cc_forms import ReportParamSchema # delayed import # noqa 

202 return ReportParamSchema 

203 

204 def get_form(self, req: "CamcopsRequest") -> Form: 

205 """ 

206 Returns a Colander form to configure the report. The default usually 

207 suffices, and it will use the schema specified in 

208 :func:`get_paramform_schema_class`. 

209 """ 

210 from camcops_server.cc_modules.cc_forms import ReportParamForm # delayed import # noqa 

211 schema_class = self.get_paramform_schema_class() 

212 return ReportParamForm(request=req, schema_class=schema_class) 

213 

214 @staticmethod 

215 def get_test_querydict() -> Dict[str, Any]: 

216 """ 

217 What this function returns is used as the specimen Colander 

218 ``appstruct`` for unit tests. The default is an empty dictionary. 

219 """ 

220 return {} 

221 

222 @staticmethod 

223 def add_task_report_filters(wheres: List[ColumnElement]) -> None: 

224 """ 

225 Adds any restrictions required to a list of SQLAlchemy Core ``WHERE`` 

226 clauses. 

227 

228 Override this (or provide additional filters and call this) to provide 

229 global filters to queries used to create reports. 

230 

231 Used by :class:`DateTimeFilteredReportMixin`, etc. 

232 

233 The presumption is that the thing being filtered is an instance of 

234 :class:`camcops_server.cc_modules.cc_task.Task`. 

235 

236 Args: 

237 wheres: 

238 list of SQL ``WHERE`` conditions, each represented as an 

239 SQLAlchemy :class:`ColumnElement`. This list is modifed in 

240 place. The caller will need to apply the final list to the 

241 query. 

242 """ 

243 # noinspection PyPep8 

244 wheres.append( 

245 column(FN_CURRENT) == True # noqa: E712 

246 ) 

247 

248 # ------------------------------------------------------------------------- 

249 # Common functionality: classmethods 

250 # ------------------------------------------------------------------------- 

251 

252 @classmethod 

253 def all_subclasses(cls) -> List[Type["Report"]]: 

254 """ 

255 Get all report subclasses, except those not implementing their 

256 ``report_id`` property. Optionally, sort by their title. 

257 """ 

258 # noinspection PyTypeChecker 

259 classes = all_subclasses(cls) # type: List[Type["Report"]] 

260 instantiated_report_classes = [] # type: List[Type["Report"]] 

261 for reportcls in classes: 

262 if reportcls.__name__ == 'TestReport': 

263 continue 

264 

265 try: 

266 _ = reportcls.report_id 

267 instantiated_report_classes.append(reportcls) 

268 except NotImplementedError: 

269 # This is a subclass of Report, but it's still an abstract 

270 # class; skip it. 

271 pass 

272 return instantiated_report_classes 

273 

274 # ------------------------------------------------------------------------- 

275 # Common functionality: default Response 

276 # ------------------------------------------------------------------------- 

277 

278 def get_response(self, req: "CamcopsRequest") -> Response: 

279 """ 

280 Return the report content itself, as an HTTP :class:`Response`. 

281 """ 

282 # Check the basic parameters 

283 report_id = req.get_str_param(ViewParam.REPORT_ID) 

284 

285 if report_id != self.report_id: 

286 raise HTTPBadRequest("Error - request directed to wrong report!") 

287 

288 # viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML, 

289 # lower=True) 

290 # ... NO; for a Deform radio button, the request contains parameters 

291 # like 

292 # ('__start__', 'viewtype:rename'), 

293 # ('deformField2', 'tsv'), 

294 # ('__end__', 'viewtype:rename') 

295 # ... so we need to ask the appstruct instead. 

296 # This is a bit different from how we manage trackers/CTVs, where we 

297 # recode the appstruct to a URL. 

298 # 

299 # viewtype = appstruct.get(ViewParam.VIEWTYPE) # type: str 

300 # 

301 # Ah, no... that fails with pagination of reports. Let's redirect 

302 # things to the HTTP query, as for trackers/audit! 

303 

304 viewtype = req.get_str_param(ViewParam.VIEWTYPE, ViewArg.HTML, 

305 lower=True) 

306 # Run the report (which may take additional parameters from the 

307 # request) 

308 # Serve the result 

309 if viewtype == ViewArg.HTML: 

310 return self.render_html(req=req) 

311 

312 if viewtype == ViewArg.ODS: 

313 return self.render_ods(req=req) 

314 

315 if viewtype == ViewArg.TSV: 

316 return self.render_tsv(req=req) 

317 

318 if viewtype == ViewArg.XLSX: 

319 return self.render_xlsx(req=req) 

320 

321 raise HTTPBadRequest("Bad viewtype") 

322 

323 def render_html(self, req: "CamcopsRequest") -> Response: 

324 rows_per_page = req.get_int_param(ViewParam.ROWS_PER_PAGE, 

325 DEFAULT_ROWS_PER_PAGE) 

326 page_num = req.get_int_param(ViewParam.PAGE, 1) 

327 

328 plain_report = self._get_plain_report(req) 

329 

330 page = CamcopsPage(collection=plain_report.rows, 

331 page=page_num, 

332 items_per_page=rows_per_page, 

333 url_maker=PageUrl(req), 

334 request=req) 

335 

336 return self.render_single_page_html( 

337 req=req, 

338 column_names=plain_report.column_names, 

339 page=page 

340 ) 

341 

342 def render_tsv(self, req: "CamcopsRequest") -> TsvResponse: 

343 filename = self.get_filename(req, ViewArg.TSV) 

344 

345 # By default there is only one page. If there are more, 

346 # we only output the first 

347 page = self.get_tsv_pages(req)[0] 

348 

349 return TsvResponse(body=page.get_tsv(), filename=filename) 

350 

351 def render_xlsx(self, req: "CamcopsRequest") -> XlsxResponse: 

352 filename = self.get_filename(req, ViewArg.XLSX) 

353 tsvcoll = self.get_tsv_collection(req) 

354 content = tsvcoll.as_xlsx() 

355 

356 return XlsxResponse(body=content, filename=filename) 

357 

358 def render_ods(self, req: "CamcopsRequest") -> OdsResponse: 

359 filename = self.get_filename(req, ViewArg.ODS) 

360 tsvcoll = self.get_tsv_collection(req) 

361 content = tsvcoll.as_ods() 

362 

363 return OdsResponse(body=content, filename=filename) 

364 

365 def get_tsv_collection(self, req: "CamcopsRequest") -> TsvCollection: 

366 tsvcoll = TsvCollection() 

367 tsvcoll.add_pages(self.get_tsv_pages(req)) 

368 

369 return tsvcoll 

370 

371 def get_tsv_pages(self, req: "CamcopsRequest") -> List[TsvPage]: 

372 plain_report = self._get_plain_report(req) 

373 

374 page = self.get_tsv_page(name=self.title(req), 

375 column_names=plain_report.column_names, 

376 rows=plain_report.rows) 

377 return [page] 

378 

379 @staticmethod 

380 def get_tsv_page(name: str, 

381 column_names: Sequence[str], 

382 rows: Sequence[Sequence[Any]]) -> TsvPage: 

383 keyed_rows = [dict(zip(column_names, r)) for r in rows] 

384 page = TsvPage(name=name, rows=keyed_rows) 

385 

386 return page 

387 

388 def get_filename(self, req: "CamcopsRequest", viewtype: str) -> str: 

389 extension_dict = { 

390 ViewArg.ODS: 'ods', 

391 ViewArg.TSV: 'tsv', 

392 ViewArg.XLSX: 'xlsx', 

393 } 

394 

395 if viewtype not in extension_dict: 

396 raise HTTPBadRequest("Unsupported viewtype") 

397 

398 extension = extension_dict.get(viewtype) 

399 

400 return ( 

401 "CamCOPS_" + 

402 self.report_id + 

403 "_" + 

404 format_datetime(req.now, DateFormat.FILENAME) + 

405 "." + 

406 extension 

407 ) 

408 

409 def render_single_page_html(self, 

410 req: "CamcopsRequest", 

411 column_names: Sequence[str], 

412 page: CamcopsPage) -> Response: 

413 """ 

414 Converts a paginated report into an HTML response. 

415 

416 If you wish, you can override this for more report customization. 

417 """ 

418 return render_to_response( 

419 self.template_name, 

420 dict(title=self.title(req), 

421 page=page, 

422 column_names=column_names, 

423 report_id=self.report_id), 

424 request=req 

425 ) 

426 

427 def _get_plain_report(self, req: "CamcopsRequest") -> PlainReportType: 

428 """ 

429 Uses :meth:`get_query`, or if absent, :meth:`get_rows_colnames`, to 

430 fetch data. Returns a "single-page" type report, in the form of a 

431 :class:`PlainReportType`. 

432 """ 

433 statement = self.get_query(req) 

434 if statement is not None: 

435 rp = req.dbsession.execute(statement) # type: ResultProxy 

436 column_names = rp.keys() 

437 rows = rp.fetchall() 

438 

439 plain_report = PlainReportType(rows=rows, 

440 column_names=column_names) 

441 else: 

442 plain_report = self.get_rows_colnames(req) 

443 if plain_report is None: 

444 raise NotImplementedError( 

445 "Report did not implement either of get_query()" 

446 " or get_rows_colnames()") 

447 

448 return plain_report 

449 

450 

451class PercentageSummaryReportMixin(object): 

452 """ 

453 Mixin to be used with :class:`Report`. 

454 """ 

455 @classproperty 

456 def task_class(self) -> Type["Task"]: 

457 raise NotImplementedError("implement in subclass") 

458 

459 def get_percentage_summaries(self, 

460 req: "CamcopsRequest", 

461 column_dict: Dict[str, str], 

462 num_answers: int, 

463 cell_format: str = "{}", 

464 min_answer: int = 0) -> List[List[str]]: 

465 """ 

466 Provides a summary of each question, x% of people said each response. 

467 """ 

468 rows = [] 

469 

470 for column_name, question in column_dict.items(): 

471 """ 

472 e.g. SELECT COUNT(col) FROM perinatal_poem WHERE col IS NOT NULL 

473 """ 

474 wheres = [ 

475 column(column_name).isnot(None) 

476 ] 

477 

478 # noinspection PyUnresolvedReferences 

479 self.add_task_report_filters(wheres) 

480 

481 # noinspection PyUnresolvedReferences 

482 total_query = ( 

483 select([func.count(column_name)]) 

484 .select_from(self.task_class.__table__) 

485 .where(and_(*wheres)) 

486 ) 

487 

488 total_responses = req.dbsession.execute(total_query).fetchone()[0] 

489 

490 row = [question] + [total_responses] + [""] * num_answers 

491 

492 """ 

493 e.g. 

494 SELECT total_responses,col, ((100 * COUNT(col)) / total_responses) 

495 FROM perinatal_poem WHERE col is not NULL 

496 GROUP BY col 

497 """ 

498 # noinspection PyUnresolvedReferences 

499 query = ( 

500 select([ 

501 column(column_name), 

502 ((100 * func.count(column_name))/total_responses) 

503 ]) 

504 .select_from(self.task_class.__table__) 

505 .where(and_(*wheres)) 

506 .group_by(column_name) 

507 ) 

508 

509 # row output is: 

510 # 0 1 2 3 

511 # +----------+-----------------+--------------+--------------+---- 

512 # | question | total responses | % 1st answer | % 2nd answer | ... 

513 # +----------+-----------------+--------------+--------------+---- 

514 for result in req.dbsession.execute(query): 

515 col = 2 + (result[0] - min_answer) 

516 row[col] = cell_format.format(result[1]) 

517 

518 rows.append(row) 

519 

520 return rows 

521 

522 

523class DateTimeFilteredReportMixin(object): 

524 def __init__(self, *args, **kwargs): 

525 super().__init__(*args, **kwargs) 

526 self.start_datetime = None # type: Optional[str] 

527 self.end_datetime = None # type: Optional[str] 

528 

529 @staticmethod 

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

531 from camcops_server.cc_modules.cc_forms import DateTimeFilteredReportParamSchema # delayed import # noqa 

532 return DateTimeFilteredReportParamSchema 

533 

534 @classmethod 

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

536 # noinspection PyUnresolvedReferences 

537 return super().get_specific_http_query_keys() + [ 

538 ViewParam.START_DATETIME, 

539 ViewParam.END_DATETIME, 

540 ] 

541 

542 def get_response(self, req: "CamcopsRequest") -> Response: 

543 self.start_datetime = format_datetime( 

544 req.get_datetime_param(ViewParam.START_DATETIME), 

545 DateFormat.ERA 

546 ) 

547 self.end_datetime = format_datetime( 

548 req.get_datetime_param(ViewParam.END_DATETIME), 

549 DateFormat.ERA 

550 ) 

551 

552 # noinspection PyUnresolvedReferences 

553 return super().get_response(req) 

554 

555 def add_task_report_filters(self, wheres: List[ColumnElement]) -> None: 

556 """ 

557 Adds any restrictions required to a list of SQLAlchemy Core ``WHERE`` 

558 clauses. 

559 

560 See :meth:`Report.add_task_report_filters`. 

561 

562 Args: 

563 wheres: 

564 list of SQL ``WHERE`` conditions, each represented as an 

565 SQLAlchemy :class:`ColumnElement`. This list is modifed in 

566 place. The caller will need to apply the final list to the 

567 query. 

568 """ 

569 # noinspection PyUnresolvedReferences 

570 super().add_task_report_filters(wheres) 

571 

572 if self.start_datetime is not None: 

573 wheres.append( 

574 column(TFN_WHEN_CREATED) >= self.start_datetime 

575 ) 

576 

577 if self.end_datetime is not None: 

578 wheres.append( 

579 column(TFN_WHEN_CREATED) < self.end_datetime 

580 ) 

581 

582 

583class ScoreDetails(object): 

584 """ 

585 Represents a type of score whose progress we want to track over time. 

586 """ 

587 def __init__(self, 

588 name: str, 

589 scorefunc: Callable[["Task"], Union[None, int, float]], 

590 minimum: int, 

591 maximum: int, 

592 higher_score_is_better: bool = False) -> None: 

593 """ 

594 Args: 

595 name: 

596 human-friendly name of this score 

597 scorefunc: 

598 function that can be called with a task instance as its 

599 sole parameter and which will return a numerical score (or 

600 ``None``) 

601 minimum: 

602 minimum possible value of this score (for display purposes) 

603 maximum: 

604 maximum possible value of this score (for display purposes) 

605 higher_score_is_better: 

606 is a higher score a better thing? 

607 """ 

608 self.name = name 

609 self.scorefunc = scorefunc 

610 self.minimum = minimum 

611 self.maximum = maximum 

612 self.higher_score_is_better = higher_score_is_better 

613 

614 def calculate_improvement(self, 

615 first_score: float, 

616 latest_score: float) -> float: 

617 """ 

618 Improvement is positive. 

619 

620 So if higher scores are better, returns ``latest - first``; otherwise 

621 returns ``first - latest``. 

622 """ 

623 if self.higher_score_is_better: 

624 return latest_score - first_score 

625 else: 

626 return first_score - latest_score 

627 

628 

629class AverageScoreReport(DateTimeFilteredReportMixin, Report, ABC): 

630 """ 

631 Used by MAAS, CORE-10 and PBQ to report average scores and progress 

632 """ 

633 template_name = "average_score_report.mako" 

634 

635 def __init__(self, *args, via_index: bool = True, **kwargs) -> None: 

636 """ 

637 Args: 

638 via_index: 

639 set this to ``False`` for unit test when you don't want to 

640 have to build a dummy task index. 

641 """ 

642 super().__init__(*args, **kwargs) 

643 self.via_index = via_index 

644 

645 # noinspection PyMethodParameters 

646 @classproperty 

647 def superuser_only(cls) -> bool: 

648 return False 

649 

650 # noinspection PyMethodParameters 

651 @classproperty 

652 def task_class(cls) -> Type["Task"]: 

653 raise NotImplementedError( 

654 "Report did not implement task_class" 

655 ) 

656 

657 # noinspection PyMethodParameters 

658 @classmethod 

659 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]: 

660 raise NotImplementedError( 

661 "Report did not implement 'scoretypes'" 

662 ) 

663 

664 @staticmethod 

665 def no_data_value() -> Any: 

666 """ 

667 The value used for a "no data" cell. 

668 

669 The only reason this is accessible outside this class is for unit 

670 testing. 

671 """ 

672 return "" 

673 

674 def render_html(self, req: "CamcopsRequest") -> Response: 

675 tsv_pages = self.get_tsv_pages(req) 

676 return render_to_response( 

677 self.template_name, 

678 dict(title=self.title(req), 

679 mainpage=tsv_pages[0], 

680 datepage=tsv_pages[1], 

681 report_id=self.report_id), 

682 request=req 

683 ) 

684 

685 def get_tsv_pages(self, req: "CamcopsRequest") -> List[TsvPage]: 

686 """ 

687 We use an SQLAlchemy ORM, rather than Core, method. Why? 

688 

689 - "Patient equality" is complex (e.g. same patient_id on same device, 

690 or a shared ID number, etc.) -- simplicity via Patient.__eq__. 

691 - Facilities "is task complete?" checks, and use of Python 

692 calculations. 

693 """ 

694 _ = req.gettext 

695 from camcops_server.cc_modules.cc_taskcollection import ( 

696 TaskCollection, 

697 task_when_created_sorter, 

698 ) # delayed import 

699 from camcops_server.cc_modules.cc_taskfilter import TaskFilter # delayed import # noqa 

700 

701 # Which tasks? 

702 taskfilter = TaskFilter() 

703 taskfilter.task_types = [self.task_class.__tablename__] 

704 taskfilter.start_datetime = self.start_datetime 

705 taskfilter.end_datetime = self.end_datetime 

706 taskfilter.complete_only = True 

707 

708 # Get tasks 

709 collection = TaskCollection( 

710 req=req, 

711 taskfilter=taskfilter, 

712 current_only=True, 

713 via_index=self.via_index, 

714 ) 

715 all_tasks = collection.all_tasks 

716 

717 # Get all distinct patients 

718 patients = set(t.patient for t in all_tasks) 

719 # log.critical("all_tasks: {}", all_tasks) 

720 # log.critical("patients: {}", [str(p) for p in patients]) 

721 

722 scoretypes = self.scoretypes(req) 

723 n_scoretypes = len(scoretypes) 

724 

725 # Sum first/last/progress scores by patient 

726 sum_first_by_score = [0] * n_scoretypes 

727 sum_last_by_score = [0] * n_scoretypes 

728 sum_improvement_by_score = [0] * n_scoretypes 

729 n_first = 0 

730 n_last = 0 # also n_progress 

731 for patient in patients: 

732 # Find tasks for this patient 

733 patient_tasks = [t for t in all_tasks if t.patient == patient] 

734 assert patient_tasks, f"No tasks for patient {patient}" 

735 # log.critical("For patient {}, tasks: {}", patient, patient_tasks) 

736 # Find first and last task (last may be absent) 

737 patient_tasks.sort(key=task_when_created_sorter) 

738 first = patient_tasks[0] 

739 n_first += 1 

740 if len(patient_tasks) > 1: 

741 last = patient_tasks[-1] 

742 n_last += 1 

743 else: 

744 last = None 

745 

746 # Obtain first/last scores and progress 

747 for scoreidx, scoretype in enumerate(scoretypes): 

748 firstscore = scoretype.scorefunc(first) 

749 # Scores should not be None, because all tasks are complete. 

750 sum_first_by_score[scoreidx] += firstscore 

751 if last: 

752 lastscore = scoretype.scorefunc(last) 

753 sum_last_by_score[scoreidx] += lastscore 

754 improvement = scoretype.calculate_improvement( 

755 firstscore, lastscore) 

756 sum_improvement_by_score[scoreidx] += improvement 

757 

758 # Format output 

759 column_names = [ 

760 _("Number of initial records"), 

761 _("Number of latest subsequent records"), 

762 ] 

763 row = [n_first, n_last] 

764 no_data = self.no_data_value() 

765 for scoreidx, scoretype in enumerate(scoretypes): 

766 # Calculations 

767 if n_first == 0: 

768 avg_first = no_data 

769 else: 

770 avg_first = sum_first_by_score[scoreidx] / n_first 

771 if n_last == 0: 

772 avg_last = no_data 

773 avg_improvement = no_data 

774 else: 

775 avg_last = sum_last_by_score[scoreidx] / n_last 

776 avg_improvement = sum_improvement_by_score[scoreidx] / n_last 

777 

778 # Columns and row data 

779 column_names += [ 

780 f"{scoretype.name} ({scoretype.minimum}–{scoretype.maximum}): " 

781 f"{_('First')}", 

782 f"{scoretype.name} ({scoretype.minimum}–{scoretype.maximum}): " 

783 f"{_('Latest')}", 

784 f"{scoretype.name}: {_('Improvement')}", 

785 ] 

786 row += [avg_first, avg_last, avg_improvement] 

787 

788 # Create and return report 

789 mainpage = self.get_tsv_page( 

790 name=self.title(req), 

791 column_names=column_names, 

792 rows=[row] 

793 ) 

794 datepage = self.get_tsv_page( 

795 name=_("Date filters"), 

796 column_names=[_("Start date"), _("End date")], 

797 rows=[[str(self.start_datetime), str(self.end_datetime)]] 

798 ) 

799 return [mainpage, datepage] 

800 

801 

802# ============================================================================= 

803# Report framework 

804# ============================================================================= 

805 

806def get_all_report_classes(req: "CamcopsRequest") -> List[Type["Report"]]: 

807 """ 

808 Returns all :class:`Report` (sub)classes, i.e. all report types. 

809 """ 

810 classes = Report.all_subclasses() 

811 classes.sort(key=lambda c: c.title(req)) 

812 return classes 

813 

814 

815def get_report_instance(report_id: str) -> Optional[Report]: 

816 """ 

817 Creates an instance of a :class:`Report`, given its ID (name), or return 

818 ``None`` if the ID is invalid. 

819 """ 

820 if not report_id: 

821 return None 

822 for cls in Report.all_subclasses(): 

823 if cls.report_id == report_id: 

824 return cls() 

825 return None