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
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-08 23:14 +0000
1#!/usr/bin/env python
3"""
4camcops_server/cc_modules/cc_taskschedule.py
6===============================================================================
8 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
9 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
11 This file is part of CamCOPS.
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.
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.
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/>.
26===============================================================================
28"""
30import logging
31from typing import List, Iterable, Optional, Tuple, TYPE_CHECKING
32from urllib.parse import urlencode, urlunsplit
34from cardinal_pythonlib.uriconst import UriSchemes
35from pendulum import DateTime as Pendulum, Duration
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
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)
67if TYPE_CHECKING:
68 from sqlalchemy.sql.elements import Cast
69 from camcops_server.cc_modules.cc_request import CamcopsRequest
71log = logging.getLogger(__name__)
74# =============================================================================
75# ScheduledTaskInfo
76# =============================================================================
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 """
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
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
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()
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
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
137# =============================================================================
138# PatientTaskSchedule
139# =============================================================================
142class PatientTaskSchedule(Base):
143 """
144 Joining table that associates a patient with a task schedule
145 """
147 __tablename__ = "_patient_task_schedule"
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 )
170 patient = relationship("Patient", back_populates="task_schedules")
171 task_schedule = relationship(
172 "TaskSchedule", back_populates="patient_task_schedules"
173 )
175 emails = relationship(
176 "PatientTaskScheduleEmail",
177 back_populates="patient_task_schedule",
178 cascade="all, delete",
179 )
181 def get_list_of_scheduled_tasks(
182 self, req: "CamcopsRequest"
183 ) -> List[ScheduledTaskInfo]:
184 """
185 Tasks scheduled for this patient.
186 """
188 task_list = []
190 task_class_lookup = tablename_to_task_class_dict()
192 for tsi in self.task_schedule.items:
193 start_datetime = None
194 end_datetime = None
195 task = None
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)
203 task = self.find_scheduled_task(
204 req, tsi, start_datetime, end_datetime
205 )
207 task_class = task_class_lookup[tsi.task_table_name]
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 )
220 return task_list
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)
240 taskfilter.task_types = [tsi.task_table_name]
242 taskfilter.start_datetime = start_datetime
243 taskfilter.end_datetime = end_datetime
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()
257 collection = TaskCollection(
258 req=req,
259 taskfilter=taskfilter,
260 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
261 )
263 if len(collection.all_tasks) > 0:
264 return collection.all_tasks[0]
266 return None
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 )
282 formatter = TaskScheduleEmailTemplateFormatter()
283 return formatter.format(
284 self.task_schedule.email_template, **template_dict
285 )
287 def launch_url(self, req: "CamcopsRequest", scheme: str) -> str:
288 # Matches intent-filter in AndroidManifest.xml
289 # And CFBundleURLSchemes in Info.plist
291 # iOS doesn't care about these:
292 netloc = "camcops.org"
293 path = "/register/"
294 fragment = ""
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)
303 return urlunsplit((scheme, netloc, path, query, fragment))
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)
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.
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.
322 This will fail if durations ever get stored any other way.
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())
335 return due_from_order, due_by_order
338# =============================================================================
339# Emails sent to patient
340# =============================================================================
343class PatientTaskScheduleEmail(Base):
344 """
345 Represents an email send to a patient for a particular task schedule.
346 """
348 __tablename__ = "_patient_task_schedule_email"
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 )
375 patient_task_schedule = relationship(
376 PatientTaskSchedule, back_populates="emails"
377 )
378 email = relationship(Email, cascade="all, delete")
381# =============================================================================
382# Task schedule
383# =============================================================================
386class TaskSchedule(Base):
387 """
388 A named collection of task schedule items
389 """
391 __tablename__ = "_task_schedule"
393 id = Column(
394 "id",
395 Integer,
396 primary_key=True,
397 autoincrement=True,
398 comment="Arbitrary primary key",
399 )
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 )
409 name = Column("name", UnicodeText, comment="name")
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 )
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]
446 group = relationship(Group)
448 patient_task_schedules = relationship(
449 "PatientTaskSchedule",
450 back_populates="task_schedule",
451 cascade="all, delete",
452 )
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)
461class TaskScheduleItem(Base):
462 """
463 An individual item in a task schedule
464 """
466 __tablename__ = "_task_schedule_item"
468 id = Column(
469 "id",
470 Integer,
471 primary_key=True,
472 autoincrement=True,
473 comment="Arbitrary primary key",
474 )
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 )
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 )
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]
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]
511 task_schedule = relationship("TaskSchedule", back_populates="items")
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()
520 return task_class_lookup[self.task_table_name].shortname
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
532 if self.due_from is None:
533 return self.due_by
535 return self.due_by - self.due_from
537 def description(self, req: "CamcopsRequest") -> str:
538 """
539 Description of this schedule item -- which task, due when.
540 """
541 _ = req.gettext
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()
549 return _("{task_name} @ {due_days} days").format(
550 task_name=self.task_shortname, due_days=due_days
551 )
554class TaskScheduleEmailTemplateFormatter(SafeFormatter):
555 """
556 Safe template formatter for task schedule e-mails.
557 """
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 )