Coverage for cc_modules/cc_specialnote.py: 49%

1"""

2 camcops_server/cc_modules/cc_specialnote.py

3

4===============================================================================

5

6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.

7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

8

9 This file is part of CamCOPS.

10

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.

15

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.

20

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/>.

23

24===============================================================================

25

26**Special notes that are attached, on the server, to tasks or patients.**

27

28"""

29

30 from typing import List, Optional, TYPE_CHECKING

31

32 import cardinal_pythonlib.rnc_web as ws

33 from pendulum import DateTime as Pendulum

34 from sqlalchemy.orm import (

35 mapped_column,

36 Mapped,

37 relationship,

38 Session as SqlASession,

39)

40 from sqlalchemy.sql.expression import update

41 from sqlalchemy.sql.schema import ForeignKey

42 from sqlalchemy.sql.sqltypes import UnicodeText

43

44 from camcops_server.cc_modules.cc_constants import ERA_NOW

45 from camcops_server.cc_modules.cc_request import CamcopsRequest

46 from camcops_server.cc_modules.cc_sqla_coltypes import (

47 PendulumDateTimeAsIsoTextColType,

48 EraColType,

49 TableNameColType,

50)

51 from camcops_server.cc_modules.cc_sqlalchemy import Base

52 from camcops_server.cc_modules.cc_user import User

53 from camcops_server.cc_modules.cc_xml import (

54 make_xml_branches_from_columns,

55 XmlElement,

56)

57

58 if TYPE_CHECKING:

59 from camcops_server.cc_modules.cc_patient import Patient

60 from camcops_server.cc_modules.cc_task import Task

61

62

63# =============================================================================

64# SpecialNote class

65# =============================================================================

66

67 SPECIALNOTE_FWD_REF = "SpecialNote"

68

69

70 class SpecialNote(Base):

71 """

72 Represents a special note, attached server-side to a task or patient.

73

74 "Task" means all records representing versions of a single task instance,

75 identified by the combination of {id, device, era}.

76 """

77

78 __tablename__ = "_special_notes"

79

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 )

121

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 )

131

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 )

140

141 def get_username(self) -> Optional[str]:

142 if self.user is None:

143 return None

144 return self.user.username

145

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)

155

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.

162

163 WRITES TO DATABASE.

164

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

170

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

180

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

184

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 )

192

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()

201

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

209

210 return self.basetable == Patient.__tablename__

211

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()

217

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

225

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 )

235

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

243

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 )

254

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

272

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)