Coverage for cc_modules/cc_patientidnum.py: 57%

81 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-15 15:51 +0100

1""" 

2camcops_server/cc_modules/cc_patientidnum.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**Represent patient ID numbers.** 

27 

28We were looking up ID descriptors from the device's stored variables. 

29However, that is a bit of a nuisance for a server-side researcher, and 

30it's a pain to copy the server's storedvar values (and -- all or some?) 

31when a patient gets individually moved off the tablet. Anyway, they're 

32important, so a little repetition is not the end of the world. So, 

33let's have the tablet store its current ID descriptors in the patient 

34record at the point of upload, and then it's available here directly. 

35Thus, always complete and contemporaneous. 

36 

37... DECISION CHANGED 2017-07-08; see justification in tablet 

38 overall_design.txt 

39 

40""" 

41 

42import logging 

43from typing import List, Optional, Tuple, TYPE_CHECKING 

44 

45from cardinal_pythonlib.logs import BraceStyleAdapter 

46from cardinal_pythonlib.reprfunc import simple_repr 

47from sqlalchemy.orm import mapped_column, Mapped, relationship 

48from sqlalchemy.sql.schema import Column, ForeignKey 

49from sqlalchemy.sql.sqltypes import BigInteger 

50 

51from camcops_server.cc_modules.cc_constants import ( 

52 EXTRA_COMMENT_PREFIX, 

53 EXTRA_IDNUM_FIELD_PREFIX, 

54 NUMBER_OF_IDNUMS_DEFUNCT, 

55) 

56from camcops_server.cc_modules.cc_db import GenericTabletRecordMixin 

57from camcops_server.cc_modules.cc_idnumdef import IdNumDefinition 

58from camcops_server.cc_modules.cc_simpleobjects import IdNumReference 

59from camcops_server.cc_modules.cc_sqla_coltypes import ( 

60 mapped_camcops_column, 

61 camcops_column, 

62) 

63from camcops_server.cc_modules.cc_sqlalchemy import Base 

64 

65if TYPE_CHECKING: 

66 from camcops_server.cc_modules.cc_patient import Patient 

67 from camcops_server.cc_modules.cc_request import CamcopsRequest 

68 

69log = BraceStyleAdapter(logging.getLogger(__name__)) 

70 

71 

72# ============================================================================= 

73# PatientIdNum class 

74# ============================================================================= 

75# Stores ID numbers for a specific patient 

76 

77 

78class PatientIdNum(GenericTabletRecordMixin, Base): 

79 """ 

80 SQLAlchemy ORM class representing an ID number (as a 

81 which_idnum/idnum_value pair) for a patient. 

82 """ 

83 

84 __tablename__ = "patient_idnum" 

85 

86 id: Mapped[int] = mapped_column( 

87 comment="Primary key on the source tablet device", 

88 ) 

89 patient_id: Mapped[int] = mapped_column( 

90 comment="FK to patient.id (for this device/era)", 

91 ) 

92 which_idnum: Mapped[int] = mapped_column( 

93 ForeignKey(IdNumDefinition.which_idnum), 

94 comment="Which of the server's ID numbers is this?", 

95 ) 

96 idnum_value: Mapped[Optional[int]] = mapped_camcops_column( 

97 "idnum_value", 

98 BigInteger, 

99 identifies_patient=True, 

100 comment="The value of the ID number", 

101 ) 

102 # Note: we don't use a relationship() to IdNumDefinition here; we do that 

103 # sort of work via the CamcopsRequest, which caches them for speed. 

104 

105 patient = relationship( 

106 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-custom-foreign 

107 # https://docs.sqlalchemy.org/en/latest/orm/relationship_api.html#sqlalchemy.orm.relationship # noqa 

108 # https://docs.sqlalchemy.org/en/latest/orm/join_conditions.html#relationship-primaryjoin # noqa 

109 "Patient", 

110 primaryjoin=( 

111 "and_(" 

112 " remote(Patient.id) == foreign(PatientIdNum.patient_id), " 

113 " remote(Patient._device_id) == foreign(PatientIdNum._device_id), " 

114 " remote(Patient._era) == foreign(PatientIdNum._era), " 

115 " remote(Patient._current) == True " 

116 ")" 

117 ), 

118 uselist=False, 

119 viewonly=True, 

120 ) 

121 

