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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_report.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28**CamCOPS reports.**
30"""
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)
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
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)
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
91log = BraceStyleAdapter(logging.getLogger(__name__))
94# =============================================================================
95# Other constants
96# =============================================================================
99class PlainReportType(object):
100 """
101 Simple class to hold the results of a plain report.
102 """
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
111# =============================================================================
112# Report class
113# =============================================================================
116class Report(object):
117 """
118 Abstract base class representing a report.
120 If you are writing a report, you must override these attributes:
122 - :meth:`report_id`
123 - :meth:`report_title`
124 - One combination of:
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
131 See the explanations of each.
132 """
134 template_name = "report.mako"
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")
148 @classmethod
149 def title(cls, req: "CamcopsRequest") -> str:
150 """
151 Descriptive title for display purposes.
152 """
153 raise NotImplementedError("implement in subclass")
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
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:
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()
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 []
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`.)
197 To override this function, return the SQLAlchemy Base :class:`Select`
198 statement or the SQLAlchemy ORM :class:`Query` to execute the report.
200 Parameters are passed in via the request.
201 """
202 return None
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`.)
211 To override this function, return a :class:`PlainReportType` with
212 column names and row content.
213 """
214 return None
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
229 return ReportParamSchema
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
241 schema_class = self.get_paramform_schema_class()
242 return ReportParamForm(request=req, schema_class=schema_class)
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 {}
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.
258 Override this (or provide additional filters and call this) to provide
259 global filters to queries used to create reports.
261 Used by :class:`DateTimeFilteredReportMixin`, etc.
263 The presumption is that the thing being filtered is an instance of
264 :class:`camcops_server.cc_modules.cc_task.Task`.
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
276 # -------------------------------------------------------------------------
277 # Common functionality: classmethods
278 # -------------------------------------------------------------------------
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
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
302 # -------------------------------------------------------------------------
303 # Common functionality: default Response
304 # -------------------------------------------------------------------------
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)
313 if report_id != self.report_id:
314 raise HTTPBadRequest("Error - request directed to wrong report!")
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!
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)
341 if viewtype == ViewArg.ODS:
342 return self.render_ods(req=req)
344 if viewtype == ViewArg.TSV:
345 return self.render_tsv(req=req)
347 if viewtype == ViewArg.XLSX:
348 return self.render_xlsx(req=req)
350 raise HTTPBadRequest("Bad viewtype")
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)
358 plain_report = self._get_plain_report(req)
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 )
368 return self.render_single_page_html(
369 req=req, column_names=plain_report.column_names, page=page
370 )
372 def render_tsv(self, req: "CamcopsRequest") -> TsvResponse:
373 filename = self.get_filename(req, ViewArg.TSV)
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]
379 return TsvResponse(body=page.get_tsv(), filename=filename)
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()
386 return XlsxResponse(body=content, filename=filename)
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()
393 return OdsResponse(body=content, filename=filename)
395 def get_spreadsheet_collection(
396 self, req: "CamcopsRequest"
397 ) -> SpreadsheetCollection:
398 coll = SpreadsheetCollection()
399 coll.add_pages(self.get_spreadsheet_pages(req))
401 return coll
403 def get_spreadsheet_pages(
404 self, req: "CamcopsRequest"
405 ) -> List[SpreadsheetPage]:
406 plain_report = self._get_plain_report(req)
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]
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)
422 return page
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 }
431 if viewtype not in extension_dict:
432 raise HTTPBadRequest("Unsupported viewtype")
434 extension = extension_dict.get(viewtype)
436 return (
437 "CamCOPS_"
438 + self.report_id
439 + "_"
440 + format_datetime(req.now, DateFormat.FILENAME)
441 + "."
442 + extension
443 )
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.
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 )
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()
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 )
490 return plain_report
493class PercentageSummaryReportMixin(object):
494 """
495 Mixin to be used with :class:`Report`.
496 """
498 @classproperty
499 def task_class(self) -> Type["Task"]:
500 raise NotImplementedError("implement in subclass")
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 = []
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)]
521 # noinspection PyUnresolvedReferences
522 self.add_task_report_filters(wheres)
524 # noinspection PyUnresolvedReferences
525 total_query = (
526 select([func.count(column_name)])
527 .select_from(self.task_class.__table__)
528 .where(and_(*wheres))
529 )
531 total_responses = req.dbsession.execute(total_query).fetchone()[0]
533 row = [question] + [total_responses] + [""] * num_answers
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 )
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])
563 rows.append(row)
565 return rows
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]
574 @staticmethod
575 def get_paramform_schema_class() -> Type["ReportParamSchema"]:
576 from camcops_server.cc_modules.cc_forms import (
577 DateTimeFilteredReportParamSchema,
578 ) # delayed import
580 return DateTimeFilteredReportParamSchema
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 ]
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 )
598 # noinspection PyUnresolvedReferences
599 return super().get_response(req)
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.
606 See :meth:`Report.add_task_report_filters`.
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)
618 if self.start_datetime is not None:
619 wheres.append(column(TFN_WHEN_CREATED) >= self.start_datetime)
621 if self.end_datetime is not None:
622 wheres.append(column(TFN_WHEN_CREATED) < self.end_datetime)
625class ScoreDetails(object):
626 """
627 Represents a type of score whose progress we want to track over time.
628 """
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
659 def calculate_improvement(
660 self, first_score: float, latest_score: float
661 ) -> float:
662 """
663 Improvement is positive.
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
674class AverageScoreReport(DateTimeFilteredReportMixin, Report, ABC):
675 """
676 Used by MAAS, CORE-10 and PBQ to report average scores and progress
677 """
679 template_name = "average_score_report.mako"
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
691 # noinspection PyMethodParameters
692 @classproperty
693 def superuser_only(cls) -> bool:
694 return False
696 # noinspection PyMethodParameters
697 @classproperty
698 def task_class(cls) -> Type["Task"]:
699 raise NotImplementedError("Report did not implement task_class")
701 # noinspection PyMethodParameters
702 @classmethod
703 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]:
704 raise NotImplementedError("Report did not implement 'scoretypes'")
706 @staticmethod
707 def no_data_value() -> Any:
708 """
709 The value used for a "no data" cell.
711 The only reason this is accessible outside this class is for unit
712 testing.
713 """
714 return ""
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 )
729 def get_spreadsheet_pages(
730 self, req: "CamcopsRequest"
731 ) -> List[SpreadsheetPage]:
732 """
733 We use an SQLAlchemy ORM, rather than Core, method. Why?
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
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
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
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])
770 scoretypes = self.scoretypes(req)
771 n_scoretypes = len(scoretypes)
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
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
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
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]
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]
849# =============================================================================
850# Report framework
851# =============================================================================
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
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