Coverage for cc_modules/cc_report.py: 33%

280 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_report.py 

5 

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

7 

8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry. 

9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk). 

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**CamCOPS reports.** 

29 

30""" 

31 

32import logging 

33from abc import ABC 

34from typing import ( 

35 Any, 

36 Callable, 

37 Dict, 

38 List, 

39 Optional, 

40 Sequence, 

41 Type, 

42 TYPE_CHECKING, 

43 Union, 

44) 

45 

46from cardinal_pythonlib.classes import all_subclasses, classproperty 

47from cardinal_pythonlib.datetimefunc import format_datetime 

48from cardinal_pythonlib.logs import BraceStyleAdapter 

49from cardinal_pythonlib.pyramid.responses import ( 

50 OdsResponse, 

51 TsvResponse, 

52 XlsxResponse, 

53) 

54from deform.form import Form 

55from pyramid.httpexceptions import HTTPBadRequest 

56from pyramid.renderers import render_to_response 

57from pyramid.response import Response 

58from sqlalchemy.engine.result import ResultProxy 

59from sqlalchemy.orm.query import Query 

60from sqlalchemy.sql.elements import ColumnElement 

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

62from sqlalchemy.sql.selectable import SelectBase 

63 

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

65from camcops_server.cc_modules.cc_constants import ( 

66 DateFormat, 

67 DEFAULT_ROWS_PER_PAGE, 

68) 

69from camcops_server.cc_modules.cc_db import FN_CURRENT, TFN_WHEN_CREATED 

70from camcops_server.cc_modules.cc_pyramid import ( 

71 CamcopsPage, 

72 PageUrl, 

73 ViewArg, 

74 ViewParam, 

75) 

76from camcops_server.cc_modules.cc_spreadsheet import ( 

77 SpreadsheetCollection, 

78 SpreadsheetPage, 

79) 

80 

81if TYPE_CHECKING: 

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

83 ReportParamForm, 

84 ReportParamSchema, 

85 ) 

86 from camcops_server.cc_modules.cc_request import ( # noqa: F401 

87 CamcopsRequest, 

88 ) 

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

90 

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

92 

93 

94# ============================================================================= 

95# Other constants 

96# ============================================================================= 

97 

98 

99class PlainReportType(object): 

100 """ 

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

102 """ 

103 

104 def __init__( 

105 self, rows: Sequence[Sequence[Any]], column_names: Sequence[str] 

106 ) -> None: 

107 self.rows = rows 

108 self.column_names = column_names 

109 

110 

111# ============================================================================= 

112# Report class 

113# ============================================================================= 

114 

115 

116class Report(object): 

117 """ 

118 Abstract base class representing a report. 

119 

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

121 

122 - :meth:`report_id` 

123 - :meth:`report_title` 

124 - One combination of: 

125 

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

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

128 :meth:`get_spreadsheet_pages` 

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

130 

131 See the explanations of each. 

132 """ 

133 

134 template_name = "report.mako" 

135 

136 # ------------------------------------------------------------------------- 

137 # Attributes that must be provided 

138 # ------------------------------------------------------------------------- 

139 # noinspection PyMethodParameters 

140 @classproperty 

141 def report_id(cls) -> str: 

142 """ 

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

144 report selector. 

145 """ 

146 raise NotImplementedError("implement in subclass") 

147 

148 @classmethod 

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

150 """ 

151 Descriptive title for display purposes. 

152 """ 

153 raise NotImplementedError("implement in subclass") 

154 

155 # noinspection PyMethodParameters 

156 @classproperty 

157 def superuser_only(cls) -> bool: 

158 """ 

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

160 You must explicitly override this property to permit others. 

161 """ 

162 return True 

163 

164 @classmethod 

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

166 """ 

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

168 

169 - which report? 

170 - how to view it? 

171 - pagination options 

172 - report-specific configuration details from 

173 :func:`get_specific_http_query_keys`. 

174 """ 

175 return [ 

176 ViewParam.REPORT_ID, 

177 ViewParam.VIEWTYPE, 

178 ViewParam.ROWS_PER_PAGE, 

179 ViewParam.PAGE, 

180 ] + cls.get_specific_http_query_keys() 

181 

182 @classmethod 

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

184 """ 

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

