Coverage for cc_modules/cc_taskschedule.py: 57%

155 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_taskschedule.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""" 

29 

30import logging 

31from typing import List, Iterable, Optional, Tuple, TYPE_CHECKING 

32from urllib.parse import urlencode, urlunsplit 

33 

34from cardinal_pythonlib.uriconst import UriSchemes 

35from pendulum import DateTime as Pendulum, Duration 

36 

37from sqlalchemy import cast, Numeric 

38from sqlalchemy.orm import relationship 

39from sqlalchemy.sql.functions import func 

40from sqlalchemy.sql.schema import Column, ForeignKey 

41from sqlalchemy.sql.sqltypes import BigInteger, Integer, UnicodeText 

42 

43from camcops_server.cc_modules.cc_email import Email 

44from camcops_server.cc_modules.cc_formatter import SafeFormatter 

45from camcops_server.cc_modules.cc_group import Group 

46from camcops_server.cc_modules.cc_pyramid import Routes 

47from camcops_server.cc_modules.cc_simpleobjects import IdNumReference 

48from camcops_server.cc_modules.cc_sqlalchemy import Base 

49from camcops_server.cc_modules.cc_sqla_coltypes import ( 

50 EmailAddressColType, 

51 JsonColType, 

52 PendulumDateTimeAsIsoTextColType, 

53 PendulumDurationAsIsoTextColType, 

54 TableNameColType, 

55) 

56from camcops_server.cc_modules.cc_task import ( 

57 Task, 

58 tablename_to_task_class_dict, 

59) 

60from camcops_server.cc_modules.cc_taskcollection import ( 

61 TaskFilter, 

62 TaskCollection, 

63 TaskSortMethod, 

64) 

65 

66 

67if TYPE_CHECKING: 

68 from sqlalchemy.sql.elements import Cast 

69 from camcops_server.cc_modules.cc_request import CamcopsRequest 

70 

71log = logging.getLogger(__name__) 

72 

73 

74# ============================================================================= 

75# ScheduledTaskInfo 

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

77 

78 

79class ScheduledTaskInfo(object): 

80 """ 

81 Simple representation of a scheduled task (which may also contain the 

82 actual completed task, in its ``task`` member, if there is one). 

83 """ 

84 

85 def __init__( 

86 self, 

87 shortname: str, 

88 tablename: str, 

89 is_anonymous: bool, 

90 task: Optional[Task] = None, 

91 start_datetime: Optional[Pendulum] = None, 

92 end_datetime: Optional[Pendulum] = None, 

93 ) -> None: 

94 self.shortname = shortname 

95 self.tablename = tablename 

96 self.is_anonymous = is_anonymous 

97 self.task = task 

98 self.start_datetime = start_datetime 

99 self.end_datetime = end_datetime 

100 

101 @property 

102 def due_now(self) -> bool: 

103 """ 

104 Are we in the range [start_datetime, end_datetime)? 

105 """ 

106 if not self.start_datetime or not self.end_datetime: 

107 return False 

108 return self.start_datetime <= Pendulum.now() < self.end_datetime 

109 

110 @property 

111 def is_complete(self) -> bool: 

112 """ 

113 Returns whether its associated task is complete.. 

114 """ 

115 if not self.task: 

116 return False 

117 return self.task.is_complete() 

118 

119 @property 

120 def is_identifiable_and_incomplete(self) -> bool: 

121 """ 

122 If this is an anonymous task, returns ``False``. 

123 If this is an identifiable task, returns ``not is_complete``. 

124 """ 

125 if self.is_anonymous: 

126 return False 

127 return not self.is_complete 

128 

129 @property 

130 def due_now_identifiable_and_incomplete(self) -> bool: 

131 """ 

132 Is this task currently due, identifiable, and incomplete? 

133 """ 

134 return self.due_now and self.is_identifiable_and_incomplete 

135 

136 

137# ============================================================================= 

138# PatientTaskSchedule 

139# ============================================================================= 

140 

141 

142class PatientTaskSchedule(Base): 

143 """ 

144 Joining table that associates a patient with a task schedule 

