Coverage for cc_modules/cc_tabletsession.py: 80%

103 statements  

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

1""" 

2camcops_server/cc_modules/cc_tabletsession.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**Session information for client devices (tablets etc.).** 

27 

28""" 

29 

30import logging 

31from typing import Optional, Set, TYPE_CHECKING 

32 

33from cardinal_pythonlib.httpconst import HttpMethod 

34from cardinal_pythonlib.logs import BraceStyleAdapter 

35from cardinal_pythonlib.reprfunc import simple_repr 

36from pyramid.exceptions import HTTPBadRequest 

37 

38from camcops_server.cc_modules.cc_client_api_core import ( 

39 fail_user_error, 

40 TabletParam, 

41) 

42from camcops_server.cc_modules.cc_constants import ( 

43 DEVICE_NAME_FOR_SERVER, 

44 USER_NAME_FOR_SYSTEM, 

45) 

46from camcops_server.cc_modules.cc_device import Device 

47from camcops_server.cc_modules.cc_validators import ( 

48 validate_anything, 

49 validate_device_name, 

50 validate_username, 

51) 

52from camcops_server.cc_modules.cc_version import ( 

53 FIRST_CPP_TABLET_VER, 

54 FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE, 

55 FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE, 

56 FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE, 

57 make_version, 

58 MINIMUM_TABLET_VERSION, 

59) 

60 

61if TYPE_CHECKING: 

62 from camcops_server.cc_modules.cc_request import CamcopsRequest 

63 

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

65 

66INVALID_USERNAME_PASSWORD = ( 

67 "Invalid username/password (or user not authorized)" 

68) 

69NO_UPLOAD_GROUP_SET = "No upload group set for user " 

70 

71 

72class TabletSession(object): 

73 """ 

74 Represents session information for client devices. They use HTTPS POST 

75 calls and do not bother with cookies. 

76 """ 

77 

78 def __init__(self, req: "CamcopsRequest") -> None: 

79 # Check the basics 

80 if req.method != HttpMethod.POST: 

81 raise HTTPBadRequest("Must use POST method") 

82 # ... this is for humans to view, so it has a pretty error 

83 

84 # Read key things 

85 self.req = req 

86 self.operation = req.get_str_param(TabletParam.OPERATION) 

87 try: 

88 self.device_name = req.get_str_param( 

89 TabletParam.DEVICE, validator=validate_device_name 

90 ) 

91 self.username = req.get_str_param( 

92 TabletParam.USER, validator=validate_username 

93 ) 

94 except ValueError as e: 

95 fail_user_error(str(e)) 

96 self.password = req.get_str_param( 

97 TabletParam.PASSWORD, validator=validate_anything 

98 ) 

99 self.session_id = req.get_int_param(TabletParam.SESSION_ID) 

100 self.session_token = req.get_str_param( 

101 TabletParam.SESSION_TOKEN, validator=validate_anything 

102 ) 

103 self.tablet_version_str = req.get_str_param( 

104 TabletParam.CAMCOPS_VERSION, validator=validate_anything 

105 ) 

106 try: 

107 self.tablet_version_ver = make_version(self.tablet_version_str) 

108 except ValueError: 

109 fail_user_error( 

110 f"CamCOPS tablet version nonsensical: " 

111 f"{self.tablet_version_str!r}" 

112 ) 

113 

114 # Basic security check: no pretending to be the server 

115 if self.device_name == DEVICE_NAME_FOR_SERVER: 

116 fail_user_error( 

117 f"Tablets cannot use the device name " 

118 f"{DEVICE_NAME_FOR_SERVER!r}" 

119 ) 

120 if self.username == USER_NAME_FOR_SYSTEM: 

121 fail_user_error( 

122 f"Tablets cannot use the username {USER_NAME_FOR_SYSTEM!r}" 

123 ) 

124 

125 self._device_obj = None # type: Optional[Device] 

126 

127 # Ensure table version is OK 

128 if self.tablet_version_ver < MINIMUM_TABLET_VERSION: 

129 fail_user_error( 

130 f"Tablet CamCOPS version too old: is " 

131 f"{self.tablet_version_str}, need {MINIMUM_TABLET_VERSION}" 

132 ) 

133 # Other version things are done via properties 

134 

135 # Upload efficiency 

136 self._dirty_table_names = set() # type: Set[str] 

137 

138 # Report 

139 log.info( 

140 "Incoming client API connection from IP={i}, port={p}, " 

141 "device_name={dn!r}, " 

142 # "device_id={di}, " 

143 "camcops_version={v}, " "username={u}, operation={o}", 

144 i=req.remote_addr, 

145 p=req.remote_port, 

146 dn=self.device_name, 

147 # di=self.device_id, 

148 v=self.tablet_version_str, 

149 u=self.username, 

150 o=self.operation, 

151 ) 

152 

153 def __repr__(self) -> str: 

154 return simple_repr( 

155 self, 

156 [ 

157 "session_id", 

158 "session_token", 

159 "device_name", 

160 "username", 

161 "operation", 

162 ], 

163 with_addr=True, 

164 ) 

165 

166 # ------------------------------------------------------------------------- 

167 # Database objects, accessed on demand 

