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

28 

29import logging 

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

31from urllib.parse import quote, urlencode 

32 

33from pendulum import DateTime as Pendulum, Duration 

34 

35from sqlalchemy import cast, Numeric 

36from sqlalchemy.orm import relationship 

37from sqlalchemy.sql.functions import func 

38from sqlalchemy.sql.schema import Column, ForeignKey 

39from sqlalchemy.sql.sqltypes import Integer, UnicodeText 

40 

41from camcops_server.cc_modules.cc_formatter import SafeFormatter 

42from camcops_server.cc_modules.cc_group import Group 

43from camcops_server.cc_modules.cc_pyramid import Routes 

44from camcops_server.cc_modules.cc_simpleobjects import IdNumReference 

45from camcops_server.cc_modules.cc_sqlalchemy import Base 

46from camcops_server.cc_modules.cc_sqla_coltypes import ( 

47 JsonColType, 

48 PendulumDateTimeAsIsoTextColType, 

49 PendulumDurationAsIsoTextColType, 

50 TableNameColType, 

51) 

52from camcops_server.cc_modules.cc_task import Task, tablename_to_task_class_dict 

53from camcops_server.cc_modules.cc_taskcollection import ( 

54 TaskFilter, 

55 TaskCollection, 

56 TaskSortMethod, 

57) 

58 

59 

60if TYPE_CHECKING: 

61 from sqlalchemy.sql.elements import Cast 

62 from camcops_server.cc_modules.cc_request import CamcopsRequest 

63 

64log = logging.getLogger(__name__) 

65 

66 

67# ============================================================================= 

68# ScheduledTaskInfo 

69# ============================================================================= 

70 

71class ScheduledTaskInfo(object): 

72 """ 

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

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

75 """ 

76 def __init__(self, 

77 shortname: str, 

78 tablename: str, 

79 is_anonymous: bool, 

80 task: Optional[Task] = None, 

81 start_datetime: Optional[Pendulum] = None, 

82 end_datetime: Optional[Pendulum] = None) -> None: 

83 self.shortname = shortname 

84 self.tablename = tablename 

85 self.is_anonymous = is_anonymous 

86 self.task = task 

87 self.start_datetime = start_datetime 

88 self.end_datetime = end_datetime 

89 

90 

91# ============================================================================= 

92# PatientTaskSchedule 

93# ============================================================================= 

94 

95class PatientTaskSchedule(Base): 

96 """ 

97 Joining table that associates a patient with a task schedule 

98 """ 

99 __tablename__ = "_patient_task_schedule" 

100 

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

102 patient_pk = Column( 

103 "patient_pk", Integer, 

104 ForeignKey("patient._pk"), 

105 nullable=False, 

106 ) 

107 schedule_id = Column( 

108 "schedule_id", Integer, 

109 ForeignKey("_task_schedule.id"), 

110 nullable=False, 

111 ) 

112 start_datetime = Column( 

113 "start_datetime", PendulumDateTimeAsIsoTextColType, 

114 comment=( 

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

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

117 ) 

118 ) 

119 settings = Column( 

120 "settings", JsonColType, 

121 comment="Task-specific settings for this patient" 

122 ) 

123 

124 patient = relationship( 

125 "Patient", 

126 back_populates="task_schedules" 

127 ) 

128 task_schedule = relationship( 

129 "TaskSchedule", 

130 back_populates="patient_task_schedules" 

131 ) 

132 

133 def get_list_of_scheduled_tasks(self, req: "CamcopsRequest") \ 

134 -> List[ScheduledTaskInfo]: 

135 

136 task_list = [] 

137 

138 task_class_lookup = tablename_to_task_class_dict() 

139 

140 for tsi in self.task_schedule.items: 

141 start_datetime = None 

142 end_datetime = None 

143 task = None 

144 

145 if self.start_datetime is not None: 

146 start_datetime = self.start_datetime.add( 

147 days=tsi.due_from.days 

148 ) 

149 end_datetime = self.start_datetime.add( 

150 days=tsi.due_by.days 

151 ) 

152 

153 task = self.find_scheduled_task( 

154 req, tsi, start_datetime, end_datetime 

155 ) 

156 

157 task_class = task_class_lookup[tsi.task_table_name] 

158 

159 task_list.append( 

160 ScheduledTaskInfo( 

161 task_class.shortname, 

162 tsi.task_table_name, 

163 is_anonymous=task_class.is_anonymous, 

164 task=task, 

165 start_datetime=start_datetime, 

166 end_datetime=end_datetime, 

167 ) 

168 ) 

169 

170 return task_list 

171 

172 def find_scheduled_task(self, 

173 req: "CamcopsRequest", 

174 tsi: "TaskScheduleItem", 

175 start_datetime: Pendulum, 

176 end_datetime: Pendulum) -> Optional[Task]: 

177 """ 

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

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

180 """ 

181 taskfilter = TaskFilter() 

182 for idnum in self.patient.idnums: 

183 idnum_ref = IdNumReference(which_idnum=idnum.which_idnum, 

184 idnum_value=idnum.idnum_value) 

185 taskfilter.idnum_criteria.append(idnum_ref) 

186 

187 taskfilter.task_types = [tsi.task_table_name] 

