Source code for camcops_server.cc_modules.cc_tabletsession

"""
camcops_server/cc_modules/cc_tabletsession.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

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

"""

import logging
from typing import Optional, Set, TYPE_CHECKING

from cardinal_pythonlib.httpconst import HttpMethod
from cardinal_pythonlib.logs import BraceStyleAdapter
from cardinal_pythonlib.reprfunc import simple_repr
from pyramid.exceptions import HTTPBadRequest

from camcops_server.cc_modules.cc_client_api_core import (
    fail_user_error,
    TabletParam,
)
from camcops_server.cc_modules.cc_constants import (
    DEVICE_NAME_FOR_SERVER,
    USER_NAME_FOR_SYSTEM,
)
from camcops_server.cc_modules.cc_device import Device
from camcops_server.cc_modules.cc_validators import (
    validate_anything,
    validate_device_name,
    validate_username,
)
from camcops_server.cc_modules.cc_version import (
    FIRST_CPP_TABLET_VER,
    FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE,
    FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE,
    FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE,
    make_version,
    MINIMUM_TABLET_VERSION,
)

if TYPE_CHECKING:
    from camcops_server.cc_modules.cc_request import CamcopsRequest

log = BraceStyleAdapter(logging.getLogger(__name__))

INVALID_USERNAME_PASSWORD = (
    "Invalid username/password (or user not authorized)"
)
NO_UPLOAD_GROUP_SET = "No upload group set for user "


