Coverage for cc_modules/cc_taskschedule.py: 77%
154 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:51 +0100
1"""
2camcops_server/cc_modules/cc_taskschedule.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
11 CamCOPS is free software: you can redistribute it and/or modify
12 it under the terms of the GNU General Public License as published by
13 the Free Software Foundation, either version 3 of the License, or
14 (at your option) any later version.
16 CamCOPS is distributed in the hope that it will be useful,
17 but WITHOUT ANY WARRANTY; without even the implied warranty of
18 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 GNU General Public License for more details.
21 You should have received a copy of the GNU General Public License
22 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.
24===============================================================================
26"""
28import logging
29from typing import Any, List, Optional, Tuple, TYPE_CHECKING
30from urllib.parse import urlencode, urlunsplit
32from cardinal_pythonlib.uriconst import UriSchemes
33from pendulum import DateTime as Pendulum, Duration
35from sqlalchemy import cast, Numeric
36from sqlalchemy.orm import Mapped, mapped_column, relationship
37from sqlalchemy.sql.functions import func
38from sqlalchemy.sql.schema import ForeignKey
39from sqlalchemy.sql.sqltypes import BigInteger, UnicodeText
41from camcops_server.cc_modules.cc_email import Email
42from camcops_server.cc_modules.cc_formatter import SafeFormatter
43from camcops_server.cc_modules.cc_group import Group
44from camcops_server.cc_modules.cc_pyramid import Routes
45from camcops_server.cc_modules.cc_simpleobjects import IdNumReference
46from camcops_server.cc_modules.cc_sqlalchemy import Base
47from camcops_server.cc_modules.cc_sqla_coltypes import (
48 EmailAddressColType,
49 JsonColType,
50 PendulumDateTimeAsIsoTextColType,
51 PendulumDurationAsIsoTextColType,
52 TableNameColType,
53)
54from camcops_server.cc_modules.cc_task import (
55 Task,
56 tablename_to_task_class_dict,
57)
58from camcops_server.cc_modules.cc_taskcollection import (
59 TaskFilter,
60 TaskCollection,
61 TaskSortMethod,
62)
65if TYPE_CHECKING:
66 from sqlalchemy.sql.elements import Cast
67 from camcops_server.cc_modules.cc_request import CamcopsRequest
69log = logging.getLogger(__name__)
72# =============================================================================
73# ScheduledTaskInfo
74# =============================================================================
77class ScheduledTaskInfo(object):
78 """
79 Simple representation of a scheduled task (which may also contain the
80 actual completed task, in its ``task`` member, if there is one).
81 """
83 def __init__(
84 self,
85 shortname: str,
86 tablename: str,
87 is_anonymous: bool,
88 task: Optional[Task] = None,
89 start_datetime: Optional[Pendulum] = None,
90 end_datetime: Optional[Pendulum] = None,
91 ) -> None:
92 self.shortname = shortname
93 self.tablename = tablename
94 self.is_anonymous = is_anonymous
95 self.task = task
96 self.start_datetime = start_datetime
97 self.end_datetime = end_datetime
99 @property
100 def due_now(self) -> bool:
101 """
102 Are we in the range [start_datetime, end_datetime)?
103 """
104 if not self.start_datetime or not self.end_datetime:
105 return False
106 return self.start_datetime <= Pendulum.now() < self.end_datetime
108 @property
109 def is_complete(self) -> bool:
110 """
111 Returns whether its associated task is complete..
112 """
113 if not self.task:
114 return False
115 return self.task.is_complete()
117 @property
118 def is_identifiable_and_incomplete(self) -> bool:
119 """
120 If this is an anonymous task, returns ``False``.
121 If this is an identifiable task, returns ``not is_complete``.
122 """
123 if self.is_anonymous:
124 return False
125 return not self.is_complete
127 @property
128 def due_now_identifiable_and_incomplete(self) -> bool:
129 """
130 Is this task currently due, identifiable, and incomplete?
131 """
132 return self.due_now and self.is_identifiable_and_incomplete
135# =============================================================================
136# PatientTaskSchedule
137# =============================================================================
140class PatientTaskSchedule(Base):
141 """
142 Joining table that associates a patient with a task schedule
143 """
145 __tablename__ = "_patient_task_schedule"
147 id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
148 patient_pk: Mapped[int] = mapped_column(ForeignKey("patient._pk"))
149 schedule_id: Mapped[int] = mapped_column(ForeignKey("_task_schedule.id"))
150 start_datetime: Mapped[Optional[Pendulum]] = mapped_column(
151 PendulumDateTimeAsIsoTextColType,
152 comment=(
153 "Schedule start date for the patient. Due from/within "
154 "durations for a task schedule item are relative to this."
155 ),
156 )
157 settings: Mapped[Optional[Any]] = mapped_column(
158 JsonColType,
159 comment="Task-specific settings for this patient",
160 )
162 patient = relationship("Patient", back_populates="task_schedules")
163 task_schedule: Mapped["TaskSchedule"] = relationship(
164 back_populates="patient_task_schedules",
165 cascade_backrefs=False,
166 )
168 emails = relationship(
169 "PatientTaskScheduleEmail",
170 back_populates="patient_task_schedule",
171 cascade="all, delete",
172 cascade_backrefs=False,
173 )
175 def get_list_of_scheduled_tasks(
176 self, req: "CamcopsRequest"
177 ) -> List[ScheduledTaskInfo]:
178 """
179 Tasks scheduled for this patient.
180 """
182 task_list = []
184 task_class_lookup = tablename_to_task_class_dict()
186 for tsi in self.task_schedule.items:
187 start_datetime = None
188 end_datetime = None
189 task = None
191 if self.start_datetime is not None:
192 start_datetime = self.start_datetime.add(
193 days=tsi.due_from.days
194 )
195 end_datetime = self.start_datetime.add(days=tsi.due_by.days)
197 task = self.find_scheduled_task(
198 req, tsi, start_datetime, end_datetime
199 )
201 task_class = task_class_lookup[tsi.task_table_name]
203 task_list.append(
204 ScheduledTaskInfo(
205 task_class.shortname,
206 tsi.task_table_name,
207 is_anonymous=task_class.is_anonymous,
208 task=task,
209 start_datetime=start_datetime,
210 end_datetime=end_datetime,
211 )
212 )
214 return task_list
216 def find_scheduled_task(
217 self,
218 req: "CamcopsRequest",
219 tsi: "TaskScheduleItem",
220 start_datetime: Pendulum,
221 end_datetime: Pendulum,
222 ) -> Optional[Task]:
223 """
224 Returns the most recently uploaded task that matches the patient (by
225 any ID number, i.e. via OR), task type and timeframe
226 """
227 taskfilter = TaskFilter()
228 for idnum in self.patient.idnums:
229 idnum_ref = IdNumReference(
230 which_idnum=idnum.which_idnum, idnum_value=idnum.idnum_value
231 )
232 taskfilter.idnum_criteria.append(idnum_ref)
234 taskfilter.task_types = [tsi.task_table_name]
236 taskfilter.start_datetime = start_datetime
237 taskfilter.end_datetime = end_datetime
239 # TODO: Improve error reporting
240 # Shouldn't happen in normal operation as the task schedule item form
241 # validation will ensure the dates are correct.
242 # However, it's quite easy to write tests with unintentionally
243 # inconsistent dates.
244 # If we don't assert this here, we get a more cryptic assertion
245 # failure later:
246 #
247 # cc_taskcollection.py _fetch_tasks_from_indexes()
248 # assert self._all_indexes is not None
249 assert not taskfilter.dates_inconsistent()
251 collection = TaskCollection(
252 req=req,
253 taskfilter=taskfilter,
254 sort_method_global=TaskSortMethod.CREATION_DATE_DESC,
255 )
257 if len(collection.all_tasks) > 0:
258 return collection.all_tasks[0]
260 return None
262 def email_body(self, req: "CamcopsRequest") -> str:
263 """
264 Body content (HTML) for an e-mail to the patient -- the schedule's
265 template, populated with patient-specific information.
266 """
267 template_dict = dict(
268 access_key=self.patient.uuid_as_proquint,
269 android_launch_url=self.launch_url(req, UriSchemes.HTTP),
270 ios_launch_url=self.launch_url(req, "camcops"),
271 forename=self.patient.forename,
272 server_url=req.route_url(Routes.CLIENT_API),
273 surname=self.patient.surname,
274 )
276 formatter = TaskScheduleEmailTemplateFormatter()
277 return formatter.format(
278 self.task_schedule.email_template, **template_dict
279 )
281 def launch_url(self, req: "CamcopsRequest", scheme: str) -> str:
282 # Matches intent-filter in AndroidManifest.xml
283 # And CFBundleURLSchemes in Info.plist
285 # iOS doesn't care about these:
286 netloc = "camcops.org"
287 path = "/register/"
288 fragment = ""
290 query_dict = {
291 "default_single_user_mode": "true",
292 "default_server_location": req.route_url(Routes.CLIENT_API),
293 "default_access_key": self.patient.uuid_as_proquint,
294 }
295 query = urlencode(query_dict)
297 return urlunsplit((scheme, netloc, path, query, fragment))
299 @property
300 def email_sent(self) -> bool:
301 """
302 Has an e-mail been sent to the patient for this schedule?
303 """
304 return any(e.email.sent for e in self.emails)
307def task_schedule_item_sort_order() -> Tuple["Cast", "Cast"]:
308 """
309 Returns a tuple of sorting functions for use with SQLAlchemy ORM queries,
310 to sort task schedule items.
312 The durations are currently stored as seconds e.g. P0Y0MT2594592000.0S
313 and the seconds aren't zero padded, so we need to do some processing
314 to get them in the order we want.
316 This will fail if durations ever get stored any other way.
318 Note that MySQL does not permit "CAST(... AS DOUBLE)" or "CAST(... AS
319 FLOAT)"; you need to use NUMERIC or DECIMAL. However, this raises a warning
320 when running self-tests under SQLite: "SAWarning: Dialect sqlite+pysqlite
321 does *not* support Decimal objects natively, and SQLAlchemy must convert
322 from floating point - rounding errors and other issues may occur. Please
323 consider storing Decimal numbers as strings or integers on this platform
324 for lossless storage."
325 """
326 due_from_order = cast(func.substr(TaskScheduleItem.due_from, 7), Numeric())
327 due_by_order = cast(func.substr(TaskScheduleItem.due_by, 7), Numeric())
329 return due_from_order, due_by_order
332# =============================================================================
333# Emails sent to patient
334# =============================================================================
337class PatientTaskScheduleEmail(Base):
338 """
339 Represents an email send to a patient for a particular task schedule.
340 """
342 __tablename__ = "_patient_task_schedule_email"
344 id: Mapped[int] = mapped_column(
345 primary_key=True,
346 autoincrement=True,
347 comment="Arbitrary primary key",
348 )
349 patient_task_schedule_id: Mapped[int] = mapped_column(
350 ForeignKey(PatientTaskSchedule.id),
351 comment=(
352 f"FK to {PatientTaskSchedule.__tablename__}."
353 f"{PatientTaskSchedule.id.name}"
354 ),
355 )
356 email_id: Mapped[int] = mapped_column(
357 BigInteger,
358 ForeignKey(Email.id),
359 comment=f"FK to {Email.__tablename__}.{Email.id.name}",
360 )
362 patient_task_schedule = relationship(
363 PatientTaskSchedule,
364 back_populates="emails",
365 cascade_backrefs=False,
366 )
367 email = relationship(Email, cascade="all, delete")
370# =============================================================================
371# Task schedule
372# =============================================================================
375class TaskSchedule(Base):
376 """
377 A named collection of task schedule items
378 """
380 __tablename__ = "_task_schedule"
382 id: Mapped[int] = mapped_column(
383 primary_key=True,
384 autoincrement=True,
385 comment="Arbitrary primary key",
386 )
388 group_id: Mapped[int] = mapped_column(
389 ForeignKey(Group.id),
390 comment="FK to {}.{}".format(Group.__tablename__, Group.id.name),
391 )
393 name: Mapped[Optional[str]] = mapped_column(UnicodeText, comment="name")
395 email_subject: Mapped[str] = mapped_column(
396 UnicodeText,
397 comment="email subject",
398 default="",
399 )
400 email_template: Mapped[str] = mapped_column(
401 UnicodeText,
402 comment="email template",
403 default="",
404 )
405 email_from: Mapped[Optional[str]] = mapped_column(
406 EmailAddressColType, comment="Sender's e-mail address"
407 )
408 email_cc: Mapped[Optional[str]] = mapped_column(
409 UnicodeText,
410 comment="Send a carbon copy of the email to these addresses",
411 )
412 email_bcc: Mapped[Optional[str]] = mapped_column(
413 UnicodeText,
414 comment="Send a blind carbon copy of the email to these addresses",
415 )
417 items: Mapped[list["TaskScheduleItem"]] = relationship(
418 back_populates="task_schedule",
419 order_by=task_schedule_item_sort_order,
420 cascade="all, delete",
421 cascade_backrefs=False,
422 )
424 group = relationship(Group)
426 patient_task_schedules = relationship(
427 "PatientTaskSchedule",
428 back_populates="task_schedule",
429 cascade="all, delete",
430 cascade_backrefs=False,
431 )
433 def user_may_edit(self, req: "CamcopsRequest") -> bool:
434 """
435 May the current user edit this schedule?
436 """
437 return req.user.may_administer_group(self.group_id)
440class TaskScheduleItem(Base):
441 """
442 An individual item in a task schedule
443 """
445 __tablename__ = "_task_schedule_item"
447 id: Mapped[int] = mapped_column(
448 primary_key=True,
449 autoincrement=True,
450 comment="Arbitrary primary key",
451 )
453 schedule_id: Mapped[int] = mapped_column(
454 ForeignKey(TaskSchedule.id),
455 comment="FK to {}.{}".format(
456 TaskSchedule.__tablename__, TaskSchedule.id.name
457 ),
458 )
460 task_table_name: Mapped[Optional[str]] = mapped_column(
461 TableNameColType,
462 index=True,
463 comment="Table name of the task's base table",
464 )
466 due_from: Mapped[Optional[Duration]] = mapped_column(
467 PendulumDurationAsIsoTextColType,
468 comment=(
469 "Relative time from the start date by which the task may be "
470 "started"
471 ),
472 )
474 due_by: Mapped[Optional[Duration]] = mapped_column(
475 PendulumDurationAsIsoTextColType,
476 comment=(
477 "Relative time from the start date by which the task must be "
478 "completed"
479 ),
480 )
482 task_schedule = relationship(
483 "TaskSchedule", back_populates="items", cascade_backrefs=False
484 )
486 @property
487 def task_shortname(self) -> str:
488 """
489 Short name of the task being scheduled.
490 """
491 task_class_lookup = tablename_to_task_class_dict()
493 return task_class_lookup[self.task_table_name].shortname
495 @property
496 def due_within(self) -> Optional[Duration]:
497 """
498 Returns the "due within" property, e.g. "due within 7 days (of being
499 scheduled to start)". This is calculated from due_from and due_by.
500 """
501 if self.due_by is None:
502 # Should not be possible if created through the form
503 return None
505 if self.due_from is None:
506 return self.due_by
508 return self.due_by - self.due_from
510 def description(self, req: "CamcopsRequest") -> str:
511 """
512 Description of this schedule item -- which task, due when.
513 """
514 _ = req.gettext
516 if self.due_from is None:
517 # Should not be possible if created through the form
518 due_days = "?"
519 else:
520 due_days = str(self.due_from.in_days())
522 return _("{task_name} @ {due_days} days").format(
523 task_name=self.task_shortname, due_days=due_days
524 )
527class TaskScheduleEmailTemplateFormatter(SafeFormatter):
528 """
529 Safe template formatter for task schedule e-mails.
530 """
532 def __init__(self) -> None:
533 super().__init__(
534 [
535 "access_key",
536 "android_launch_url",
537 "forename",
538 "ios_launch_url",
539 "server_url",
540 "surname",
541 ]
542 )