Coverage for cc_modules/cc_tabletsession.py: 38%

104 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-11-08 23:14 +0000

1#!/usr/bin/env python 

2 

3""" 

4camcops_server/cc_modules/cc_tabletsession.py 

5 

6=============================================================================== 

7 

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

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

10 

11 This file is part of CamCOPS. 

12 

13 CamCOPS is free software: you can redistribute it and/or modify 

14 it under the terms of the GNU General Public License as published by 

15 the Free Software Foundation, either version 3 of the License, or 

16 (at your option) any later version. 

17 

18 CamCOPS is distributed in the hope that it will be useful, 

19 but WITHOUT ANY WARRANTY; without even the implied warranty of 

20 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

21 GNU General Public License for more details. 

22 

23 You should have received a copy of the GNU General Public License 

24 along with CamCOPS. If not, see <https://www.gnu.org/licenses/>. 

25 

26=============================================================================== 

27 

28**Session information for client devices (tablets etc.).** 

29 

30""" 

31 

32import logging 

33from typing import Optional, Set, TYPE_CHECKING 

34 

35from cardinal_pythonlib.httpconst import HttpMethod 

36from cardinal_pythonlib.logs import BraceStyleAdapter 

37from cardinal_pythonlib.reprfunc import simple_repr 

38from pyramid.exceptions import HTTPBadRequest 

39 

40from camcops_server.cc_modules.cc_client_api_core import ( 

41 fail_user_error, 

42 TabletParam, 

43) 

44from camcops_server.cc_modules.cc_constants import ( 

45 DEVICE_NAME_FOR_SERVER, 

46 USER_NAME_FOR_SYSTEM, 

47) 

48from camcops_server.cc_modules.cc_device import Device 

49from camcops_server.cc_modules.cc_validators import ( 

50 validate_anything, 

51 validate_device_name, 

52 validate_username, 

53) 

54from camcops_server.cc_modules.cc_version import ( 

55 FIRST_CPP_TABLET_VER, 

56 FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE, 

57 FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE, 

58 FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE, 

59 make_version, 

60 MINIMUM_TABLET_VERSION, 

61) 

62 

63if TYPE_CHECKING: 

64 from camcops_server.cc_modules.cc_request import CamcopsRequest 

65 

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

67 

68INVALID_USERNAME_PASSWORD = ( 

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

70) 

71NO_UPLOAD_GROUP_SET = "No upload group set for user " 

72 

73 

74class TabletSession(object): 

75 """ 

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

77 calls and do not bother with cookies. 

78 """ 

79 

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

81 # Check the basics 

82 if req.method != HttpMethod.POST: 

83 raise HTTPBadRequest("Must use POST method") 

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

85 

86 # Read key things 

87 self.req = req 

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

89 try: 

90 self.device_name = req.get_str_param( 

91 TabletParam.DEVICE, validator=validate_device_name 

92 ) 

93 self.username = req.get_str_param( 

94 TabletParam.USER, validator=validate_username 

95 ) 

96 except ValueError as e: 

97 fail_user_error(str(e)) 

98 self.password = req.get_str_param( 

99 TabletParam.PASSWORD, validator=validate_anything 

100 ) 

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

102 self.session_token = req.get_str_param( 

103 TabletParam.SESSION_TOKEN, validator=validate_anything 

104 ) 

105 self.tablet_version_str = req.get_str_param( 

106 TabletParam.CAMCOPS_VERSION, validator=validate_anything 

107 ) 

108 try: 

109 self.tablet_version_ver = make_version(self.tablet_version_str) 

110 except ValueError: 

111 fail_user_error( 

112 f"CamCOPS tablet version nonsensical: " 

113 f"{self.tablet_version_str!r}" 

114 ) 

115 

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

117 if self.device_name == DEVICE_NAME_FOR_SERVER: 

118 fail_user_error( 

119 f"Tablets cannot use the device name " 

120 f"{DEVICE_NAME_FOR_SERVER!r}" 

121 ) 

122 if self.username == USER_NAME_FOR_SYSTEM: 

123 fail_user_error( 

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

125 ) 

126 

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

128 

129 # Ensure table version is OK 

130 if self.tablet_version_ver < MINIMUM_TABLET_VERSION: 

131 fail_user_error( 

132 f"Tablet CamCOPS version too old: is " 

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

134 ) 

135 # Other version things are done via properties 

136 

137 # Upload efficiency 

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

139 

140 # Report 