168 # ------------------------------------------------------------------------- 

169 

170 @property 

171 def device(self) -> Optional[Device]: 

172 """ 

173 Returns the :class:`camcops_server.cc_modules.cc_device.Device` 

174 associated with this request/session, or ``None``. 

175 """ 

176 if self._device_obj is None: 

177 dbsession = self.req.dbsession 

178 self._device_obj = Device.get_device_by_name( 

179 dbsession, self.device_name 

180 ) 

181 return self._device_obj 

182 

183 # ------------------------------------------------------------------------- 

184 # Permissions and similar 

185 # ------------------------------------------------------------------------- 

186 

187 @property 

188 def device_id(self) -> Optional[int]: 

189 """ 

190 Returns the integer device ID, if known. 

191 """ 

192 device = self.device 

193 if not device: 

194 return None 

195 return device.id 

196 

197 @property 

198 def user_id(self) -> Optional[int]: 

199 """ 

200 Returns the integer user ID, if known. 

201 """ 

202 user = self.req.user 

203 if not user: 

204 return None 

205 return user.id 

206 

207 def is_device_registered(self) -> bool: 

208 """ 

209 Is the device registered with our server? 

210 """ 

211 return self.device is not None 

212 

213 def reload_device(self) -> None: 

214 """ 

215 Re-fetch the device information from the database. 

216 (Or, at least, do so when it's next required.) 

217 """ 

218 self._device_obj = None 

219 

220 def ensure_device_registered(self) -> None: 

221 """ 

222 Ensure the device is registered. Raises :exc:`UserErrorException` 

223 on failure. 

224 """ 

225 if not self.is_device_registered(): 

226 fail_user_error("Unregistered device") 

227 

228 def ensure_valid_device_and_user_for_uploading(self) -> None: 

229 """ 

230 Ensure the device/username/password combination is valid for uploading. 

231 Raises :exc:`UserErrorException` on failure. 

232 """ 

233 user = self.req.user 

234 if not user: 

235 fail_user_error(INVALID_USERNAME_PASSWORD) 

236 if user.upload_group_id is None: 

237 fail_user_error(NO_UPLOAD_GROUP_SET + user.username) 

238 if not user.may_upload: 

239 fail_user_error("User not authorized to upload to selected group") 

240 # Username/password combination found and is valid. Now check device. 

241 self.ensure_device_registered() 

242 

243 def ensure_valid_user_for_device_registration(self) -> None: 

244 """ 

245 Ensure the username/password combination is valid for device 

246 registration. Raises :exc:`UserErrorException` on failure. 

247 """ 

248 user = self.req.user 

249 if not user: 

250 fail_user_error(INVALID_USERNAME_PASSWORD) 

251 if user.upload_group_id is None: 

252 fail_user_error(NO_UPLOAD_GROUP_SET + user.username) 

253 if not user.may_register_devices: 

254 fail_user_error( 

255 "User not authorized to register devices for " "selected group" 

256 ) 

257 

258 def set_session_id_token( 

259 self, session_id: int, session_token: str 

260 ) -> None: 

261 """ 

262 Sets the session ID and token. 

263 Typical situation: 

264 

265 - :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession` 

266 created; may or may not have an ID/token as part of the POST request 

267 - :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` 

268 translates that into a server-side session 

269 - If one wasn't found and needs to be created, we write back 

270 the values here. 

271 """ 

272 self.session_id = session_id 

273 self.session_token = session_token 

274 

275 # ------------------------------------------------------------------------- 

276 # Version information (via property as not always needed) 

277 # ------------------------------------------------------------------------- 

278 

279 @property 

280 def cope_with_deleted_patient_descriptors(self) -> bool: 

281 """ 

282 Must we cope with an old client that had ID descriptors 

283 in the Patient table? 

284 """ 

285 return ( 

286 self.tablet_version_ver 

287 < FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE 

288 ) 

289 

290 @property 

291 def cope_with_old_idnums(self) -> bool: 

292 """ 

293 Must we cope with an old client that had ID numbers embedded in the 

294 Patient table? 

295 """ 

296 return ( 

297 self.tablet_version_ver 

298 < FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE 

299 ) 

300 

301 @property 

302 def explicit_pkname_for_upload_table(self) -> bool: 

303 """ 

304 Is the client a nice new one that explicitly names the 

305 primary key when uploading tables? 

306 """ 

307 return ( 

308 self.tablet_version_ver 

309 >= FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE 

310 ) 

311 

312 @property 

313 def pkname_in_upload_table_neither_first_nor_explicit(self) -> bool: 

314 """ 

315 Is the client a particularly tricky old version that is a C++ client 

316 (generally a good thing, but meaning that the primary key might not be 

317 the first field in uploaded tables) but had a bug such that it did not 

318 explicitly name its PK either? 

319 

320 See discussion of bug in ``NetworkManager::sendTableWhole`` (C++). 

321 For these versions, the only safe thing is to take ``"id"`` as the 

322 name of the (client-side) primary key. 

323 """ 

324 return ( 

325 self.tablet_version_ver >= FIRST_CPP_TABLET_VER 

326 and not self.explicit_pkname_for_upload_table 

327 )