122 duplicates: Mapped[list["PatientIdNum"]] = relationship( 

123 primaryjoin=( 

124 "and_(" 

125 " remote(PatientIdNum._pk) != foreign(PatientIdNum._pk), " 

126 " remote(PatientIdNum._group_id) == foreign(PatientIdNum._group_id), " # noqa: E501 

127 " remote(PatientIdNum.which_idnum) == foreign(PatientIdNum.which_idnum), " # noqa: E501 

128 " remote(PatientIdNum.idnum_value) == foreign(PatientIdNum.idnum_value), " # noqa: E501 

129 " remote(PatientIdNum._device_id) == foreign(PatientIdNum._device_id), " # noqa: E501 

130 " remote(PatientIdNum._era) == foreign(PatientIdNum._era), " 

131 " remote(PatientIdNum._current) == True, " 

132 ")" 

133 ), 

134 viewonly=True, 

135 uselist=True, 

136 ) 

137 

138 # ------------------------------------------------------------------------- 

139 # String representations 

140 # ------------------------------------------------------------------------- 

141 

142 def __str__(self) -> str: 

143 return f"idnum{self.which_idnum}={self.idnum_value}" 

144 

145 def prettystr(self, req: "CamcopsRequest") -> str: 

146 """ 

147 A prettified version of __str__. 

148 

149 Args: 

150 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

151 """ 

152 return f"{self.short_description(req)} {self.idnum_value}" 

153 

154 def full_prettystr(self, req: "CamcopsRequest") -> str: 

155 """ 

156 A long-version prettified version of __str__. 

157 

158 Args: 

159 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

160 """ 

161 return f"{self.description(req)} {self.idnum_value}" 

162 

163 def __repr__(self) -> str: 

164 return simple_repr( 

165 self, 

166 [ 

167 "_pk", 

168 "_device_id", 

169 "_era", 

170 "id", 

171 "patient_id", 

172 "which_idnum", 

173 "idnum_value", 

174 ], 

175 ) 

176 

177 # ------------------------------------------------------------------------- 

178 # Equality 

179 # ------------------------------------------------------------------------- 

180 

181 def __members(self) -> Tuple: 

182 """ 

183 For :meth:`__hash__` and :meth:`__eq__`, as per 

184 https://stackoverflow.com/questions/45164691/recommended-way-to-implement-eq-and-hash 

185 """ 

186 return self.which_idnum, self.idnum_value 

187 

188 def __hash__(self) -> int: 

189 """ 

190 Must be compatible with __eq__. 

191 

192 See also 

193 https://stackoverflow.com/questions/45164691/recommended-way-to-implement-eq-and-hash 

194 """ 

195 return hash(self.__members()) 

196 

197 def __eq__(self, other: object) -> bool: 

198 """ 

199 Do ``self`` and ``other`` represent the same ID number? 

200 

201 Equivalent to: 

202 

203 .. code-block:: python 

204 

205 return ( 

206 self.which_idnum == other.which_idnum and 

207 self.idnum_value == other.idnum_value and 

208 self.which_idnum is not None and 

209 self.idnum_value is not None 

210 ) 

211 """ 

212 if not isinstance(other, PatientIdNum): 

213 return NotImplemented 

214 

215 sm = self.__members() 

216 return (None not in sm) and sm == other.__members() 

217 

218 # ------------------------------------------------------------------------- 

219 # Validity 

220 # ------------------------------------------------------------------------- 

221 

222 def is_superficially_valid(self) -> bool: 

223 """ 

224 Is this a valid ID number? 

225 """ 

226 return ( 

227 self.which_idnum is not None 

228 and self.idnum_value is not None 

229 and self.which_idnum >= 0 

230 and self.idnum_value >= 0 

231 ) 

232 

233 def is_fully_valid(self, req: "CamcopsRequest") -> bool: 

234 if not self.is_superficially_valid(): 

235 return False 

236 return req.is_idnum_valid(self.which_idnum, self.idnum_value) 

237 

238 def why_invalid(self, req: "CamcopsRequest") -> str: 

239 if not self.is_superficially_valid(): 

240 _ = req.gettext 

241 return _("ID number fails basic checks") 

242 return req.why_idnum_invalid(self.which_idnum, self.idnum_value) 

243 

244 # ------------------------------------------------------------------------- 

245 # ID type description 

246 # ------------------------------------------------------------------------- 

247 

