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

1""" 

2camcops_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 

30from typing import List, Optional, TYPE_CHECKING 

31 

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 

43 

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) 

57 

58if 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 

67SPECIALNOTE_FWD_REF = "SpecialNote" 

68 

69 

70class 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)