Coverage for cc_modules/cc_tracker.py: 21%

1"""

2 camcops_server/cc_modules/cc_tracker.py

3

4===============================================================================

5

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

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

8

9 This file is part of CamCOPS.

10

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

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

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

14 (at your option) any later version.

15

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

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

18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the

19 GNU General Public License for more details.

20

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

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

23

24===============================================================================

25

26**Trackers, showing numerical information over time, and clinical text views,

27 showing text that a clinician might care about.**

28

29"""

30

31 import logging

32 from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING

33

34 from cardinal_pythonlib.datetimefunc import format_datetime

35 from cardinal_pythonlib.logs import BraceStyleAdapter

36 from pendulum import DateTime as Pendulum

37 from pyramid.renderers import render

38

39 from camcops_server.cc_modules.cc_audit import audit

40 from camcops_server.cc_modules.cc_constants import (

41 CssClass,

42 CSS_PAGED_MEDIA,

43 DateFormat,

44 MatplotlibConstants,

45 PlotDefaults,

46)

47 from camcops_server.cc_modules.cc_filename import get_export_filename

48 from camcops_server.cc_modules.cc_plot import matplotlib

49 from camcops_server.cc_modules.cc_pdf import pdf_from_html

50 from camcops_server.cc_modules.cc_pyramid import ViewArg, ViewParam

51 from camcops_server.cc_modules.cc_simpleobjects import TaskExportOptions

52 from camcops_server.cc_modules.cc_task import Task

53 from camcops_server.cc_modules.cc_taskcollection import (

54 TaskCollection,

55 TaskFilter,

56 TaskSortMethod,

57)

58 from camcops_server.cc_modules.cc_xml import (

59 get_xml_document,

60 XmlDataTypes,

61 XmlElement,

62)

63

64 import matplotlib.dates # delayed until after the cc_plot import

65

66 if TYPE_CHECKING:

67 from camcops_server.cc_modules.cc_patient import Patient # noqa: F401

68 from camcops_server.cc_modules.cc_patientidnum import (

69 PatientIdNum,

70 )

71 from camcops_server.cc_modules.cc_request import (

72 CamcopsRequest,

73 )

74 from camcops_server.cc_modules.cc_trackerhelpers import (

75 TrackerInfo,

76 )

77

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

79

80

81# =============================================================================

82# Constants

83# =============================================================================

84

85 TRACKER_DATEFORMAT = "%Y-%m-%d"

86 WARNING_NO_PATIENT_FOUND = f"""

87 <div class="{CssClass.WARNING}">

88 </div>

89"""

90 WARNING_DENIED_INFORMATION = f"""

91 <div class="{CssClass.WARNING}">

92 Other tasks exist for this patient that you do not have access to view.

93 </div>

94"""

95

96 DEBUG_TRACKER_TASK_INCLUSION = False # should be False for production system

97

98

99# =============================================================================

100# Helper functions

101# =============================================================================

102# http://stackoverflow.com/questions/11788195

103

104

105 def consistency(

106 req: "CamcopsRequest",

107 values: List[Any],

108 servervalue: Any = None,

109 case_sensitive: bool = True,

110) -> Tuple[bool, str]:

111 """

112 Checks for consistency in a set of values (e.g. names, dates of birth).

113 (ID numbers are done separately via :func:`consistency_idnums`.)

114

115 The list of values (with the ``servervalue`` appended, if not ``None``) is

116 checked to ensure that it contains only one unique value (ignoring ``None``

117 values or empty ``""`` values).

118

119 Returns:

120 the tuple ``consistent, msg``, where ``consistent`` is a bool and

121 ``msg`` is a descriptive HTML message

122 """

123 if case_sensitive:

124 vallist = [str(v) if v is not None else v for v in values]

125 if servervalue is not None:

126 vallist.append(str(servervalue))

127 else:

128 vallist = [str(v).upper() if v is not None else v for v in values]

129 if servervalue is not None:

130 vallist.append(str(servervalue).upper())

131 # Replace "" with None, so we only have a single "not-present" value

