Coverage for cc_modules/cc_tracker.py: 21%
259 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_tracker.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**Trackers, showing numerical information over time, and clinical text views,
29showing text that a clinician might care about.**
31"""
33import logging
34from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING
36from cardinal_pythonlib.datetimefunc import format_datetime
37from cardinal_pythonlib.logs import BraceStyleAdapter
38from pendulum import DateTime as Pendulum
39from pyramid.renderers import render
41from camcops_server.cc_modules.cc_audit import audit
42from camcops_server.cc_modules.cc_constants import (
43 CssClass,
44 CSS_PAGED_MEDIA,
45 DateFormat,
46 MatplotlibConstants,
47 PlotDefaults,
48)
49from camcops_server.cc_modules.cc_filename import get_export_filename
50from camcops_server.cc_modules.cc_plot import matplotlib
51from camcops_server.cc_modules.cc_pdf import pdf_from_html
52from camcops_server.cc_modules.cc_pyramid import ViewArg, ViewParam
53from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions
54from camcops_server.cc_modules.cc_task import Task
55from camcops_server.cc_modules.cc_taskcollection import (
56 TaskCollection,
57 TaskFilter,
58 TaskSortMethod,
59)
60from camcops_server.cc_modules.cc_xml import (
61 get_xml_document,
62 XmlDataTypes,
63 XmlElement,
64)
66import matplotlib.dates # delayed until after the cc_plot import
68if TYPE_CHECKING:
69 from camcops_server.cc_modules.cc_patient import Patient # noqa: F401
70 from camcops_server.cc_modules.cc_patientidnum import ( # noqa: F401
71 PatientIdNum,
72 )
73 from camcops_server.cc_modules.cc_request import ( # noqa: F401
74 CamcopsRequest,
75 )
76 from camcops_server.cc_modules.cc_trackerhelpers import ( # noqa: F401
77 TrackerInfo,
78 )
80log = BraceStyleAdapter(logging.getLogger(__name__))
83# =============================================================================
84# Constants
85# =============================================================================
87TRACKER_DATEFORMAT = "%Y-%m-%d"
88WARNING_NO_PATIENT_FOUND = f"""
89 <div class="{CssClass.WARNING}">
90 </div>
91"""
92WARNING_DENIED_INFORMATION = f"""
93 <div class="{CssClass.WARNING}">
94 Other tasks exist for this patient that you do not have access to view.
95 </div>
96"""
98DEBUG_TRACKER_TASK_INCLUSION = False # should be False for production system
101# =============================================================================
102# Helper functions
103# =============================================================================
104# http://stackoverflow.com/questions/11788195
107def consistency(
108 req: "CamcopsRequest",
109 values: List[Any],
110 servervalue: Any = None,
111 case_sensitive: bool = True,
112) -> Tuple[bool, str]:
113 """
114 Checks for consistency in a set of values (e.g. names, dates of birth).
115 (ID numbers are done separately via :func:`consistency_idnums`.)
117 The list of values (with the ``servervalue`` appended, if not ``None``) is
118 checked to ensure that it contains only one unique value (ignoring ``None``
119 values or empty ``""`` values).
121 Returns:
122 the tuple ``consistent, msg``, where ``consistent`` is a bool and
123 ``msg`` is a descriptive HTML message
124 """
125 if case_sensitive:
126 vallist = [str(v) if v is not None else v for v in values]
127 if servervalue is not None:
128 vallist.append(str(servervalue))
129 else:
130 vallist = [str(v).upper() if v is not None else v for v in values]
131 if servervalue is not None:
132 vallist.append(str(servervalue).upper())
133 # Replace "" with None, so we only have a single "not-present" value
134 vallist = [None if x == "" else x for x in vallist]
135 unique = list(set(vallist))
136 _ = req.gettext
137 if len(unique) == 0:
138 return True, _("consistent (no values)")
139 if len(unique) == 1:
140 return True, f"{_('consistent')} ({unique[0]})"
141 if len(unique) == 2:
142 if None in unique:
143 return (
144 True,
145 (
146 f"{_('consistent')} "
147 f"({_('all blank or')} {unique[1 - unique.index(None)]})"
148 ),
149 )
150 return (
151 False,
152 (
153 f"<b>{_('INCONSISTENT')} "
154 f"({_('contains values')} {', '.join(unique)})</b>"
155 ),
156 )
159def consistency_idnums(
160 req: "CamcopsRequest", idnum_lists: List[List["PatientIdNum"]]
161) -> Tuple[bool, str]:
162 """
163 Checks the consistency of a set of :class:`PatientIdNum` objects.
164 "Are all these records from the same patient?"
166 Args:
167 req:
168 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`
169 idnum_lists:
170 a list of lists (one per task/patient instance) of
171 :class:`PatientIdNum` objects
173 Returns:
174 the tuple ``consistent, msg``, where ``consistent`` is a bool and
175 ``msg`` is a descriptive HTML message
177 """
178 # 1. Generate "known", mapping which_idnum -> set of observed non-NULL
179 # idnum_values
180 known = {} # type: Dict[int, Set[int]]
181 for task_idnum_list in idnum_lists:
182 for idnum in task_idnum_list:
183 idnum_value = idnum.idnum_value
184 if idnum_value is not None:
185 which_idnum = idnum.which_idnum
186 if which_idnum not in known:
187 known[which_idnum] = set() # type: Set[int]
188 known[which_idnum].add(idnum_value)
190 # 2. For every observed which_idnum, was it observed in all tasks?
191 present_in_all = {} # type: Dict[int, bool]
192 for which_idnum in known.keys():
193 present_for_all_tasks = all(
194 # "For all tasks..."
195 (
196 # "At least one ID number record relates to this which_idnum".
197 any(
198 idnum.which_idnum == which_idnum
199 and idnum.idnum_value is not None
200 )
201 for idnum in task_idnum_list
202 )
203 for task_idnum_list in idnum_lists
204 )
205 present_in_all[which_idnum] = present_for_all_tasks
207 # 3. Summarize
208 failures = [] # type: List[str]
209 successes = [] # type: List[str]
210 _ = req.gettext
211 for which_idnum, encountered_values in known.items():
212 value_str = ", ".join(str(v) for v in sorted(list(encountered_values)))
213 if len(encountered_values) > 1:
214 failures.append(
215 f"idnum{which_idnum} {_('contains values')} {value_str}"
216 )
217 else:
218 if present_in_all[which_idnum]:
219 successes.append(
220 f"idnum{which_idnum} {_('consistent')} ({value_str})"
221 )
222 else:
223 successes.append(
224 f"idnum{which_idnum} {_('all blank or')} {value_str}"
225 )
226 if failures:
227 return (
228 False,
229 (
230 f"<b>{_('INCONSISTENT')} "
231 f"({'; '.join(failures + successes)})</b>"
232 ),
233 )
234 else:
235 return True, f"{_('consistent')} ({'; '.join(successes)})"
238def format_daterange(
239 start: Optional[Pendulum], end: Optional[Pendulum]
240) -> str:
241 """
242 Textual representation of an inclusive-to-exclusive date range.
244 Arguments are datetime values.
245 """
246 start_str = format_datetime(
247 start, DateFormat.ISO8601_DATE_ONLY, default="−∞"
248 )
249 end_str = format_datetime(end, DateFormat.ISO8601_DATE_ONLY, default="+∞")
250 return f"[{start_str}, {end_str})"
253# =============================================================================
254# ConsistencyInfo class
255# =============================================================================
258class ConsistencyInfo(object):
259 """
260 Represents ID consistency information about a set of tasks.
261 """
263 def __init__(self, req: "CamcopsRequest", tasklist: List[Task]) -> None:
264 """
265 Initialize values, from a list of task instances.
266 """
267 self.request = req
268 self.consistent_forename, self.msg_forename = consistency(
269 req,
270 [task.get_patient_forename() for task in tasklist],
271 servervalue=None,
272 case_sensitive=False,
273 )
274 self.consistent_surname, self.msg_surname = consistency(
275 req,
276 [task.get_patient_surname() for task in tasklist],
277 servervalue=None,
278 case_sensitive=False,
279 )
280 self.consistent_dob, self.msg_dob = consistency(
281 req, [task.get_patient_dob_first11chars() for task in tasklist]
282 )
283 self.consistent_sex, self.msg_sex = consistency(
284 req, [task.get_patient_sex() for task in tasklist]
285 )
286 self.consistent_idnums, self.msg_idnums = consistency_idnums(
287 req, [task.get_patient_idnum_objects() for task in tasklist]
288 )
289 self.all_consistent = (
290 self.consistent_forename
291 and self.consistent_surname
292 and self.consistent_dob
293 and self.consistent_sex
294 and self.consistent_idnums
295 )
297 def are_all_consistent(self) -> bool:
298 """
299 Is all the ID information consistent?
300 """
301 return self.all_consistent
303 def get_description_list(self) -> List[str]:
304 """
305 Textual representation of ID information, indicating consistency or
306 lack of it.
307 """
308 _ = self.request.gettext
309 cons = [
310 f"{_('Forename:')} {self.msg_forename}",
311 f"{_('Surname:')} {self.msg_surname}",
312 f"{_('DOB:')} {self.msg_dob}",
313 f"{_('Sex:')} {self.msg_sex}",
314 f"{_('ID numbers:')} {self.msg_idnums}",
315 ]
316 return cons
318 def get_xml_root(self) -> XmlElement:
319 """
320 XML tree (as root :class:`camcops_server.cc_modules.cc_xml.XmlElement`)
321 of consistency information.
322 """
323 branches = [
324 XmlElement(
325 name="all_consistent",
326 value=self.are_all_consistent(),
327 datatype="boolean",
328 )
329 ]
330 for c in self.get_description_list():
331 branches.append(XmlElement(name="consistency_check", value=c))
332 return XmlElement(name="_consistency", value=branches)
335# =============================================================================
336# TrackerCtvCommon class:
337# =============================================================================
340class TrackerCtvCommon(object):
341 """
342 Base class for :class:`camcops_server.cc_modules.cc_tracker.Tracker` and
343 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`.
344 """
346 def __init__(
347 self,
348 req: "CamcopsRequest",
349 taskfilter: TaskFilter,
350 as_ctv: bool,
351 via_index: bool = True,
352 ) -> None:
353 """
354 Initialize, fetching applicable tasks.
355 """
357 # Record input variables at this point (for URL regeneration)
358 self.req = req
359 self.taskfilter = taskfilter
360 self.as_ctv = as_ctv
361 assert taskfilter.tasks_with_patient_only
363 self.collection = TaskCollection(
364 req=req,
365 taskfilter=taskfilter,
366 sort_method_by_class=TaskSortMethod.CREATION_DATE_ASC,
367 sort_method_global=TaskSortMethod.CREATION_DATE_ASC,
368 via_index=via_index,
369 )
370 all_tasks = self.collection.all_tasks
371 if all_tasks:
372 self.earliest = all_tasks[0].when_created
373 self.latest = all_tasks[-1].when_created
374 self.patient = all_tasks[0].patient
375 else:
376 self.earliest = None # type: Optional[Pendulum]
377 self.latest = None # type: Optional[Pendulum]
378 self.patient = None # type: Optional[Patient]
380 # Summary information
381 self.summary = ""
382 if DEBUG_TRACKER_TASK_INCLUSION:
383 first = True
384 for cls in self.taskfilter.task_classes:
385 if not first:
386 self.summary += " // "
387 self.summary += cls.tablename
388 first = False
389 task_instances = self.collection.tasks_for_task_class(cls)
390 if not task_instances:
391 if DEBUG_TRACKER_TASK_INCLUSION:
392 self.summary += " (no instances)"
393 continue
394 for task in task_instances:
395 if DEBUG_TRACKER_TASK_INCLUSION:
396 self.summary += f" / PK {task.pk}"
397 self.summary += " ~~~ "
398 self.summary += " — ".join(
399 [
400 "; ".join(
401 [
402 f"({task.tablename},{task.pk},"
403 f"{task.get_patient_server_pk()})"
404 for task in self.collection.tasks_for_task_class(cls)
405 ]
406 )
407 for cls in self.taskfilter.task_classes
408 ]
409 )
411 # Consistency information
412 self.consistency_info = ConsistencyInfo(req, all_tasks)
414 # -------------------------------------------------------------------------
415 # Required for implementation
416 # -------------------------------------------------------------------------
418 def get_xml(
419 self,
420 indent_spaces: int = 4,
421 eol: str = "\n",
422 include_comments: bool = False,
423 ) -> str:
424 """
425 Returns an XML representation.
427 Args:
428 indent_spaces: number of spaces to indent formatted XML
429 eol: end-of-line string
430 include_comments: include comments describing each field?
432 Returns:
433 an XML UTF-8 document representing our object.
434 """
435 raise NotImplementedError("implement in subclass")
437 def _get_html(self) -> str:
438 """
439 Returns an HTML representation.
440 """
441 raise NotImplementedError("implement in subclass")
443 def _get_pdf_html(self) -> str:
444 """
445 Returns HTML used for making PDFs.
446 """
447 raise NotImplementedError("implement in subclass")
449 # -------------------------------------------------------------------------
450 # XML view
451 # -------------------------------------------------------------------------
453 def _get_xml(
454 self,
455 audit_string: str,
456 xml_name: str,
457 indent_spaces: int = 4,
458 eol: str = "\n",
459 include_comments: bool = False,
460 ) -> str:
461 """
462 Returns an XML document representing this object.
464 Args:
465 audit_string: description used to audit access to this information
466 xml_name: name of the root XML element
467 indent_spaces: number of spaces to indent formatted XML
468 eol: end-of-line string
469 include_comments: include comments describing each field?
471 Returns:
472 an XML UTF-8 document representing the task.
473 """
474 iddef = self.taskfilter.get_only_iddef()
475 if not iddef:
476 raise ValueError(
477 "Tracker/CTV doesn't have a single ID number " "criterion"
478 )
479 branches = [
480 self.consistency_info.get_xml_root(),
481 XmlElement(
482 name="_search_criteria",
483 value=[
484 XmlElement(
485 name="task_tablename_list",
486 value=",".join(self.taskfilter.task_tablename_list),
487 ),
488 XmlElement(
489 name=ViewParam.WHICH_IDNUM,
490 value=iddef.which_idnum,
491 datatype=XmlDataTypes.INTEGER,
492 ),
493 XmlElement(
494 name=ViewParam.IDNUM_VALUE,
495 value=iddef.idnum_value,
496 datatype=XmlDataTypes.INTEGER,
497 ),
498 XmlElement(
499 name=ViewParam.START_DATETIME,
500 value=format_datetime(
501 self.taskfilter.start_datetime, DateFormat.ISO8601
502 ),
503 datatype=XmlDataTypes.DATETIME,
504 ),
505 XmlElement(
506 name=ViewParam.END_DATETIME,
507 value=format_datetime(
508 self.taskfilter.end_datetime, DateFormat.ISO8601
509 ),
510 datatype=XmlDataTypes.DATETIME,
511 ),
512 ],
513 ),
514 ]
515 options = TaskExportOptions(
516 xml_include_plain_columns=True,
517 xml_include_calculated=True,
518 include_blobs=False,
519 )
520 for t in self.collection.all_tasks:
521 branches.append(t.get_xml_root(self.req, options))
522 audit(
523 self.req,
524 audit_string,
525 table=t.tablename,
526 server_pk=t.pk,
527 patient_server_pk=t.get_patient_server_pk(),
528 )
529 tree = XmlElement(name=xml_name, value=branches)
530 return get_xml_document(
531 tree,
532 indent_spaces=indent_spaces,
533 eol=eol,
534 include_comments=include_comments,
535 )
537 # -------------------------------------------------------------------------
538 # HTML view
539 # -------------------------------------------------------------------------
541 def get_html(self) -> str:
542 """
543 Get HTML representing this object.
544 """
545 self.req.prepare_for_html_figures()
546 return self._get_html()
548 # -------------------------------------------------------------------------
549 # PDF view
550 # -------------------------------------------------------------------------
552 def get_pdf_html(self) -> str:
553 """
554 Returns HTML to be made into a PDF representing this object.
555 """
556 self.req.prepare_for_pdf_figures()
557 return self._get_pdf_html()
559 def get_pdf(self) -> bytes:
560 """
561 Get PDF representing tracker/CTV.
562 """
563 req = self.req
564 html = self.get_pdf_html() # main content
565 if CSS_PAGED_MEDIA:
566 return pdf_from_html(req, html)
567 else:
568 return pdf_from_html(
569 req,
570 html=html,
571 header_html=render(
572 "wkhtmltopdf_header.mako",
573 dict(
574 inner_text=render(
575 "tracker_ctv_header.mako",
576 dict(tracker=self),
577 request=req,
578 )
579 ),
580 request=req,
581 ),
582 footer_html=render(
583 "wkhtmltopdf_footer.mako",
584 dict(
585 inner_text=render(
586 "tracker_ctv_footer.mako",
587 dict(tracker=self),
588 request=req,
589 )
590 ),
591 request=req,
592 ),
593 extra_wkhtmltopdf_options={"orientation": "Portrait"},
594 )
596 def suggested_pdf_filename(self) -> str:
597 """
598 Get suggested filename for tracker/CTV PDF.
599 """
600 cfg = self.req.config
601 return get_export_filename(
602 req=self.req,
603 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous,
604 patient_spec=cfg.patient_spec,
605 filename_spec=cfg.ctv_filename_spec
606 if self.as_ctv
607 else cfg.tracker_filename_spec, # noqa
608 filetype=ViewArg.PDF,
609 is_anonymous=self.patient is None,
610 surname=self.patient.get_surname() if self.patient else "",
611 forename=self.patient.get_forename() if self.patient else "",
612 dob=self.patient.get_dob() if self.patient else None,
613 sex=self.patient.get_sex() if self.patient else None,
614 idnum_objects=self.patient.get_idnum_objects()
615 if self.patient
616 else None, # noqa
617 creation_datetime=None,
618 basetable=None,
619 serverpk=None,
620 )
623# =============================================================================
624# Tracker class
625# =============================================================================
628class Tracker(TrackerCtvCommon):
629 """
630 Class representing a numerical tracker.
631 """
633 def __init__(
634 self,
635 req: "CamcopsRequest",
636 taskfilter: TaskFilter,
637 via_index: bool = True,
638 ) -> None:
639 super().__init__(
640 req=req, taskfilter=taskfilter, as_ctv=False, via_index=via_index
641 )
643 def get_xml(
644 self,
645 indent_spaces: int = 4,
646 eol: str = "\n",
647 include_comments: bool = False,
648 ) -> str:
649 return self._get_xml(
650 audit_string="Tracker XML accessed",
651 xml_name="tracker",
652 indent_spaces=indent_spaces,
653 eol=eol,
654 include_comments=include_comments,
655 )
657 def _get_html(self) -> str:
658 return render(
659 "tracker.mako",
660 dict(tracker=self, viewtype=ViewArg.HTML),
661 request=self.req,
662 )
664 def _get_pdf_html(self) -> str:
665 return render(
666 "tracker.mako",
667 dict(tracker=self, pdf_landscape=False, viewtype=ViewArg.PDF),
668 request=self.req,
669 )
671 # -------------------------------------------------------------------------
672 # Plotting
673 # -------------------------------------------------------------------------
675 def get_all_plots_for_one_task_html(self, tasks: List[Task]) -> str:
676 """
677 HTML for all plots for a given task type.
678 """
679 html = ""
680 ntasks = len(tasks)
681 if ntasks == 0:
682 return html
683 if not tasks[0].provides_trackers:
684 # ask the first of the task instances
685 return html
686 alltrackers = [task.get_trackers(self.req) for task in tasks]
687 datetimes = [task.get_creation_datetime() for task in tasks]
688 ntrackers = len(alltrackers[0])
689 # ... number of trackers supplied by the first task (and all tasks)
690 for tracker in range(ntrackers):
691 values = [
692 alltrackers[tasknum][tracker].value
693 for tasknum in range(ntasks)
694 ]
695 html += self.get_single_plot_html(
696 datetimes, values, specimen_tracker=alltrackers[0][tracker]
697 )
698 for task in tasks:
699 audit(
700 self.req,
701 "Tracker data accessed",
702 table=task.tablename,
703 server_pk=task.pk,
704 patient_server_pk=task.get_patient_server_pk(),
705 )
706 return html
708 def get_single_plot_html(
709 self,
710 datetimes: List[Pendulum],
711 values: List[Optional[float]],
712 specimen_tracker: "TrackerInfo",
713 ) -> str:
714 """
715 HTML for a single figure.
716 """
717 nonblank_values = [x for x in values if x is not None]
718 # NB DIFFERENT to list(filter(None, values)), which implements the
719 # test "if x", not "if x is not None" -- thus eliminating zero values!
720 # We don't want that.
721 if not nonblank_values:
722 return ""
724 plot_label = specimen_tracker.plot_label
725 axis_label = specimen_tracker.axis_label
726 axis_min = specimen_tracker.axis_min
727 axis_max = specimen_tracker.axis_max
728 axis_ticks = specimen_tracker.axis_ticks
729 horizontal_lines = specimen_tracker.horizontal_lines
730 horizontal_labels = specimen_tracker.horizontal_labels
731 aspect_ratio = specimen_tracker.aspect_ratio
733 figsize = (
734 PlotDefaults.FULLWIDTH_PLOT_WIDTH,
735 (1.0 / float(aspect_ratio)) * PlotDefaults.FULLWIDTH_PLOT_WIDTH,
736 )
737 fig = self.req.create_figure(figsize=figsize)
738 ax = fig.add_subplot(MatplotlibConstants.WHOLE_PANEL)
739 x = [matplotlib.dates.date2num(t) for t in datetimes]
740 datelabels = [dt.strftime(TRACKER_DATEFORMAT) for dt in datetimes]
742 # Plot lines and markers (on top of lines)
743 ax.plot(
744 x, # x
745 values, # y
746 color=MatplotlibConstants.COLOUR_BLUE, # line colour
747 linestyle=MatplotlibConstants.LINESTYLE_SOLID,
748 marker=MatplotlibConstants.MARKER_PLUS, # point shape
749 markeredgecolor=MatplotlibConstants.COLOUR_RED, # point colour
750 markerfacecolor=MatplotlibConstants.COLOUR_RED, # point colour
751 label=None,
752 zorder=PlotDefaults.ZORDER_DATA_LINES_POINTS,
753 )
755 # x axis
756 ax.set_xlabel("Date/time", fontdict=self.req.fontdict)
757 ax.set_xticks(x)
758 ax.set_xticklabels(datelabels, fontdict=self.req.fontdict)
759 if (
760 self.earliest is not None
761 and self.latest is not None
762 and self.earliest != self.latest
763 ):
764 xlim = matplotlib.dates.date2num((self.earliest, self.latest))
765 margin = (2.5 / 95.0) * (xlim[1] - xlim[0])
766 xlim[0] -= margin
767 xlim[1] += margin
768 ax.set_xlim(xlim)
769 xlim = ax.get_xlim()
770 fig.autofmt_xdate(rotation=90)
771 # ... autofmt_xdate must be BEFORE twinx:
772 # http://stackoverflow.com/questions/8332395
773 if axis_ticks is not None and len(axis_ticks) > 0:
774 tick_positions = [m.y for m in axis_ticks]
775 tick_labels = [m.label for m in axis_ticks]
776 ax.set_yticks(tick_positions)
777 ax.set_yticklabels(tick_labels, fontdict=self.req.fontdict)
779 # y axis
780 ax.set_ylabel(axis_label, fontdict=self.req.fontdict)
781 axis_min = (
782 min(axis_min, min(nonblank_values))
783 if axis_min is not None
784 else min(nonblank_values)
785 )
786 axis_max = (
787 max(axis_max, max(nonblank_values))
788 if axis_max is not None
789 else max(nonblank_values)
790 )
791 # ... the supplied values are stretched if the data are outside them
792 # ... but min(something, None) is None, so beware
793 # If we get something with no sense of scale whatsoever, then what
794 # we do is arbitrary. Matplotlib does its own thing, but we could do:
795 if axis_min == axis_max:
796 if axis_min == 0:
797 axis_min, axis_min = -1.0, 1.0
798 else:
799 singlevalue = axis_min
800 axis_min = 0.9 * singlevalue
801 axis_max = 1.1 * singlevalue
802 if axis_min > axis_max:
803 axis_min, axis_max = axis_max, axis_min
804 ax.set_ylim(axis_min, axis_max)
806 # title
807 ax.set_title(plot_label, fontdict=self.req.fontdict)
809 # Horizontal lines
810 stupid_jitter = 0.001
811 if horizontal_lines is not None:
812 for y in horizontal_lines:
813 ax.plot(
814 xlim, # x
815 [y, y + stupid_jitter], # y
816 color=MatplotlibConstants.COLOUR_GREY_50,
817 linestyle=MatplotlibConstants.LINESTYLE_DOTTED,
818 zorder=PlotDefaults.ZORDER_PRESET_LINES,
819 )
820 # PROBLEM: horizontal lines becoming invisible
821 # (whether from ax.axhline or plot)
823 # Horizontal labels
824 if horizontal_labels is not None:
825 label_left = xlim[0] + 0.01 * (xlim[1] - xlim[0])
826 for lab in horizontal_labels:
827 y = lab.y
828 l_ = lab.label
829 va = lab.vertical_alignment.value
830 ax.text(
831 label_left, # x
832 y, # y
833 l_, # text
834 verticalalignment=va,
835 # alpha=0.5,
836 # ... was "0.5" rather than 0.5, which led to a
837 # tricky-to-find "TypeError: a float is required" exception
838 # after switching to Python 3.
839 # ... and switched to grey colour with zorder on 2020-06-28
840 # after wkhtmltopdf 0.12.5 had problems rendering
841 # opacity=0.5 with SVG lines
842 color=MatplotlibConstants.COLOUR_GREY_50,
843 fontdict=self.req.fontdict,
844 zorder=PlotDefaults.ZORDER_PRESET_LABELS,
845 )
847 self.req.set_figure_font_sizes(ax)
849 fig.tight_layout()
850 # ... stop the labels dropping off
851 # (only works properly for LEFT labels...)
853 # http://matplotlib.org/faq/howto_faq.html
854 # ... tried it - didn't work (internal numbers change fine,
855 # check the logger, but visually doesn't help)
856 # - http://stackoverflow.com/questions/9126838
857 # - http://matplotlib.org/examples/pylab_examples/finance_work2.html
858 return self.req.get_html_from_pyplot_figure(fig) + "<br>"
859 # ... extra line break for the PDF rendering
862# =============================================================================
863# ClinicalTextView class
864# =============================================================================
867class ClinicalTextView(TrackerCtvCommon):
868 """
869 Class representing a clinical text view.
870 """
872 def __init__(
873 self,
874 req: "CamcopsRequest",
875 taskfilter: TaskFilter,
876 via_index: bool = True,
877 ) -> None:
878 super().__init__(
879 req=req, taskfilter=taskfilter, as_ctv=True, via_index=via_index
880 )
882 def get_xml(
883 self,
884 indent_spaces: int = 4,
885 eol: str = "\n",
886 include_comments: bool = False,
887 ) -> str:
888 return self._get_xml(
889 audit_string="Clinical text view XML accessed",
890 xml_name="ctv",
891 indent_spaces=indent_spaces,
892 eol=eol,
893 include_comments=include_comments,
894 )
896 def _get_html(self) -> str:
897 return render(
898 "ctv.mako",
899 dict(tracker=self, viewtype=ViewArg.HTML),
900 request=self.req,
901 )
903 def _get_pdf_html(self) -> str:
904 return render(
905 "ctv.mako",
906 dict(tracker=self, pdf_landscape=False, viewtype=ViewArg.PDF),
907 request=self.req,
908 )