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

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_tracker.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

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. 

17 

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. 

22 

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/>. 

25 

26=============================================================================== 

27 

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

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

30 

31""" 

32 

33import logging 

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

35 

36from cardinal_pythonlib.datetimefunc import format_datetime 

37from cardinal_pythonlib.logs import BraceStyleAdapter 

38from pendulum import DateTime as Pendulum 

39from pyramid.renderers import render 

40 

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) 

65 

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

67 

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 ) 

79 

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

81 

82 

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

84# Constants 

85# ============================================================================= 

86 

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

97 

98DEBUG_TRACKER_TASK_INCLUSION = False # should be False for production system 

99 

100 

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

102# Helper functions 

103# ============================================================================= 

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

105 

106 

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`.) 

116 

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). 

120 

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 ) 

157 

158 

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

165 

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 

172 

173 Returns: 

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

175 ``msg`` is a descriptive HTML message 

176 

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) 

189 

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 

206 

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

236 

237 

238def format_daterange( 

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

240) -> str: 

241 """ 

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

243 

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

251 

252 

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

254# ConsistencyInfo class 

255# ============================================================================= 

256 

257 

258class ConsistencyInfo(object): 

259 """ 

260 Represents ID consistency information about a set of tasks. 

261 """ 

262 

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 ) 

296 

297 def are_all_consistent(self) -> bool: 

298 """ 

299 Is all the ID information consistent? 

300 """ 

301 return self.all_consistent 

302 

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 

317 

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) 

333 

334 

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

336# TrackerCtvCommon class: 

337# ============================================================================= 

338 

339 

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

345 

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

356 

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 

362 

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] 

379 

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 ) 

410 

411 # Consistency information 

412 self.consistency_info = ConsistencyInfo(req, all_tasks) 

413 

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

415 # Required for implementation 

416 # ------------------------------------------------------------------------- 

417 

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. 

426 

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? 

431 

432 Returns: 

433 an XML UTF-8 document representing our object. 

434 """ 

435 raise NotImplementedError("implement in subclass") 

436 

437 def _get_html(self) -> str: 

438 """ 

439 Returns an HTML representation. 

440 """ 

441 raise NotImplementedError("implement in subclass") 

442 

443 def _get_pdf_html(self) -> str: 

444 """ 

445 Returns HTML used for making PDFs. 

446 """ 

447 raise NotImplementedError("implement in subclass") 

448 

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

450 # XML view 

451 # ------------------------------------------------------------------------- 

452 

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. 

463 

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? 

470 

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 ) 

536 

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

538 # HTML view 

539 # ------------------------------------------------------------------------- 

540 

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

547 

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

549 # PDF view 

550 # ------------------------------------------------------------------------- 

551 

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

558 

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 ) 

595 

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 ) 

621 

622 

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

624# Tracker class 

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

626 

627 

628class 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 

867class 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 )