145 """ 

146 

147 __tablename__ = "_patient_task_schedule" 

148 

149 id = Column("id", Integer, primary_key=True, autoincrement=True) 

150 patient_pk = Column( 

151 "patient_pk", Integer, ForeignKey("patient._pk"), nullable=False 

152 ) 

153 schedule_id = Column( 

154 "schedule_id", Integer, ForeignKey("_task_schedule.id"), nullable=False 

155 ) 

156 start_datetime = Column( 

157 "start_datetime", 

158 PendulumDateTimeAsIsoTextColType, 

159 comment=( 

160 "Schedule start date for the patient. Due from/within " 

161 "durations for a task schedule item are relative to this." 

162 ), 

163 ) 

164 settings = Column( 

165 "settings", 

166 JsonColType, 

167 comment="Task-specific settings for this patient", 

168 ) 

169 

170 patient = relationship("Patient", back_populates="task_schedules") 

171 task_schedule = relationship( 

172 "TaskSchedule", back_populates="patient_task_schedules" 

173 ) 

174 

175 emails = relationship( 

176 "PatientTaskScheduleEmail", 

177 back_populates="patient_task_schedule", 

178 cascade="all, delete", 

179 ) 

180 

181 def get_list_of_scheduled_tasks( 

182 self, req: "CamcopsRequest" 

183 ) -> List[ScheduledTaskInfo]: 

184 """ 

185 Tasks scheduled for this patient. 

186 """ 

187 

188 task_list = [] 

189 

190 task_class_lookup = tablename_to_task_class_dict() 

191 

192 for tsi in self.task_schedule.items: 

193 start_datetime = None 

194 end_datetime = None 

195 task = None 

196 

197 if self.start_datetime is not None: 

198 start_datetime = self.start_datetime.add( 

199 days=tsi.due_from.days 

200 ) 

201 end_datetime = self.start_datetime.add(days=tsi.due_by.days) 

202 

203 task = self.find_scheduled_task( 

204 req, tsi, start_datetime, end_datetime 

205 ) 

206 

207 task_class = task_class_lookup[tsi.task_table_name] 

208 

209 task_list.append( 

210 ScheduledTaskInfo( 

211 task_class.shortname, 

212 tsi.task_table_name, 

213 is_anonymous=task_class.is_anonymous, 

214 task=task, 

215 start_datetime=start_datetime, 

216 end_datetime=end_datetime, 

217 ) 

218 ) 

219 

220 return task_list 

221 

222 def find_scheduled_task( 

223 self, 

224 req: "CamcopsRequest", 

225 tsi: "TaskScheduleItem", 

226 start_datetime: Pendulum, 

227 end_datetime: Pendulum, 

228 ) -> Optional[Task]: 

229 """ 

230 Returns the most recently uploaded task that matches the patient (by 

231 any ID number, i.e. via OR), task type and timeframe 

232 """ 

233 taskfilter = TaskFilter() 

234 for idnum in self.patient.idnums: 

235 idnum_ref = IdNumReference( 

236 which_idnum=idnum.which_idnum, idnum_value=idnum.idnum_value 

237 ) 

238 taskfilter.idnum_criteria.append(idnum_ref) 

239 

240 taskfilter.task_types = [tsi.task_table_name] 

241 

242 taskfilter.start_datetime = start_datetime 

243 taskfilter.end_datetime = end_datetime 

244 

245 # TODO: Improve error reporting 

246 # Shouldn't happen in normal operation as the task schedule item form 

247 # validation will ensure the dates are correct. 

248 # However, it's quite easy to write tests with unintentionally 

249 # inconsistent dates. 

250 # If we don't assert this here, we get a more cryptic assertion 

251 # failure later: 

252 # 

253 # cc_taskcollection.py _fetch_tasks_from_indexes() 

254 # assert self._all_indexes is not None 

255 assert not taskfilter.dates_inconsistent() 

256 

257 collection = TaskCollection( 

258 req=req, 

259 taskfilter=taskfilter, 

260 sort_method_global=TaskSortMethod.CREATION_DATE_DESC, 

261 ) 

262 

263 if len(collection.all_tasks) > 0: 

264 return collection.all_tasks[0] 

265 

266 return None 

267 

268 def email_body(self, req: "CamcopsRequest") -> str: 

269 """ 

270 Body content (HTML) for an e-mail to the patient -- the schedule's 

271 template, populated with patient-specific information. 