132 vallist = [None if x == "" else x for x in vallist]

133 unique = list(set(vallist))

134 _ = req.gettext

135 if len(unique) == 0:

136 return True, _("consistent (no values)")

137 if len(unique) == 1:

138 return True, f"{_('consistent')} ({unique[0]})"

139 if len(unique) == 2:

140 if None in unique:

141 return (

142 True,

143 (

144 f"{_('consistent')} "

145 f"({_('all blank or')} {unique[1 - unique.index(None)]})"

146 ),

147 )

148 return (

149 False,

150 (

151 f"<b>{_('INCONSISTENT')} "

152 f"({_('contains values')} {', '.join(unique)})</b>"

153 ),

154 )

155

156

157 def consistency_idnums(

158 req: "CamcopsRequest", idnum_lists: List[List["PatientIdNum"]]

159) -> Tuple[bool, str]:

160 """

161 Checks the consistency of a set of :class:`PatientIdNum` objects.

162 "Are all these records from the same patient?"

163

164 Args:

165 req:

166 a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest`

167 idnum_lists:

168 a list of lists (one per task/patient instance) of

169 :class:`PatientIdNum` objects

170

171 Returns:

172 the tuple ``consistent, msg``, where ``consistent`` is a bool and

173 ``msg`` is a descriptive HTML message

174

175 """

176 # 1. Generate "known", mapping which_idnum -> set of observed non-NULL

177 # idnum_values

178 known = {} # type: Dict[int, Set[int]]

179 for task_idnum_list in idnum_lists:

180 for idnum in task_idnum_list:

181 idnum_value = idnum.idnum_value

182 if idnum_value is not None:

183 which_idnum = idnum.which_idnum

184 if which_idnum not in known:

185 known[which_idnum] = set()

186 known[which_idnum].add(idnum_value)

187

188 # 2. For every observed which_idnum, was it observed in all tasks?

189 present_in_all = {} # type: Dict[int, bool]

190 for which_idnum in known.keys():

191 present_for_all_tasks = all(

192 # "For all tasks..."

193 (

194 # "At least one ID number record relates to this which_idnum".

195 any(

196 idnum.which_idnum == which_idnum # type: ignore[arg-type]

197 and idnum.idnum_value is not None

198 )

199 for idnum in task_idnum_list

200 )

201 for task_idnum_list in idnum_lists

202 )

203 present_in_all[which_idnum] = present_for_all_tasks

204

205 # 3. Summarize

206 failures = [] # type: List[str]

207 successes = [] # type: List[str]

208 _ = req.gettext

209 for which_idnum, encountered_values in known.items():

210 value_str = ", ".join(str(v) for v in sorted(list(encountered_values)))

211 if len(encountered_values) > 1:

212 failures.append(

213 f"idnum{which_idnum} {_('contains values')} {value_str}"

214 )

215 else:

216 if present_in_all[which_idnum]:

217 successes.append(

218 f"idnum{which_idnum} {_('consistent')} ({value_str})"

219 )

220 else:

221 successes.append(

222 f"idnum{which_idnum} {_('all blank or')} {value_str}"

223 )

224 if failures:

225 return (

226 False,

227 (

228 f"<b>{_('INCONSISTENT')} "

229 f"({'; '.join(failures + successes)})</b>"

230 ),

231 )

232 else:

233 return True, f"{_('consistent')} ({'; '.join(successes)})"

234

235

236 def format_daterange(

237 start: Optional[Pendulum], end: Optional[Pendulum]

238) -> str:

239 """

240 Textual representation of an inclusive-to-exclusive date range.

241

242 Arguments are datetime values.

243 """

244 start_str = format_datetime(

245 start, DateFormat.ISO8601_DATE_ONLY, default="−∞"

246 )

247 end_str = format_datetime(end, DateFormat.ISO8601_DATE_ONLY, default="+∞")

248 return f"[{start_str}, {end_str})"

249

250

251# =============================================================================

252# ConsistencyInfo class

253# =============================================================================

254

255

256 class ConsistencyInfo(object):

257 """

258 Represents ID consistency information about a set of tasks.

259 """