186 custom ones. 

187 """ 

188 return [] 

189 

190 def get_query( 

191 self, req: "CamcopsRequest" 

192 ) -> Union[None, SelectBase, Query]: 

193 """ 

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

195 is :func:`get_rows_colnames`.) 

196 

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

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

199 

200 Parameters are passed in via the request. 

201 """ 

202 return None 

203 

204 def get_rows_colnames( 

205 self, req: "CamcopsRequest" 

206 ) -> Optional[PlainReportType]: 

207 """ 

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

209 is :func:`get_query`.) 

210 

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

212 column names and row content. 

213 """ 

214 return None 

215 

216 @staticmethod 

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

218 """ 

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

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

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

222 extensive one (an example being in 

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

224 """ 

225 from camcops_server.cc_modules.cc_forms import ( 

226 ReportParamSchema, 

227 ) # delayed import 

228 

229 return ReportParamSchema 

230 

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

232 """ 

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

234 suffices, and it will use the schema specified in 

235 :func:`get_paramform_schema_class`. 

236 """ 

237 from camcops_server.cc_modules.cc_forms import ( # noqa: F811 

238 ReportParamForm, 

239 ) # delayed import 

240 

241 schema_class = self.get_paramform_schema_class() 

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

243 

244 @staticmethod 

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

246 """ 

247 What this function returns is used as the specimen Colander 

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

249 """ 

250 return {} 

251 

252 @staticmethod 

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

254 """ 

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

256 clauses. 

257 

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

259 global filters to queries used to create reports. 

260 

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

262 

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

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

265 

266 Args: 

267 wheres: 

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

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

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

271 query. 

272 """ 

273 # noinspection PyPep8 

274 wheres.append(column(FN_CURRENT) == True) # noqa: E712 

275 

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

277 # Common functionality: classmethods 

278 # ------------------------------------------------------------------------- 

279 

280 @classmethod 

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

282 """ 

283 Get all report subclasses, except those not implementing their 

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

285 """ 

286 # noinspection PyTypeChecker 

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

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

289 for reportcls in classes: 

290 if reportcls.__name__ == "TestReport": 

291 continue 

292 

293 try: 

294 _ = reportcls.report_id 

295 instantiated_report_classes.append(reportcls) 

296 except NotImplementedError: 

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

298 # class; skip it. 

299 pass 

300 return instantiated_report_classes 

301 

302 # ------------------------------------------------------------------------- 

303 # Common functionality: default Response 

304 # ------------------------------------------------------------------------- 

305 

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

307 """ 

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

309 """ 

310 # Check the basic parameters 

311 report_id = req.get_str_param(ViewParam.REPORT_ID) 

312 

313 if report_id != self.report_id: 

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

315 

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

317 # lower=True) 

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

319 # like 

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

321 # ('deformField2', 'tsv'), 

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

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

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

325 # recode the appstruct to a URL. 

326 # 

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

328 # 

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

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

331 

332 viewtype = req.get_str_param( 

333 ViewParam.VIEWTYPE, ViewArg.HTML, lower=True 

334 ) 

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

336 # request) 

337 # Serve the result 

338 if viewtype == ViewArg.HTML: 

339 return self.render_html(req=req) 

340 

341 if viewtype == ViewArg.ODS: 

342 return self.render_ods(req=req) 

343 

344 if viewtype == ViewArg.TSV: 

345 return self.render_tsv(req=req) 

346 

347 if viewtype == ViewArg.XLSX: 

348 return self.render_xlsx(req=req) 

349 

350 raise HTTPBadRequest("Bad viewtype") 

351 

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

353 rows_per_page = req.get_int_param( 

354 ViewParam.ROWS_PER_PAGE, DEFAULT_ROWS_PER_PAGE 

355 ) 

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

357 

358 plain_report = self._get_plain_report(req) 

359 

360 page = CamcopsPage( 

361 collection=plain_report.rows, 

362 page=page_num, 

363 items_per_page=rows_per_page, 

364 url_maker=PageUrl(req), 

365 request=req, 

366 ) 

367 

368 return self.render_single_page_html( 

369 req=req, column_names=plain_report.column_names, page=page 

370 ) 

371 

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

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

374 

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

376 # we only output the first 

377 page = self.get_spreadsheet_pages(req)[0] 

378 

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

380 

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

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

383 tsvcoll = self.get_spreadsheet_collection(req) 

384 content = tsvcoll.as_xlsx() 

385 

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

387 

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

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

390 tsvcoll = self.get_spreadsheet_collection(req) 

391 content = tsvcoll.as_ods() 

392 

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

394 

395 def get_spreadsheet_collection( 

396 self, req: "CamcopsRequest" 

397 ) -> SpreadsheetCollection: 

398 coll = SpreadsheetCollection() 

399 coll.add_pages(self.get_spreadsheet_pages(req)) 

400 

401 return coll 

402 

403 def get_spreadsheet_pages( 

404 self, req: "CamcopsRequest" 

405 ) -> List[SpreadsheetPage]: 

406 plain_report = self._get_plain_report(req) 

407 

408 page = self.get_spreadsheet_page( 

409 name=self.title(req), 

410 column_names=plain_report.column_names, 

411 rows=plain_report.rows, 

412 ) 

413 return [page] 

414 

415 @staticmethod 

416 def get_spreadsheet_page( 

417 name: str, column_names: Sequence[str], rows: Sequence[Sequence[Any]] 

418 ) -> SpreadsheetPage: 

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

420 page = SpreadsheetPage(name=name, rows=keyed_rows) 

421 

422 return page 

423 

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

425 extension_dict = { 

426 ViewArg.ODS: "ods", 

427 ViewArg.TSV: "tsv", 

428 ViewArg.XLSX: "xlsx", 

429 } 

430 

431 if viewtype not in extension_dict: 

432 raise HTTPBadRequest("Unsupported viewtype") 

433 

434 extension = extension_dict.get(viewtype) 

435 

436 return ( 

437 "CamCOPS_" 

438 + self.report_id 

439 + "_" 

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

441 + "." 

442 + extension 

443 ) 

444 

445 def render_single_page_html( 

446 self, 

447 req: "CamcopsRequest", 

448 column_names: Sequence[str], 

449 page: CamcopsPage, 

450 ) -> Response: 

451 """ 

452 Converts a paginated report into an HTML response. 

453 

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

455 """ 

456 return render_to_response( 

457 self.template_name, 

458 dict( 

459 title=self.title(req), 

460 page=page, 

461 column_names=column_names, 

462 report_id=self.report_id, 

463 ), 

464 request=req, 

465 ) 

466 

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

468 """ 

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

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

471 :class:`PlainReportType`. 

472 """ 

473 statement = self.get_query(req) 

474 if statement is not None: 

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

476 column_names = rp.keys() 

477 rows = rp.fetchall() 

478 

479 plain_report = PlainReportType( 

480 rows=rows, column_names=column_names 

481 ) 

482 else: 

483 plain_report = self.get_rows_colnames(req) 

484 if plain_report is None: 

485 raise NotImplementedError( 

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

487 " or get_rows_colnames()" 

488 ) 

489 

490 return plain_report 

491 

492 

493class PercentageSummaryReportMixin(object): 

494 """ 

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

496 """ 

497 

498 @classproperty 

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

500 raise NotImplementedError("implement in subclass") 

501 

502 def get_percentage_summaries( 

503 self, 

504 req: "CamcopsRequest", 

505 column_dict: Dict[str, str], 

506 num_answers: int, 

507 cell_format: str = "{}", 

508 min_answer: int = 0, 

509 ) -> List[List[str]]: 

510 """ 

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

512 """ 

513 rows = [] 

514 

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

516 """ 

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

518 """ 

519 wheres = [column(column_name).isnot(None)] 

520 

521 # noinspection PyUnresolvedReferences 

522 self.add_task_report_filters(wheres) 

523 

524 # noinspection PyUnresolvedReferences 

525 total_query = ( 

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

527 .select_from(self.task_class.__table__) 

528 .where(and_(*wheres)) 

529 ) 

530 

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

532 

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

534 

535 """ 

536 e.g. 

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

538 FROM perinatal_poem WHERE col is not NULL 

539 GROUP BY col 

540 """ 

541 # noinspection PyUnresolvedReferences 

542 query = ( 

543 select( 

544 [ 

545 column(column_name), 

546 ((100 * func.count(column_name)) / total_responses), 

547 ] 

548 ) 

549 .select_from(self.task_class.__table__) 

550 .where(and_(*wheres)) 

551 .group_by(column_name) 

552 ) 

553 

554 # row output is: 

555 # 0 1 2 3 

556 # +----------+-----------------+--------------+--------------+---- 

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

558 # +----------+-----------------+--------------+--------------+---- 

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

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

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

562 

563 rows.append(row) 

564 

565 return rows 

566 

567 

568class DateTimeFilteredReportMixin(object): 

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

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

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

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

573 

574 @staticmethod 

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

576 from camcops_server.cc_modules.cc_forms import ( 

577 DateTimeFilteredReportParamSchema, 

578 ) # delayed import 

579 

580 return DateTimeFilteredReportParamSchema 

581 

582 @classmethod 

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

584 # noinspection PyUnresolvedReferences 

585 return super().get_specific_http_query_keys() + [ 

586 ViewParam.START_DATETIME, 

587 ViewParam.END_DATETIME, 

588 ] 

589 

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

591 self.start_datetime = format_datetime( 

592 req.get_datetime_param(ViewParam.START_DATETIME), DateFormat.ERA 

593 ) 

594 self.end_datetime = format_datetime( 

595 req.get_datetime_param(ViewParam.END_DATETIME), DateFormat.ERA 

596 ) 

597 

598 # noinspection PyUnresolvedReferences 

599 return super().get_response(req) 

600 

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

602 """ 

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

604 clauses. 

605 

606 See :meth:`Report.add_task_report_filters`. 

607 

608 Args: 

609 wheres: 

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

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

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

613 query. 

614 """ 

615 # noinspection PyUnresolvedReferences 

616 super().add_task_report_filters(wheres) 

617 

618 if self.start_datetime is not None: 

619 wheres.append(column(TFN_WHEN_CREATED) >= self.start_datetime) 

620 

621 if self.end_datetime is not None: 

622 wheres.append(column(TFN_WHEN_CREATED) < self.end_datetime) 

623 

624 

625class ScoreDetails(object): 

626 """ 

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

628 """ 

629 

630 def __init__( 

631 self, 

632 name: str, 

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

634 minimum: int, 

635 maximum: int, 

636 higher_score_is_better: bool = False, 

637 ) -> None: 

638 """ 

639 Args: 

640 name: 

641 human-friendly name of this score 

642 scorefunc: 

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

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

645 ``None``) 

