Coverage for cc_modules/cc_specialnote.py: 49%
84 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_specialnote.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**Special notes that are attached, on the server, to tasks or patients.**
30"""
32from typing import List, Optional, TYPE_CHECKING
34import cardinal_pythonlib.rnc_web as ws
35from sqlalchemy.orm import relationship, Session as SqlASession
36from sqlalchemy.sql.expression import update
37from sqlalchemy.sql.schema import Column, ForeignKey
38from sqlalchemy.sql.sqltypes import Boolean, Integer, UnicodeText
40from camcops_server.cc_modules.cc_constants import ERA_NOW
41from camcops_server.cc_modules.cc_request import CamcopsRequest
42from camcops_server.cc_modules.cc_sqla_coltypes import (
43 PendulumDateTimeAsIsoTextColType,
44 EraColType,
45 TableNameColType,
46)
47from camcops_server.cc_modules.cc_sqlalchemy import Base
48from camcops_server.cc_modules.cc_user import User
49from camcops_server.cc_modules.cc_xml import (
50 make_xml_branches_from_columns,
51 XmlElement,
52)
54if TYPE_CHECKING:
55 from camcops_server.cc_modules.cc_patient import Patient
56 from camcops_server.cc_modules.cc_task import Task
59# =============================================================================
60# SpecialNote class
61# =============================================================================
63SPECIALNOTE_FWD_REF = "SpecialNote"
66class SpecialNote(Base):
67 """
68 Represents a special note, attached server-side to a task or patient.
70 "Task" means all records representing versions of a single task instance,
71 identified by the combination of {id, device, era}.
72 """
74 __tablename__ = "_special_notes"
76 # PK:
77 note_id = Column(
78 "note_id",
79 Integer,
80 primary_key=True,
81 autoincrement=True,
82 comment="Arbitrary primary key",
83 )
84 # Composite FK:
85 basetable = Column(
86 "basetable",
87 TableNameColType,
88 index=True,
89 comment="Base table of task concerned (part of FK)",
90 )
91 task_id = Column(
92 "task_id",
93 Integer,
94 index=True,
95 comment="Client-side ID of the task, or patient, concerned "
96 "(part of FK)",
97 )
98 device_id = Column(
99 "device_id",
100 Integer,
101 index=True,
102 comment="Source tablet device (part of FK)",
103 )
104 era = Column("era", EraColType, index=True, comment="Era (part of FK)")
105 # Details of note
106 note_at = Column(
107 "note_at",
108 PendulumDateTimeAsIsoTextColType,
109 comment="Date/time of note entry (ISO 8601)",
110 )
111 user_id = Column(
112 "user_id",
113 Integer,
114 ForeignKey("_security_users.id"),
115 comment="User that entered this note",
116 )
117 user = relationship("User")
118 note = Column("note", UnicodeText, comment="Special note, added manually")
119 hidden = Column(
120 "hidden",
121 Boolean,
122 nullable=False,
123 default=False,
124 comment="Manually hidden (effectively: deleted)",
125 )
127 def get_note_as_string(self) -> str:
128 """
129 Return a string-formatted version of the note.
130 """
131 return (
132 f"[{self.note_at or '?'}, "
133 f"{self.get_username() or '?'}]\n"
134 f"{self.note or ''}"
135 )
137 def get_note_as_html(self) -> str:
138 """
139 Return an HTML-formatted version of the note.
140 """
141 return (
142 f"[{self.note_at or '?'}, {self.get_username() or '?'}]<br>"
143 f"<b>{ws.webify(self.note) or ''}</b>"
144 )
146 def get_username(self) -> Optional[str]:
147 if self.user is None:
148 return None
149 return self.user.username
151 def get_xml_root(self, skip_fields: List[str] = None) -> XmlElement:
152 """
153 Get root of XML tree, as an
154 :class:`camcops_server.cc_modules.cc_xml.XmlElement`.
155 """
156 branches = make_xml_branches_from_columns(
157 self, skip_fields=skip_fields
158 )
159 return XmlElement(name=self.__tablename__, value=branches)
161 @classmethod
162 def forcibly_preserve_special_notes_for_device(
163 cls, req: CamcopsRequest, device_id: int
164 ) -> None:
165 """
166 Force-preserve all special notes for a given device.
168 WRITES TO DATABASE.
170 For update methods, see also:
171 https://docs.sqlalchemy.org/en/latest/orm/persistence_techniques.html
172 """
173 dbsession = req.dbsession
174 new_era = req.now_era_format
176 # METHOD 1: use the ORM, object by object
177 #
178 # noinspection PyProtectedMember
179 # notes = dbsession.query(cls)\
180 # .filter(cls._device_id == device_id)\
181 # .filter(cls._era == ERA_NOW)\
182 # .all()
183 # for note in notes:
184 # note._era = new_era
186 # METHOD 2: use the Core, in bulk
187 # You can use update(table)... or table.update()...;
188 # https://docs.sqlalchemy.org/en/latest/core/dml.html#sqlalchemy.sql.expression.update # noqa
190 # noinspection PyUnresolvedReferences
191 dbsession.execute(
192 update(cls.__table__)
193 .where(cls.device_id == device_id)
194 .where(cls.era == ERA_NOW)
195 .values(era=new_era)
196 )
198 @classmethod
199 def get_specialnote_by_id(
200 cls, dbsession: SqlASession, note_id: int
201 ) -> Optional["SpecialNote"]:
202 """
203 Returns a special note, given its ID.
204 """
205 return dbsession.query(cls).filter(cls.note_id == note_id).first()
207 def refers_to_patient(self) -> bool:
208 """
209 Is this a note relating to a patient, rather than a task?
210 """
211 from camcops_server.cc_modules.cc_patient import (
212 Patient,
213 ) # delayed import
215 return self.basetable == Patient.__tablename__
217 def refers_to_task(self) -> bool:
218 """
219 Is this a note relating to a task, rather than a patient?
220 """
221 return not self.refers_to_patient()
223 def target_patient(self) -> Optional["Patient"]:
224 """
225 Get the patient to which this note refers, or ``None`` if it doesn't.
226 """
227 from camcops_server.cc_modules.cc_patient import (
228 Patient,
229 ) # delayed import
231 if not self.refers_to_patient():
232 return None
233 dbsession = SqlASession.object_session(self)
234 return Patient.get_patient_by_id_device_era(
235 dbsession=dbsession,
236 client_id=self.task_id,
237 device_id=self.device_id,
238 era=self.era,
239 )
241 def target_task(self) -> Optional["Task"]:
242 """
243 Get the patient to which this note refers, or ``None`` if it doesn't.
244 """
245 from camcops_server.cc_modules.cc_taskfactory import (
246 task_factory_clientkeys_no_security_checks,
247 ) # delayed import
249 if not self.refers_to_task():
250 return None
251 dbsession = SqlASession.object_session(self)
252 return task_factory_clientkeys_no_security_checks(
253 dbsession=dbsession,
254 basetable=self.basetable,
255 client_id=self.task_id,
256 device_id=self.device_id,
257 era=self.era,
258 )
260 def get_group_id_of_target(self) -> Optional[int]:
261 """
262 Returns the group ID for the object (task or patient) that this
263 special note is about.
264 """
265 group_id = None
266 if self.refers_to_patient():
267 # Patient
268 patient = self.target_patient()
269 if patient:
270 group_id = patient.group_id
271 else:
272 # Task
273 task = self.target_task()
274 if task:
275 group_id = task.group_id
276 return group_id
278 def user_may_delete_specialnote(self, user: "User") -> bool:
279 """
280 May the specified user delete this note?
281 """
282 if user.superuser:
283 # Superuser can delete anything
284 return True
285 if self.user_id == user.id:
286 # Created by the current user, therefore deletable by them.
287 return True
288 group_id = self.get_group_id_of_target()
289 if group_id is None:
290 return False
291 # Can the current user administer the group that the task/patient
292 # belongs to? If so, they may delete the special note.
293 return user.may_administer_group(group_id)