272 """ 

273 template_dict = dict( 

274 access_key=self.patient.uuid_as_proquint, 

275 android_launch_url=self.launch_url(req, UriSchemes.HTTP), 

276 ios_launch_url=self.launch_url(req, "camcops"), 

277 forename=self.patient.forename, 

278 server_url=req.route_url(Routes.CLIENT_API), 

279 surname=self.patient.surname, 

280 ) 

281 

282 formatter = TaskScheduleEmailTemplateFormatter() 

283 return formatter.format( 

284 self.task_schedule.email_template, **template_dict 

285 ) 

286 

287 def launch_url(self, req: "CamcopsRequest", scheme: str) -> str: 

288 # Matches intent-filter in AndroidManifest.xml 

289 # And CFBundleURLSchemes in Info.plist 

290 

291 # iOS doesn't care about these: 

292 netloc = "camcops.org" 

293 path = "/register/" 

294 fragment = "" 

295 

296 query_dict = { 

297 "default_single_user_mode": "true", 

298 "default_server_location": req.route_url(Routes.CLIENT_API), 

299 "default_access_key": self.patient.uuid_as_proquint, 

300 } 

301 query = urlencode(query_dict) 

302 

303 return urlunsplit((scheme, netloc, path, query, fragment)) 

304 

305 @property 

306 def email_sent(self) -> bool: 

307 """ 

308 Has an e-mail been sent to the patient for this schedule? 

309 """ 

310 return any(e.email.sent for e in self.emails) 

311 

312 

313def task_schedule_item_sort_order() -> Tuple["Cast", "Cast"]: 

314 """ 

315 Returns a tuple of sorting functions for use with SQLAlchemy ORM queries, 

316 to sort task schedule items. 

317 

318 The durations are currently stored as seconds e.g. P0Y0MT2594592000.0S 

319 and the seconds aren't zero padded, so we need to do some processing 

320 to get them in the order we want. 

321 

322 This will fail if durations ever get stored any other way. 

323 

324 Note that MySQL does not permit "CAST(... AS DOUBLE)" or "CAST(... AS 

325 FLOAT)"; you need to use NUMERIC or DECIMAL. However, this raises a warning 

326 when running self-tests under SQLite: "SAWarning: Dialect sqlite+pysqlite 

327 does *not* support Decimal objects natively, and SQLAlchemy must convert 

328 from floating point - rounding errors and other issues may occur. Please 

329 consider storing Decimal numbers as strings or integers on this platform 

330 for lossless storage." 

331 """ 

332 due_from_order = cast(func.substr(TaskScheduleItem.due_from, 7), Numeric()) 

333 due_by_order = cast(func.substr(TaskScheduleItem.due_by, 7), Numeric()) 

334 

335 return due_from_order, due_by_order 

336 

337 

338# ============================================================================= 

339# Emails sent to patient 

340# ============================================================================= 

341 

342 

343class PatientTaskScheduleEmail(Base): 

344 """ 

345 Represents an email send to a patient for a particular task schedule. 

346 """ 

347 

348 __tablename__ = "_patient_task_schedule_email" 

349 

350 id = Column( 

351 "id", 

352 Integer, 

353 primary_key=True, 

354 autoincrement=True, 

355 comment="Arbitrary primary key", 

356 ) 

357 patient_task_schedule_id = Column( 

358 "patient_task_schedule_id", 

359 Integer, 

360 ForeignKey(PatientTaskSchedule.id), 

361 nullable=False, 

362 comment=( 

363 f"FK to {PatientTaskSchedule.__tablename__}." 

364 f"{PatientTaskSchedule.id.name}" 

365 ), 

366 ) 

367 email_id = Column( 

368 "email_id", 

369 BigInteger, 

370 ForeignKey(Email.id), 

371 nullable=False, 

372 comment=f"FK to {Email.__tablename__}.{Email.id.name}", 

373 ) 

374 

375 patient_task_schedule = relationship( 

376 PatientTaskSchedule, back_populates="emails" 

377 ) 

378 email = relationship(Email, cascade="all, delete") 

379 

380 

381# ============================================================================= 

382# Task schedule 

383# ============================================================================= 

384 

385 

386class TaskSchedule(Base): 

387 """ 

388 A named collection of task schedule items 

