Coverage for cc_modules/cc_report.py : 32%

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
3"""
4camcops_server/cc_modules/cc_report.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27**CamCOPS reports.**
29"""
31import logging
32from abc import ABC
33from typing import (Any, Callable, Dict, List, Optional, Sequence,
34 Type, TYPE_CHECKING, Union)
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
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
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
74log = BraceStyleAdapter(logging.getLogger(__name__))
77# =============================================================================
78# Other constants
79# =============================================================================
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
91# =============================================================================
92# Report class
93# =============================================================================
95class Report(object):
96 """
97 Abstract base class representing a report.
99 If you are writing a report, you must override these attributes:
101 - :meth:`report_id`
102 - :meth:`report_title`
103 - One combination of:
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
109 See the explanations of each.
110 """
112 template_name = "report.mako"
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")
126 @classmethod
127 def title(cls, req: "CamcopsRequest") -> str:
128 """
129 Descriptive title for display purposes.
130 """
131 raise NotImplementedError("implement in subclass")
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
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:
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()
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 []
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`.)
174 To override this function, return the SQLAlchemy Base :class:`Select`
175 statement or the SQLAlchemy ORM :class:`Query` to execute the report.
177 Parameters are passed in via the request.
178 """
179 return None
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`.)
187 To override this function, return a :class:`PlainReportType` with
188 column names and row content.
189 """
190 return None
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
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)
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 {}
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.
228 Override this (or provide additional filters and call this) to provide
229 global filters to queries used to create reports.
231 Used by :class:`DateTimeFilteredReportMixin`, etc.
233 The presumption is that the thing being filtered is an instance of
234 :class:`camcops_server.cc_modules.cc_task.Task`.
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 )
248 # -------------------------------------------------------------------------
249 # Common functionality: classmethods
250 # -------------------------------------------------------------------------
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
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
274 # -------------------------------------------------------------------------
275 # Common functionality: default Response
276 # -------------------------------------------------------------------------
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)
285 if report_id != self.report_id:
286 raise HTTPBadRequest("Error - request directed to wrong report!")
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!
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)
312 if viewtype == ViewArg.ODS:
313 return self.render_ods(req=req)
315 if viewtype == ViewArg.TSV:
316 return self.render_tsv(req=req)
318 if viewtype == ViewArg.XLSX:
319 return self.render_xlsx(req=req)
321 raise HTTPBadRequest("Bad viewtype")
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)
328 plain_report = self._get_plain_report(req)
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)
336 return self.render_single_page_html(
337 req=req,
338 column_names=plain_report.column_names,
339 page=page
340 )
342 def render_tsv(self, req: "CamcopsRequest") -> TsvResponse:
343 filename = self.get_filename(req, ViewArg.TSV)
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]
349 return TsvResponse(body=page.get_tsv(), filename=filename)
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()
356 return XlsxResponse(body=content, filename=filename)
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()
363 return OdsResponse(body=content, filename=filename)
365 def get_tsv_collection(self, req: "CamcopsRequest") -> TsvCollection:
366 tsvcoll = TsvCollection()
367 tsvcoll.add_pages(self.get_tsv_pages(req))
369 return tsvcoll
371 def get_tsv_pages(self, req: "CamcopsRequest") -> List[TsvPage]:
372 plain_report = self._get_plain_report(req)
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]
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)
386 return page
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 }
395 if viewtype not in extension_dict:
396 raise HTTPBadRequest("Unsupported viewtype")
398 extension = extension_dict.get(viewtype)
400 return (
401 "CamCOPS_" +
402 self.report_id +
403 "_" +
404 format_datetime(req.now, DateFormat.FILENAME) +
405 "." +
406 extension
407 )
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.
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 )
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()
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()")
448 return plain_report
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")
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 = []
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 ]
478 # noinspection PyUnresolvedReferences
479 self.add_task_report_filters(wheres)
481 # noinspection PyUnresolvedReferences
482 total_query = (
483 select([func.count(column_name)])
484 .select_from(self.task_class.__table__)
485 .where(and_(*wheres))
486 )
488 total_responses = req.dbsession.execute(total_query).fetchone()[0]
490 row = [question] + [total_responses] + [""] * num_answers
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 )
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])
518 rows.append(row)
520 return rows
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]
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
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 ]
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 )
552 # noinspection PyUnresolvedReferences
553 return super().get_response(req)
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.
560 See :meth:`Report.add_task_report_filters`.
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)
572 if self.start_datetime is not None:
573 wheres.append(
574 column(TFN_WHEN_CREATED) >= self.start_datetime
575 )
577 if self.end_datetime is not None:
578 wheres.append(
579 column(TFN_WHEN_CREATED) < self.end_datetime
580 )
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
614 def calculate_improvement(self,
615 first_score: float,
616 latest_score: float) -> float:
617 """
618 Improvement is positive.
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
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"
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
645 # noinspection PyMethodParameters
646 @classproperty
647 def superuser_only(cls) -> bool:
648 return False
650 # noinspection PyMethodParameters
651 @classproperty
652 def task_class(cls) -> Type["Task"]:
653 raise NotImplementedError(
654 "Report did not implement task_class"
655 )
657 # noinspection PyMethodParameters
658 @classmethod
659 def scoretypes(cls, req: "CamcopsRequest") -> List[ScoreDetails]:
660 raise NotImplementedError(
661 "Report did not implement 'scoretypes'"
662 )
664 @staticmethod
665 def no_data_value() -> Any:
666 """
667 The value used for a "no data" cell.
669 The only reason this is accessible outside this class is for unit
670 testing.
671 """
672 return ""
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 )
685 def get_tsv_pages(self, req: "CamcopsRequest") -> List[TsvPage]:
686 """
687 We use an SQLAlchemy ORM, rather than Core, method. Why?
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
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
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
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])
722 scoretypes = self.scoretypes(req)
723 n_scoretypes = len(scoretypes)
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
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
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
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]
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]
802# =============================================================================
803# Report framework
804# =============================================================================
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
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