188 

189 taskfilter.start_datetime = start_datetime 

190 taskfilter.end_datetime = end_datetime 

191 

192 # TODO: Improve error reporting 

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

194 # validation will ensure the dates are correct. 

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

196 # inconsistent dates. 

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

198 # failure later: 

199 # 

200 # cc_taskcollection.py _fetch_tasks_from_indexes() 

201 # assert self._all_indexes is not None 

202 assert not taskfilter.dates_inconsistent() 

203 

204 collection = TaskCollection( 

205 req=req, 

206 taskfilter=taskfilter, 

207 sort_method_global=TaskSortMethod.CREATION_DATE_DESC 

208 ) 

209 

210 if len(collection.all_tasks) > 0: 

211 return collection.all_tasks[0] 

212 

213 return None 

214 

215 def mailto_url(self, req: "CamcopsRequest") -> str: 

216 template_dict = dict( 

217 access_key=self.patient.uuid_as_proquint, 

218 server_url=req.route_url(Routes.CLIENT_API) 

219 ) 

220 

221 formatter = TaskScheduleEmailTemplateFormatter() 

222 email_body = formatter.format(self.task_schedule.email_template, 

223 **template_dict) 

224 

225 mailto_params = urlencode({ 

226 "subject": self.task_schedule.email_subject, 

227 "body": email_body, 

228 }, quote_via=quote) 

229 

230 mailto_url = f"mailto:{self.patient.email}?{mailto_params}" 

231 

232 return mailto_url 

233 

234 

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

236 """ 

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

238 to sort task schedule items. 

239 

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

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

242 to get them in the order we want. 

243 

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

245 """ 

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

247 Numeric()) 

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

249 Numeric()) 

250 

251 return due_from_order, due_by_order 

252 

253 

254# ============================================================================= 

255# Task schedule 

256# ============================================================================= 

257 

258class TaskSchedule(Base): 

259 """ 

260 A named collection of task schedule items 

261 """ 

262 __tablename__ = "_task_schedule" 

263 

264 id = Column( 

265 "id", Integer, 

266 primary_key=True, autoincrement=True, 

267 comment="Arbitrary primary key" 

268 ) 

269 

270 group_id = Column( 

271 "group_id", Integer, ForeignKey(Group.id), 

272 nullable=False, 

273 comment="FK to {}.{}".format(Group.__tablename__, 

274 Group.id.name) 

275 ) 

276 

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

278 

279 email_subject = Column("email_subject", UnicodeText, 

280 comment="email subject", nullable=False, default="") 

281 email_template = Column("email_template", UnicodeText, 

282 comment="email template", nullable=False, 

283 default="") 

284 

285 items = relationship( 

286 "TaskScheduleItem", 

287 order_by=task_schedule_item_sort_order, 

288 cascade="all, delete" 

289 ) # type: Iterable[TaskScheduleItem] 

290 

291 group = relationship(Group) 

292 

293 patient_task_schedules = relationship( 

294 "PatientTaskSchedule", 

295 back_populates="task_schedule", 

296 cascade="all, delete" 

297 ) 

298 

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

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

301 

302 

303class TaskScheduleItem(Base): 

304 """ 

305 An individual item in a task schedule 

306 """ 

307 __tablename__ = "_task_schedule_item" 

308 

309 id = Column( 

310 "id", Integer, 

311 primary_key=True, autoincrement=True, 

312 comment="Arbitrary primary key" 

313 ) 

314 

315 schedule_id = Column( 

316 "schedule_id", Integer, ForeignKey(TaskSchedule.id), 

317 nullable=False, 

318 comment="FK to {}.{}".format(TaskSchedule.__tablename__, 

319 TaskSchedule.id.name) 

320 ) 

321 

322 task_table_name = Column( 

323 "task_table_name", TableNameColType, 

324 index=True, 

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

326 ) 

327 

328 due_from = Column( 

329 "due_from", PendulumDurationAsIsoTextColType, 

330 comment=("Relative time from the start date by which the task may be " 

331 "started") 

332 ) # type: Optional[Duration] 

333 

334 due_by = Column( 

335 "due_by", PendulumDurationAsIsoTextColType, 

336 comment=("Relative time from the start date by which the task must be " 

337 "completed") 

338 ) # type: Optional[Duration] 

339 

340 @property 

341 def task_shortname(self) -> str: 

342 task_class_lookup = tablename_to_task_class_dict() 

343 

344 return task_class_lookup[self.task_table_name].shortname 

345 

346 @property 

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

348 if self.due_by is None: 

349 # Should not be possible if created through the form 

350 return None 

351 

352 if self.due_from is None: 

353 return self.due_by 

354 

355 return self.due_by - self.due_from 

356 

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

358 _ = req.gettext 

359 

360 if self.due_from is None: 

361 # Should not be possible if created through the form 

362 due_days = "?" 

363 else: 

364 due_days = self.due_from.in_days() 

365 

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

367 task_name=self.task_shortname, 

368 due_days=due_days 

369 ) 

370 

371 

372class TaskScheduleEmailTemplateFormatter(SafeFormatter): 

373 def __init__(self): 

374 super().__init__(["access_key", "server_url"])