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
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-15 15:55 +0100
1"""
2camcops_server/cc_modules/cc_tabletsession.py
4===============================================================================
6 Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
7 Created by Rudolf Cardinal (rnc1001@cam.ac.uk).
9 This file is part of CamCOPS.
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.
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.
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/>.
24===============================================================================
26**Session information for client devices (tablets etc.).**
28"""
30import logging
31from typing import Optional, Set, TYPE_CHECKING
33from cardinal_pythonlib.httpconst import HttpMethod
34from cardinal_pythonlib.logs import BraceStyleAdapter
35from cardinal_pythonlib.reprfunc import simple_repr
36from pyramid.exceptions import HTTPBadRequest
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)
61if TYPE_CHECKING:
62 from camcops_server.cc_modules.cc_request import CamcopsRequest
64log = BraceStyleAdapter(logging.getLogger(__name__))
66INVALID_USERNAME_PASSWORD = (
67 "Invalid username/password (or user not authorized)"
68)
69NO_UPLOAD_GROUP_SET = "No upload group set for user "
72class TabletSession(object):
73 """
74 Represents session information for client devices. They use HTTPS POST
75 calls and do not bother with cookies.
76 """
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
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 )
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 )
125 self._device_obj = None # type: Optional[Device]
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
135 # Upload efficiency
136 self._dirty_table_names = set() # type: Set[str]
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 )
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 )
166 # -------------------------------------------------------------------------
167 # Database objects, accessed on demand
168 # -------------------------------------------------------------------------
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
183 # -------------------------------------------------------------------------
184 # Permissions and similar
185 # -------------------------------------------------------------------------
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
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
207 def is_device_registered(self) -> bool:
208 """
209 Is the device registered with our server?
210 """
211 return self.device is not None
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
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")
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()
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 )
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:
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
275 # -------------------------------------------------------------------------
276 # Version information (via property as not always needed)
277 # -------------------------------------------------------------------------
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 )
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 )
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 )
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?
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 )