Coverage for cc_modules/cc_tracker.py : 21%

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