248 def description(self, req: "CamcopsRequest") -> str: 

249 """ 

250 Returns the full description for this ID number. 

251 """ 

252 which_idnum = self.which_idnum # type: int 

253 return req.get_id_desc(which_idnum, default="?") 

254 

255 def short_description(self, req: "CamcopsRequest") -> str: 

256 """ 

257 Returns the short description for this ID number. 

258 """ 

259 which_idnum = self.which_idnum # type: int 

260 return req.get_id_shortdesc(which_idnum, default="?") 

261 

262 # ------------------------------------------------------------------------- 

263 # Other representations 

264 # ------------------------------------------------------------------------- 

265 

266 def get_idnum_reference(self) -> IdNumReference: 

267 """ 

268 Returns an 

269 :class:`camcops_server.cc_modules.cc_simpleobjects.IdNumReference` 

270 object summarizing this ID number. 

271 """ 

272 return IdNumReference( 

273 which_idnum=self.which_idnum, idnum_value=self.idnum_value 

274 ) 

275 

276 def get_filename_component(self, req: "CamcopsRequest") -> str: 

277 """ 

278 Returns a string including the short description of the ID number, and 

279 the number itself, for use in filenames. 

280 """ 

281 if self.which_idnum is None or self.idnum_value is None: 

282 return "" 

283 return f"{self.short_description(req)}-{self.idnum_value}" 

284 

285 # ------------------------------------------------------------------------- 

286 # Set value 

287 # ------------------------------------------------------------------------- 

288 

289 def set_idnum(self, idnum_value: int) -> None: 

290 """ 

291 Sets the ID number value. 

292 """ 

293 self.idnum_value = idnum_value 

294 

295 # ------------------------------------------------------------------------- 

296 # Patient 

297 # ------------------------------------------------------------------------- 

298 

299 def get_patient_server_pk(self) -> int: 

300 patient = self.patient # type: Patient 

301 if not patient: 

302 raise ValueError( 

303 "Corrupted database? PatientIdNum can't fetch its Patient" 

304 ) 

305 return patient.pk 

306 

307 

308# ============================================================================= 

309# Fake ID values when upgrading from old ID number system 

310# ============================================================================= 

311 

312 

313def fake_tablet_id_for_patientidnum(patient_id: int, which_idnum: int) -> int: 

314 """ 

315 Returns a fake client-side PK (tablet ID) for a patient number. Only for 

316 use in upgrading old databases. 

317 """ 

318 return patient_id * NUMBER_OF_IDNUMS_DEFUNCT + which_idnum 

319 

320 

321# ============================================================================= 

322# Additional ID number column info for DB_PATIENT_ID_PER_ROW export option 

323# ============================================================================= 

324 

325 

326def extra_id_colname(which_idnum: int) -> str: 

327 """ 

328 The column name used for the extra ID number columns provided by the 

329 ``DB_PATIENT_ID_PER_ROW`` export option. 

330 

331 Args: 

332 which_idnum: ID number type 

333 

334 Returns: 

335 str: ``idnum<which_idnum>`` 

336 

337 """ 

338 return f"{EXTRA_IDNUM_FIELD_PREFIX}{which_idnum}" 

339 

340 

341def extra_id_column(req: "CamcopsRequest", which_idnum: int) -> Column: 

342 """ 

343 The column definition used for the extra ID number columns provided by the 

344 ``DB_PATIENT_ID_PER_ROW`` export option. 

345 

346 Args: 

347 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

348 which_idnum: ID number type 

349 

350 Returns: 

351 the column definition 

352 

353 """ 

354 desc = req.get_id_desc(which_idnum) 

355 return camcops_column( 

356 extra_id_colname(which_idnum), 

357 BigInteger, 

358 identifies_patient=True, 

359 comment=EXTRA_COMMENT_PREFIX + f"ID number {which_idnum}: {desc}", 

360 ) 

361 

362 

363def all_extra_id_columns(req: "CamcopsRequest") -> List[Column]: 

364 """ 

365 Returns all column definitions used for the extra ID number columns 

366 provided by the ``DB_PATIENT_ID_PER_ROW`` export option. 

367 

368 Args: 

369 req: a :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

370 

371 Returns: 

372 list: the column definitions 

373 """ 

374 return [ 

375 extra_id_column(req, which_idnum) 

376 for which_idnum in req.valid_which_idnums 

377 ]