260

261 def __init__(self, req: "CamcopsRequest", tasklist: List[Task]) -> None:

262 """

263 Initialize values, from a list of task instances.

264 """

265 self.request = req

266 self.consistent_forename, self.msg_forename = consistency(

267 req,

268 [task.get_patient_forename() for task in tasklist],

269 servervalue=None,

270 case_sensitive=False,

271 )

272 self.consistent_surname, self.msg_surname = consistency(

273 req,

274 [task.get_patient_surname() for task in tasklist],

275 servervalue=None,

276 case_sensitive=False,

277 )

278 self.consistent_dob, self.msg_dob = consistency(

279 req, [task.get_patient_dob_first11chars() for task in tasklist]

280 )

281 self.consistent_sex, self.msg_sex = consistency(

282 req, [task.get_patient_sex() for task in tasklist]

283 )

284 self.consistent_idnums, self.msg_idnums = consistency_idnums(

285 req, [task.get_patient_idnum_objects() for task in tasklist]

286 )

287 self.all_consistent = (

288 self.consistent_forename

289 and self.consistent_surname

290 and self.consistent_dob

291 and self.consistent_sex

292 and self.consistent_idnums

293 )

294

295 def are_all_consistent(self) -> bool:

296 """

297 Is all the ID information consistent?

298 """

299 return self.all_consistent

300

301 def get_description_list(self) -> List[str]:

302 """

303 Textual representation of ID information, indicating consistency or

304 lack of it.

305 """

306 _ = self.request.gettext

307 cons = [

308 f"{_('Forename:')} {self.msg_forename}",

309 f"{_('Surname:')} {self.msg_surname}",

310 f"{_('DOB:')} {self.msg_dob}",

311 f"{_('Sex:')} {self.msg_sex}",

312 f"{_('ID numbers:')} {self.msg_idnums}",

313 ]

314 return cons

315

316 def get_xml_root(self) -> XmlElement:

317 """

318 XML tree (as root :class:`camcops_server.cc_modules.cc_xml.XmlElement`)

319 of consistency information.

320 """

321 branches = [

322 XmlElement(

323 name="all_consistent",

324 value=self.are_all_consistent(),

325 datatype="boolean",

326 )

327 ]

328 for c in self.get_description_list():

329 branches.append(XmlElement(name="consistency_check", value=c))

330 return XmlElement(name="_consistency", value=branches)

331

332

333# =============================================================================

334# TrackerCtvCommon class:

335# =============================================================================

336

337

338 class TrackerCtvCommon(object):

339 """

340 Base class for :class:`camcops_server.cc_modules.cc_tracker.Tracker` and

341 :class:`camcops_server.cc_modules.cc_tracker.ClinicalTextView`.

342 """

343

344 def __init__(

345 self,

346 req: "CamcopsRequest",

347 taskfilter: TaskFilter,

348 as_ctv: bool,

349 via_index: bool = True,

350 ) -> None:

351 """

352 Initialize, fetching applicable tasks.

353 """

354

355 # Record input variables at this point (for URL regeneration)

356 self.req = req

357 self.taskfilter = taskfilter

358 self.as_ctv = as_ctv

359 assert taskfilter.tasks_with_patient_only

360

361 self.collection = TaskCollection(

362 req=req,

363 taskfilter=taskfilter,

364 sort_method_by_class=TaskSortMethod.CREATION_DATE_ASC,

365 sort_method_global=TaskSortMethod.CREATION_DATE_ASC,

366 via_index=via_index,

367 )

368 all_tasks = self.collection.all_tasks

369 if all_tasks:

370 self.earliest = all_tasks[0].when_created

371 self.latest = all_tasks[-1].when_created

372 self.patient = all_tasks[0].patient

373 else:

374 self.earliest = None # type: ignore[no-redef]

375 self.latest = None # type: ignore[no-redef]

376 self.patient = None # type: ignore[no-redef]

377

378 # Summary information

379 self.summary = ""

380 if DEBUG_TRACKER_TASK_INCLUSION:

381 first = True