646 minimum: 

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

648 maximum: 

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

650 higher_score_is_better: 

651 is a higher score a better thing? 

652 """ 

653 self.name = name 

654 self.scorefunc = scorefunc 

655 self.minimum = minimum 

656 self.maximum = maximum 

657 self.higher_score_is_better = higher_score_is_better 

658 

659 def calculate_improvement( 

660 self, first_score: float, latest_score: float 

661 ) -> float: 

662 """ 

663 Improvement is positive. 

664 

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

666 returns ``first - latest``. 

667 """ 

668 if self.higher_score_is_better: 

669 return latest_score - first_score 

670 else: 

671 return first_score - latest_score 

672 

673 

674class AverageScoreReport(DateTimeFilteredReportMixin, Report, ABC): 

675 """ 

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

677 """ 

678 

679 template_name = "average_score_report.mako" 

680 

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

682 """ 

683 Args: 

684 via_index: 

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

686 have to build a dummy task index. 

687 """ 

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

689 self.via_index = via_index 

690 

691 # noinspection PyMethodParameters 

692 @classproperty 

693 def superuser_only(cls) -> bool: 

694 return False 

695 

696 # noinspection PyMethodParameters 

697 @classproperty 

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

699 raise NotImplementedError("Report did not implement task_class") 

700 

701 # noinspection PyMethodParameters 

702 @classmethod 

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

704 raise NotImplementedError("Report did not implement 'scoretypes'") 

705 

706 @staticmethod 

707 def no_data_value() -> Any: 

708 """ 

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