[docs]class TabletSession(object): """ Represents session information for client devices. They use HTTPS POST calls and do not bother with cookies. """
[docs] def __init__(self, req: "CamcopsRequest") -> None: # Check the basics if req.method != HttpMethod.POST: raise HTTPBadRequest("Must use POST method") # ... this is for humans to view, so it has a pretty error # Read key things self.req = req self.operation = req.get_str_param(TabletParam.OPERATION) try: self.device_name = req.get_str_param( TabletParam.DEVICE, validator=validate_device_name ) self.username = req.get_str_param( TabletParam.USER, validator=validate_username ) except ValueError as e: fail_user_error(str(e)) self.password = req.get_str_param( TabletParam.PASSWORD, validator=validate_anything ) self.session_id = req.get_int_param(TabletParam.SESSION_ID) self.session_token = req.get_str_param( TabletParam.SESSION_TOKEN, validator=validate_anything ) self.tablet_version_str = req.get_str_param( TabletParam.CAMCOPS_VERSION, validator=validate_anything ) try: self.tablet_version_ver = make_version(self.tablet_version_str) except ValueError: fail_user_error( f"CamCOPS tablet version nonsensical: " f"{self.tablet_version_str!r}" ) # Basic security check: no pretending to be the server if self.device_name == DEVICE_NAME_FOR_SERVER: fail_user_error( f"Tablets cannot use the device name " f"{DEVICE_NAME_FOR_SERVER!r}" ) if self.username == USER_NAME_FOR_SYSTEM: fail_user_error( f"Tablets cannot use the username {USER_NAME_FOR_SYSTEM!r}" ) self._device_obj = None # type: Optional[Device] # Ensure table version is OK if self.tablet_version_ver < MINIMUM_TABLET_VERSION: fail_user_error( f"Tablet CamCOPS version too old: is " f"{self.tablet_version_str}, need {MINIMUM_TABLET_VERSION}" ) # Other version things are done via properties # Upload efficiency self._dirty_table_names = set() # type: Set[str] # Report log.info( "Incoming client API connection from IP={i}, port={p}, " "device_name={dn!r}, " # "device_id={di}, " "camcops_version={v}, " "username={u}, operation={o}", i=req.remote_addr, p=req.remote_port, dn=self.device_name, # di=self.device_id, v=self.tablet_version_str, u=self.username, o=self.operation, )
def __repr__(self) -> str: return simple_repr( self, [ "session_id", "session_token", "device_name", "username", "operation", ], with_addr=True, ) # ------------------------------------------------------------------------- # Database objects, accessed on demand # ------------------------------------------------------------------------- @property def device(self) -> Optional[Device]: """ Returns the :class:`camcops_server.cc_modules.cc_device.Device` associated with this request/session, or ``None``. """ if self._device_obj is None: dbsession = self.req.dbsession self._device_obj = Device.get_device_by_name( dbsession, self.device_name ) return self._device_obj # ------------------------------------------------------------------------- # Permissions and similar # ------------------------------------------------------------------------- @property def device_id(self) -> Optional[int]: """ Returns the integer device ID, if known. """ device = self.device if not device: return None return device.id @property def user_id(self) -> Optional[int]: """ Returns the integer user ID, if known. """ user = self.req.user if not user: return None return user.id
[docs] def is_device_registered(self) -> bool: """ Is the device registered with our server? """ return self.device is not None
[docs] def reload_device(self): """ Re-fetch the device information from the database. (Or, at least, do so when it's next required.) """ self._device_obj = None
[docs] def ensure_device_registered(self) -> None: """ Ensure the device is registered. Raises :exc:`UserErrorException` on failure. """ if not self.is_device_registered(): fail_user_error("Unregistered device")
[docs] def ensure_valid_device_and_user_for_uploading(self) -> None: """ Ensure the device/username/password combination is valid for uploading. Raises :exc:`UserErrorException` on failure. """ user = self.req.user if not user: fail_user_error(INVALID_USERNAME_PASSWORD) if user.upload_group_id is None: fail_user_error(NO_UPLOAD_GROUP_SET + user.username) if not user.may_upload: fail_user_error("User not authorized to upload to selected group") # Username/password combination found and is valid. Now check device. self.ensure_device_registered()
[docs] def ensure_valid_user_for_device_registration(self) -> None: """ Ensure the username/password combination is valid for device registration. Raises :exc:`UserErrorException` on failure. """ user = self.req.user if not user: fail_user_error(INVALID_USERNAME_PASSWORD) if user.upload_group_id is None: fail_user_error(NO_UPLOAD_GROUP_SET + user.username) if not user.may_register_devices: fail_user_error( "User not authorized to register devices for " "selected group" )
[docs] def set_session_id_token( self, session_id: int, session_token: str ) -> None: """ Sets the session ID and token. Typical situation: - :class:`camcops_server.cc_modules.cc_tabletsession.TabletSession` created; may or may not have an ID/token as part of the POST request - :class:`camcops_server.cc_modules.cc_request.CamcopsRequest` translates that into a server-side session - If one wasn't found and needs to be created, we write back the values here. """ self.session_id = session_id self.session_token = session_token
# ------------------------------------------------------------------------- # Version information (via property as not always needed) # ------------------------------------------------------------------------- @property def cope_with_deleted_patient_descriptors(self) -> bool: """ Must we cope with an old client that had ID descriptors in the Patient table? """ return ( self.tablet_version_ver < FIRST_TABLET_VER_WITHOUT_IDDESC_IN_PT_TABLE ) @property def cope_with_old_idnums(self) -> bool: """ Must we cope with an old client that had ID numbers embedded in the Patient table? """ return ( self.tablet_version_ver < FIRST_TABLET_VER_WITH_SEPARATE_IDNUM_TABLE ) @property def explicit_pkname_for_upload_table(self) -> bool: """ Is the client a nice new one that explicitly names the primary key when uploading tables? """ return ( self.tablet_version_ver >= FIRST_TABLET_VER_WITH_EXPLICIT_PKNAME_IN_UPLOAD_TABLE ) @property def pkname_in_upload_table_neither_first_nor_explicit(self): """ Is the client a particularly tricky old version that is a C++ client (generally a good thing, but meaning that the primary key might not be the first field in uploaded tables) but had a bug such that it did not explicitly name its PK either? See discussion of bug in ``NetworkManager::sendTableWhole`` (C++). For these versions, the only safe thing is to take ``"id"`` as the name of the (client-side) primary key. """ return ( self.tablet_version_ver >= FIRST_CPP_TABLET_VER and not self.explicit_pkname_for_upload_table )