382 for cls in self.taskfilter.task_classes:

383 if not first:

384 self.summary += " // "

385 self.summary += cls.tablename

386 first = False

387 task_instances = self.collection.tasks_for_task_class(cls)

388 if not task_instances:

389 if DEBUG_TRACKER_TASK_INCLUSION:

390 self.summary += " (no instances)"

391 continue

392 for task in task_instances:

393 if DEBUG_TRACKER_TASK_INCLUSION:

394 self.summary += f" / PK {task.pk}"

395 self.summary += " ~~~ "

396 self.summary += " — ".join(

397 [

398 "; ".join(

399 [

400 f"({task.tablename},{task.pk},"

401 f"{task.get_patient_server_pk()})"

402 for task in self.collection.tasks_for_task_class(cls)

403 ]

404 )

405 for cls in self.taskfilter.task_classes

406 ]

407 )

408

409 # Consistency information

410 self.consistency_info = ConsistencyInfo(req, all_tasks)

411

412 # -------------------------------------------------------------------------

413 # Required for implementation

414 # -------------------------------------------------------------------------

415

416 def get_xml(

417 self,

418 indent_spaces: int = 4,

419 eol: str = "\n",

420 include_comments: bool = False,

421 ) -> str:

422 """

423 Returns an XML representation.

424

425 Args:

426 indent_spaces: number of spaces to indent formatted XML

427 eol: end-of-line string

428 include_comments: include comments describing each field?

429

430 Returns:

431 an XML UTF-8 document representing our object.

432 """

433 raise NotImplementedError("implement in subclass")

434

435 def _get_html(self) -> str:

436 """

437 Returns an HTML representation.

438 """

439 raise NotImplementedError("implement in subclass")

440

441 def _get_pdf_html(self) -> str:

442 """

443 Returns HTML used for making PDFs.

444 """

445 raise NotImplementedError("implement in subclass")

446

447 # -------------------------------------------------------------------------

448 # XML view

449 # -------------------------------------------------------------------------

450

451 def _get_xml(

452 self,

453 audit_string: str,

454 xml_name: str,

455 indent_spaces: int = 4,

456 eol: str = "\n",

457 include_comments: bool = False,

458 ) -> str:

459 """

460 Returns an XML document representing this object.

461

462 Args:

463 audit_string: description used to audit access to this information

464 xml_name: name of the root XML element

465 indent_spaces: number of spaces to indent formatted XML

466 eol: end-of-line string

467 include_comments: include comments describing each field?

468

469 Returns:

470 an XML UTF-8 document representing the task.

471 """

472 iddef = self.taskfilter.get_only_iddef()

473 if not iddef:

474 raise ValueError(

475 "Tracker/CTV doesn't have a single ID number " "criterion"

476 )

477 branches = [

478 self.consistency_info.get_xml_root(),

479 XmlElement(

480 name="_search_criteria",

481 value=[

482 XmlElement(

483 name="task_tablename_list",

484 value=",".join(self.taskfilter.task_tablename_list),

485 ),

486 XmlElement(

487 name=ViewParam.WHICH_IDNUM,

488 value=iddef.which_idnum,

489 datatype=XmlDataTypes.INTEGER,

490 ),

491 XmlElement(

492 name=ViewParam.IDNUM_VALUE,

493 value=iddef.idnum_value,

494 datatype=XmlDataTypes.INTEGER,

495 ),

496 XmlElement(

497 name=ViewParam.START_DATETIME,

498 value=format_datetime(

499 self.taskfilter.start_datetime, DateFormat.ISO8601

500 ),

501 datatype=XmlDataTypes.DATETIME,

502 ),

503 XmlElement(

504 name=ViewParam.END_DATETIME,

505 value=format_datetime(

506 self.taskfilter.end_datetime, DateFormat.ISO8601

507 ),

508 datatype=XmlDataTypes.DATETIME,

509 ),

510 ],

511 ),

512 ]

513 options = TaskExportOptions(

514 xml_include_plain_columns=True,

515 xml_include_calculated=True,

516 include_blobs=False,

517 )