389 """ 

390 

391 __tablename__ = "_task_schedule" 

392 

393 id = Column( 

394 "id", 

395 Integer, 

396 primary_key=True, 

397 autoincrement=True, 

398 comment="Arbitrary primary key", 

399 ) 

400 

401 group_id = Column( 

402 "group_id", 

403 Integer, 

404 ForeignKey(Group.id), 

405 nullable=False, 

406 comment="FK to {}.{}".format(Group.__tablename__, Group.id.name), 

407 ) 

408 

409 name = Column("name", UnicodeText, comment="name") 

410 

411 email_subject = Column( 

412 "email_subject", 

413 UnicodeText, 

414 comment="email subject", 

415 nullable=False, 

416 default="", 

417 ) 

418 email_template = Column( 

419 "email_template", 

420 UnicodeText, 

421 comment="email template", 

422 nullable=False, 

423 default="", 

424 ) 

425 email_from = Column( 

426 "email_from", EmailAddressColType, comment="Sender's e-mail address" 

427 ) 

428 email_cc = Column( 

429 "email_cc", 

430 UnicodeText, 

431 comment="Send a carbon copy of the email to these addresses", 

432 ) 

433 email_bcc = Column( 

434 "email_bcc", 

435 UnicodeText, 

436 comment="Send a blind carbon copy of the email to these addresses", 

437 ) 

438 

439 items = relationship( 

440 "TaskScheduleItem", 

441 back_populates="task_schedule", 

442 order_by=task_schedule_item_sort_order, 

443 cascade="all, delete", 

444 ) # type: Iterable[TaskScheduleItem] 

445 

446 group = relationship(Group) 

447 

448 patient_task_schedules = relationship( 

449 "PatientTaskSchedule", 

450 back_populates="task_schedule", 

451 cascade="all, delete", 

452 ) 

453 

454 def user_may_edit(self, req: "CamcopsRequest") -> bool: 

455 """ 

456 May the current user edit this schedule? 

457 """ 

458 return req.user.may_administer_group(self.group_id) 

459 

460 

461class TaskScheduleItem(Base): 

462 """ 

463 An individual item in a task schedule 

464 """ 

465 

466 __tablename__ = "_task_schedule_item" 

467 

468 id = Column( 

469 "id", 

470 Integer, 

471 primary_key=True, 

472 autoincrement=True, 

473 comment="Arbitrary primary key", 

474 ) 

475 

476 schedule_id = Column( 

477 "schedule_id", 

478 Integer, 

479 ForeignKey(TaskSchedule.id), 

480 nullable=False, 

481 comment="FK to {}.{}".format( 

482 TaskSchedule.__tablename__, TaskSchedule.id.name 

483 ), 

484 ) 

485 

486 task_table_name = Column( 

487 "task_table_name", 

488 TableNameColType, 

489 index=True, 

490 comment="Table name of the task's base table", 

491 ) 

492 

493 due_from = Column( 

494 "due_from", 

495 PendulumDurationAsIsoTextColType, 

496 comment=( 

497 "Relative time from the start date by which the task may be " 

498 "started" 

499 ), 

500 ) # type: Optional[Duration] 

501 

502 due_by = Column( 

503 "due_by", 

504 PendulumDurationAsIsoTextColType, 

505 comment=( 

506 "Relative time from the start date by which the task must be " 

507 "completed" 

508 ), 

509 ) # type: Optional[Duration] 

510 

511 task_schedule = relationship("TaskSchedule", back_populates="items") 

512 

513 @property 

514 def task_shortname(self) -> str: 

515 """ 

516 Short name of the task being scheduled. 

517 """ 

518 task_class_lookup = tablename_to_task_class_dict() 

519 

520 return task_class_lookup[self.task_table_name].shortname 

521 

522 @property 

523 def due_within(self) -> Optional[Duration]: 

524 """ 

525 Returns the "due within" property, e.g. "due within 7 days (of being 

526 scheduled to start)". This is calculated from due_from and due_by. 

527 """ 

528 if self.due_by is None: 

529 # Should not be possible if created through the form 

530 return None 

531 

532 if self.due_from is None: 

533 return self.due_by 

534 

535 return self.due_by - self.due_from 

536 

537 def description(self, req: "CamcopsRequest") -> str: 

538 """ 

539 Description of this schedule item -- which task, due when. 

540 """ 

541 _ = req.gettext 

542 

543 if self.due_from is None: 

544 # Should not be possible if created through the form 

545 due_days = "?" 

546 else: 

547 due_days = self.due_from.in_days() 

548 

549 return _("{task_name} @ {due_days} days").format( 

550 task_name=self.task_shortname, due_days=due_days 

551 ) 

552 

553 

554class TaskScheduleEmailTemplateFormatter(SafeFormatter): 

555 """ 

556 Safe template formatter for task schedule e-mails. 

557 """ 

558 

559 def __init__(self): 

560 super().__init__( 

561 [ 

562 "access_key", 

563 "android_launch_url", 

564 "forename", 

565 "ios_launch_url", 

566 "server_url", 

567 "surname", 

568 ] 

569 )