710 

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

712 testing. 

713 """ 

714 return "" 

715 

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

717 pages = self.get_spreadsheet_pages(req) 

718 return render_to_response( 

719 self.template_name, 

720 dict( 

721 title=self.title(req), 

722 mainpage=pages[0], 

723 datepage=pages[1], 

724 report_id=self.report_id, 

725 ), 

726 request=req, 

727 ) 

728 

729 def get_spreadsheet_pages( 

730 self, req: "CamcopsRequest" 

731 ) -> List[SpreadsheetPage]: 

732 """ 

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

734 

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

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

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

738 calculations. 

739 """ 

740 _ = req.gettext 

741 from camcops_server.cc_modules.cc_taskcollection import ( 

742 TaskCollection, 

743 task_when_created_sorter, 

744 ) # delayed import 

745 from camcops_server.cc_modules.cc_taskfilter import ( 

746 TaskFilter, 

747 ) # delayed import 

748 

749 # Which tasks? 

750 taskfilter = TaskFilter() 

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

752 taskfilter.start_datetime = self.start_datetime 

753 taskfilter.end_datetime = self.end_datetime 

754 taskfilter.complete_only = True 

755 

756 # Get tasks 

757 collection = TaskCollection( 

758 req=req, 

759 taskfilter=taskfilter, 

760 current_only=True, 

761 via_index=self.via_index, 

762 ) 

763 all_tasks = collection.all_tasks 

764 

765 # Get all distinct patients 

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

767 # log.debug("all_tasks: {}", all_tasks) 

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

769 

770 scoretypes = self.scoretypes(req) 

771 n_scoretypes = len(scoretypes) 

772 

773 # Sum first/last/progress scores by patient 

774 sum_first_by_score = [0] * n_scoretypes 

775 sum_last_by_score = [0] * n_scoretypes 

776 sum_improvement_by_score = [0] * n_scoretypes 

777 n_first = 0 

778 n_last = 0 # also n_progress 

779 for patient in patients: 

780 # Find tasks for this patient 

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

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

783 # log.debug("For patient {}, tasks: {}", patient, patient_tasks) 

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

785 patient_tasks.sort(key=task_when_created_sorter) 

786 first = patient_tasks[0] 

787 n_first += 1 

788 if len(patient_tasks) > 1: 

789 last = patient_tasks[-1] 

790 n_last += 1 

791 else: 

792 last = None 

793 

794 # Obtain first/last scores and progress 

795 for scoreidx, scoretype in enumerate(scoretypes): 

796 firstscore = scoretype.scorefunc(first) 

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

798 sum_first_by_score[scoreidx] += firstscore 

799 if last: 

800 lastscore = scoretype.scorefunc(last) 

801 sum_last_by_score[scoreidx] += lastscore 

802 improvement = scoretype.calculate_improvement( 

803 firstscore, lastscore 

804 ) 

805 sum_improvement_by_score[scoreidx] += improvement 

806 

807 # Format output 

808 column_names = [ 

809 _("Number of initial records"), 

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

811 ] 

812 row = [n_first, n_last] 

813 no_data = self.no_data_value() 

814 for scoreidx, scoretype in enumerate(scoretypes): 

815 # Calculations 

816 if n_first == 0: 

817 avg_first = no_data 

818 else: 

819 avg_first = sum_first_by_score[scoreidx] / n_first 

820 if n_last == 0: 

821 avg_last = no_data 

822 avg_improvement = no_data 

823 else: 

824 avg_last = sum_last_by_score[scoreidx] / n_last 

825 avg_improvement = sum_improvement_by_score[scoreidx] / n_last 

826 

827 # Columns and row data 

828 column_names += [ 

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

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

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

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

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

834 ] 

835 row += [avg_first, avg_last, avg_improvement] 

836 

837 # Create and return report 

838 mainpage = self.get_spreadsheet_page( 

839 name=self.title(req), column_names=column_names, rows=[row] 

840 ) 

841 datepage = self.get_spreadsheet_page( 

842 name=_("Date filters"), 

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

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

845 ) 

846 return [mainpage, datepage] 

847 

848 

849# ============================================================================= 

850# Report framework 

851# ============================================================================= 

852 

853 

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

855 """ 

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

857 """ 

858 classes = Report.all_subclasses() 

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

860 return classes 

861 

862 

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

864 """ 

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

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

867 """ 

868 if not report_id: 

869 return None 

870 for cls in Report.all_subclasses(): 

871 if cls.report_id == report_id: 

872 return cls() 

873 return None