141 log.info( 

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

143 "device_name={dn!r}, " 

144 # "device_id={di}, " 

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

146 i=req.remote_addr, 

147 p=req.remote_port, 

148 dn=self.device_name, 

149 # di=self.device_id, 

150 v=self.tablet_version_str, 

151 u=self.username, 

152 o=self.operation, 

153 ) 

154 

155 def __repr__(self) -> str: 

156 return simple_repr( 

157 self, 

158 [ 

159 "session_id", 

160 "session_token", 

161 "device_name", 

162 "username", 

163 "operation", 

164 ], 

165 with_addr=True, 

166 ) 

167 

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

169 # Database objects, accessed on demand 

170 # ------------------------------------------------------------------------- 

171 

172 @property 

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

174 """ 

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

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

177 """ 

178 if self._device_obj is None: 

179 dbsession = self.req.dbsession 

180 self._device_obj = Device.get_device_by_name( 

181 dbsession, self.device_name 

182 ) 

183 return self._device_obj 

184 

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

186 # Permissions and similar 

187 # ------------------------------------------------------------------------- 

188 

189 @property 

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

191 """ 

192 Returns the integer device ID, if known. 

193 """ 

194 device = self.device 

195 if not device: 

196 return None 

197 return device.id 

198 

199 @property 

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

201 """ 

202 Returns the integer user ID, if known. 

203 """ 

204 user = self.req.user 

205 if not user: 

206 return None 

207 return user.id 

208 

209 def is_device_registered(self) -> bool: 

210 """ 

211 Is the device registered with our server? 

212 """ 

213 return self.device is not None 

214 

215 def reload_device(self): 

216 """ 

217 Re-fetch the device information from the database. 

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

219 """ 

220 self._device_obj = None 

221 

222 def ensure_device_registered(self) -> None: 

223 """ 

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

225 on failure. 

226 """ 

227 if not self.is_device_registered(): 

228 fail_user_error("Unregistered device") 

229 

230 def ensure_valid_device_and_user_for_uploading(self) -> None: 

231 """ 

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

233 Raises :exc:`UserErrorException` on failure. 

234 """ 

235 user = self.req.user 

236 if not user: 

237 fail_user_error(INVALID_USERNAME_PASSWORD) 

238 if user.upload_group_id is None: 

239 fail_user_error(NO_UPLOAD_GROUP_SET + user.username) 

240 if not user.may_upload: 

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

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

243 self.ensure_device_registered() 

244 

245 def ensure_valid_user_for_device_registration(self) -> None: 

246 """ 

247 Ensure the username/password combination is valid for device 

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

249 """ 

250 user = self.req.user 

251 if not user: 

252 fail_user_error(INVALID_USERNAME_PASSWORD) 

253 if user.upload_group_id is None: 

254 fail_user_error(NO_UPLOAD_GROUP_SET + user.username) 

255 if not user.may_register_devices: 

256 fail_user_error( 

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

258 ) 

259 

260 def set_session_id_token( 

261 self, session_id: int, session_token: str 

262 ) -> None: 

263 """ 

264 Sets the session ID and token. 

265 Typical situation: 

266 

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

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

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

270 translates that into a server-side session 

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

272 the values here. 

273 """ 

274 self.session_id = session_id 

275 self.session_token = session_token 

276 

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

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

279 # ------------------------------------------------------------------------- 

280 

281 @property 

282 def cope_with_deleted_patient_descriptors(self) -> bool: 

283 """ 

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

285 in the Patient table? 

286 """ 

287 return ( 

288 self.tablet_version_ver 

289 < FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE 

290 ) 

291 

292 @property 

293 def cope_with_old_idnums(self) -> bool: 

294 """ 

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

296 Patient table? 

297 """ 

298 return ( 

299 self.tablet_version_ver 

300 < FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE 

301 ) 

302 

303 @property 

304 def explicit_pkname_for_upload_table(self) -> bool: 

305 """ 

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

307 primary key when uploading tables? 

308 """ 

309 return ( 

310 self.tablet_version_ver 

311 >= FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE 

312 ) 

313 

314 @property 

315 def pkname_in_upload_table_neither_first_nor_explicit(self): 

316 """ 

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

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

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

320 explicitly name its PK either? 

321 

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

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

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

325 """ 

326 return ( 

327 self.tablet_version_ver >= FIRST_CPP_TABLET_VER 

328 and not self.explicit_pkname_for_upload_table 

329 )