518 for t in self.collection.all_tasks:

519 branches.append(t.get_xml_root(self.req, options))

520 audit(

521 self.req,

522 audit_string,

523 table=t.tablename,

524 server_pk=t.pk,

525 patient_server_pk=t.get_patient_server_pk(),

526 )

527 tree = XmlElement(name=xml_name, value=branches)

528 return get_xml_document(

529 tree,

530 indent_spaces=indent_spaces,

531 eol=eol,

532 include_comments=include_comments,

533 )

534

535 # -------------------------------------------------------------------------

536 # HTML view

537 # -------------------------------------------------------------------------

538

539 def get_html(self) -> str:

540 """

541 Get HTML representing this object.

542 """

543 self.req.prepare_for_html_figures()

544 return self._get_html()

545

546 # -------------------------------------------------------------------------

547 # PDF view

548 # -------------------------------------------------------------------------

549

550 def get_pdf_html(self) -> str:

551 """

552 Returns HTML to be made into a PDF representing this object.

553 """

554 self.req.prepare_for_pdf_figures()

555 return self._get_pdf_html()

556

557 def get_pdf(self) -> bytes:

558 """

559 Get PDF representing tracker/CTV.

560 """

561 req = self.req

562 html = self.get_pdf_html() # main content

563 if CSS_PAGED_MEDIA:

564 return pdf_from_html(req, html)

565 else:

566 return pdf_from_html(

567 req,

568 html=html,

569 header_html=render(

570 "wkhtmltopdf_header.mako",

571 dict(

572 inner_text=render(

573 "tracker_ctv_header.mako",

574 dict(tracker=self),

575 request=req,

576 )

577 ),

578 request=req,

579 ),

580 footer_html=render(

581 "wkhtmltopdf_footer.mako",

582 dict(

583 inner_text=render(

584 "tracker_ctv_footer.mako",

585 dict(tracker=self),

586 request=req,

587 )

588 ),

589 request=req,

590 ),

591 extra_wkhtmltopdf_options={"orientation": "Portrait"},

592 )

593

594 def suggested_pdf_filename(self) -> str:

595 """

596 Get suggested filename for tracker/CTV PDF.

597 """

598 cfg = self.req.config

599 return get_export_filename(

600 req=self.req,

601 patient_spec_if_anonymous=cfg.patient_spec_if_anonymous,

602 patient_spec=cfg.patient_spec,

603 filename_spec=(

604 cfg.ctv_filename_spec

605 if self.as_ctv

606 else cfg.tracker_filename_spec

607 ),

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=(

615 self.patient.get_idnum_objects() if self.patient else None

616 ),

617 creation_datetime=None,

618 basetable=None,

619 serverpk=None,

620 )

621

622

623# =============================================================================

624# Tracker class

625# =============================================================================

626

627

628 class Tracker(TrackerCtvCommon):

629 """

630 Class representing a numerical tracker.

631 """

632

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 )

642

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 )

656

657 def _get_html(self) -> str:

658 return render(

659 "tracker.mako",

660 dict(tracker=self, viewtype=ViewArg.HTML),

661 request=self.req,

662 )

663

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 )

670

671 # -------------------------------------------------------------------------

672 # Plotting

673 # -------------------------------------------------------------------------

674

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

707

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 ""

723

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

732

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]

741

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 )

754

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)

778

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)

805

806 # title

807 ax.set_title(plot_label, fontdict=self.req.fontdict)

808

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)

822

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 )

846

847 self.req.set_figure_font_sizes(ax)

848

849 fig.tight_layout()

850 # ... stop the labels dropping off

851 # (only works properly for LEFT labels...)

852

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

860

861

862# =============================================================================

863# ClinicalTextView class

864# =============================================================================

865

866

867 class ClinicalTextView(TrackerCtvCommon):

868 """

869 Class representing a clinical text view.

870 """

871

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 )

881

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 )

895

896 def _get_html(self) -> str:

897 return render(

898 "ctv.mako",

899 dict(tracker=self, viewtype=ViewArg.HTML),

900 request=self.req,

901 )

902

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 )