Coverage for cc_modules/cc_taskschedule.py : 55%

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
3"""
4camcops_server/cc_modules/cc_taskschedule.py
6===============================================================================
8 Copyright (C) 2012-2020 Rudolf Cardinal (rudolf@pobox.com).
10 This file is part of CamCOPS.
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.
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.
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/>.
25===============================================================================
27"""
29import logging
30from typing import List, Iterable, Optional, Tuple, TYPE_CHECKING
31from urllib.parse import quote, urlencode
33from pendulum import DateTime as Pendulum, Duration
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
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)
60if TYPE_CHECKING:
61 from sqlalchemy.sql.elements import Cast
62 from camcops_server.cc_modules.cc_request import CamcopsRequest
64log = logging.getLogger(__name__)
67# =============================================================================
68# ScheduledTaskInfo
69# =============================================================================
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
91# =============================================================================
92# PatientTaskSchedule
93# =============================================================================
95class PatientTaskSchedule(Base):
96 """
97 Joining table that associates a patient with a task schedule
98 """
99 __tablename__ = "_patient_task_schedule"
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 )
124 patient = relationship(
125 "Patient",
126 back_populates="task_schedules"
127 )
128 task_schedule = relationship(
129 "TaskSchedule",
130 back_populates="patient_task_schedules"
131 )
133 def get_list_of_scheduled_tasks(self, req: "CamcopsRequest") \
134 -> List[ScheduledTaskInfo]:
136 task_list = []
138 task_class_lookup = tablename_to_task_class_dict()
140 for tsi in self.task_schedule.items:
141 start_datetime = None
142 end_datetime = None
143 task = None
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 )
153 task = self.find_scheduled_task(
154 req, tsi, start_datetime, end_datetime
155 )
157 task_class = task_class_lookup[tsi.task_table_name]
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 )
170 return task_list
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)
187 taskfilter.task_types = [tsi.task_table_name]
189 taskfilter.start_datetime = start_datetime
190 taskfilter.end_datetime = end_datetime
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()
204 collection = TaskCollection(
205 req=req,
206 taskfilter=taskfilter,
207 sort_method_global=TaskSortMethod.CREATION_DATE_DESC
208 )
210 if len(collection.all_tasks) > 0:
211 return collection.all_tasks[0]
213 return None
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 )
221 formatter = TaskScheduleEmailTemplateFormatter()
222 email_body = formatter.format(self.task_schedule.email_template,
223 **template_dict)
225 mailto_params = urlencode({
226 "subject": self.task_schedule.email_subject,
227 "body": email_body,
228 }, quote_via=quote)
230 mailto_url = f"mailto:{self.patient.email}?{mailto_params}"
232 return mailto_url
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.
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.
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())
251 return due_from_order, due_by_order
254# =============================================================================
255# Task schedule
256# =============================================================================
258class TaskSchedule(Base):
259 """
260 A named collection of task schedule items
261 """
262 __tablename__ = "_task_schedule"
264 id = Column(
265 "id", Integer,
266 primary_key=True, autoincrement=True,
267 comment="Arbitrary primary key"
268 )
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 )
277 name = Column("name", UnicodeText, comment="name")
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="")
285 items = relationship(
286 "TaskScheduleItem",
287 order_by=task_schedule_item_sort_order,
288 cascade="all, delete"
289 ) # type: Iterable[TaskScheduleItem]
291 group = relationship(Group)
293 patient_task_schedules = relationship(
294 "PatientTaskSchedule",
295 back_populates="task_schedule",
296 cascade="all, delete"
297 )
299 def user_may_edit(self, req: "CamcopsRequest") -> bool:
300 return req.user.may_administer_group(self.group_id)
303class TaskScheduleItem(Base):
304 """
305 An individual item in a task schedule
306 """
307 __tablename__ = "_task_schedule_item"
309 id = Column(
310 "id", Integer,
311 primary_key=True, autoincrement=True,
312 comment="Arbitrary primary key"
313 )
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 )
322 task_table_name = Column(
323 "task_table_name", TableNameColType,
324 index=True,
325 comment="Table name of the task's base table"
326 )
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]
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]
340 @property
341 def task_shortname(self) -> str:
342 task_class_lookup = tablename_to_task_class_dict()
344 return task_class_lookup[self.task_table_name].shortname
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
352 if self.due_from is None:
353 return self.due_by
355 return self.due_by - self.due_from
357 def description(self, req: "CamcopsRequest") -> str:
358 _ = req.gettext
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()
366 return _("{task_name} @ {due_days} days").format(
367 task_name=self.task_shortname,
368 due_days=due_days
369 )
372class TaskScheduleEmailTemplateFormatter(SafeFormatter):
373 def __init__(self):
374 super().__init__(["access_key", "server_url"])