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