Hide keyboard shortcuts

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 

2 

3""" 

4camcops_server/cc_modules/cc_tracker.py 

5 

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

7 

8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com). 

9 

10 This file is part of CamCOPS. 

11 

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. 

16 

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. 

21 

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

24 

25=============================================================================== 

26 

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

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

29 

30""" 

31 

32import logging 

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

34 

35from cardinal_pythonlib.datetimefunc import format_datetime 

36from cardinal_pythonlib.logs import BraceStyleAdapter 

37from pendulum import DateTime as Pendulum 

38from pyramid.renderers import render 

39 

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) 

64 

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

66 

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 

72 

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

74 

75 

76# ============================================================================= 

77# Constants 

78# ============================================================================= 

79 

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

90 

91DEBUG_TRACKER_TASK_INCLUSION = False # should be False for production system 

92 

93 

94# ============================================================================= 

95# Helper functions 

96# ============================================================================= 

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

98 

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

106 

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

110 

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 ) 

141 

142 

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

149 

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 

156 

157 Returns: 

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

159 ``msg`` is a descriptive HTML message 

160 

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) 

173 

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 

188 

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

211 

212 

213def format_daterange(start: Optional[Pendulum], 

214 end: Optional[Pendulum]) -> str: 

215 """ 

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

217 

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

224 

225 

226# ============================================================================= 

227# ConsistencyInfo class 

228# ============================================================================= 

229 

230class ConsistencyInfo(object): 

231 """ 

232 Represents ID consistency information about a set of tasks. 

233 """ 

234 

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 ) 

264 

265 def are_all_consistent(self) -> bool: 

266 """ 

267 Is all the ID information consistent? 

268 """ 

269 return self.all_consistent 

270 

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 

285 

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) 

304 

305 

306# ============================================================================= 

307# TrackerCtvCommon class: 

308# ============================================================================= 

309 

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

315 

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

324 

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 

330 

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] 

347 

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

374 

375 # Consistency information 

376 self.consistency_info = ConsistencyInfo(req, all_tasks) 

377 

378 # ------------------------------------------------------------------------- 

379 # Required for implementation 

380 # ------------------------------------------------------------------------- 

381 

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. 

388 

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? 

393 

394 Returns: 

395 an XML UTF-8 document representing our object. 

396 """ 

397 raise NotImplementedError("implement in subclass") 

398 

399 def _get_html(self) -> str: 

400 """ 

401 Returns an HTML representation. 

402 """ 

403 raise NotImplementedError("implement in subclass") 

404 

405 def _get_pdf_html(self) -> str: 

406 """ 

407 Returns HTML used for making PDFs. 

408 """ 

409 raise NotImplementedError("implement in subclass") 

410 

411 # ------------------------------------------------------------------------- 

412 # XML view 

413 # ------------------------------------------------------------------------- 

414 

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. 

423 

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? 

430 

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 ) 

491 

492 # ------------------------------------------------------------------------- 

493 # HTML view 

494 # ------------------------------------------------------------------------- 

495 

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

502 

503 # ------------------------------------------------------------------------- 

504 # PDF view 

505 # ------------------------------------------------------------------------- 

506 

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

513 

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 ) 

544 

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 ) 

566 

567 

568# ============================================================================= 

569# Tracker class 

570# ============================================================================= 

571 

572class Tracker(TrackerCtvCommon): 

573 """ 

574 Class representing a numerical tracker. 

575 """ 

576 

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 ) 

587 

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 ) 

599 

600 def _get_html(self) -> str: 

601 return render("tracker.mako", 

602 dict(tracker=self, 

603 viewtype=ViewArg.HTML), 

604 request=self.req) 

605 

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) 

612 

613 # ------------------------------------------------------------------------- 

614 # Plotting 

615 # ------------------------------------------------------------------------- 

616 

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 

648 

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

662 

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 

671 

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] 

680 

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 ) 

693 

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) 

715 

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) 

742 

743 # title 

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

745 

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) 

759 

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 ) 

783 

784 self.req.set_figure_font_sizes(ax) 

785 

786 fig.tight_layout() 

787 # ... stop the labels dropping off 

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

789 

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 

797 

798 

799# ============================================================================= 

800# ClinicalTextView class 

801# ============================================================================= 

802 

803class ClinicalTextView(TrackerCtvCommon): 

804 """ 

805 Class representing a clinical text view. 

806 """ 

807 

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 ) 

818 

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 ) 

830 

831 def _get_html(self) -> str: 

832 return render("ctv.mako", 

833 dict(tracker=self, 

834 viewtype=ViewArg.HTML), 

835 request=self.req) 

836 

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)