diff --git a/src/client/plantdb/client/api_endpoints.py b/src/client/plantdb/client/api_endpoints.py index 70e9c012..4624c31b 100644 --- a/src/client/plantdb/client/api_endpoints.py +++ b/src/client/plantdb/client/api_endpoints.py @@ -451,3 +451,22 @@ def scan_file(scan_id: str, file_path: str, **kwargs) -> str: """ scan_id = sanitize_name(scan_id) return f"/files/{scan_id}/{file_path.lstrip('/')}" + + + +@url_prefix +def create_user(**kwargs): + """Create the user registration URL. + + Returns + ------- + str + The URL path for user registration. + + Examples + -------- + >>> from plantdb.client import api_endpoints + >>> api_endpoints.create_user() + '/register' + """ + return f"/register" diff --git a/src/client/plantdb/client/plantdb_client.py b/src/client/plantdb/client/plantdb_client.py index b63e3763..ad9affbe 100644 --- a/src/client/plantdb/client/plantdb_client.py +++ b/src/client/plantdb/client/plantdb_client.py @@ -40,9 +40,9 @@ import requests from ada_url import join_url -from plantdb.client import api_endpoints from requests import RequestException +from plantdb.client import api_endpoints from plantdb.commons.log import get_logger @@ -148,7 +148,7 @@ def validate_session_token(self, token): """ url = join_url(self.base_url, api_endpoints.token_validation()) response = self.session.post(url, headers={"Authorization": f"Bearer {token}"}) - if response.status_code == 200: + if response.ok: self.jwt_token = token self.username = response.json().get('username') # Add the JWT to the header @@ -182,10 +182,12 @@ def login(self, username: str, password: str) -> bool: try: response = self.session.post(url, json=data) - if response.status_code == 200: + if response.ok: result = response.json() self.jwt_token = result.get('access_token') self.username = username + # Add the JWT to the header + self.session.headers.update({'Authorization': f'Bearer {self.jwt_token}'}) return True else: error_msg = response.json().get('message', 'Login failed') @@ -208,7 +210,7 @@ def logout(self) -> bool: url = join_url(self.base_url, api_endpoints.logout()) try: response = self.session.post(url) - if response.status_code == 200: + if response.ok: self.username = None # Remove the Authorization with the JWT from the header self.session.headers.pop('Authorization') @@ -217,12 +219,34 @@ def logout(self) -> bool: except Exception: return False + def create_user(self, username: str, password: str, fullname: str) -> bool: + """Create a new user in the PlantDB API.""" + url = join_url(self.base_url, api_endpoints.create_user()) + data = { + 'username': username, + 'password': password, + 'fullname': fullname, + } + + try: + response = self.session.post(url, json=data) + if response.ok: + return True + else: + error_msg = response.json().get('message', 'Unknown server error.') + self.logger.error(f"Failed to create user: {error_msg}") + return False + + except RequestException as e: + self.logger.error(f"User registration request failed: {e}") + return False + def refresh(self) -> bool: """Refresh the database.""" url = join_url(self.base_url, api_endpoints.refresh()) try: response = self.session.get(url) - if response.status_code == 200: + if response.ok: return True return False except Exception: @@ -233,7 +257,7 @@ def refresh_token(self) -> bool: url = join_url(self.base_url, api_endpoints.token_refresh()) try: response = self.session.post(url) - if response.status_code == 200: + if response.ok: result = response.json() self.jwt_token = result.get('access_token') self.username = result.get('username') @@ -739,7 +763,6 @@ def create_file(self, file_data, file_id, ext, scan_id, fileset_id, metadata=Non >>> metadata = {'description': 'Random RGB test image', 'author': 'John Doe'} >>> response = client.create_file(image_data, file_id='random_image', ext='png', scan_id='real_plant', fileset_id='images', metadata=metadata) >>> print(response) - """ import os import json @@ -748,8 +771,8 @@ def create_file(self, file_data, file_id, ext, scan_id, fileset_id, metadata=Non url = f"{self.base_url}/api/file" - ext = ext.lstrip('.').lower() # Remove leading dot if present - # Prepare form data + ext = ext.lstrip('.').lower() # Remove the leading dot if present + # Prepare data data = { 'file_id': file_id, 'ext': ext, @@ -775,10 +798,10 @@ def create_file(self, file_data, file_id, ext, scan_id, fileset_id, metadata=Non } response = self.session.post(url, files=files, data=data) else: - # Convert to Path object if it's a string + # Convert to a Path object if it's a string file_path = Path(file_data) if isinstance(file_data, str) else file_data - # Handle file from path + # Handle file from a path with open(file_path, 'rb') as file_handle: filename = os.path.basename(str(file_path)) files = { diff --git a/src/client/plantdb/client/rest_api.py b/src/client/plantdb/client/rest_api.py index 8464d77b..ba26e960 100644 --- a/src/client/plantdb/client/rest_api.py +++ b/src/client/plantdb/client/rest_api.py @@ -105,8 +105,7 @@ def origin_url(host, port=None, ssl=False, **kwargs) -> str: def plantdb_url(host, port=PLANTDB_PORT, prefix=PLANTDB_PREFIX, ssl=False) -> str: - """ - Generates the URL for the PlantDB REST API using the specified host and port. + """Generates the URL for the PlantDB REST API using the specified host and port. This function constructs a URL by combining the provided host, port, prefix, and SSL settings. @@ -121,7 +120,7 @@ def plantdb_url(host, port=PLANTDB_PORT, prefix=PLANTDB_PREFIX, ssl=False) -> st If provided, it will be added to the end of the URL. Defaults to ``None``. ssl : bool, optional - Flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + Flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -155,8 +154,7 @@ def plantdb_url(host, port=PLANTDB_PORT, prefix=PLANTDB_PREFIX, ssl=False) -> st def login_url(host, **kwargs): - """ - Generate the full URL for the PlantDB API login endpoint. + """Generate the full URL for the PlantDB API login endpoint. Parameters ---------- @@ -166,11 +164,11 @@ def login_url(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -194,8 +192,7 @@ def login_url(host, **kwargs): def logout_url(host, **kwargs): - """ - Generate the full URL for the PlantDB API logoutn endpoint. + """Generate the full URL for the PlantDB API logoutn endpoint. Parameters ---------- @@ -205,11 +202,11 @@ def logout_url(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -233,8 +230,7 @@ def logout_url(host, **kwargs): def register_url(host, **kwargs): - """ - Generate the full URL for the PlantDB API register endpoint. + """Generate the full URL for the PlantDB API register endpoint. Parameters ---------- @@ -244,11 +240,11 @@ def register_url(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -283,11 +279,11 @@ def scans_url(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -319,11 +315,11 @@ def scan_url(host, scan_id, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -361,11 +357,11 @@ def scan_preview_image_url(host, scan_id, size="thumb", **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns @@ -428,11 +424,11 @@ def scan_image_url(host, scan_id, fileset_id, file_id, size='orig', **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns @@ -467,11 +463,11 @@ def refresh_url(host, scan_id=None, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns @@ -504,11 +500,11 @@ def archive_url(host, scan_id, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -546,11 +542,11 @@ def scan_file_url(host, scan_id, file_path, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -576,11 +572,11 @@ def scan_config_url(host, scan_id, cfg_fname='scan.toml', **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -611,11 +607,11 @@ def scan_reconstruction_url(host, scan_id, cfg_fname='pipeline.toml', **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -654,11 +650,11 @@ def list_task_images_uri(host, scan_id, task_name='images', size='orig', **kwarg Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -690,8 +686,7 @@ def list_task_images_uri(host, scan_id, task_name='images', size='orig', **kwarg def make_api_request(url, method="GET", params=None, json_data=None, allow_redirects=True, **kwargs): - """ - Function to make an API request with various HTTP methods and options. + """Function to make an API request with various HTTP methods and options. Parameters ---------- @@ -723,7 +718,7 @@ def make_api_request(url, method="GET", params=None, json_data=None, Returns ------- - response : requests.Response + requests.Response The response object from the API request. Raises @@ -787,8 +782,7 @@ def make_api_request(url, method="GET", params=None, json_data=None, def request_login(host, username, password, **kwargs): - """ - Send a login request to the authentication service. + """Send a login request to the authentication service. This helper function constructs a POST request to the login endpoint and forwards any additional keyword arguments to the URL generator @@ -801,23 +795,22 @@ def request_login(host, username, password, **kwargs): username : str The user identifier for authentication. password : str - The user's secret password. It is sent in the request body and - should be handled securely (e.g., over HTTPS). + The user's secret password. It is sent in the request body and + should be handled securely (_e.g._, over HTTPS). Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- - dict - The parsed JSON response from the authentication API. - In successful cases this will include tokens or user metadata. + requests.Response + The response from the API. Notes ----- @@ -831,7 +824,7 @@ def request_login(host, username, password, **kwargs): >>> # Start a test PlantDB REST API server first, in a terminal: >>> # $ fsdb_rest_api --test >>> from plantdb.client.rest_api import request_login - >>> login_data = request_login('localhost', 'admin', 'admin', port=5000) + >>> login_data = request_login('localhost', 'admin', 'admin', port=5000).json() >>> print(login_data) """ url = login_url(host, **kwargs) @@ -839,12 +832,11 @@ def request_login(host, username, password, **kwargs): 'username': username, 'password': password } - return make_api_request(url, method="POST", json_data=data).json() + return make_api_request(url, method="POST", json_data=data) def request_check_username(host, username, **kwargs): - """ - Send a username availability request to the authentication service. + """Send a username availability request to the authentication service. This helper function constructs a GET request to the login endpoint and forwards any additional keyword arguments to the URL generator @@ -860,17 +852,16 @@ def request_check_username(host, username, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- - dict - The parsed JSON response from the authentication API. - In successful cases this will include tokens or user metadata. + requests.Response + The response from the API. Notes ----- @@ -885,15 +876,15 @@ def request_check_username(host, username, **kwargs): >>> # $ fsdb_rest_api --test >>> from plantdb.client.rest_api import request_check_username >>> username_exists = request_check_username('localhost', 'admin', port=5000) - >>> print(username_exists) + >>> print(username_exists.json()['exists']) + True """ url = login_url(host, **kwargs) - return make_api_request(url, method="GET", params={'username': username}).json() + return make_api_request(url, method="GET", params={'username': username}) def request_logout(host, **kwargs): - """ - Send a logout request to the authentication service. + """Send a logout request to the authentication service. This helper function constructs a POST request to the logout endpoint and forwards any additional keyword arguments to the URL generator @@ -907,18 +898,18 @@ def request_logout(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. session_token : str The PlantDB REST API session token of the user. Returns ------- - bool - Indicate if the logout was successful. + requests.Response + The response from the API. Notes ----- @@ -931,17 +922,17 @@ def request_logout(host, **kwargs): >>> # $ fsdb_rest_api --test >>> from plantdb.client.rest_api import request_login >>> from plantdb.client.rest_api import request_logout - >>> login_data = request_login('localhost', 'admin', 'admin', port=5000) + >>> login_data = request_login('localhost', 'admin', 'admin', port=5000).json() >>> logout = request_logout('localhost', port=5000, session_token=login_data['access_token']) - >>> print(logout) + >>> print(logout.ok) + True """ url = logout_url(host, **kwargs) - return make_api_request(url, method="POST", session_token=kwargs.get('session_token', None)).ok + return make_api_request(url, method="POST", session_token=kwargs.get('session_token', None)) def request_new_user(host, username, password, fullname, **kwargs): - """ - Send a registration request to the authentication service. + """Send a registration request to the authentication service. This helper function constructs a POST request to the register endpoint and forwards any additional keyword arguments to the URL generator @@ -962,18 +953,18 @@ def request_new_user(host, username, password, fullname, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. session_token : str The PlantDB REST API session token of the user. Returns ------- - bool - Indicate if the logout was successful. + requests.Response + The response from the API. Notes ----- @@ -987,15 +978,15 @@ def request_new_user(host, username, password, fullname, **kwargs): >>> from plantdb.client.rest_api import request_login >>> from plantdb.client.rest_api import request_logout >>> from plantdb.client.rest_api import request_new_user - >>> login_data = request_login('localhost', 'admin', 'admin', port=5000) + >>> login_data = request_login('localhost', 'admin', 'admin', port=5000).json() >>> user_added = request_new_user('localhost', 'testuser', 'fake_password', 'Test User', port=5000, session_token=login_data['access_token']) - >>> print(user_added) + >>> print(user_added.ok) True >>> logout = request_logout('localhost', port=5000, session_token=login_data['access_token']) """ url = register_url(host, **kwargs) data = {'username': username, 'fullname': fullname, 'password': password} - return make_api_request(url, method="POST", json_data=data, session_token=kwargs.get('session_token', None)).ok + return make_api_request(url, method="POST", json_data=data, session_token=kwargs.get('session_token', None)) def request_scan_names_list(host, **kwargs): @@ -1009,30 +1000,29 @@ def request_scan_names_list(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. session_token : str The PlantDB REST API session token of the user. Returns ------- - list - The list of scan dataset names served by the PlantDB REST API. + requests.Response + The response from the API. The list of scan dataset names should be in the JSON dictionary. Examples -------- >>> # Start a test PlantDB REST API server first, in a terminal: >>> # $ fsdb_rest_api --test >>> from plantdb.client.rest_api import request_scan_names_list - >>> print(request_scan_names_list('localhost', port=5000)) + >>> print(request_scan_names_list('localhost', port=5000).json()) ['arabidopsis000', 'real_plant', 'real_plant_analyzed', 'virtual_plant', 'virtual_plant_analyzed'] """ url = scans_url(host, **kwargs) - response = make_api_request(url=url, method="GET", session_token=kwargs.get('session_token', None)) - return sorted(response.json()) + return make_api_request(url=url, method="GET", session_token=kwargs.get('session_token', None)) def request_scans_info(host, **kwargs): @@ -1041,11 +1031,11 @@ def request_scans_info(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. session_token : str The PlantDB REST API session token of the user. @@ -1059,13 +1049,13 @@ def request_scans_info(host, **kwargs): >>> # Start a test PlantDB REST API server first, in a terminal: >>> # $ fsdb_rest_api --test >>> from plantdb.client.rest_api import request_scans_info - >>> scans_info = request_scans_info('localhost', port=5000) - >>> + >>> from plantdb.client.rest_api import request_login + >>> login_data = request_login('localhost', 'admin', 'admin', port=5000).json() + >>> scans_info = request_scans_info('localhost', port=5000, session_token=login_data['access_token']).json() """ - scan_list = request_scan_names_list(host, **kwargs) + scan_list = request_scan_names_list(host, **kwargs).json() return [make_api_request(url=scan_url(host, scan, **kwargs), session_token=kwargs.get('session_token', None)).json() - for - scan in scan_list] + for scan in scan_list] def request_scan_data(host, scan_id, **kwargs): @@ -1081,11 +1071,13 @@ def request_scan_data(host, scan_id, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1139,11 +1131,13 @@ def request_scan_image(host, scan_id, fileset_id, file_id, size='orig', **kwargs Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1181,11 +1175,13 @@ def request_scan_tasks_fileset(host, scan_id, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1233,9 +1229,11 @@ def request_refresh(host, scan_id=None, **kwargs): The prefix to be prepended to the URL. If provided, it will be stripped of leading and trailing slashes. Defaults to ``None``. ssl : bool, optional - Flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + Flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. timeout : int, optional A timeout, in seconds, to succeed the refresh request. Defaults to ``5``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1286,9 +1284,11 @@ def request_archive_download(host, scan_id, out_dir=None, **kwargs): The prefix to be prepended to the URL. If provided, it will be stripped of leading and trailing slashes. Defaults to ``None``. ssl : bool, optional - Flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + Flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. timeout : int, optional A timeout, in seconds, to succeed the download request. Defaults to ``10``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1360,7 +1360,7 @@ def request_archive_upload(host, scan_id, path, **kwargs): The prefix to be prepended to the URL. If provided, it will be stripped of leading and trailing slashes. Defaults to ``None``. ssl : bool, optional - Flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + Flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. timeout : int, optional A timeout, in seconds, to succeed the upload request. Defaults to ``120``. session_token : str @@ -1449,11 +1449,11 @@ def request_dataset_file_upload(host, scan_id, file_path, chunk_size=0, **kwargs Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. session_token : str The PlantDB REST API session token of the user. @@ -1530,11 +1530,11 @@ def parse_scans_info(host, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -1579,11 +1579,11 @@ def parse_task_images(host, scan_id, task_name='images', size='orig', **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -1749,8 +1749,7 @@ def parse_requests_json(data): def parse_task_requests_data(task, data, extension=None): - """ - Parse raw request data for a specified task. + """Parse raw request data for a specified task. The function selects an appropriate parser based on the provided *extension* (if any) or the *task* name, then applies that parser @@ -1841,11 +1840,11 @@ def get_task_data(host, scan_id, task, filename=None, api_data=None, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -1915,11 +1914,11 @@ def get_toml_file(host, scan_id, file_path, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -1952,11 +1951,11 @@ def get_scan_config(host, scan_id, cfg_fname='scan.toml', **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -1989,11 +1988,11 @@ def get_reconstruction_config(host, scan_id, cfg_fname='pipeline.toml', **kwargs Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- @@ -2026,11 +2025,11 @@ def get_angles_and_internodes_data(host, scan_id, **kwargs): Other Parameters ---------------- port : int - The PlantDB API port number, defaults to `None`. + The PlantDB API port number, defaults to ``None``. prefix : str - A path prefix for the PlantDB API, defaults to `None`. + A path prefix for the PlantDB API, defaults to ``None``. ssl : bool - A boolean flag indicating whether to use HTTPS (True) or HTTP (False). Defaults to ``False``. + A boolean flag indicating whether to use HTTPS (``True``) or HTTP (``False``). Defaults to ``False``. Returns ------- diff --git a/src/commons/plantdb/commons/auth/manager.py b/src/commons/plantdb/commons/auth/manager.py index 26c79b9a..3354b9db 100644 --- a/src/commons/plantdb/commons/auth/manager.py +++ b/src/commons/plantdb/commons/auth/manager.py @@ -20,7 +20,7 @@ - **User activation/deactivation** and role assignment. - **Group management**: create, delete, add/remove users, and query memberships. - **Atomic JSON persistence** to avoid data corruption. -- **Minimal dependencies** – relies only on standard library plus `argon2`. +- **Minimal dependencies** – relies only on the standard library plus `argon2`. Usage Examples -------------- @@ -59,9 +59,8 @@ ph = PasswordHasher() -class UserManager(): - """ - UserManager class for managing user data. +class UserManager(object): + """UserManager class for managing user data. The UserManager class provides methods to create, load, save, and manage users. It uses a JSON file to persist user data. @@ -124,8 +123,7 @@ def __init__(self, users_file: str | Path = 'users.json', max_login_attempts=3, int) else lockout_duration def _load_users(self) -> None: - """ - Loads the user database from a JSON file and populates the internal dictionary. + """Loads the user database from a JSON file and populates the internal dictionary. This method checks if the users database file exists. If it does not exist, it initializes an empty dictionary, creates the file, and logs a warning message. If the file does exist, it loads the user data from the file, @@ -154,11 +152,11 @@ def _load_users(self) -> None: return def _save_users(self) -> None: - """ - Save the user data to a JSON file. + """Save the user data to a JSON file. This method serializes the user objects into dictionaries, writes them to a temporary file, - and then renames the temporary file to replace the original file. This ensures atomicity of the operation. + and then renames the temporary file to replace the original file. + This ensures the atomicity of the operation. Raises ------ @@ -194,8 +192,7 @@ def _save_users(self) -> None: @staticmethod def _hash_password(password: str) -> str: - """ - Hash a plaintext password using argon2. + """Hash a plaintext password using argon2. Parameters ---------- @@ -214,8 +211,7 @@ def _hash_password(password: str) -> str: return ph.hash(password) def exists(self, username: str) -> bool: - """ - Check if a user exists. + """Check if a user exists. Parameters ---------- @@ -230,7 +226,7 @@ def exists(self, username: str) -> bool: Notes ----- This function is case-sensitive. For case-insensitive checks, convert both - the username and the keys to lower or upper case before comparison. + the username and the keys to the lower or upper case before comparison. """ return username in self.users @@ -261,7 +257,7 @@ def create(self, username: str, fullname: str, password: str, roles: Union[Role, username = username.lower() # Convert the username to lowercase to maintain uniformity. timestamp = datetime.now() # Get the current timestamp for tracking user creation time. - # Verify if the login is available + # Verify if the username is available try: assert not self.exists(username) except AssertionError: @@ -281,16 +277,15 @@ def create(self, username: str, fullname: str, password: str, roles: Union[Role, created_at=timestamp, password_last_change=timestamp, ) - # Save all user data (including the newly created user) to 'users.json' file. + # Save all user data (including the newly created user) to the 'users.json' file. self._save_users() self.logger.debug(f"Created user '{username}' with fullname '{fullname}'.") if not username == self.GUEST_USERNAME: - self.logger.info(f"Welcome {fullname}, please login...'") + self.logger.info(f"Welcome {fullname}, please log in...'") return def _ensure_guest_user(self) -> None: - """ - Ensure that a guest user exists in the system. + """Ensure that a guest user exists in the system. If the guest user does not already exist, it creates one with a default username and password. This method is intended to be used internally by @@ -314,12 +309,12 @@ def _ensure_guest_user(self) -> None: return def _ensure_admin_user(self) -> None: - """ - Ensure that an admin user exists in the system. + """Ensure that an admin user exists in the system. If the admin user does not already exist, it creates one with a default username. The password is a string of 25 hex digits, printed-out to the terminal. - This method is intended to be used internally the class to ensure the presence of an admin account for various operational purposes. + This method is intended to be used internally in the class to ensure the presence + of an admin account for various operational purposes. Notes ----- @@ -345,8 +340,7 @@ def _ensure_admin_user(self) -> None: return def get_user(self, username: str) -> Union[User, None]: - """ - Retrieve a User object based on the provided username. + """Retrieve a User object based on the provided username. Parameters ---------- @@ -365,8 +359,7 @@ def get_user(self, username: str) -> Union[User, None]: return self.users[username] def is_locked_out(self, username) -> bool: - """ - Check if an account is locked. + """Check if an account is locked. Parameters ---------- @@ -381,12 +374,11 @@ def is_locked_out(self, username) -> bool: user = self.get_user(username) is_locked = user._is_locked_out() if is_locked: - self.logger.info(f"Account {user} is locked, try logging in after {user.locked_until}.") + self.logger.info(f"Account {username} is locked, try logging in after {user.locked_until}.") return is_locked def is_active(self, username) -> bool: - """ - Check whether a user account is active. + """Check whether a user account is active. Parameters ---------- @@ -404,8 +396,7 @@ def is_active(self, username) -> bool: return user.is_active def validate_user_password(self, username: str, password: str) -> bool: - """ - Validate a user's password. + """Validate a user's password. This function checks if the provided plaintext password matches the hashed password stored for the given username. @@ -420,12 +411,11 @@ def validate_user_password(self, username: str, password: str) -> bool: ------- bool ``True`` if the password is valid, ``False`` otherwise. - """ - hash = self.get_user(username).password_hash + pw_hash = self.get_user(username).password_hash try: # Verify password, raises exception if wrong. - ph.verify(hash, password) + ph.verify(pw_hash, password) except Exception as e: self.logger.error(f"Failed to verify password for {username}: {e}") return False @@ -433,8 +423,7 @@ def validate_user_password(self, username: str, password: str) -> bool: return True def _record_failed_attempt(self, username: str, max_failed_attempts: int, lockout_duration: timedelta) -> None: - """ - Record a failed login attempt for a user and apply lockout if necessary. + """Record a failed login attempt for a user and apply lockout if necessary. This method logs a failed login attempt for the specified user. If the number of failed attempts reaches or exceeds `max_failed_attempts`, the user is locked out @@ -448,21 +437,19 @@ def _record_failed_attempt(self, username: str, max_failed_attempts: int, lockou max_failed_attempts : int The maximum number of allowed failed login attempts before lockout. lockout_duration : timedelta - The duration for which the user will be locked out after exceeding - `max_failed_attempts`. + The duration for which the user will be locked out after exceeding `max_failed_attempts`. """ user = self.get_user(username) user._record_failed_attempt() if user.failed_attempts >= max_failed_attempts: self._lock_user(username, lockout_duration) - # Save the updated user data to file + # Save the updated user data to a file self._save_users() self.logger.warning(f"Failed login attempt (n={user.failed_attempts}) for user: {username}") return def _lock_user(self, username: str, lockout_duration: timedelta) -> None: - """ - Locks a user account for a specified duration. + """Locks a user account for a specified duration. This function sets the 'locked_until' attribute of the given user to the current time plus the lockout duration. It also logs an informational message about the action taken. @@ -487,12 +474,11 @@ def _lock_user(self, username: str, lockout_duration: timedelta) -> None: # Admin methods def unlock_user(self, user: User) -> None: - """ - Unlock a specified user. + """Unlock a specified user. Parameters ---------- - username : plantdb.commons.auth.User + user : plantdb.commons.auth.User The user to unlock. """ user.locked_until = None @@ -500,8 +486,7 @@ def unlock_user(self, user: User) -> None: return def activate(self, user: User) -> None: - """ - Activates a user. + """Activates a user. Parameters ---------- @@ -520,8 +505,7 @@ def activate(self, user: User) -> None: return def deactivate(self, user: User) -> None: - """ - Deactivates a user. + """Deactivates a user. Parameters ---------- @@ -577,8 +561,7 @@ def validate(self, username: str, password: str) -> bool: return False def update_password(self, username: str, password: str, new_password: str) -> None: - """ - Update the password of an existing user. + """Update the password of an existing user. Parameters ---------- @@ -587,7 +570,7 @@ def update_password(self, username: str, password: str, new_password: str) -> No password : str The current password of the user to verify their identity. new_password : str - The new password to set for the user. If None, no change will occur. + The new password to set for the user. If ``None``, no change will occur. """ # Verify if the login exists try: @@ -611,27 +594,25 @@ def update_password(self, username: str, password: str, new_password: str) -> No class GroupManager: - """ - Manages groups for the RBAC system. + """Manages groups for the RBAC system. This class handles the creation, modification, and persistence of user groups. Groups are stored in a JSON file and loaded/saved as needed. Attributes ---------- - groups_file : str + groups_file : Union[str, Path] Path to the JSON file where groups are stored. groups : Dict[str, plantdb.commons.auth.models.Group] Dictionary mapping group names to Group objects. """ - def __init__(self, groups_file: str = "groups.json"): - """ - Initialize the GroupManager. + def __init__(self, groups_file: Union[str, Path] = "groups.json"): + """Initialize the GroupManager. Parameters ---------- - groups_file : str, optional + groups_file : Union[str, Path] Path to the JSON file for storing groups. Defaults to "groups.json". """ self.groups_file = Path(groups_file) @@ -691,8 +672,7 @@ def _save_groups(self) -> None: def create_group(self, name: str, creator: str, users: Optional[Set[str]] = None, description: Optional[str] = None) -> Group: - """ - Create a new group. + """Create a new group. Parameters ---------- @@ -700,9 +680,9 @@ def create_group(self, name: str, creator: str, users: Optional[Set[str]] = None The unique name for the group. creator : str The username of the user creating the group. - users : Optional[Set[str]], optional + users : Optional[Set[str]] Initial set of users to add to the group. Creator is automatically added. - description : Optional[str], optional + description : Optional[str] Optional description of the group. Returns @@ -718,7 +698,7 @@ def create_group(self, name: str, creator: str, users: Optional[Set[str]] = None if name in self.groups: raise ValueError(f"Group '{name}' already exists") - # Initialize users set and ensure creator is included + # Initialize users set and ensure the creator is included if users is None: users = set() users.add(creator) @@ -736,8 +716,7 @@ def create_group(self, name: str, creator: str, users: Optional[Set[str]] = None return group def get_group(self, name: str) -> Optional[Group]: - """ - Get a group by name. + """Get a group by name. Parameters ---------- @@ -747,13 +726,12 @@ def get_group(self, name: str) -> Optional[Group]: Returns ------- Optional[Group] - The group object if it exists, None otherwise. + The group object if it exists, ``None`` otherwise. """ return self.groups.get(name) def delete_group(self, name: str) -> bool: - """ - Delete a group. + """Delete a group. Parameters ---------- @@ -763,7 +741,7 @@ def delete_group(self, name: str) -> bool: Returns ------- bool - True if the group was deleted, False if it didn't exist. + ``True`` if the group was deleted, ``False`` if it didn't exist. """ if name not in self.groups: return False @@ -773,8 +751,7 @@ def delete_group(self, name: str) -> bool: return True def add_user_to_group(self, group_name: str, username: str) -> bool: - """ - Add a user to a group. + """Add a user to a group. Parameters ---------- @@ -786,7 +763,7 @@ def add_user_to_group(self, group_name: str, username: str) -> bool: Returns ------- bool - True if the user was added, False if the group doesn't exist or user was already in group. + ``True`` if the user was added, ``False`` if the group doesn't exist or the user was already in the group. """ group = self.get_group(group_name) if not group: @@ -798,8 +775,7 @@ def add_user_to_group(self, group_name: str, username: str) -> bool: return result def remove_user_from_group(self, group_name: str, username: str) -> bool: - """ - Remove a user from a group. + """Remove a user from a group. Parameters ---------- @@ -811,7 +787,7 @@ def remove_user_from_group(self, group_name: str, username: str) -> bool: Returns ------- bool - True if the user was removed, False if the group doesn't exist or user wasn't in group. + ``True`` if the user was removed, ``False`` if the group doesn't exist or the user wasn't in the group. """ group = self.get_group(group_name) if not group: @@ -823,8 +799,7 @@ def remove_user_from_group(self, group_name: str, username: str) -> bool: return result def get_user_groups(self, username: str) -> List[Group]: - """ - Get all groups that a user belongs to. + """Get all groups that a user belongs to. Parameters ---------- @@ -843,8 +818,7 @@ def get_user_groups(self, username: str) -> List[Group]: return user_groups def list_groups(self) -> List[Group]: - """ - Get a list of all groups. + """Get a list of all groups. Returns ------- @@ -854,8 +828,7 @@ def list_groups(self) -> List[Group]: return list(self.groups.values()) def group_exists(self, name: str) -> bool: - """ - Check if a group exists. + """Check if a group exists. Parameters ---------- diff --git a/src/commons/plantdb/commons/auth/models.py b/src/commons/plantdb/commons/auth/models.py index fb229345..247ca2f7 100644 --- a/src/commons/plantdb/commons/auth/models.py +++ b/src/commons/plantdb/commons/auth/models.py @@ -119,6 +119,51 @@ class Role(Enum): CONTRIBUTOR = "contributor" ADMIN = "admin" + @property + def rank(self) -> int: + """Get the hierarchical rank of the role. Higher is more powerful. + + Returns + ------- + int + The rank of the role. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> guest = Role.READER + >>> guest.rank + 1 + """ + ranks = { + Role.READER: 1, + Role.CONTRIBUTOR: 2, + Role.ADMIN: 3, + } + return ranks[self] + + def can_assign(self, target_role: 'Role') -> bool: + """Check if this role has the authority to assign the target_role. + + A user can only assign roles that are less than or equal to their own. + + Returns + ------- + bool + ``True`` if this role has the authority to assign the target_role; ``False`` otherwise. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> guest = Role.READER + >>> guest.can_assign(Role.CONTRIBUTOR) + False + >>> user = Role.CONTRIBUTOR + >>> user.can_assign(Role.CONTRIBUTOR) + True + """ + return self.rank >= target_role.rank + @property def permissions(self) -> Set[Permission]: """Get the set of permissions associated with this role. @@ -127,6 +172,18 @@ def permissions(self) -> Set[Permission]: ------- Set[Permission] A set containing all permissions granted to this role. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> guest = Role.READER + >>> guest.permissions + {} + >>> user = Role.CONTRIBUTOR + >>> user.permissions + {, + , + } """ role_permissions = { Role.READER: { @@ -151,13 +208,12 @@ def permissions(self) -> Set[Permission]: @dataclass class User: - """ - Summarize the purpose of the User class. + """Represents a user entity in the application. - The User class represents a user entity in an application. It contains attributes related to user authentication, roles, - permissions, and activity timestamps. The class is designed to encapsulate user data and provide methods for user management. - Users can have multiple roles and permissions, which are stored as sets. The class also tracks the creation time and last login - time of a user. + It contains attributes related to user authentication, roles, permissions, and activity timestamps. + The class is designed to encapsulate user data and provide methods for user management. + Users can have multiple roles and permissions, which are stored as sets. + The class also tracks the creation time and last login time of a user. Attributes ---------- @@ -169,20 +225,24 @@ class User: A set containing roles assigned to the user. created_at : datetime The timestamp when the user account was created. - permissions : Set[Permission], optional + permissions : Optional[Set[Permission]] A set containing specific permissions granted to the user. - last_login : Optional[datetime], optional - The timestamp of the last login. If not provided, defaults to None. - is_active : bool, optional - Indicates if the user account is active. Defaults to True. - failed_attempts : int, optional - Number of failed login attempts for the user. Defaults to 0. - locked_until : Optional[datetime], optional - Timestamp until which the user is locked out due to multiple failed attempts. Defaults to None. + last_login : Optional[datetime] + The timestamp of the last login. If not provided, defaults to ``None``. + is_active : bool + Indicates if the user account is active. Defaults to ``True``. + failed_attempts : int + Number of failed login attempts for the user. Defaults to ``0``. + last_failed_attempt: Optional[datetime] + The timestamp of the last failed attempts. Defaults to ``None``. + locked_until : Optional[datetime] + Timestamp until which the user is locked out due to multiple failed attempts. Defaults to ``None``. + password_last_change : Optional[datetime] + The timestamp of the last password change. Defaults to ``None``. Notes ----- - Ensure that sensitive data like `password_hash` is handled securely and not exposed in logs or error messages. + Ensure that sensitive data like ``password_hash`` is handled securely and not exposed in logs or error messages. Examples -------- @@ -218,10 +278,9 @@ class User: password_last_change: Optional[datetime] = None def __eq__(self, other): - """ - Compare two User objects for equality. + """Compare two ``User`` objects for equality. - Two User objects are considered equal if all their attributes have the same values. + Two ``User`` objects are considered equal if all their attributes have the same values. Parameters ---------- @@ -239,13 +298,12 @@ def __eq__(self, other): return all(self.__dict__[attr] == other.__dict__[attr] for attr in self.__dict__) def to_dict(self) -> dict: - """ - Convert User object to dictionary for JSON serialization. + """Convert a ``User`` object to a dictionary for JSON serialization. Returns ------- dict - Dictionary representation of the user object. + Dictionary representation of the ``User`` object. """ return { 'username': self.username, @@ -264,8 +322,7 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> 'User': - """ - Create User object from dictionary (JSON deserialization). + """Create a ``User`` object from a dictionary (JSON deserialization). Parameters ---------- @@ -275,7 +332,7 @@ def from_dict(cls, data: dict) -> 'User': Returns ------- User - User object created from the dictionary data. + The ``User`` object created from the dictionary data. Raises ------ @@ -333,22 +390,18 @@ def _datetime_convert(data): raise ValueError(f"Invalid data format in user data: {e}") def to_json(self) -> str: - """ - Convert User object to JSON string. + """Convert ``User`` object to JSON string. Returns ------- str - JSON string representation of the user object. - - + JSON string representation of the ``User`` object. """ return json.dumps(self.to_dict(), indent=2) @classmethod def from_json(cls, json_str: str) -> 'User': - """ - Create User object from JSON string. + """Create ``User`` object from JSON string. Parameters ---------- @@ -358,7 +411,7 @@ def from_json(cls, json_str: str) -> 'User': Returns ------- User - User object created from the JSON data. + The ``User`` object created from the JSON data. Raises ------ @@ -413,11 +466,10 @@ def _record_failed_attempt(self) -> None: @dataclass class Group: - """ - Represents a group of users for sharing scan datasets. + """Represents a group of users for sharing scan datasets. Groups allow multiple users to collaborate on scan datasets. When a scan is shared - with a group, all members of that group get CONTRIBUTOR role for that specific dataset. + with a group, all members of that group get the `` CONTRIBUTOR `` role for that specific dataset. Attributes ---------- @@ -425,7 +477,7 @@ class Group: The unique name of the group. users : Set[str] A set of usernames that belong to this group. - description : Optional[str], optional + description : Optional[str] An optional description of the group's purpose. created_at : datetime The timestamp when the group was created. @@ -455,8 +507,7 @@ class Group: description: Optional[str] = None def add_user(self, username: str) -> bool: - """ - Add a user to the group. + """Add a user to the group. Parameters ---------- @@ -474,8 +525,7 @@ def add_user(self, username: str) -> bool: return True def remove_user(self, username: str) -> bool: - """ - Remove a user from the group. + """Remove a user from the group. Parameters ---------- @@ -493,8 +543,7 @@ def remove_user(self, username: str) -> bool: return True def has_user(self, username: str) -> bool: - """ - Check if a user is a member of the group. + """Check if a user is a member of the group. Parameters ---------- diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index dea1c32d..5b631b61 100644 --- a/src/commons/plantdb/commons/auth/rbac.py +++ b/src/commons/plantdb/commons/auth/rbac.py @@ -54,8 +54,7 @@ def requires_permission(required_permissions: Union[Permission, List[Permission]]): - """ - Decorator to check if the specified user has the required permission(s). + """Decorator to check if the specified user has the required permission(s). Parameters ---------- @@ -121,8 +120,7 @@ class RBACManager: def __init__(self, users_file: str = 'users.json', groups_file: str = "groups.json", max_login_attempts=3, lockout_duration=900): - """ - Initialize the RBACManager. + """Initialize the RBACManager. Parameters ---------- @@ -140,8 +138,7 @@ def __init__(self, users_file: str = 'users.json', groups_file: str = "groups.js self.groups = GroupManager(groups_file) def get_user_permissions(self, user: User) -> Set[Permission]: - """ - Get the set of permissions a user has based on their assigned roles. + """Get the set of permissions a user has based on their assigned roles. This function returns a set containing all permissions directly assigned to the user as well as those inherited from any roles they are part of. The @@ -159,6 +156,17 @@ def get_user_permissions(self, user: User) -> Set[Permission]: A set containing all permissions that the specified user has access to, including those inherited from roles. + Notes + ----- + The result depends on the `role_permissions` attribute of the class instance. + Ensure that this dictionary is properly initialized before calling this method. + + See Also + -------- + User : Represents a user with permissions and roles. + Permission : Represents a permission that can be assigned to users or roles. + Role : Represents a role with specific permissions. + Examples -------- >>> from plantdb.commons.auth.models import Permission @@ -171,18 +179,6 @@ def get_user_permissions(self, user: User) -> Set[Permission]: >>> role_permissions = {Role('admin'): {permission_b}} >>> user.get_user_permissions(user) # doctest: +SKIP {<__main__.Permission object at 0x...>} - - Notes - ----- - The result depends on the `role_permissions` attribute of the class instance. - Ensure that this dictionary is properly initialized before calling this method. - - See Also - -------- - User : Represents a user with permissions and roles. - Permission : Represents a permission that can be assigned to users or roles. - Role : Represents a role with specific permissions. - """ permissions = set(user.permissions) if user.permissions else set() for role in user.roles: @@ -190,8 +186,7 @@ def get_user_permissions(self, user: User) -> Set[Permission]: return permissions def has_permission(self, user: User, permission: Permission) -> bool: - """ - Check if a user has a specific permission. + """Check if a user has a specific permission. This function determines whether the given user has the specified permission, including administrative privileges. @@ -199,7 +194,7 @@ def has_permission(self, user: User, permission: Permission) -> bool: Parameters ---------- user : User - The user object to check for permissions. + The User object to check for permissions. permission : Permission The permission level or type to verify against the user's permissions. @@ -219,9 +214,25 @@ def has_permission(self, user: User, permission: Permission) -> bool: user_permissions = self.get_user_permissions(user) return permission in user_permissions - def is_guest_user(self, user: User) -> bool: + def has_role(self, user: User, role: Role) -> bool: + """Check if a user has a specific role. + + Parameters + ---------- + user : User + The user object to check for roles. + role : Role + The role to verify against the user's roles. + + Returns + ------- + bool + `True` if the user has the given role, `False` otherwise. """ - Check if the given user is the guest user. + return role in user.roles + + def is_guest_user(self, user: User) -> bool: + """Check if the given user is the guest user. Parameters ---------- @@ -236,11 +247,17 @@ def is_guest_user(self, user: User) -> bool: return user.username == self.users.GUEST_USERNAME def get_guest_user(self) -> User: - return self.users.get_user(self.users.GUEST_USERNAME) + """Retrieves the guest user from the user repository. - def can_manage_groups(self, user: User) -> bool: + Returns + ------- + User + The User object corresponding to the guest username. """ - Check if a user can manage groups (create/delete groups). + return self.users.get_user(self.users.GUEST_USERNAME) + + def can_create_user(self, user: User) -> bool: + """Check if a user can create new users. Parameters ---------- @@ -250,15 +267,14 @@ def can_manage_groups(self, user: User) -> bool: Returns ------- bool - True if the user can manage groups, False otherwise. + True if the user can create new users, False otherwise. """ - return self.has_permission(user, Permission.MANAGE_GROUPS) + return self.has_permission(user, Permission.MANAGE_USERS) - def can_create_group(self, user: User) -> bool: - """ - Check if a user can create groups. + def can_manage_groups(self, user: User) -> bool: + """Check if a user can manage groups (create/delete groups). - Any user with CONTRIBUTOR role or higher can create groups. + Any user with a role that has a ``MANAGE_GROUPS`` permission can create groups. Parameters ---------- @@ -268,13 +284,12 @@ def can_create_group(self, user: User) -> bool: Returns ------- bool - True if the user can create groups, False otherwise. + True if the user can manage groups, False otherwise. """ - return self.has_permission(user, Permission.CREATE) + return self.has_permission(user, Permission.MANAGE_GROUPS) def can_add_to_group(self, user: User, group_name: str) -> bool: - """ - Check if a user can add members to a specific group. + """Check if a user can add members to a specific group. Users can add members to a group if: 1. They are an admin (MANAGE_GROUPS permission), OR @@ -293,16 +308,32 @@ def can_add_to_group(self, user: User, group_name: str) -> bool: True if the user can add members to the group, False otherwise. """ # Admins can manage any group - if self.has_permission(user, Permission.MANAGE_GROUPS): + if self.has_role(user, Role.ADMIN): return True # Group members can add users to their groups group = self.groups.get_group(group_name) return group is not None and group.has_user(user.username) - def can_delete_group(self, user: User) -> bool: + def can_create_group(self, user: User) -> bool: + """Check if a user can create groups. + + Any user with a role that has a ``MANAGE_GROUPS`` permission can create groups. + + Parameters + ---------- + user : User + The user to check. + + Returns + ------- + bool + True if the user can create groups, False otherwise. """ - Check if a user can delete a group. + return self.has_permission(user, Permission.MANAGE_GROUPS) + + def can_delete_group(self, user: User) -> bool: + """Check if a user can delete a group. Only users with the `MANAGE_GROUPS` permission can delete groups. @@ -316,42 +347,45 @@ def can_delete_group(self, user: User) -> bool: bool ``True`` if the user can delete the group, ``False`` otherwise. """ + # Admins can manage any group + if self.has_role(user, Role.ADMIN): + return True + return self.has_permission(user, Permission.MANAGE_GROUPS) - def create_group(self, user: User, name: str, users: Optional[Set[str]] = None, + def create_group(self, user: User, group_name: str, users: Optional[Set[str]] = None, description: Optional[str] = None) -> Optional[Group]: - """ - Create a new group if the user has permission. + """Create a new group if the user has permission. Parameters ---------- user : User The user creating the group. - name : str + group_name : str The unique name for the group. - users : Optional[Set[str]], optional + users : Optional[Set[str]] Initial set of users to add to the group. - description : Optional[str], optional + description : Optional[str] Optional description of the group. Returns ------- Optional[Group] - The created group object if successful, None if permission denied. + The created group object if successful, ``None`` if the permission was denied. Raises ------ ValueError If a group with the same name already exists. """ - if not self.can_create_group(user): + if not self.can_manage_groups(user): + self.logger.error(f"Insufficient permission to create group '{group_name}' by user '{user.username}!") return None - - return self.groups.create_group(name, user.username, users, description) + self.logger.info(f"Creating group '{group_name}' by user '{user.username}, with users '{users}'") + return self.groups.create_group(group_name, user.username, users, description) def add_user_to_group(self, user: User, group_name: str, username_to_add: str) -> bool: - """ - Add a user to a group if the requesting user has permission. + """Add a user to a group if the requesting user has permission. Parameters ---------- @@ -365,16 +399,17 @@ def add_user_to_group(self, user: User, group_name: str, username_to_add: str) - Returns ------- bool - True if the user was added successfully, False if permission denied or operation failed. + ``True`` if the user was added successfully. + ``False`` if the permission was denied or the operation failed. """ if not self.can_add_to_group(user, group_name): + self.logger.error(f"Insufficient permission to add user '{username_to_add}' to group '{group_name}' by user '{user.username}!") return False - + self.logger.info(f"Adding user '{username_to_add}' from group '{group_name}' by user '{user.username}'!") return self.groups.add_user_to_group(group_name, username_to_add) def remove_user_from_group(self, user: User, group_name: str, username_to_remove: str) -> bool: - """ - Remove a user from a group if the requesting user has permission. + """Remove a user from a group if the requesting user has permission. Parameters ---------- @@ -388,16 +423,17 @@ def remove_user_from_group(self, user: User, group_name: str, username_to_remove Returns ------- bool - True if the user was removed successfully, False if permission denied or operation failed. + ``True`` if the user was removed successfully. + ``False`` if the permission was denied or the operation failed. """ if not self.can_add_to_group(user, group_name): # Same permission as adding + self.logger.error(f"Insufficient permission to remove user '{username_to_remove}' from group '{group_name}' by user '{user.username}!") return False - + self.logger.warning(f"Removing user '{username_to_remove}' from group '{group_name}' by user '{user.username}'!") return self.groups.remove_user_from_group(group_name, username_to_remove) def delete_group(self, user: User, group_name: str) -> bool: - """ - Delete a group if the user has permission. + """Delete a group if the user has permission. Parameters ---------- @@ -409,16 +445,17 @@ def delete_group(self, user: User, group_name: str) -> bool: Returns ------- bool - True if the group was deleted successfully, False if permission denied or group not found. + ``True`` if the group was deleted successfully. + ``False`` if the permission was denied or the group is not found. """ if not self.can_delete_group(user): + self.logger.error(f"Insufficient permission to delete group '{group_name}' by user '{user.username}!") return False - + self.logger.warning(f"Deleting group '{group_name}' by user '{user.username}'!") return self.groups.delete_group(group_name) def get_user_groups(self, username: str) -> List[Group]: - """ - Get all groups that a user belongs to. + """Get all groups that a user belongs to. Parameters ---------- @@ -433,8 +470,7 @@ def get_user_groups(self, username: str) -> List[Group]: return self.groups.get_user_groups(username) def list_groups(self, user: User) -> Optional[List[Group]]: - """ - List all groups if the user has permission. + """List all groups if the user has permission. Parameters ---------- @@ -451,8 +487,7 @@ def list_groups(self, user: User) -> Optional[List[Group]]: return self.groups.list_groups() def get_effective_role_for_scan(self, user: User, scan_metadata: dict) -> Role: - """ - Get the effective role a user has for a specific scan dataset. + """Get the effective role a user has for a specific scan dataset. This method determines the user's role based on: 1. Dataset ownership (owner gets CONTRIBUTOR role) @@ -512,8 +547,7 @@ def get_effective_role_for_scan(self, user: User, scan_metadata: dict) -> Role: return Role.READER def get_scan_permissions(self, user: User, scan_metadata: dict) -> Set[Permission]: - """ - Get the set of permissions a user has for a specific scan dataset. + """Get the set of permissions a user has for a specific scan dataset. This method considers the user's effective role for the specific scan, taking into account ownership and group sharing. @@ -534,10 +568,9 @@ def get_scan_permissions(self, user: User, scan_metadata: dict) -> Set[Permissio return effective_role.permissions def can_access_scan(self, user: User, scan_metadata: dict, operation: Permission) -> bool: - """ - Check if a user can perform a specific operation on a scan dataset. + """Check if a user can perform a specific operation on a scan dataset. - This method implements the complete access control logic including: + This method implements the complete access control logic, including: - Owner-based access (owners get CONTRIBUTOR role for their scans) - Group-based access (shared group members get CONTRIBUTOR role) - Global role-based access (fallback to user's global role) @@ -560,36 +593,10 @@ def can_access_scan(self, user: User, scan_metadata: dict, operation: Permission scan_permissions = self.get_scan_permissions(user, scan_metadata) return operation in scan_permissions - def can_access_scan_by_owner(self, user: User, scan_owner: str, operation: Permission) -> bool: - """ - Legacy method for checking scan access by owner name only. - - This method provides backward compatibility but doesn't support group sharing. - For full RBAC support, use can_access_scan() with full metadata. - - Parameters - ---------- - user : User - The user requesting access. - scan_owner : str - The username of the scan owner. - operation : Permission - The operation/permission being requested. - - Returns - ------- - bool - True if the user can perform the operation, False otherwise. - """ - # Create minimal metadata with just owner - scan_metadata = {'owner': scan_owner} - return self.can_access_scan(user, scan_metadata, operation) - def can_modify_scan_owner(self, user: User, scan_metadata: dict) -> bool: - """ - Check if a user can modify the 'owner' field of a scan. + """Check if a user can modify the 'owner' field of a scan. - Only admins can modify scan ownership. + Only users with permission to ``MANAGE_USERS`` can modify scan ownership. Parameters ---------- @@ -601,16 +608,15 @@ def can_modify_scan_owner(self, user: User, scan_metadata: dict) -> bool: Returns ------- bool - True if the user can modify the owner field, False otherwise. + ``True`` if the user can modify the owner field, ``False`` otherwise. """ return self.has_permission(user, Permission.MANAGE_USERS) def can_modify_scan_sharing(self, user: User, scan_metadata: dict) -> bool: - """ - Check if a user can modify the 'sharing' field of a scan. + """Check if a user can modify the 'sharing' field of a scan. Users can modify sharing if they have WRITE permission for the scan - (i.e., they are the owner, in a shared group, or have global CONTRIBUTOR+ role). + (_i.e._, they are the owner, in a shared group, or have a global CONTRIBUTOR+ role). Parameters ---------- @@ -622,13 +628,12 @@ def can_modify_scan_sharing(self, user: User, scan_metadata: dict) -> bool: Returns ------- bool - True if the user can modify the sharing field, False otherwise. + ``True`` if the user can modify the sharing field, ``False`` otherwise. """ return self.can_access_scan(user, scan_metadata, Permission.WRITE) def validate_scan_metadata_access(self, user: User, old_metadata: dict, new_metadata: dict) -> bool: - """ - Validate that a user can make the proposed metadata changes. + """Validate that a user can make the proposed metadata changes. This method checks if the user has permission to modify specific fields like 'owner' and 'sharing' based on the RBAC rules. @@ -645,9 +650,9 @@ def validate_scan_metadata_access(self, user: User, old_metadata: dict, new_meta Returns ------- bool - True if all proposed changes are allowed, False otherwise. + ``True`` if all proposed changes are allowed, ``False`` otherwise. """ - # Check if owner field is being modified + # Check if the owner field is being modified old_owner = old_metadata.get('owner', self.users.GUEST_USERNAME) new_owner = new_metadata.get('owner', self.users.GUEST_USERNAME) @@ -655,7 +660,7 @@ def validate_scan_metadata_access(self, user: User, old_metadata: dict, new_meta if not self.can_modify_scan_owner(user, old_metadata): return False - # Check if sharing field is being modified + # Check if the sharing field is being modified old_sharing = old_metadata.get('sharing', []) new_sharing = new_metadata.get('sharing', []) @@ -666,8 +671,7 @@ def validate_scan_metadata_access(self, user: User, old_metadata: dict, new_meta return True def ensure_scan_owner(self, scan_metadata: dict) -> dict: - """ - Ensure a scan has an owner field, defaulting to guest if missing. + """Ensure a scan has an owner field, defaulting to guest if missing. This method should be called when loading or creating scans to ensure the owner field is always present. @@ -680,7 +684,7 @@ def ensure_scan_owner(self, scan_metadata: dict) -> dict: Returns ------- dict - The metadata with owner field guaranteed to be present. + The metadata updated with the 'owner' field. """ if 'owner' not in scan_metadata: scan_metadata = scan_metadata.copy() @@ -688,8 +692,7 @@ def ensure_scan_owner(self, scan_metadata: dict) -> dict: return scan_metadata def validate_sharing_groups(self, sharing_groups: List[str]) -> bool: - """ - Validate that all groups in the sharing list exist. + """Validate that all groups in the sharing list exist. Parameters ---------- @@ -707,8 +710,7 @@ def validate_sharing_groups(self, sharing_groups: List[str]) -> bool: return True def get_accessible_scans_for_user(self, user: User, all_scan_metadata: Dict[str, dict]) -> Dict[str, dict]: - """ - Filter scans to only include those the user has READ access to. + """Filter scans to only include those the user has READ access to. This method can be used to implement scan listing with proper access control. @@ -737,8 +739,7 @@ def get_accessible_scans_for_user(self, user: User, all_scan_metadata: Dict[str, return accessible_scans def get_user_scan_role_summary(self, user: User, scan_metadata: dict) -> dict: - """ - Get a summary of the user's access to a specific scan. + """Get a summary of the user's access to a specific scan. This is useful for debugging and user interfaces to show access levels. @@ -753,9 +754,12 @@ def get_user_scan_role_summary(self, user: User, scan_metadata: dict) -> dict: ------- dict A dictionary containing access information including: - - effective_role: The user's effective role for this scan - - permissions: List of permissions the user has - - access_reason: Why the user has this level of access + + - 'effective_role': the user's effective role for this scan + - 'permissions': list of permissions the user has + - 'access_reason': why the user has this level of access + - 'is_owner': a boolean flag indicating if the user is the owner of the scan + - 'shared_groups': a list of groups that share this scan, if any """ metadata = self.ensure_scan_owner(scan_metadata) effective_role = self.get_effective_role_for_scan(user, metadata) diff --git a/src/commons/plantdb/commons/auth/session.py b/src/commons/plantdb/commons/auth/session.py index b47f9ad9..04a81822 100644 --- a/src/commons/plantdb/commons/auth/session.py +++ b/src/commons/plantdb/commons/auth/session.py @@ -12,7 +12,7 @@ Key Features ------------ - Centralized session store with expiration tracking -- Support for plain token and JWT tokens with standard claims +- Support for plain token and JSON Web Tokens with standard claims - Concurrency control (max concurrent sessions) - Automatic cleanup of expired sessions - Session refresh mechanism to extend validity @@ -42,8 +42,7 @@ class SessionManager: - """ - Manages user sessions with expiration and validation. + """Manages user sessions with expiration and validation. This class provides methods to create, validate, invalidate, and cleanup expired sessions. Each session is associated with a @@ -68,8 +67,7 @@ class SessionManager: """ def __init__(self, session_timeout: int = 3600, max_concurrent_sessions: int = 10): # 1 hour default - """ - Manage user sessions with timeout. + """Manage user sessions with timeout. Parameters ---------- @@ -88,8 +86,7 @@ def __init__(self, session_timeout: int = 3600, max_concurrent_sessions: int = 1 self.logger = get_logger(__class__.__name__) def _user_has_session(self, username) -> bool: - """ - Check if a user has an active session. + """Check if a user has an active session. Parameters ---------- @@ -102,15 +99,14 @@ def _user_has_session(self, username) -> bool: ``True`` if the user has an active session, ``False`` otherwise. """ self.cleanup_expired_sessions() - if username is not None: + if username: for _, session in self.sessions.items(): if session['username'] == username: return True return False def n_active_sessions(self) -> int: - """ - Returns the number of active sessions. + """Returns the number of active sessions. Cleans up expired sessions before counting and returns the number of remaining active sessions in the collection. @@ -128,8 +124,7 @@ def n_active_sessions(self) -> int: return len(self.sessions) def _create_token(self, **kwargs) -> str: - """ - Generate a secure random token. + """Generate a secure random token. Returns ------- @@ -139,8 +134,7 @@ def _create_token(self, **kwargs) -> str: return secrets.token_urlsafe(32) def create_session(self, username: str) -> Union[str, None]: - """ - Create a new session for a user. + """Create a new session for a user. If the user already has an active session, it returns the existing session ID. Otherwise, it creates a new session and returns its ID. @@ -160,10 +154,6 @@ def create_session(self, username: str) -> Union[str, None]: The session ID is a token generated using `secrets.token_urlsafe`. The session data includes the user ID, creation timestamp, last accessed timestamp, and expiration timestamp. """ - if self._user_has_session(username): - self.logger.warning(f"User '{username}' already has an active session!") - return None - if self.n_active_sessions() >= self.max_concurrent_sessions: self.logger.warning( f"Reached max concurrent sessions limit ({self.max_concurrent_sessions})") @@ -183,8 +173,7 @@ def create_session(self, username: str) -> Union[str, None]: return session_token def validate_session(self, session_id: str) -> Optional[dict]: - """ - Validate a given session by checking its existence and expiration status. + """Validate a given session by checking its existence and expiration status. Parameters ---------- @@ -222,8 +211,7 @@ def validate_session(self, session_id: str) -> Optional[dict]: return session def invalidate_session(self, session_id: str) -> Tuple[bool, str | None]: - """ - Remove the given session identifier from the active sessions. + """Remove the given session identifier from the active sessions. Parameters ---------- @@ -250,8 +238,7 @@ def invalidate_session(self, session_id: str) -> Tuple[bool, str | None]: return False, None def cleanup_expired_sessions(self) -> None: - """ - Remove expired sessions from the session dictionary. + """Remove expired sessions from the session dictionary. This method iterates through all stored sessions and deletes any that have an expiration time earlier than the current time. @@ -270,13 +257,51 @@ def cleanup_expired_sessions(self) -> None: return def session_username(self, session_id: str) -> Optional[str]: - """Return the username associated with a session.""" + """Retrieve the username associated with a given session ID. + + The method validates the supplied session ID by delegating to `validate_session`. + If the session is active, the username stored in the session data is returned; + otherwise ``None`` is returned. + + Parameters + ---------- + session_id + The unique identifier for the session to query. + + Returns + ------- + Optional[str] + The username linked to the session, or ``None`` if the + session is not found or is invalid. + """ session_data = self.validate_session(session_id) return session_data['username'] if session_data else None - def refresh_session(self, session_id: str) -> Optional[str]: + def session_token(self, username) -> Optional[str]: + """Retrieve the active session token, if any, for a given username. + + This method cleans up any expired sessions first and then searches the internal + ``sessions`` attribute dictionary for a session belonging to the supplied username. + + Parameters + ---------- + username : str + The username whose session ID is requested. + + Returns + ------- + Optional[str] + The session ID associated with `username` if an active session exists; otherwise, ``None``. """ - Refresh a session if it's still valid. + self.cleanup_expired_sessions() + if username: + for session_id, session in self.sessions.items(): + if session['username'] == username: + return session_id + return None + + def refresh_session(self, session_id: str) -> Optional[str]: + """Refresh a session if it's still valid. Parameters ---------- @@ -301,8 +326,7 @@ def refresh_session(self, session_id: str) -> Optional[str]: class SingleSessionManager(SessionManager): - """ - Generate a single-session manager for handling database connections. + """Generate a single-session manager for handling database connections. The `SingleSessionManager` class is designed to manage a single active database session at any given time. It inherits from the base `SessionManager` @@ -366,8 +390,7 @@ def __init__(self, session_timeout: int = 3600, **kwargs) -> None: class JWTSessionManager(SessionManager): - """ - Manages user sessions with expiration and validation. + """Manages user sessions with expiration and validation. This class provides methods to create, validate, invalidate, and cleanup expired sessions. Each session is associated with a @@ -394,8 +417,7 @@ class JWTSessionManager(SessionManager): """ def __init__(self, session_timeout: int = 3600, max_concurrent_sessions: int = 10, secret_key: str = None): - """ - Manage user sessions with timeout. + """Manage user sessions with timeout. Parameters ---------- @@ -413,8 +435,7 @@ def __init__(self, session_timeout: int = 3600, max_concurrent_sessions: int = 1 self.secret_key = secret_key or secrets.token_urlsafe(32) def _create_token(self, username, jti, exp_time, now): - """ - Create a JSON Web Token (JWT) with registered claims. + """Create a JSON Web Token (JWT) with registered claims. Generates and encodes a JWT using the provided username, unique identifier (jti), expiration time, and current time. The token includes standard @@ -458,8 +479,7 @@ def _create_token(self, username, jti, exp_time, now): ) def create_session(self, username: str) -> Union[str, None]: - """ - Create a new session for a user. + """Create a new session for a user. If the user already has an active session, it returns the existing session ID. Otherwise, it creates a new session and returns its ID. @@ -476,7 +496,7 @@ def create_session(self, username: str) -> Union[str, None]: Notes ----- - Creates a JWT token following RFC 7519 standards with registered claims: + Creates a JSON Web Token following RFC 7519 standards with registered claims: - iss (issuer): Identifies the token issuer - sub (subject): The username of the authenticated user - aud (audience): Intended audience for the token @@ -484,10 +504,6 @@ def create_session(self, username: str) -> Union[str, None]: - iat (issued at): Token creation timestamp - jti (JWT ID): Unique identifier for the token generated using `secrets.token_urlsafe`. """ - if self._user_has_session(username): - self.logger.warning(f"User '{username}' already has an active session!") - return None - if self.n_active_sessions() >= self.max_concurrent_sessions: self.logger.warning( f"Too any users currently active, reached max concurrent sessions limit ({self.max_concurrent_sessions})") @@ -499,10 +515,10 @@ def create_session(self, username: str) -> Union[str, None]: jti = secrets.token_urlsafe(16) # unique token ID for tracking try: - # Generate JWT token + # Generate JSON Web Token jwt_token = self._create_token(username, jti, exp_time, now) except Exception as e: - self.logger.error(f"Failed to create JWT token for {username}: {e}") + self.logger.error(f"Failed to create JSON Web Token for {username}: {e}") return None # Track session for concurrent limit enforcement @@ -512,29 +528,28 @@ def create_session(self, username: str) -> Union[str, None]: 'last_accessed': now, 'expires_at': exp_time } - self.logger.debug(f"Created JWT token for '{username}'") + self.logger.debug(f"Created JSON Web Token for '{username}'") return jwt_token - def _payload_from_token(self, jwt_token: str) -> dict: - """ - Decode the payload from a JWT token. + def _payload_from_token(self, token: str) -> dict: + """Decode the payload from a JSON Web Token. This function decodes the JSON Web Token (JWT) using the specified secret key and verifies the token's audience and issuer. It returns the decoded payload as a dictionary. Parameters ---------- - jwt_token : str - The JWT token to decode. + token : str + The JSON Web Token to decode. Returns ------- dict - The decoded payload from the JWT token. + The decoded payload from the JSON Web Token. Notes ----- - The JWT token must be correctly formatted and signed using the specified secret key. + The JSON Web Token must be correctly formatted and signed using the specified secret key. If the token is invalid or the signature does not match, a `jwt.ExpiredSignatureError`, `jwt.InvalidTokenError`, or `jwt.DecodeError` may be raised. @@ -543,21 +558,20 @@ def _payload_from_token(self, jwt_token: str) -> dict: jwt.decode : Decodes the JSON Web Token. """ return jwt.decode( - jwt_token, + token, self.secret_key, algorithms=['HS512'], audience='plantdb-client', # Verify audience issuer='plantdb-api' # Verify issuer ) - def validate_session(self, jwt_token: str) -> Optional[Dict[str, Any]]: - """ - Validate a JWT token and return user information. + def validate_session(self, token: str) -> Optional[Dict[str, Any]]: + """Validate a JSON Web Token and return user information. Parameters ---------- - jwt_token : str - The JWT token to validate. + token : str + The JSON Web Token to validate. Returns ------- @@ -572,23 +586,23 @@ def validate_session(self, jwt_token: str) -> Optional[Dict[str, Any]]: - audience: Token audience """ try: - # Decode and verify JWT token with proper validation - payload = self._payload_from_token(jwt_token) + # Decode and verify JSON Web Token with proper validation + payload = self._payload_from_token(token) except jwt.ExpiredSignatureError: - self.logger.error("JWT token expired") + self.logger.error("JSON Web Token expired") return None except jwt.InvalidAudienceError: - self.logger.error("JWT token has invalid audience") + self.logger.error("JSON Web Token has invalid audience") return None except jwt.InvalidIssuerError: - self.logger.error("JWT token has invalid issuer") + self.logger.error("JSON Web Token has invalid issuer") return None except jwt.InvalidTokenError as e: - self.logger.error(f"Invalid JWT token: {e}") + self.logger.error(f"Invalid JSON Web Token: {e}") return None except Exception as e: - self.logger.error(f"Error validating JWT token: {e}") + self.logger.error(f"Error validating JSON Web Token: {e}") return None # Update last accessed time in session tracking @@ -605,14 +619,13 @@ def validate_session(self, jwt_token: str) -> Optional[Dict[str, Any]]: 'audience': payload['aud'] # audience } - def invalidate_session(self, jwt_token: str = None, jti: str = None) -> Tuple[bool, str | None]: - """ - Invalidate a session by removing it from tracking. + def invalidate_session(self, token: str = None, jti: str = None) -> Tuple[bool, str | None]: + """Invalidate a session by removing it from tracking. Parameters ---------- - jwt_token : str, optional - JWT token to invalidate + token : str, optional + JSON Web Token to invalidate jti : str, optional Token ID to invalidate directly @@ -621,11 +634,11 @@ def invalidate_session(self, jwt_token: str = None, jti: str = None) -> Tuple[bo bool `True` if the specified session was found and removed, `False` otherwise. str - The username corresponding to the invalidated JWT token + The username corresponding to the invalidated JSON Web Token """ - if jwt_token: + if token: try: - payload = self._payload_from_token(jwt_token) + payload = self._payload_from_token(token) jti = payload.get('jti') except: return False, None @@ -648,38 +661,36 @@ def cleanup_expired_sessions(self) -> None: del self.sessions[jti] return - def session_username(self, jwt_token: str) -> Optional[str]: - """ - Extract username from JWT token. + def session_username(self, token: str) -> Optional[str]: + """Extract username from JSON Web Token. Parameters ---------- - jwt_token : str - JWT token + token : str + Current JSON Web Token. Returns ------- str or None - Username if token is valid + Username if token is valid. """ - session_data = self.validate_session(jwt_token) + session_data = self.validate_session(token) return session_data['username'] if session_data else None - def refresh_session(self, jwt_token: str) -> Optional[str]: - """ - Refresh a JWT token if it's still valid. + def refresh_session(self, token: str) -> Optional[str]: + """Refresh a JSON Web Token if it's still valid. Parameters ---------- - jwt_token : str - Current JWT token + token : str + Current JSON Web Token. Returns ------- str or None - New JWT token if refresh successful + New JSON Web Token if refresh is successful. """ - session_data = self.validate_session(jwt_token) + session_data = self.validate_session(token) if not session_data: return None @@ -687,6 +698,6 @@ def refresh_session(self, jwt_token: str) -> Optional[str]: old_jti = session_data['jti'] self.invalidate_session(jti=old_jti) - # Create new session + # Create a new session username = session_data['username'] return self.create_session(username) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 41f1a615..b116c037 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -106,7 +106,10 @@ from typing import Union from plantdb.commons import db +from plantdb.commons.auth.models import Group from plantdb.commons.auth.models import Permission +from plantdb.commons.auth.models import Role +from plantdb.commons.auth.models import User from plantdb.commons.auth.rbac import RBACManager from plantdb.commons.auth.session import JWTSessionManager from plantdb.commons.auth.session import SessionManager @@ -144,8 +147,7 @@ def require_connected_db(method): - """ - Decorator that ensures the method is only called when the database is connected. + """Decorator that ensures the method is only called when the database is connected. This ensures that operations that require a valid database connection are properly guarded against calls when the connection is inactive. @@ -196,65 +198,96 @@ def wrapper(self, *args, **kwargs): return wrapper -def require_authentication(method): - """ - Decorator that extracts the username using the session manager and passes it to the decorated method. +def get_logged_username(fsdb, default_user=None, token=None, **kwargs): + """Returns the username of the currently logged user based on the session management system. - The object of the decorated method is expected to have the following attributes: - - session_manager: a class managing user session(s) - - logger: a logger instance to log messages + This function identifies the username of the logged-in user by inspecting the session manager + associated with the given `fsdb` object. It supports multiple types of session managers, including + ``SingleSessionManager``, ``JWTSessionManager``, and generic ``SessionManager``. + If no valid session is found or if necessary arguments for session validation are missing, it + falls back to the `default_user`. + + Parameters + ---------- + fsdb : object + The filesystem database instance containing configuration, logger, and session manager. + The session manager is responsible for handling user sessions. + default_user : str, optional + A fallback username to use if no valid session or token is found. Defaults to ``None``. + token : str + The session token or JWT for validating the user's session. - The object of the decorated method is expected to have the following methods: - - get_user: a method that returns the username to use for authentication credentials + Returns + ------- + str + The username of the logged-in user or the fallback `default_user`. Notes ----- - - The token should be passed as a 'token' kwarg to the decorated method. - - The username will default to 'guest'. - - The username should be passed as a 'username' kwarg to the decorated method. - - The user data will be passed as a 'username' kwarg to the decorated method. - """ + - If no valid session manager is attached to the `fsdb` object, an error will be logged. + - Token validation for both JWTSessionManager and SessionManager assumes that `fsdb` implements methods like + `get_username` for retrieving usernames based on the provided token. - def wrapper(self, *args, **kwargs): + See Also + -------- + plantdb.session_managers.SingleSessionManager : Manages single session systems. + plantdb.session_managers.JWTSessionManager : Handles JSON Web Token-based authentication. + plantdb.session_managers.SessionManager : Manages multiple generic user sessions. - if isinstance(self.session_manager, SingleSessionManager): - # If a Single SessionManager, get the username from the session manager or use 'guest' user - try: - session = list(self.session_manager.sessions.keys())[0] - except IndexError: - kwargs['username'] = "guest" + Examples + -------- + >>> import os + >>> from plantdb.commons.test_database import dummy_db + >>> from plantdb.commons.fsdb.core import get_logged_username + >>> db = dummy_db() # SingleSessionManager with automatic login as 'admin' + >>> get_logged_username(db) + 'admin' + >>> db.logout() + >>> get_logged_username(db) is None + True + >>> get_logged_username(db, default_user='guest') + """ + logged_user = default_user + if isinstance(fsdb.session_manager, SingleSessionManager): + # If a Single SessionManager, get the username from the session manager (as only one user can be logged at once) + try: + session = list(fsdb.session_manager.sessions.keys())[0] + except IndexError: + logged_user = default_user + else: + logged_user = fsdb.session_manager.validate_session(session)['username'] + elif isinstance(fsdb.session_manager, (JWTSessionManager, SessionManager)): + # If a JSON Web Token Session Manager or a Session Manager, require the token to retrieve the username + if token: + if isinstance(fsdb, (Scan, Fileset, File)): + username = fsdb.db.get_username(token) else: - kwargs['username'] = self.session_manager.validate_session(session)['username'] + username = fsdb.get_username(token) + logged_user = username + else: + logged_user = default_user + else: + fsdb.logger.error("Can't serve a local PlantDB without a session manager!") + return logged_user - elif isinstance(self.session_manager, JWTSessionManager): - # If a JSON Web Token Session Manager, require the token or default to 'guest' user - if 'token' in kwargs: - jwt_token = kwargs.pop('token', None) - # Get username from JWT - if isinstance(self, (Scan, Fileset, File)): - username = self.db.get_user(jwt_token) - else: - username = self.get_user(jwt_token) - kwargs['username'] = username - else: - kwargs['username'] = 'guest' - elif isinstance(self.session_manager, SessionManager): - # If a regular Session Manager, require the session token or default to 'guest' user - if 'token' in kwargs: - token = kwargs.pop('token', None) - # Get username from session token - if isinstance(self, (Scan, Fileset, File)): - username = self.db.get_user(token) - else: - username = self.get_user(token) - kwargs['username'] = username - else: - kwargs['username'] = 'guest' +def require_authentication(method): + """Decorator enforcing authentication by supplying the username of the logged-in user to the wrapped method. - else: - self.logger.error("Can't serve a local PlantDB without a session manager!") + This decorator retrieves the logged-in user's username using the + `get_logged_user` function, appends it to the keyword arguments, and + then calls the wrapped method. It ensures that the method always + receives the correct authentication context without needing to manually + pass the username. + See Also + -------- + get_logged_user : Retrieves the username of the currently logged-in user. + """ + + def wrapper(self, *args, **kwargs): + kwargs['username'] = get_logged_username(self, default_user=kwargs.pop('default_user', 'guest'), + token=kwargs.pop('token', None), **kwargs) return method(self, *args, **kwargs) return wrapper @@ -263,9 +296,9 @@ def wrapper(self, *args, **kwargs): class FSDB(db.DB): """Implement a local *File System DataBase* version of abstract class ``db.DB``. - Implement as a simple local file structure with following directory structure and marker files: - * directory ``${FSDB.basedir}`` as database root directory; - * marker file ``MARKER_FILE_NAME`` at database root directory; + Implement as a simple local file structure with the following directory structure and marker files: + * directory ``${FSDB.basedir}`` as the database root directory; + * marker file ``MARKER_FILE_NAME`` at the database root directory; Attributes ---------- @@ -276,9 +309,9 @@ class FSDB(db.DB): is_connected : bool ``True`` if the database is connected (locked directory), else ``False``. required_filesets : List[str] - A list of required filesets to consider a scan valid. Set it to None to accept any subdirectory of basedir as a valid scan. Defaults to ['metadata']. + A list of required filesets to consider a scan valid. Set it to ``None`` to accept any subdirectory of basedir as a valid scan. Defaults to ['metadata']. logger : logging.Logger - Logger instance to use for logging. Defaults to the module logger. + An instance to use for logging. Defaults to the module logger. session_manager : Union[SingleSessionManager, SessionManager, JWTSessionManager] The session manager to use for session authentication. lock_manager : ScanLockManager @@ -374,7 +407,7 @@ def __init__(self, basedir: Union[str, Path], self.logger = logger or get_logger(__class__.__name__) basedir = Path(basedir) - # Check the given path to root directory of the database is a directory: + # Check the given path to the root directory of the database is a directory: if not basedir.is_dir(): raise NotADirectoryError(f"Directory {basedir} does not exists!") self.basedir = Path(basedir).resolve() @@ -431,8 +464,7 @@ def connect(self) -> bool: @require_connected_db def disconnect(self) -> None: - """ - Disconnect from the database. + """Disconnect from the database. This method disconnects from the database, if currently connected, by erasing all scans (from memory) and reseting the connection status. @@ -443,14 +475,27 @@ def disconnect(self) -> None: >>> db = dummy_db() >>> print(db.is_connected) True + >>> print(db.path().exists()) + True >>> db.disconnect() # clean up (delete) the temporary dummy database >>> print(db.is_connected) False + >>> print(db.path().exists()) + False """ for s_id, scan in self.scans.items(): scan._erase() self.scans = {} self.is_connected = False + + # If this FSDB instance was created by dummy_db, clean up the temp directory + if getattr(self, "_is_dummy", False): + import shutil + try: + shutil.rmtree(self.basedir) + self.logger.info(f"Removed temporary database directory {self.basedir}") + except Exception as e: + self.logger.warning(f"Failed to remove temporary directory {self.basedir}: {e}") return @require_connected_db @@ -550,7 +595,7 @@ def get_scans(self, query=None, **kwargs) -> List: for scan_id, scan in self.scans.items(): try: - metadata = self.rbac_manager.ensure_scan_owner(scan.get_metadata()) + metadata = scan.get_metadata() if self.rbac_manager.can_access_scan(current_user, metadata, Permission.READ): accessible_scans[scan_id] = scan except Exception as e: @@ -574,7 +619,7 @@ def get_scans(self, query=None, **kwargs) -> List: @require_connected_db @require_authentication def get_scan(self, scan_id, **kwargs): - """Get `Scan` instance in the local database. + """Get a ` Scan ` instance in the local database. Parameters ---------- @@ -585,7 +630,7 @@ def get_scan(self, scan_id, **kwargs): Raises ------ plantdb.commons.fsdb.ScanNotFoundError - If the `scan_id` do not exist in the local database and `create` is ``False``. + If the `scan_id` does not exist in the local database and `create` is ``False``. Returns ------- @@ -613,7 +658,7 @@ def get_scan(self, scan_id, **kwargs): raise Exception("No valid user!") scan = self.scans[scan_id] - metadata = self.rbac_manager.ensure_scan_owner(scan.get_metadata()) + metadata = scan.get_metadata() if self.rbac_manager.can_access_scan(current_user, metadata, Permission.READ): # Use shared lock for read operations with self.lock_manager.acquire_lock(scan_id, LockType.SHARED, current_user.username): @@ -642,18 +687,23 @@ def create_scan(self, scan_id, metadata=None, **kwargs): Returns ------- - plantdb.commons.fsdb.core.Scan + Optional[plantdb.commons.fsdb.core.Scan] The ``Scan`` instance created in the local database. Raises ------ - OSError - If the `scan_id` is not valid or already exists in the local database. + PermissionError + If no user is authenticated. + If the user lacks permission to create groups. + ScanExistsError + If the ``scan_id`` already exists in the local database. + ValueError + If the given ``scan_id`` is invalid. See Also -------- - plantdb.commons.fsdb._is_valid_id - plantdb.commons.fsdb._make_scan + plantdb.commons.fsdb.validation._is_valid_id + plantdb.commons.fsdb.file_ops._make_scan Examples -------- @@ -665,9 +715,9 @@ def create_scan(self, scan_id, metadata=None, **kwargs): >>> print(new_scan.get_metadata('project')) GoldenEye >>> scan = db.create_scan('007') # attempt to create an existing scan dataset - OSError: Given scan identifier '007' already exists! + plantdb.commons.fsdb.exceptions.ScanExistsError: Scan id '007' already exists in database '/tmp/ROMI_DB_bx519w11'! >>> scan = db.create_scan('0/07') # attempt to create a scan dataset using invalid characters - OSError: Invalid scan identifier '0/07'! + ValueError: Invalid scan identifier '0/07'! >>> db.disconnect() # clean up (delete) the temporary dummy database """ current_user = self.get_user_data(**kwargs) @@ -676,8 +726,12 @@ def create_scan(self, scan_id, metadata=None, **kwargs): # Check CREATE permission if not self.rbac_manager.has_permission(current_user, Permission.CREATE): - raise PermissionError(f"Insufficient permissions to create scan with user '{current_user.username}'") + raise PermissionError(f"Insufficient permissions to create a scan as '{current_user.username}' user!") + # Verify if the given `fs_id` is valid + if not _is_valid_id(scan_id): + raise ValueError(f"Invalid scan identifier '{scan_id}'!") + # Verify if the given `scan_id` already exists in the local database if self.scan_exists(scan_id): raise ScanExistsError(self, scan_id) @@ -685,13 +739,12 @@ def create_scan(self, scan_id, metadata=None, **kwargs): if metadata is None: metadata = {} - # Set current user as owner if not specified - if 'owner' not in metadata: - metadata['owner'] = current_user.username - now = iso_date_now() - metadata['created'] = now # creation timestamp - metadata['last_modified'] = now # modification timestamp - metadata['created_by'] = current_user.fullname + # Set basic metadata + metadata['owner'] = current_user.username + now = iso_date_now() + metadata['created'] = now # creation timestamp + metadata['last_modified'] = now # modification timestamp + metadata['created_by'] = current_user.fullname # Validate sharing groups if specified if 'sharing' in metadata: @@ -702,39 +755,23 @@ def create_scan(self, scan_id, metadata=None, **kwargs): raise ValueError("One or more sharing groups do not exist") # Use exclusive lock for scan creation + self.logger.info(f"Creating a scan '{scan_id}' as user '{current_user.username}'...") with self.lock_manager.acquire_lock(scan_id, LockType.EXCLUSIVE, current_user.username): - # Verify if the given `scan_id` already exists in the local database - if self.scan_exists(scan_id): - raise IOError(f"Given scan identifier '{scan_id}' already exists!") - try: - # Initialize scan object - scan = Scan(self, scan_id) # Initialize a new Scan instance - scan_path = _make_scan(scan) # Create directory structure - - # Cannot use scan.set_metadata(initial_metadata) here as ownership is not granted yet! - _set_metadata(scan.metadata, metadata, None) # add metadata dictionary to the new scan - _store_scan_metadata(scan) - - scan.store() # store the new scan in the local database - self.scans[scan_id] = scan # Update scans dictionary with newly created - - self.logger.info(f"Created scan '{scan_id}' for user '{current_user.username}'") - return scan - - except Exception as e: - self.logger.error(f"Failed to create scan {scan_id}: {e}") - # Cleanup on failure - try: - if os.path.exists(scan_path): - import shutil - shutil.rmtree(scan_path) - except: - pass - return None + # Initialize scan object + scan = Scan(self, scan_id) # Initialize a new Scan instance + scan_path = _make_scan(scan) # Create directory structure + # Cannot use scan.set_metadata(initial_metadata) here as ownership is not granted yet! + _set_metadata(scan.metadata, metadata, None) # add metadata dictionary to the new scan + _store_scan_metadata(scan) + scan.store() # store the new scan in the local database + self.scans[scan_id] = scan # Update scans dictionary with the new one + + self.logger.info(f"Done creating scan.") + return scan @require_connected_db @require_authentication - def delete_scan(self, scan_id, **kwargs): + def delete_scan(self, scan_id, **kwargs) -> bool: """Delete an existing `Scan` from the local database. Parameters @@ -742,14 +779,24 @@ def delete_scan(self, scan_id, **kwargs): scan_id : str The name of the scan to delete from the local database. + Returns + ------- + bool + A boolean value indicating whether the scan was successfully deleted. + Raises ------ + PermissionError + If no user is authenticated. + If the user lacks permission to create groups. + ValueError + If the ``scan_id`` does not exist in the local database. IOError - If the `id` do not exist in the local database. + If the scan is locked by another user. See Also -------- - plantdb.commons.fsdb._delete_scan + plantdb.commons.fsdb.file_ops._delete_scan Examples -------- @@ -769,39 +816,32 @@ def delete_scan(self, scan_id, **kwargs): """ current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") if not self.scan_exists(scan_id): - raise ValueError(f"Scan {scan_id} does not exist") + raise ValueError(f"Scan '{scan_id}' does not exist!") # Check DELETE permission for this specific scan scan = self.scans[scan_id] - metadata = self.rbac_manager.ensure_scan_owner(scan.get_metadata()) - - if not self.rbac_manager.can_access_scan(current_user, metadata, Permission.DELETE): - raise PermissionError("Insufficient permissions to delete scan") - - # Check if scan is locked - if self.is_scan_locked(scan_id): - self.logger.error(f"Scan {scan_id} is locked by another user") + if not self.rbac_manager.can_access_scan(current_user, scan.get_metadata(), Permission.DELETE): + raise PermissionError( + f"Insufficient permissions to delete '{scan_id}' scan as '{current_user.username}' user!") # Use exclusive lock for scan deletion + self.logger.info(f"Deleting scan '{scan_id}' as '{current_user.username}' user...") with self.lock_manager.acquire_lock(scan_id, LockType.EXCLUSIVE, current_user.username): - try: - # Get the Scan instance from database - scan = self.scans[scan_id] - _delete_scan(scan) # delete the scan directory - self.scans.pop(scan_id) # remove the scan from the scan list - self.logger.info(f"Deleted scan '{scan_id}' by user '{current_user.username}'") - except Exception as e: - self.logger.error(f"Failed to delete scan {scan_id}: {e}") - raise + # Get the Scan instance from the database + scan = self.scans[scan_id] + _delete_scan(scan) # delete the scan directory + self.scans.pop(scan_id) # remove the scan from the scan list + + self.logger.info(f"Done deleting scan.") return True @require_connected_db @require_authentication - def list_scans(self, query=None, fuzzy=False, owner_only=True, **kwargs) -> list: - """Get the list of scans in identifiers the local database. + def list_scans(self, query=None, fuzzy=False, owner_only=True, **kwargs) -> list[str]: + """Get the list of scan identifiers from the local database. Parameters ---------- @@ -814,11 +854,11 @@ def list_scans(self, query=None, fuzzy=False, owner_only=True, **kwargs) -> list Returns ------- list[str] - The list of scan identifiers in the local database. + The list of scan identifiers from the local database. See Also -------- - plantdb.commons.fsdb._filter_query + plantdb.commons.fsdb.core._filter_query Examples -------- @@ -851,8 +891,7 @@ def list_scans(self, query=None, fuzzy=False, owner_only=True, **kwargs) -> list @require_connected_db def get_scan_lock_status(self, scan_id: str) -> Dict: - """ - Get the current lock status for a specific scan. + """Get the current lock status for a specific scan. Parameters ---------- @@ -868,8 +907,7 @@ def get_scan_lock_status(self, scan_id: str) -> Dict: @require_connected_db def is_scan_locked(self, scan_id: str) -> bool: - """ - Check if a scan is locked in the system. + """Check if a scan is locked in the system. This method determines whether the specified scan is currently locked by fetching its lock status. A scan is considered locked if it does not have @@ -897,9 +935,8 @@ def is_scan_locked(self, scan_id: str) -> bool: return True return False - def cleanup_scan_locks(self): - """ - Emergency cleanup of all scan locks. + def cleanup_scan_locks(self) -> None: + """Emergency cleanup of all scan locks. Use with caution - only call when you're sure no operations are in progress. """ self.lock_manager.cleanup_all_locks() @@ -907,8 +944,7 @@ def cleanup_scan_locks(self): @require_connected_db def list_active_locks(self) -> Dict[str, Dict]: - """ - List all currently active locks across all scans. + """List all currently active locks across all scans. Returns ------- @@ -944,57 +980,101 @@ def validate_user(self, username: str, password: str) -> bool: ------ KeyError If there is an issue accessing necessary user data. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # SingleSessionManager with automatic login as 'admin' + >>> db.validate_user('guest', 'guest') + True + >>> db.disconnect() """ return self.rbac_manager.users.validate(username, password) @require_connected_db - def login(self, username: str, password: str) -> Union[str, None]: + def login(self, username: str, password: str, **kwargs) -> Optional[str]: """Authenticate user and create session. Parameters ---------- username : str - Username for authentication + Username for authentication. password : str - Password for authentication + Password for authentication. Returns ------- - Union[str, None] + Optional[str] Returns the user session ID if successful, ``None`` otherwise. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # SingleSessionManager with automatic login as 'admin' + >>> token = db.login('guest', 'guest') + ERROR [FSDB] Failed to login as 'guest'! Another user is logged in. + >>> db.logout() + INFO [FSDB] User 'admin' logged out successfully. + True + >>> token = db.login('guest', 'guest') + [FSDB] Successfully logged in as 'guest'. + >>> db.disconnect() """ if self.validate_user(username, password): + + # If a SingleSessionManager and a currently logged user, abort + if isinstance(self.session_manager, SingleSessionManager): + current_username = get_logged_username(self) + if current_username: + if current_username != username: + self.logger.error(f"Failed to login as '{username}'! Another user is logged in.") + return None + else: + self.logger.info(f"Already logged in as '{username}'.") + return + + # Else try to create a new session: session_token = self.session_manager.create_session(username) try: assert session_token is not None except AssertionError: - self.logger.warning(f"User {username} has reached max concurrent sessions") + self.logger.warning(f"User '{username}' has reached max concurrent sessions") else: - self.logger.info(f"User {username} logged in successfully") + self.logger.info(f"Successfully logged in as '{username}'.") return session_token else: - self.logger.error(f"Failed to login user {username}") + self.logger.error(f"Failed to login as '{username}'!") return None @require_token - def logout(self, **kwargs): - """Logout user and by invalidating its session.""" + def logout(self, **kwargs) -> bool: + """Log out a user by invalidating its session. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + INFO [FSDB] Successfully logged in as 'admin'. + >>> db.logout() + INFO [FSDB] Successfully logged out from 'admin'. + True + >>> db.disconnect() + """ success, username = self.session_manager.invalidate_session(kwargs.get('token', None)) if success: - self.logger.info(f"User {username} logged out successfully") + self.logger.info(f"Successfully logged out from '{username}'.") return True else: self.logger.warning(f"Failed to logout!") return False @require_authentication - def create_user(self, username, fullname, password, roles=None) -> None: - """ - Create a new user with the specified details. + def create_user(self, new_username, fullname, password, roles=None, **kwargs) -> None: + """Create a new user with the specified details. Parameters ---------- - username : str + new_username : str The unique username for the new user. fullname : str The full name of the new user. @@ -1003,102 +1083,193 @@ def create_user(self, username, fullname, password, roles=None) -> None: roles : list[str], optional A list of roles to assign to the new user. Default is None. + Raises + ------ + PermissionError + If no user is authenticated. + If the user lacks permission to create groups. + See Also -------- RBACManager.users.create : Method used to actually create the user. - """ - return self.rbac_manager.users.create(username, fullname, password, roles) - def get_guest_user(self): + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + INFO [FSDB] Successfully logged in as 'admin'. + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + INFO [UserManager] Welcome Bruce Wayne, please log in...' + >>> db.logout() + >>> token = db.login('batman', 'joker') + INFO [FSDB] Successfully logged in as 'batman'. + >>> db.disconnect() """ - Retrieve the guest user information from the RBAC manager. + current_user = self.get_user_data(**kwargs) + if not current_user: + raise PermissionError("No authenticated user!") - Returns the guest user object containing all relevant data. + # Check user creation permissions + if not self.rbac_manager.can_create_user(current_user): + raise PermissionError(f"Insufficient permissions to create new user with user '{current_user.username}'") - Parameters - ---------- - None + return self.rbac_manager.users.create(new_username, fullname, password, roles) + + def get_guest_user(self) -> User: + """Retrieve the guest user information from the RBAC manager. Returns ------- - dict - A dictionary representing the guest user with all attributes. - For example, it might contain keys like 'id', 'name', etc. - - Examples - -------- - >>> rbac_manager = RBACManager() - >>> user_info = rbac_manager.get_guest_user() - >>> print(user_info) - {'id': 12345, 'name': 'Guest User', 'role': 'Guest'} + plantdb.commons.auth.models.User + The User object corresponding to the guest username. Notes ----- - This method interacts with the underlying RBAC manager to fetch guest - user information. Ensure that the RBAC manager is correctly configured. + This method interacts with the underlying RBAC manager to fetch guest user information. See Also -------- rbac_manager.get_guest_user : The underlying method used by this function. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.get_guest_user() + User(username='guest', fullname='PlantDB Guest', password_hash='$argon2id$v=19$m=65536,t=3,p=4$2++/KY75t4qvt5x1fO4dJA$MDmREeceXOJhcupT1G6yuRFvPUJ3SjNpuSga5wkUEYw', roles={}, created_at=datetime.datetime(2026, 1, 29, 17, 19, 13, 313677), permissions=None, last_login=datetime.datetime(2026, 1, 29, 17, 19, 20, 177023), is_active=True, failed_attempts=0, last_failed_attempt=None, locked_until=None, password_last_change=datetime.datetime(2026, 1, 29, 17, 19, 13, 313677)) + >>> db.disconnect() """ return self.rbac_manager.get_guest_user() - def get_user(self, session_token): + def get_username(self, token) -> Optional[str]: """Get the username. + Parameters + ---------- + token : str + The token provided by the RBAC manager. + Returns ------- - str or None - Username if token is valid + Optional[str] + The ``User.username`` if the token is valid, None otherwise. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.logout() + INFO [FSDB] Successfully logged out from 'admin'. + >>> token = db.login('guest', 'guest') + INFO [FSDB] Successfully logged in as 'guest'. + >>> db.get_username(token) + 'guest' + >>> db.disconnect() """ - return self.session_manager.session_username(session_token) + return self.session_manager.session_username(token) - def get_user_data(self, username=None, session_token=None): + def get_user_data(self, username=None, token=None) -> Optional[User]: """Get the user data. + Parameters + ---------- + username : str + The username to retrieve the user data from. + token : str + The token provided by the RBAC manager. + Returns ------- - User or None - Current user object if authenticated, None otherwise + Optional[User] + The User object corresponding to the currently authenticated user, if any, ``None`` otherwise. + + Notes + ----- + If both `username` and `token` are provided, prefer `token` for accessing user data. + + Examples + -------- + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.get_user_data(username='admin') + User(username='admin', fullname='PlantDB Admin', password_hash='$argon2id$v=19$m=65536,t=3,p=4$zMr0ZhclnHHdOgwWKv3Hbg$SZshbPdNiCdBONb8vgzZAKyWPl5sNIUwB8mQWkzGYOQ', roles={}, created_at=datetime.datetime(2026, 1, 29, 17, 22, 49, 683163), permissions=None, last_login=datetime.datetime(2026, 1, 29, 17, 22, 49, 770793), is_active=True, failed_attempts=0, last_failed_attempt=None, locked_until=None, password_last_change=datetime.datetime(2026, 1, 29, 17, 22, 49, 683163)) + >>> db.logout() + INFO [FSDB] Successfully logged out from 'admin'. + >>> token = db.login('guest', 'guest') + INFO [FSDB] Successfully logged in as 'guest'. + >>> user_data = db.get_user_data(token=token) + >>> print(user_data.fullname) + PlantDB Guest + >>> db.disconnect() """ + if username and token: + self.logger.warning("Trying to retrieve user data from both 'username' and token!") + self.logger.info("Using 'token' to access user data.") + username = None + if username: return self.rbac_manager.users.get_user(username) - elif session_token: - return self.rbac_manager.users.get_user(self.session_manager.session_username(session_token)) + elif token: + return self.rbac_manager.users.get_user(self.session_manager.session_username(token)) else: - self.logger.error("No username or session token provided") + self.logger.error("No username or token provided") return None # Group management methods @require_authentication - def create_group(self, name, users=None, description=None, **kwargs): + def create_group(self, name, users=None, description=None, **kwargs) -> Optional[Group]: """Create a new group. Parameters ---------- name : str - Unique name for the group + Unique name for the group. users : set, optional - Initial set of users to add to the group + Initial set of users to add to the group. description : str, optional - An optional description of the group + An optional description of the group. + + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. Returns ------- - Group - The created group object + Optional[Group] + The created group object if successful, ``None`` otherwise. Raises ------ PermissionError - If user lacks permission to create groups + If no user is authenticated. + If the user lacks permission to create groups. ValueError - If group already exists + If the group already exists. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> prin(group_a.users) + {'admin', 'batman'} + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") + + if isinstance(users, str): + users = [users] + if isinstance(users, Iterable): + users = set(users) return self.rbac_manager.create_group(current_user, name, users, description) @@ -1109,23 +1280,46 @@ def add_user_to_group(self, group_name, user, **kwargs): Parameters ---------- group_name : str - Name of the group + Name of the group to add the user to. user : str - Username to add to the group + Name of the user to add to the group. + + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. Returns ------- bool - True if user was added successfully + ``True`` if the `user` was successfully added to the group, ``False`` otherwise. Raises ------ PermissionError - If user lacks permission to modify the group + If no user is authenticated. + If the authenticated user lacks permission to modify the group. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> print(group_a.users) + {'admin', 'batman'} + >>> db.create_user('hquinn', 'Harley Quinn', 'joker', roles=Role.READER) + >>> db.add_user_to_group('groupA', 'hquinn') + >>> print(group_a.users) + {'hquinn', 'batman', 'admin'} + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") if not self.rbac_manager.add_user_to_group(current_user, group_name, user): raise PermissionError("Insufficient permissions or operation failed") @@ -1138,30 +1332,52 @@ def remove_user_from_group(self, group_name, user, **kwargs): Parameters ---------- group_name : str - Name of the group + Name of the group to remove the user from. user : str - Username to remove from the group + Name of the user to remove from the group. + + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. Returns ------- bool - True if user was removed successfully + ``True`` if the `user` was successfully removed from the group, ``False`` otherwise. Raises ------ PermissionError - If user lacks permission to modify the group + If no user is authenticated. + If the authenticated user lacks permission to remove the user from the group. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> print(group_a.users) + {'admin', 'batman'} + >>> db.remove_user_from_group('groupA', 'batman') + >>> print(group_a.users) + {'admin'} + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") if not self.rbac_manager.remove_user_from_group(current_user, group_name, user): raise PermissionError("Insufficient permissions or operation failed") return True @require_authentication - def delete_group(self, group_name, **kwargs): + def delete_group(self, group_name, **kwargs) -> bool: """Delete a group. Parameters @@ -1169,82 +1385,147 @@ def delete_group(self, group_name, **kwargs): group_name : str Name of the group to delete + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. + Returns ------- bool - True if group was deleted successfully + True if the group was deleted successfully Raises ------ PermissionError - If user lacks permission to delete groups + If no user is authenticated. + If the authenticated user lacks permission to delete this group. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> print(group_a.users) + {'admin', 'batman'} + >>> db.logout() + >>> token = db.login('batman', 'joker') + ERROR [RBACManager] Insufficient permission to delete group 'groupA' by user 'batman! + PermissionError: Insufficient permissions or group 'groupA' not found + >>> db.delete_group('groupA') + >>> db.logout() + >>> token = db.login('admin', 'admin') + >>> db.delete_group('groupA') + WARNING [RBACManager] Deleting group 'groupA' by user 'admin'! + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") if not self.rbac_manager.delete_group(current_user, group_name): - raise PermissionError("Insufficient permissions or group not found") + raise PermissionError(f"Insufficient permissions or group '{group_name}' not found") return True @require_authentication - def list_groups(self, **kwargs): + def list_groups(self, **kwargs) -> list[Group]: """List all groups. Returns ------- - list + list[Group] A list of Group objects Raises ------ PermissionError - If user is not authenticated + If no user is authenticated. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> print([g.name for g in db.list_groups()]) + ['groupA'] + >>> db.disconnect() """ current_user = self.get_user_data(kwargs.get('username', None)) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") groups = self.rbac_manager.list_groups(current_user) return groups if groups is not None else [] @require_authentication - def get_user_groups(self, username=None, **kwargs): + def get_user_groups(self, user=None, **kwargs) -> list[Group]: """Get groups for a user. Parameters ---------- - username : str, optional - Username to query. If None, uses current user. + user : str, optional + Username to query. + If None, uses the currently authenticated user. + + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. Returns ------- - list - A list of Group objects the user belongs to + list[Groups] + A list of Group objects the user belongs to. Raises ------ PermissionError - If no authenticated user + If no user is authenticated. + + Examples + -------- + >>> from plantdb.commons.auth.models import Role + >>> from plantdb.commons.test_database import dummy_db + >>> db = dummy_db() # automatic login as 'admin' + >>> db.create_user('batman', 'Bruce Wayne', 'joker', roles=Role.CONTRIBUTOR) + >>> group_a = db.create_group('groupA', ['batman'], description="The group A.") + >>> print([g.name for g in db.get_user_groups('batman')]) + ['groupA'] + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") - if username is None: - username = current_user.username + if user is None: + user = current_user.username - return self.rbac_manager.get_user_groups(username) + return self.rbac_manager.get_user_groups(user) @require_authentication def get_scan_access_summary(self, scan_id, **kwargs): - """Get access summary for current user on a scan. + """Get access summary for the current user on a scan. Parameters ---------- scan_id : str The scan identifier + Other Parameters + ---------------- + username : str + The username formulating the request. + token : str + A token referring to the username formulating the request. + Returns ------- dict or None @@ -1253,18 +1534,42 @@ def get_scan_access_summary(self, scan_id, **kwargs): Raises ------ PermissionError - If no authenticated user + If no user is authenticated. + + Examples + -------- + >>> from plantdb.commons.test_database import test_database + >>> db = test_database(dataset="all") + >>> db.connect() + >>> token = db.login('guest', 'guest') + >>> scan_access = db.get_scan_access_summary("real_plant") + >>> print(scan_access["effective_role"]) + contributor + >>> print(scan_access["permissions"]) + ['write', 'create', 'read'] + >>> print(scan_access["is_owner"]) + True + >>> db.logout() + >>> token = db.login('admin', 'admin') + >>> scan_access = db.get_scan_access_summary("real_plant") + >>> print(scan_access["effective_role"]) + admin + >>> print(scan_access["is_owner"]) + False + >>> print(scan_access["access_reason"]) + ['admin_role'] + >>> db.disconnect() """ - current_user = self.get_user_data(kwargs.get('username', None)) + current_user = self.get_user_data(**kwargs) if not current_user: - raise PermissionError("No authenticated user") + raise PermissionError("No authenticated user!") if scan_id not in self.scans: return None scan = self.scans[scan_id] try: - metadata = self.rbac_manager.ensure_scan_owner(scan.get_metadata()) + metadata = scan.get_metadata() return self.rbac_manager.get_user_scan_role_summary(current_user, metadata) except Exception as e: self.logger.warning(f"Error getting access summary for scan {scan_id}: {e}") @@ -1272,7 +1577,7 @@ def get_scan_access_summary(self, scan_id, **kwargs): class Scan(db.Scan): - """Implement ``Scan`` for the local *File System DataBase* from abstract class ``db.Scan``. + """Implement ``Scan`` for the local *File System DataBase* from the abstract class ``db.Scan``. Implementation of a scan as a simple file structure with: * directory ``${Scan.db.basedir}/${Scan.db.id}`` as scan root directory; @@ -1376,7 +1681,7 @@ def __init__(self, db, scan_id): self.logger = self.db.logger def _erase(self): - """Erase the filesets and metadata associated to this scan.""" + """Erase the filesets and metadata associated with this scan.""" for fs_id, fs in self.filesets.items(): fs._erase() self.metadata = {} @@ -1385,27 +1690,43 @@ def _erase(self): return @property - def owner(self): - # If no owner is defined, set it to the anonymous user + def owner(self) -> str: + """A property method to retrieve or set the `owner` of a resource. + + If the `owner` is not already defined in the resource's metadata, this property + ensures that a default owner is assigned (using a guest user). The updated metadata + is then stored in the database, and the resource is reloaded. + + Returns + ------- + str + The owner of the resource, as defined in the metadata. + + See Also + -------- + rbac_manager.ensure_scan_owner : Ensures the presence of a valid owner in the metadata. + _store_scan_metadata : Saves updated metadata to the database. + db.reload : Reloads the resource from the database after updates. + """ + # If no owner is defined, set it to the guest user, save it and reload it into the DB if 'owner' not in self.metadata: - _set_metadata(self.metadata, 'owner', 'anonymous') + metadata = self.db.rbac_manager.ensure_scan_owner(self.get_metadata()) + _set_metadata(self.metadata, metadata, None) _store_scan_metadata(self) self.db.reload(self.id) return self.metadata.get('owner') - def is_locked(self): - """ - Check if a scan is locked in the system. + def is_locked(self) -> bool: + """Check if a scan is locked in the system. Returns ------- bool - True if the scan is locked (having neither an exclusive lock nor any - shared locks), False otherwise. + ``True`` if the scan is locked (having neither an exclusive lock nor any shared locks), ``False`` otherwise. See Also -------- - ScanManager.lock_manager : Component responsible for managing scan locks. + ScanManager.lock_manager: Component responsible for managing scan locks. """ return self.db.is_scan_locked(self.id) @@ -1507,21 +1828,21 @@ def get_fileset(self, fs_id): # with self.db.lock_manager.acquire_lock(self.id, LockType.SHARED, current_user.username or "guest"): # Use shared lock for read operations - with self.db.lock_manager.acquire_lock(self.id, LockType.SHARED, "guest"): + with self.db.lock_manager.acquire_lock(self.id, LockType.SHARED, self.db.get_guest_user().username): if not self.fileset_exists(fs_id): raise FilesetNotFoundError(self, fs_id) return self.filesets[fs_id] def get_metadata(self, key=None, default={}): - """Get the metadata associated to a scan. + """Get the metadata associated with a scan. Parameters ---------- key : str A key that should exist in the scan's metadata. default : Any, optional - The default value to return if the key do not exist in the metadata. + The default value to return if the key does not exist in the metadata. Default is an empty dictionary``{}``. Returns @@ -1536,7 +1857,7 @@ def get_metadata(self, key=None, default={}): # return _get_metadata(self.metadata, key, default) # Use shared lock for read operations - with self.db.lock_manager.acquire_lock(self.id, LockType.SHARED, "guest"): + with self.db.lock_manager.acquire_lock(self.id, LockType.SHARED, self.db.get_guest_user().username): return _get_metadata(self.metadata, key, default) def get_measures(self, key=None): @@ -1575,7 +1896,8 @@ def set_metadata(self, data, value=None, **kwargs): Raises ------ PermissionError - If user lacks permission to modify metadata + If no user is authenticated. + If the user lacks permission to modify the metadata. ValueError If metadata validation fails @@ -1599,7 +1921,7 @@ def set_metadata(self, data, value=None, **kwargs): raise PermissionError("No authenticated user!") # Get current metadata for validation - old_metadata = self.db.rbac_manager.ensure_scan_owner(self.get_metadata()) + old_metadata = self.get_metadata() if isinstance(data, str): if value is None: @@ -1609,13 +1931,13 @@ def set_metadata(self, data, value=None, **kwargs): try: assert isinstance(data, dict) except AssertionError: - raise PermissionError(f"Invalid metadata type '{type(data)}'") + raise ValueError(f"Invalid metadata type '{type(data)}'") else: new_metadata = data # Validate metadata changes if not self.db.rbac_manager.validate_scan_metadata_access(current_user, old_metadata, new_metadata): - raise PermissionError("Insufficient permissions to modify scan metadata") + raise PermissionError(f"Insufficient permissions to modify scan '{self.id}' metadata!") # Validate sharing groups if present if 'sharing' in new_metadata: @@ -1627,9 +1949,10 @@ def set_metadata(self, data, value=None, **kwargs): # Check WRITE permission for this scan if not self.db.rbac_manager.can_access_scan(current_user, old_metadata, Permission.WRITE): - raise PermissionError("Insufficient permissions to modify scan") + raise PermissionError(f"Insufficient permissions to modify scan '{self.id}' metadata.") # Use exclusive lock for metadata updates + self.logger.info(f"Updating '{self.id}' scan metadata as '{current_user.username}' user...") with self.db.lock_manager.acquire_lock(self.id, LockType.EXCLUSIVE, current_user.username): # Update metadata _set_metadata(self.metadata, new_metadata, None) @@ -1637,8 +1960,7 @@ def set_metadata(self, data, value=None, **kwargs): _set_metadata(self.metadata, 'last_modified', iso_date_now()) _store_scan_metadata(self) - self.logger.info(f"Updated metadata for scan '{self.id}' by user '{current_user.username}'") - + self.logger.info(f"Done updating the scan metadata.") return @require_authentication @@ -1659,14 +1981,18 @@ def create_fileset(self, fs_id, metadata=None, **kwargs): Raises ------ - IOError - If the `id` already exists in the current `Scan` instance. - If the `id` is not valid. + PermissionError + If no user is authenticated. + If the user lacks permission to create a fileset. + FilesetExistsError + If the ``fs_id`` already exists in the local database. + ValueError + If the given ``fs_id`` is invalid. See Also -------- - plantdb.commons.fsdb._is_valid_id - plantdb.commons.fsdb._make_fileset + plantdb.commons.fsdb.validation._is_valid_id + plantdb.commons.fsdb.file_ops._make_fileset Examples -------- @@ -1688,20 +2014,20 @@ def create_fileset(self, fs_id, metadata=None, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this fileset + if not self.db.rbac_manager.can_access_scan(current_user, self.get_metadata(), Permission.WRITE): + raise PermissionError(f"Insufficient permissions to create a fileset in the '{self.id}' scan!") + # Verify if the given `fs_id` is valid if not _is_valid_id(fs_id): - raise IOError(f"Invalid fileset identifier '{fs_id}'!") + raise ValueError(f"Invalid fileset identifier '{fs_id}'!") + # Verify if the given `fs_id` already exists in the local database + if self.fileset_exists(fs_id): + raise FilesetExistsError(self, fs_id) # Use exclusive lock for fileset creation - self.logger.info(f"Creating fileset '{fs_id}' from scan '{self.id}'") + self.logger.info(f"Creating a fileset '{fs_id}' in scan '{self.id}' as '{current_user.username}' user...") with self.db.lock_manager.acquire_lock(self.id, LockType.EXCLUSIVE, current_user.username): - # Verify if the given `fs_id` already exists in the local database - if self.fileset_exists(fs_id): - raise FilesetExistsError(self, fs_id) - # Create the new Fileset fileset = Fileset(self, fs_id) # Initialize a new Fileset instance _make_fileset(fileset) # Create directory structure @@ -1711,7 +2037,7 @@ def create_fileset(self, fs_id, metadata=None, **kwargs): now = iso_date_now() initial_metadata['created'] = now # creation timestamp initial_metadata['last_modified'] = now # modification timestamp - initial_metadata['created_by'] = current_user.username + initial_metadata['created_by'] = current_user.fullname # Cannot use fileset.set_metadata(initial_metadata) here as ownership is not granted yet! _set_metadata(fileset.metadata, initial_metadata, None) # add metadata dictionary to the new fileset @@ -1720,17 +2046,11 @@ def create_fileset(self, fs_id, metadata=None, **kwargs): self.filesets.update({fs_id: fileset}) # Update scan's filesets dictionary self.store() # Store fileset instance to the JSON - self.logger.info(f"Created new fileset '{fs_id}' in scan '{self.id}' for user '{current_user.username}'") - + self.logger.info(f"Done creating the fileset.") return fileset - def store(self): - """Save changes to the scan main JSON FILE (``files.json``).""" - _store_scan(self) - return - @require_authentication - def delete_fileset(self, fs_id, **kwargs): + def delete_fileset(self, fs_id, **kwargs) -> None: """Delete a given fileset from the scan dataset. Parameters @@ -1738,6 +2058,18 @@ def delete_fileset(self, fs_id, **kwargs): fs_id : str Name of the fileset to delete. + Raises + ------ + PermissionError + If no user is authenticated. + If the user does not have permission to delete this fileset. + ValueError + If the fileset does not exist. + + See Also + -------- + plantdb.commons.fsdb.file_ops._delete_fileset + Examples -------- >>> from plantdb.commons.test_database import dummy_db @@ -1754,22 +2086,29 @@ def delete_fileset(self, fs_id, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.owner != current_user.username: - raise PermissionError(f"Only the owner can delete filesets from scan '{self.id}'") + # Check DELETE permission for this fileset + if not self.db.rbac_manager.can_access_scan(current_user, self.get_metadata(), Permission.DELETE): + raise PermissionError( + f"Insufficient permissions to delete filesets from the '{self.id}' scan as '{current_user.username}' user!") + + # Verify if the given `fs_id` exists in the local database + if not self.fileset_exists(fs_id): + raise ValueError(f"Fileset '{fs_id}' does not exist in scan '{self.id}'") # Use exclusive lock for fileset deletion + self.logger.info(f"Deleting fileset '{fs_id}' from scan '{self.id}' as '{current_user.username}' user...") with self.db.lock_manager.acquire_lock(self.id, LockType.EXCLUSIVE, current_user.username): - # Verify if the given `fs_id` exists in the local database - if not self.fileset_exists(fs_id): - raise ValueError(f"Fileset '{fs_id}' does not exist in scan '{self.id}'") - fs = self.filesets[fs_id] _delete_fileset(fs) # delete the fileset self.filesets.pop(fs_id) # remove the Fileset instance from the scan self.store() # save the changes to the scan main JSON FILE (``files.json``) - self.logger.info(f"Deleted fileset '{fs_id}' from scan '{self.id}' by user '{current_user.username}'") + self.logger.info(f"Done deleting fileset.") + return + + def store(self): + """Save changes to the scan main JSON FILE (``files.json``).""" + _store_scan(self) return def path(self) -> pathlib.Path: @@ -1821,7 +2160,7 @@ def list_filesets(self, query=None, fuzzy=False) -> list: class Fileset(db.Fileset): - """Implement ``Fileset`` for the local *File System DataBase* from abstract class ``db.Fileset``. + """Implement ``Fileset`` for the local *File System DataBase* from the abstract class ``db.Fileset``. Implementation of a fileset as a simple files structure with: * directory ``${FSDB.basedir}/${FSDB.scan.id}/${Fileset.id}`` containing set of files; @@ -1963,7 +2302,7 @@ def get_file(self, f_id): # with self.db.lock_manager.acquire_lock(self.scan.id, LockType.SHARED, current_user.username or "guest"): # Use shared lock for read operations - with self.db.lock_manager.acquire_lock(self.scan.id, LockType.SHARED, "guest"): + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.SHARED, self.db.get_guest_user().username): if not self.file_exists(f_id): raise FileNotFoundError(self, f_id) @@ -2020,6 +2359,12 @@ def set_metadata(self, data, value=None, **kwargs): value : any, optional The value to assign to `data` if the latest is not a dictionary. + Raises + ------ + PermissionError + If no user is authenticated. + If the user lacks permission to modify the metadata. + Examples -------- >>> import json @@ -2040,14 +2385,19 @@ def set_metadata(self, data, value=None, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this fileset + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError(f"Insufficient permissions to edit the '{self.scan.id}/{self.id}' fileset metadata!") - _set_metadata(self.metadata, data, value) - # Ensure modification timestamp - self.metadata['last_modified'] = iso_date_now() - _store_fileset_metadata(self) + # Use exclusive lock for this operation + self.logger.info(f"Editing the '{self.scan.id}/{self.id}' fileset metadata...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + _set_metadata(self.metadata, data, value) + # Ensure modification timestamp + self.metadata['last_modified'] = iso_date_now() + _store_fileset_metadata(self) + + self.logger.info(f"Done editing the fileset metadata.") return @require_authentication @@ -2064,6 +2414,20 @@ def create_file(self, f_id, metadata=None, **kwargs): plantdb.commons.fsdb.core.File The `File` instance created in the current `Fileset` instance. + Raises + ------ + PermissionError + If no user is authenticated. + If the user lacks permission to create a fileset. + FileExistsError + If the ``f_id`` already exists in the local database. + ValueError + If the given ``f_id`` is invalid. + + See Also + -------- + plantdb.commons.fsdb.validation._is_valid_id + Examples -------- >>> from plantdb.commons.test_database import dummy_db @@ -2088,20 +2452,21 @@ def create_file(self, f_id, metadata=None, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this file + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError( + f"Insufficient permissions to create a file in the '{self.scan.id}' scan as '{current_user.username}' user!") # Verify if the given `fs_id` is valid if not _is_valid_id(f_id): - raise IOError(f"Invalid file identifier '{f_id}'!") + raise ValueError(f"Invalid file identifier '{f_id}'!") + # Verify if the given `f_id` already exists in the local database + if self.file_exists(f_id): + raise FileExistsError(self, f_id) # Use exclusive lock for file creation + self.logger.info(f"Creating a file '{f_id}' in '{self.scan.id}/{self.id}' as '{current_user.username}' user...") with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): - # Verify if the given `fs_id` already exists in the local database - if self.file_exists(f_id): - raise FileExistsError(self, f_id) - # Create the new File file = File(self, f_id) # Initialize a new File instance @@ -2110,7 +2475,7 @@ def create_file(self, f_id, metadata=None, **kwargs): now = iso_date_now() initial_metadata['created'] = now # creation timestamp initial_metadata['last_modified'] = now # modification timestamp - initial_metadata['created_by'] = current_user.username + initial_metadata['created_by'] = current_user.fullname # Cannot use fileset.set_metadata(initial_metadata) here as ownership is not granted yet! _set_metadata(file.metadata, initial_metadata, None) # add metadata dictionary to the new scan @@ -2119,9 +2484,7 @@ def create_file(self, f_id, metadata=None, **kwargs): self.files.update({f_id: file}) # Update filesets's files dictionary self.store() # Store fileset instance to the JSON - self.logger.info( - f"Created new file '{f_id}' in '{self.scan.id}/{self.id}' for user '{current_user.username}'") - + self.logger.info(f"Done creating the file.") return file @require_authentication @@ -2133,6 +2496,18 @@ def delete_file(self, f_id, **kwargs): f_id : str Name of the file to delete. + Raises + ------ + PermissionError + If no user is authenticated. + If the user does not have permission to delete this file. + ValueError + If the file does not exist. + + See Also + -------- + plantdb.commons.fsdb.file_ops._delete_file + Examples -------- >>> from plantdb.commons.test_database import dummy_db @@ -2154,19 +2529,24 @@ def delete_file(self, f_id, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check DELETE permission for this fileset + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.DELETE): + raise PermissionError( + f"Insufficient permissions to delete the files from the '{self.scan.id}' scan as '{current_user.username}' user!") # Verify if the given `fs_id` exists in the local database if not self.file_exists(f_id): - logging.warning(f"Given file identifier '{f_id}' does NOT exists!") - return + raise ValueError(f"File '{f_id}' does not exist in scan '{self.id}'") + + # Use exclusive lock for fileset creation + self.logger.info(f"Deleting file '{f_id}' from '{self.scan.id}/{self.id}' as '{current_user.username}' user...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + f = self.files[f_id] + _delete_file(f) # delete the file + self.files.pop(f_id) # remove the File instance from the fileset + self.store() # save the changes to the scan main JSON FILE (``files.json``) - f = self.files[f_id] - _delete_file(f) # delete the file - self.files.pop(f_id) # remove the File instance from the fileset - self.store() # save the changes to the scan main JSON FILE (``files.json``) + self.logger.info(f"Done deleting file.") return def store(self): @@ -2302,7 +2682,8 @@ def get_metadata(self, key=None, default={}): """ return _get_metadata(self.metadata, key, default) - def set_metadata(self, data, value=None): + @require_authentication + def set_metadata(self, data, value=None, **kwargs): """Add a new metadata to the file. Parameters @@ -2330,10 +2711,24 @@ def set_metadata(self, data, value=None): {'random json': True, 'test': 'value'} >>> db.disconnect() # clean up (delete) the temporary dummy database """ - _set_metadata(self.metadata, data, value) - # Ensure modification timestamp - self.metadata['last_modified'] = iso_date_now() - _store_file_metadata(self) + current_user = self.db.get_user_data(**kwargs) + if not current_user: + raise PermissionError("No authenticated user!") + + # Check WRITE permission for this fileset + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError( + f"Insufficient permissions to edit the '{self.scan.id}/{self.fileset.id}/{self.id}' file metadata!") + + # Use exclusive lock for this operation + self.logger.info(f"Editing the '{self.scan.id}/{self.fileset.id}/{self.id}' fileset metadata...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + _set_metadata(self.metadata, data, value) + # Ensure modification timestamp + self.metadata['last_modified'] = iso_date_now() + _store_file_metadata(self) + + self.logger.info(f"Done editing the file metadata.") return @require_authentication @@ -2363,23 +2758,31 @@ def import_file(self, path, **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this file + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError( + f"Insufficient permissions to write '{self.filename}' file in '{self.scan.id}/{self.fileset.id}' as '{current_user.username}' user!") # Check if the path is a file if isinstance(path, str): path = Path(path) if not os.path.isfile(path): - raise ValueError("The provided path is not a file.") - # Get the file name and extension - ext = path.suffix[1:] - self.filename = _get_filename(self, ext) - # Get the path to the new `File` instance - newpath = _file_path(self) - # Copy the file to its new destination - copyfile(path, newpath) - self.store() # register it to the scan main JSON FILE + raise ValueError(f"The provided path is not a file: {path}.") + + # Use exclusive lock for this operation + self.logger.info( + f"Importing file '{self.id}' in '{self.scan.id}/{self.fileset.id}' as user '{current_user.username}'...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + # Get the file name and extension + ext = path.suffix[1:] + self.filename = _get_filename(self, ext) + # Get the path to the new `File` instance + newpath = _file_path(self) + # Copy the file to its new destination + copyfile(path, newpath) + self.store() # register it to the scan main JSON FILE + + self.logger.info(f"Done importing file.") return def store(self): @@ -2449,15 +2852,22 @@ def write_raw(self, data, ext="", **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this file + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError( + f"Insufficient permissions to write raw '{self.filename}' file in '{self.scan.id}/{self.fileset.id}' as '{current_user.username}' user!") - self.filename = _get_filename(self, ext) - path = _file_path(self) - with path.open(mode="wb") as f: - f.write(data) - self.store() + # Use exclusive lock for this operation + self.logger.info( + f"Writing raw file '{self.id}' in '{self.scan.id}/{self.fileset.id}' as user '{current_user.username}'...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + self.filename = _get_filename(self, ext) + path = _file_path(self) + with path.open(mode="wb") as f: + f.write(data) + self.store() + + self.logger.info(f"Done writing raw file.") return def read(self): @@ -2527,15 +2937,22 @@ def write(self, data, ext="", **kwargs): if not current_user: raise PermissionError("No authenticated user!") - # Check ownership - if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + # Check WRITE permission for this file + if not self.db.rbac_manager.can_access_scan(current_user, self.scan.get_metadata(), Permission.WRITE): + raise PermissionError( + f"Insufficient permissions to write '{self.filename}' file in '{self.scan.id}/{self.fileset.id}' as '{current_user.username}' user!") - self.filename = _get_filename(self, ext) - path = _file_path(self) - with path.open(mode="w") as f: - f.write(data) - self.store() + # Use exclusive lock for this operation + self.logger.info( + f"Writing file '{self.id}' in '{self.scan.id}/{self.fileset.id}' as user '{current_user.username}'...") + with self.db.lock_manager.acquire_lock(self.scan.id, LockType.EXCLUSIVE, current_user.username): + self.filename = _get_filename(self, ext) + path = _file_path(self) + with path.open(mode="w") as f: + f.write(data) + self.store() + + self.logger.info(f"Done writing file.") return def path(self) -> pathlib.Path: diff --git a/src/commons/plantdb/commons/fsdb/metadata.py b/src/commons/plantdb/commons/fsdb/metadata.py index e2433ff4..08dcf37b 100644 --- a/src/commons/plantdb/commons/fsdb/metadata.py +++ b/src/commons/plantdb/commons/fsdb/metadata.py @@ -44,6 +44,7 @@ import copy import json from pathlib import Path +from typing import Any from .path_helpers import _file_metadata_path from .path_helpers import _fileset_metadata_json_path @@ -53,7 +54,7 @@ logger = get_logger(__name__) -def _load_fileset_metadata(fileset): +def _load_fileset_metadata(fileset) -> dict: """Load the metadata for a fileset. Parameters @@ -69,7 +70,7 @@ def _load_fileset_metadata(fileset): return _load_metadata(_fileset_metadata_json_path(fileset)) -def _load_metadata(path): +def _load_metadata(path) -> dict: """Load a metadata dictionary from a JSON file. Parameters @@ -104,7 +105,7 @@ def _load_metadata(path): return md -def _load_scan_metadata(scan): +def _load_scan_metadata(scan) -> dict: """Load the metadata for a scan dataset. Parameters @@ -131,7 +132,7 @@ def _load_scan_metadata(scan): return scan_md -def _load_file_metadata(file): +def _load_file_metadata(file) -> dict: """Load the metadata for a file. Parameters @@ -147,8 +148,8 @@ def _load_file_metadata(file): return _load_metadata(_file_metadata_path(file)) -def _mkdir_metadata(path): - """Create the parent directories from given path. +def _mkdir_metadata(path) -> None: + """Create the parent directories from a given path. Parameters ---------- @@ -165,7 +166,7 @@ def _mkdir_metadata(path): return -def _store_metadata(path, metadata): +def _store_metadata(path, metadata) -> None: """Save a metadata dictionary as a JSON file. Parameters @@ -181,7 +182,7 @@ def _store_metadata(path, metadata): return -def _store_scan_metadata(scan): +def _store_scan_metadata(scan) -> None: """Save the metadata for a dataset. Parameters @@ -193,7 +194,7 @@ def _store_scan_metadata(scan): return -def _store_fileset_metadata(fileset): +def _store_fileset_metadata(fileset) -> None: """Save the metadata for a dataset. Parameters @@ -205,7 +206,7 @@ def _store_fileset_metadata(fileset): return -def _store_file_metadata(file): +def _store_file_metadata(file) -> None: """Save the metadata for a dataset. Parameters @@ -217,18 +218,18 @@ def _store_file_metadata(file): return -def _get_metadata(metadata=None, key=None, default={}): +def _get_metadata(metadata=None, key=None, default={}) -> Any: """Get a copy of `metadata[key]`. Parameters ---------- metadata : dict, optional - The metadata dictionary to get the key from. + The metadata dictionary to get the `key` from. key : str, optional - The key to get from the metadata dictionary. + The `key` to get from the metadata dictionary. By default, return a copy of the whole metadata dictionary. default : Any, optional - The default value to return if the key do not exist in the metadata. + The default value to return if the `key` does not exist in the metadata. Default is an empty dictionary ``{}``. Returns @@ -245,8 +246,8 @@ def _get_metadata(metadata=None, key=None, default={}): return copy.deepcopy(metadata.get(str(key), default)) -def _set_metadata(metadata, data, value): - """Set a `data` `value` in `metadata` dictionary. +def _set_metadata(metadata, data, value) -> None: + """Set a `data` `value` in the ` metadata ` dictionary. Parameters ---------- @@ -261,12 +262,11 @@ def _set_metadata(metadata, data, value): Raises ------ IOError - If `value` is `None` when `data` is a string. If `data` is not of the right type. """ if isinstance(data, str): if value is None: - raise IOError(f"No value given for key '{data}'!") + logger.warning(f"Metadata key '{data}' was set to `None`!") # Do a deepcopy of the value because we don't want the caller to inadvertently change the values. metadata[data] = copy.deepcopy(value) elif isinstance(data, dict): diff --git a/src/commons/plantdb/commons/test_database.py b/src/commons/plantdb/commons/test_database.py index 609f1508..5c0b56bc 100644 --- a/src/commons/plantdb/commons/test_database.py +++ b/src/commons/plantdb/commons/test_database.py @@ -562,10 +562,11 @@ def test_database(dataset='real_plant_analyzed', db_path=None, **kwargs): >>> db.disconnect() """ from plantdb.commons.fsdb.core import FSDB + session_manager = kwargs.pop('session_manager', None) if dataset is None: - return FSDB(setup_empty_database(db_path=db_path)) + return FSDB(setup_empty_database(db_path=db_path), session_manager=session_manager) else: - return FSDB(setup_test_database(dataset, db_path=db_path, **kwargs)) + return FSDB(setup_test_database(dataset, db_path=db_path, **kwargs), session_manager=session_manager) def dummy_db(with_scan=False, with_fileset=False, with_file=False): @@ -591,15 +592,19 @@ def dummy_db(with_scan=False, with_fileset=False, with_file=False): Notes ----- - - Returns a 'connected' database, no need to call the `connect()` method. - - Uses the 'anonymous' user to login. + - Returns a 'connected' database, no need to call the ``connect()`` method. + - Uses the 'admin' user to login. + - Calling the ``disconnect()`` method will clean up the associated temporary directory. Examples -------- >>> from plantdb.commons.test_database import dummy_db >>> db = dummy_db(with_file=True) >>> db.connect() - INFO [plantdb.commons.fsdb] Already connected as 'anonymous' to the database '/tmp/romidb_********'! + INFO [FSDB] Connected to database successfully + >>> from plantdb.commons.fsdb.core import get_logged_username + >>> get_logged_username(db) # 'admin' is logged by default + 'admin' >>> print(db.path()) # the database directory /tmp/romidb_******** >>> print(db.list_scans()) @@ -628,6 +633,8 @@ def dummy_db(with_scan=False, with_fileset=False, with_file=False): marker_file.open(mode='w').close() # Create the FSDB instance and connect db = FSDB(db_path, required_filesets=[], session_manager=SingleSessionManager()) + # Flag this instance as a dummy DB so that disconnect will clean up the temp folder + db._is_dummy = True db.connect() # Login as adin to get all the rights (to create and edit) _ = db.login('admin', 'admin') diff --git a/src/commons/tests/test_auth.py b/src/commons/tests/test_auth.py index 5c567704..731bb2d9 100644 --- a/src/commons/tests/test_auth.py +++ b/src/commons/tests/test_auth.py @@ -1,6 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -import copy import json import logging import os @@ -21,211 +20,6 @@ from plantdb.commons.auth.session import SessionManager -class TestPermission(unittest.TestCase): - """Test cases for Permission class""" - - def test_permission_constants_are_defined(self): - """Test that all permission constants are properly defined.""" - # Verify all expected permission constants exist - self.assertTrue(hasattr(Permission, 'READ')) - self.assertTrue(hasattr(Permission, 'WRITE')) - self.assertTrue(hasattr(Permission, 'CREATE')) - self.assertTrue(hasattr(Permission, 'DELETE')) - self.assertTrue(hasattr(Permission, 'MANAGE_USERS')) - self.assertTrue(hasattr(Permission, 'MANAGE_GROUPS')) - - def test_permission_values_are_strings(self): - """Test that permission constants have expected string values.""" - # Verify permission values are what we expect - self.assertIsInstance(Permission.READ.value, str) - self.assertIsInstance(Permission.WRITE.value, str) - self.assertIsInstance(Permission.CREATE.value, str) - self.assertIsInstance(Permission.DELETE.value, str) - self.assertIsInstance(Permission.MANAGE_USERS.value, str) - self.assertIsInstance(Permission.MANAGE_GROUPS.value, str) - - -class TestRole(unittest.TestCase): - """Test cases for Role class""" - - def test_role_constants_are_defined(self): - """Test that all role constants are properly defined.""" - # Verify all expected role constants exist - self.assertTrue(hasattr(Role, 'READER')) - self.assertTrue(hasattr(Role, 'CONTRIBUTOR')) - self.assertTrue(hasattr(Role, 'ADMIN')) - - def test_permissions_method_returns_list_for_valid_roles(self): - """Test that permissions method returns appropriate permissions for each role.""" - # Test that permissions method works for all roles - reader_perms = Role.READER.permissions - contributor_perms = Role.CONTRIBUTOR.permissions - admin_perms = Role.ADMIN.permissions - - # Verify return types are lists - self.assertIsInstance(reader_perms, set) - self.assertIsInstance(contributor_perms, set) - self.assertIsInstance(admin_perms, set) - - -class TestUser(unittest.TestCase): - """Test cases for User class""" - - def setUp(self): - """Set up test fixtures before each test method.""" - self.sample_user_data = { - 'username': 'testuser', - 'fullname': 'Test User', - 'password_hash': 'hashed_password', - 'roles': {Role.READER}, - 'created_at': datetime.now(), - 'is_active': True, - 'failed_attempts': 0, - 'last_failed_attempt': None, - 'locked_until': None - } - self.user = User(**self.sample_user_data) - - def test_user_to_dict_serializes_correctly(self): - """Test that to_dict method properly serializes user data.""" - # Create user with sample data - user_dict = self.user.to_dict() - - # Verify essential fields are present - self.assertIn('username', user_dict) - self.assertIn('fullname', user_dict) - self.assertIn('roles', user_dict) - self.assertIn('is_active', user_dict) - self.assertEqual(user_dict['username'], 'testuser') - - def test_user_from_dict_deserializes_correctly(self): - """Test that from_dict method properly creates User from dictionary.""" - # Create user from dictionary - user = User.from_dict(self.sample_user_data) - - # Verify user properties - self.assertEqual(user.username, self.sample_user_data['username']) - self.assertEqual(user.fullname, self.sample_user_data['fullname']) - self.assertEqual(user.roles, self.sample_user_data['roles']) - self.assertTrue(user.is_active) - - def test_user_to_json_returns_valid_json_string(self): - """Test that to_json method returns valid JSON string.""" - user = User(**self.sample_user_data) - json_str = user.to_json() - - # Verify it's valid JSON - self.assertIsInstance(json_str, str) - parsed_data = json.loads(json_str) - self.assertIsInstance(parsed_data, dict) - self.assertEqual(parsed_data['username'], 'testuser') - - def test_user_from_json_creates_user_from_json_string(self): - """Test that from_json method creates User from JSON string.""" - user = User(**self.sample_user_data) - json_str = user.to_json() - - # Create new user from JSON - new_user = User.from_json(json_str) - - # Verify users are equivalent - self.assertEqual(user.username, new_user.username) - self.assertEqual(user.fullname, new_user.fullname) - self.assertEqual(user.roles, new_user.roles) - - def test_is_locked_out_returns_true_when_locked(self): - """Test that _is_locked_out returns True when user is currently locked.""" - self.user.locked_until = datetime.now() + timedelta(minutes=1) - self.assertTrue(self.user._is_locked_out()) - - def test_is_locked_out_returns_false_when_lock_expired(self): - """Test that _is_locked_out returns False when lock has expired.""" - # Set lock time in past - self.user.locked_until = datetime.now() - timedelta(hours=1) - self.assertFalse(self.user._is_locked_out()) - - def test_record_failed_attempt_increments_counter(self): - """Test that _record_failed_attempt properly increments failed attempts.""" - user = User(**self.sample_user_data) - initial_attempts = copy.copy(user.failed_attempts) - - # Record failed attempt - user._record_failed_attempt() - - # Verify counter incremented and timestamp updated - self.assertEqual(user.failed_attempts, initial_attempts + 1) - self.assertIsNotNone(user.last_failed_attempt) - - -class TestGroup(unittest.TestCase): - """Test cases for Group class""" - - def setUp(self): - """Set up test fixtures before each test method.""" - self.group = Group( - name="testgroup", - users=set(), - created_by="admin", - description="Test group", - created_at=datetime.now(), - ) - - def test_add_user_adds_user_to_group(self): - """Test that add_user method successfully adds user to group.""" - # Initially group should be empty - self.assertEqual(len(self.group.users), 0) - - # Add user to group - self.group.add_user("testuser") - - # Verify user was added - self.assertEqual(len(self.group.users), 1) - self.assertIn("testuser", self.group.users) - - def test_add_user_prevents_duplicate_users(self): - """Test that add_user prevents adding the same user twice.""" - # Add user twice - self.group.add_user("testuser") - self.group.add_user("testuser") - - # User should only appear once - self.assertEqual(len(self.group.users), 1) - - def test_remove_user_removes_user_from_group(self): - """Test that remove_user method successfully removes user from group.""" - # Add user first - self.group.add_user("testuser") - self.assertIn("testuser", self.group.users) - - # Remove user - self.group.remove_user("testuser") - - # Verify user was removed - self.assertNotIn("testuser", self.group.users) - - def test_remove_user_handles_nonexistent_user(self): - """Test that remove_user gracefully handles removing non-existent user.""" - # Try to remove user that doesn't exist - initial_count = len(self.group.users) - self.group.remove_user("nonexistent") - - # Group should remain unchanged - self.assertEqual(len(self.group.users), initial_count) - - def test_has_user_returns_true_for_existing_user(self): - """Test that has_user returns True when user exists in group.""" - # Add user to group - self.group.add_user("testuser") - - # Check if user exists - self.assertTrue(self.group.has_user("testuser")) - - def test_has_user_returns_false_for_nonexistent_user(self): - """Test that has_user returns False when user doesn't exist in group.""" - # Check for non-existent user - self.assertFalse(self.group.has_user("nonexistent")) - - class TestUserManager(unittest.TestCase): """Test cases for UserManager class""" diff --git a/src/commons/tests/test_auth_models.py b/src/commons/tests/test_auth_models.py new file mode 100644 index 00000000..9641f6a7 --- /dev/null +++ b/src/commons/tests/test_auth_models.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import copy +import json +import unittest +from datetime import datetime +from datetime import timedelta + +from plantdb.commons.auth.models import Group +from plantdb.commons.auth.models import Permission +from plantdb.commons.auth.models import Role +from plantdb.commons.auth.models import User + + +class TestPermission(unittest.TestCase): + """Test cases for Permission class""" + + def test_permission_constants_are_defined(self): + """Test that all permission constants are properly defined.""" + # Verify all expected permission constants exist + self.assertTrue(hasattr(Permission, 'READ')) + self.assertTrue(hasattr(Permission, 'WRITE')) + self.assertTrue(hasattr(Permission, 'CREATE')) + self.assertTrue(hasattr(Permission, 'DELETE')) + self.assertTrue(hasattr(Permission, 'MANAGE_USERS')) + self.assertTrue(hasattr(Permission, 'MANAGE_GROUPS')) + + def test_permission_values_are_strings(self): + """Test that permission constants have expected string values.""" + # Verify permission values are what we expect + self.assertIsInstance(Permission.READ.value, str) + self.assertIsInstance(Permission.WRITE.value, str) + self.assertIsInstance(Permission.CREATE.value, str) + self.assertIsInstance(Permission.DELETE.value, str) + self.assertIsInstance(Permission.MANAGE_USERS.value, str) + self.assertIsInstance(Permission.MANAGE_GROUPS.value, str) + + +class TestRole(unittest.TestCase): + """Test cases for Role class""" + + def test_role_constants_are_defined(self): + """Test that all role constants are properly defined.""" + # Verify all expected role constants exist + self.assertTrue(hasattr(Role, 'READER')) + self.assertTrue(hasattr(Role, 'CONTRIBUTOR')) + self.assertTrue(hasattr(Role, 'ADMIN')) + + def test_permissions_method_returns_list_for_valid_roles(self): + """Test that permissions method returns appropriate permissions for each role.""" + # Test that permissions method works for all roles + reader_perms = Role.READER.permissions + contributor_perms = Role.CONTRIBUTOR.permissions + admin_perms = Role.ADMIN.permissions + + # Verify return types are lists + self.assertIsInstance(reader_perms, set) + self.assertIsInstance(contributor_perms, set) + self.assertIsInstance(admin_perms, set) + + def test_role_rank_order(self): + """Verify the hierarchical rank values are as expected.""" + self.assertEqual(Role.READER.rank, 1) + self.assertEqual(Role.CONTRIBUTOR.rank, 2) + self.assertEqual(Role.ADMIN.rank, 3) + + self.assertGreater(Role.ADMIN.rank, Role.CONTRIBUTOR.rank) + self.assertGreater(Role.CONTRIBUTOR.rank, Role.READER.rank) + + def test_can_assign_logic(self): + """Check that role assignment authority follows the rank hierarchy.""" + # ADMIN can assign anyone (including themselves) + self.assertTrue(Role.ADMIN.can_assign(Role.ADMIN)) + self.assertTrue(Role.ADMIN.can_assign(Role.CONTRIBUTOR)) + self.assertTrue(Role.ADMIN.can_assign(Role.READER)) + + # CONTRIBUTOR can assign themselves and lower roles + self.assertTrue(Role.CONTRIBUTOR.can_assign(Role.CONTRIBUTOR)) + self.assertTrue(Role.CONTRIBUTOR.can_assign(Role.READER)) + self.assertFalse(Role.CONTRIBUTOR.can_assign(Role.ADMIN)) + + # READER can only assign themselves + self.assertTrue(Role.READER.can_assign(Role.READER)) + self.assertFalse(Role.READER.can_assign(Role.CONTRIBUTOR)) + self.assertFalse(Role.READER.can_assign(Role.ADMIN)) + +class TestUser(unittest.TestCase): + """Test cases for User class""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.sample_user_data = { + 'username': 'testuser', + 'fullname': 'Test User', + 'password_hash': 'hashed_password', + 'roles': {Role.READER}, + 'created_at': datetime.now(), + 'is_active': True, + 'failed_attempts': 0, + 'last_failed_attempt': None, + 'locked_until': None + } + self.user = User(**self.sample_user_data) + + def test_user_to_dict_serializes_correctly(self): + """Test that to_dict method properly serializes user data.""" + # Create user with sample data + user_dict = self.user.to_dict() + + # Verify essential fields are present + self.assertIn('username', user_dict) + self.assertIn('fullname', user_dict) + self.assertIn('roles', user_dict) + self.assertIn('is_active', user_dict) + self.assertEqual(user_dict['username'], 'testuser') + + def test_user_from_dict_deserializes_correctly(self): + """Test that from_dict method properly creates User from dictionary.""" + # Create user from dictionary + user = User.from_dict(self.sample_user_data) + + # Verify user properties + self.assertEqual(user.username, self.sample_user_data['username']) + self.assertEqual(user.fullname, self.sample_user_data['fullname']) + self.assertEqual(user.roles, self.sample_user_data['roles']) + self.assertTrue(user.is_active) + + def test_user_to_json_returns_valid_json_string(self): + """Test that to_json method returns valid JSON string.""" + user = User(**self.sample_user_data) + json_str = user.to_json() + + # Verify it's valid JSON + self.assertIsInstance(json_str, str) + parsed_data = json.loads(json_str) + self.assertIsInstance(parsed_data, dict) + self.assertEqual(parsed_data['username'], 'testuser') + + def test_user_from_json_creates_user_from_json_string(self): + """Test that from_json method creates User from JSON string.""" + user = User(**self.sample_user_data) + json_str = user.to_json() + + # Create new user from JSON + new_user = User.from_json(json_str) + + # Verify users are equivalent + self.assertEqual(user.username, new_user.username) + self.assertEqual(user.fullname, new_user.fullname) + self.assertEqual(user.roles, new_user.roles) + + def test_is_locked_out_returns_true_when_locked(self): + """Test that _is_locked_out returns True when user is currently locked.""" + self.user.locked_until = datetime.now() + timedelta(minutes=1) + self.assertTrue(self.user._is_locked_out()) + + def test_is_locked_out_returns_false_when_lock_expired(self): + """Test that _is_locked_out returns False when lock has expired.""" + # Set lock time in past + self.user.locked_until = datetime.now() - timedelta(hours=1) + self.assertFalse(self.user._is_locked_out()) + + def test_record_failed_attempt_increments_counter(self): + """Test that _record_failed_attempt properly increments failed attempts.""" + user = User(**self.sample_user_data) + initial_attempts = copy.copy(user.failed_attempts) + + # Record failed attempt + user._record_failed_attempt() + + # Verify counter incremented and timestamp updated + self.assertEqual(user.failed_attempts, initial_attempts + 1) + self.assertIsNotNone(user.last_failed_attempt) + + +class TestGroup(unittest.TestCase): + """Test cases for Group class""" + + def setUp(self): + """Set up test fixtures before each test method.""" + self.group = Group( + name="testgroup", + users=set(), + created_by="admin", + description="Test group", + created_at=datetime.now(), + ) + + def test_add_user_adds_user_to_group(self): + """Test that add_user method successfully adds user to group.""" + # Initially group should be empty + self.assertEqual(len(self.group.users), 0) + + # Add user to group + self.group.add_user("testuser") + + # Verify user was added + self.assertEqual(len(self.group.users), 1) + self.assertIn("testuser", self.group.users) + + def test_add_user_prevents_duplicate_users(self): + """Test that add_user prevents adding the same user twice.""" + # Add user twice + self.group.add_user("testuser") + self.group.add_user("testuser") + + # User should only appear once + self.assertEqual(len(self.group.users), 1) + + def test_remove_user_removes_user_from_group(self): + """Test that remove_user method successfully removes user from group.""" + # Add user first + self.group.add_user("testuser") + self.assertIn("testuser", self.group.users) + + # Remove user + self.group.remove_user("testuser") + + # Verify user was removed + self.assertNotIn("testuser", self.group.users) + + def test_remove_user_handles_nonexistent_user(self): + """Test that remove_user gracefully handles removing non-existent user.""" + # Try to remove user that doesn't exist + initial_count = len(self.group.users) + self.group.remove_user("nonexistent") + + # Group should remain unchanged + self.assertEqual(len(self.group.users), initial_count) + + def test_has_user_returns_true_for_existing_user(self): + """Test that has_user returns True when user exists in group.""" + # Add user to group + self.group.add_user("testuser") + + # Check if user exists + self.assertTrue(self.group.has_user("testuser")) + + def test_has_user_returns_false_for_nonexistent_user(self): + """Test that has_user returns False when user doesn't exist in group.""" + # Check for non-existent user + self.assertFalse(self.group.has_user("nonexistent")) diff --git a/src/server/plantdb/server/cli/fsdb_rest_api.py b/src/server/plantdb/server/cli/fsdb_rest_api.py index b2e66b34..35bd8261 100644 --- a/src/server/plantdb/server/cli/fsdb_rest_api.py +++ b/src/server/plantdb/server/cli/fsdb_rest_api.py @@ -44,7 +44,7 @@ - ``PLANTDB_API_PREFIX``: Prefix for the REST API URL. Default is empty. - ``PLANTDB_API_SSL``: Enable SSL to use an HTTPS scheme. Default is `False`. - ``FLASK_SECRET_KEY``: The secret key to use with flask. Default to random (32 bits secret). -- ``JWT_SECRET_KEY``: The secret key to use with JWT token generator. Default to random (32 bits secret). +- ``JWT_SECRET_KEY``: The secret key to use with JSON Web Token generator. Default to random (32 bits secret). Usage Examples -------------- @@ -259,16 +259,20 @@ def _setup_test_database(empty: bool, models: bool, db_path: Optional[Union[str, db_path : Optional[Union[str, Path]] Existing database location or ``None`` to create a temp folder. logger : logging.Logger - Logger instance for warning and debugging. + A logger instance for warning and debugging. Returns ------- Path - Path to the created test database. + The path to the created test database. """ + jwt_key = _get_env_secret("JWT_SECRET_KEY", logger) if empty: logger.info("Setting up a temporary test database without any datasets or configurations...") - db_path = test_database(None, db_path=db_path).path() + db_path = test_database( + None, db_path=db_path, + session_manager=JWTSessionManager(secret_key=jwt_key) + ).path() else: logger.info("Setting up a temporary test database with sample datasets and configurations...") db_path = test_database( @@ -276,6 +280,7 @@ def _setup_test_database(empty: bool, models: bool, db_path: Optional[Union[str, db_path=db_path, with_configs=True, with_models=models, + session_manager=JWTSessionManager(secret_key=jwt_key) ).path() return Path(db_path) diff --git a/src/server/plantdb/server/cli/wsgi.py b/src/server/plantdb/server/cli/wsgi.py index d306f667..0831d78a 100644 --- a/src/server/plantdb/server/cli/wsgi.py +++ b/src/server/plantdb/server/cli/wsgi.py @@ -19,7 +19,7 @@ - ``PLANTDB_API_PREFIX``: Prefix for the REST API URL. Default is empty. - ``PLANTDB_API_SSL``: Enable SSL to use an HTTPS scheme. Default is `False`. - ``FLASK_SECRET_KEY``: The secret key to use with flask. Default to random (32 bits secret). -- ``JWT_SECRET_KEY``: The secret key to use with JWT token generator. Default to random (32 bits secret). +- ``JWT_SECRET_KEY``: The secret key to use with JSON Web Token generator. Default to random (32 bits secret). Usage Examples -------------- diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 8bcd7c5e..dfd185ee 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -26,10 +26,10 @@ """ This module regroup the classes and methods used to serve a REST API using ``fsdb_rest_api`` CLI. """ - import datetime import hashlib import json +import mimetypes import os import threading import time @@ -39,8 +39,10 @@ from math import radians from pathlib import Path from tempfile import mkstemp +from typing import Optional from zipfile import ZipFile +import pybase64 from flask import Response from flask import after_this_request from flask import jsonify @@ -697,20 +699,36 @@ def wrapped(*args, **kwargs): return decorator -def jwt_from_header(request): +def jwt_from_header(request) -> str: + """Extracts the JWT token from the Authorization header of an HTTP request. + + Parameters + ---------- + request : object + An HTTP request object that provides a ``headers`` attribute. + + Returns + ------- + str + The JWT token extracted from the ``Authorization`` header, or an + empty string if the header is missing or empty. + + Notes + ----- + * The function performs a simple string replacement and does **not** + check that the resulting token is a valid JWT. + """ return request.headers.get('Authorization', "").replace('Bearer ', '') -def requires_jwt(f): - """Decorator to require JWT validation.""" +def add_jwt_from_header(f): + """Retrieve the JSON Web Token from the header and add it to keyword arguments, if any.""" @wraps(f) def decorated_function(*args, **kwargs): - # Try to get JWT token from request header + # Try to get JSON Web Token from the request header jwt_token = jwt_from_header(request) - # Verify we actually got a token (do not test its validity, this is done by the database) - if not jwt_token: - return {'message': 'Authentication required, JSON Web Token missing!'}, 401 + # Do not verify we actually got a token or test its validity; this is done by the database) # Add token to keyword arguments (for later use by FSD methods) kwargs['token'] = jwt_token return f(*args, **kwargs) @@ -832,7 +850,8 @@ def __init__(self, db): self.db = db @rate_limit(max_requests=5, window_seconds=60) # maximum of 1 requests per minute - def post(self): + @add_jwt_from_header + def post(self, **kwargs): """Handle HTTP POST request to register a new user. Processes user registration by validating the input data and creating a new user in the database. @@ -869,19 +888,23 @@ def post(self): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json - >>> # Create a new user: + >>> # Start by login as admin to have permission to create new users + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin', 'password': 'admin'}) + >>> token = response.json()['access_token'] + >>> # Now create a new user: >>> new_user = {"username":"batman", "fullname":"Bruce Wayne", "password":"Alfred123!"} - >>> response = requests.post("http://127.0.0.1:5000/register", json=new_user) + >>> response = requests.post("http://127.0.0.1:5000/register", json=new_user, headers={'Authorization': 'Bearer ' + token}) >>> res_dict = response.json() >>> res_dict["success"] True >>> res_dict["message"] 'User successfully created' + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'batman', 'password': 'Alfred123!'}) + >>> res_dict["message"] + 'Login successful' """ # Parse JSON data from request body data = request.get_json() - # Check if all required fields are present in the request required_fields = ['username', 'fullname', 'password'] if not data or not all(field in data for field in required_fields): @@ -891,11 +914,12 @@ def post(self): }, 400 try: - # Attempt to create new user in database + # Attempt to create a new user in the database self.db.create_user( - username=data['username'], - fullname=data['fullname'], - password=data['password'] + new_username=data.pop('username'), + fullname=data.pop('fullname'), + password=data.pop('password'), + **kwargs ) # Return success response if user creation succeeds return { @@ -960,11 +984,10 @@ def get(self): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json >>> # Check if user exists (valid username): - >>> response = requests.get("http://127.0.0.1:5000/login?username=anonymous") + >>> response = requests.get("http://127.0.0.1:5000/login?username=admin") >>> print(response.json()) - {'username': 'anonymous', 'exists': True} + {'username': 'admin', 'exists': True} >>> # Check if user exists (invalid username): >>> response = requests.get("http://127.0.0.1:5000/login?username=superman") >>> print(response.json()) @@ -1017,13 +1040,13 @@ def post(self): >>> # $ fsdb_rest_api --test >>> import requests >>> # Valid login request - >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'anonymous', 'password': 'AlanMoore'}) + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin', 'password': 'admin'}) >>> print(response.json()) {'authenticated': True, 'message': 'Login successful. Welcome, Guy Fawkes!'} >>> print(response.status_code) 200 >>> # Invalid request (missing credentials) - >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'anonymous'}) + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin'}) >>> print(response.json()) {'authenticated': False, 'message': 'Missing username or password'} >>> print(response.status_code) @@ -1044,7 +1067,7 @@ def post(self): # Prepare response based on authentication result if jwt_token: - user = self.db.get_user_data(session_token=jwt_token) + user = self.db.get_user_data(token=jwt_token) # Create response with user info & access token response_data = { 'message': 'Login successful', @@ -1072,16 +1095,29 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): - """Handle user logout and clear cookie.""" - # Get token from keyword arguments (from decorator) - jwt_token = kwargs.get('token', None) - + """Handle user logout. + + Examples + -------- + >>> # Start a test REST API server first: + >>> # $ fsdb_rest_api --test + >>> import requests + >>> # Start by log in as 'admin' + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin', 'password': 'admin'}) + >>> print(response.json()['message']) + Login successful + >>> token = response.json()['access_token'] + >>> # Now try to log out: + >>> response = requests.post("http://127.0.0.1:5000/logout", headers={'Authorization': 'Bearer ' + token}) + >>> print(response.json()['message']) + Logout successful + """ try: - if jwt_token: + if 'token' in kwargs: # Invalidate session - self.db.session_manager.invalidate_session(jwt_token) + self.db.logout(**kwargs) response = {'message': 'Logout successful'}, 200 else: self.logger.error(f"Logout error: no active session!") @@ -1095,10 +1131,9 @@ def post(self, **kwargs): class TokenValidation(Resource): - """ - Validate a JSON Web Token (JWT) and retrieve associated user data. + """Validate a JSON Web Token (JWT) and retrieve associated user data. - The resource exposes a POST endpoint that accepts a JWT token, verifies its + The resource exposes a POST endpoint that accepts a JSON Web Token, verifies its validity against the database session manager, and returns the authenticated user’s basic profile information. On success a 200 response is returned containing the user’s ``username`` and ``fullname``; on failure a 401 @@ -1117,22 +1152,6 @@ class TokenValidation(Resource): Database handler providing access to the session manager. logger : Any Logger instance used for recording authentication events. - - Examples - -------- - >>> from flask import Flask - >>> from flask_restful import Api - >>> app = Flask(__name__) - >>> api = Api(app) - >>> # Assume `db` and `logger` are pre‑configured objects - >>> api.add_resource(TokenValidation, '/validate') - >>> # In a test client - >>> with app.test_client() as client: - ... response = client.post('/validate', json={'token': 'valid.jwt.token'}) - ... print(response.status_code) # 200 - ... print(response.json) - ... # {'message': 'Token validation successful', - ... # 'user': {'username': 'jdoe', 'fullname': 'John Doe'}} """ def __init__(self, db, logger): @@ -1140,14 +1159,25 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): - """Handle token validation.""" - # Get token from keyword arguments (from decorator) - jwt_token = kwargs.get('token', None) + """Handle JSON Web Token validation. + Examples + -------- + >>> # Start a test REST API server first: + >>> # $ fsdb_rest_api --test + >>> import requests + >>> # Start by login as admin + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin', 'password': 'admin'}) + >>> token = response.json()['access_token'] + >>> # Now create a new user: + >>> response = requests.post("http://127.0.0.1:5000/token-validation", headers={'Authorization': 'Bearer ' + token}) + >>> print(response.json()['message']) + Token validation successful + """ try: - user = self.db.get_user_data(session_token=jwt_token) + user = self.db.get_user_data(**kwargs) except Exception as e: response = {'message': f'Token validation failed: {e}'}, 401 else: @@ -1162,8 +1192,7 @@ def post(self, **kwargs): class TokenRefresh(Resource): - """ - Refresh JWT token for an authenticated user. + """Refresh JSON Web Token for an authenticated user. The `TokenRefresh` resource provides an endpoint that accepts an existing JSON Web Token, validates the current session, and issues a new @@ -1183,13 +1212,32 @@ class TokenRefresh(Resource): """ - def __init__(self): + def __init__(self, db): """Initialize the TokenRefresh resource.""" - self.db = None + self.db = db - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): - """Refresh JWT token.""" + """Refresh JSON Web Token. + + Examples + -------- + >>> # Start a test REST API server first: + >>> # $ fsdb_rest_api --test + >>> import requests + >>> # Start by login as admin + >>> response = requests.post('http://127.0.0.1:5000/login', json={'username': 'admin', 'password': 'admin'}) + >>> token = response.json()['access_token'] + >>> # Now refresht the token for the admin user: + >>> response = requests.post("http://127.0.0.1:5000/token-refresh", headers={'Authorization': 'Bearer ' + token}) + >>> print(response.json()['message']) + Token refreshed successfully + >>> new_token = response.json()['access_token'] + >>> # Validate this new token: + >>> response = requests.post("http://127.0.0.1:5000/token-validation", headers={'Authorization': 'Bearer ' + new_token}) + >>> print(response.json()['user']['username']) + admin + """ # Get token from keyword arguments (from decorator) jwt_token = kwargs.get('token', None) @@ -1316,16 +1364,15 @@ class ScansTable(Resource): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json >>> # Get all scan datasets >>> response = requests.get("http://127.0.0.1:5000/scans_info") - >>> scans = json.loads(response.content) + >>> scans = response.json() >>> print(scans[0]['id']) # print the id of the first scan dataset >>> print(scans[0]['metadata']) # print the metadata of the first scan dataset >>> # Get filtered results using query >>> query = {"object": {"species": "Arabidopsis.*"}} >>> response = requests.get("http://127.0.0.1:5000/scans_info", params={"filterQuery": json.dumps(query), "fuzzy": "true"}) - >>> filtered_scans = json.loads(response.content) + >>> filtered_scans = response.json() >>> print(filtered_scans[0]['id']) # print the id of the first scan dataset matching the query """ @@ -1343,7 +1390,8 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - def get(self): + @add_jwt_from_header + def get(self, **kwargs): """Retrieve a list of scan dataset information. This method handles GET requests to retrieve scan information. It supports @@ -1376,16 +1424,14 @@ def get(self): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json >>> # Get an info dict about all dataset: - >>> res = requests.get("http://127.0.0.1:5000/scans_info") - >>> scans_list = json.loads(res.content) + >>> response = requests.get("http://127.0.0.1:5000/scans_info") + >>> scans_list = response.json() >>> # List the known dataset id: >>> print(scans_list) ['arabidopsis000', 'virtual_plant_analyzed', 'real_plant_analyzed', 'real_plant', 'virtual_plant', 'models'] - >>> res = requests.get('http://127.0.0.1:5000/scans_info?filterQuery={"object":{"species":"Arabidopsis.*"}}&fuzzy="true"') - >>> res.content.decode() - + >>> response = requests.get('http://127.0.0.1:5000/scans_info?filterQuery={"object":{"species":"Arabidopsis.*"}}&fuzzy="true"') + >>> response.content.decode() """ query = request.args.get('filterQuery', None) fuzzy = request.args.get('fuzzy', False, type=bool) @@ -1395,7 +1441,7 @@ def get(self): scans_info = [] for scan_id in scans_list: - scans_info.append(get_scan_info(self.db.get_scan(scan_id), logger=self.logger)) + scans_info.append(get_scan_info(self.db.get_scan(scan_id, **kwargs), logger=self.logger)) return scans_info @@ -1437,7 +1483,8 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - def get(self, scan_id): + @add_jwt_from_header + def get(self, scan_id, **kwargs): """Retrieve detailed information about a specific scan dataset. Parameters @@ -1469,10 +1516,9 @@ def get(self, scan_id): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json >>> # Get detailed information about a specific dataset >>> response = requests.get("http://127.0.0.1:5000/scans/real_plant_analyzed") - >>> scan_data = json.loads(response.content) + >>> scan_data = response.json() >>> # Access metadata information >>> print(scan_data['metadata']['date']) 2024-08-19 11:12:25 @@ -1483,12 +1529,13 @@ def get(self, scan_id): scan_id = sanitize_name(scan_id) # return get_scan_data(self.db.get_scan(scan_id), logger=self.logger) if self.db.scan_exists(scan_id): - return get_scan_info(self.db.get_scan(scan_id), logger=self.logger) + return get_scan_info(self.db.get_scan(scan_id, **kwargs), logger=self.logger) else: return {'message': f"Scan id '{scan_id}' does not exist"}, 404 @rate_limit(max_requests=15, window_seconds=60) - def post(self, scan_id): + @add_jwt_from_header + def post(self, scan_id, **kwargs): """Create a new scan dataset. Parameters @@ -1521,7 +1568,7 @@ def post(self, scan_id): scan_id = sanitize_name(scan_id) try: # Attempt to create a new scan in the database with the given scan_id - scan = self.db.create_scan(scan_id) + scan = self.db.create_scan(scan_id, **kwargs) # Check if scan creation was successful if scan is None: self.logger.error(f"Failed to create scan: {scan_id}") @@ -1610,14 +1657,15 @@ def get(self, path): >>> # Request a TOML configuration file >>> import requests >>> import toml - >>> res = requests.get("http://127.0.0.1:5000/files/real_plant_analyzed/pipeline.toml") - >>> cfg = toml.loads(res.content.decode()) + >>> response = requests.get("http://127.0.0.1:5000/files/real_plant_analyzed/pipeline.toml") + >>> cfg = toml.loads(response.content.decode()) >>> print(cfg['Undistorted']) {'upstream_task': 'ImagesFilesetExists'} >>> # Request a JSON file - >>> import json - >>> res = requests.get("http://127.0.0.1:5000/files/Col-0_E1_1/files.json") - >>> json.loads(res.content.decode()) + >>> response = requests.get("http://127.0.0.1:5000/files/real_plant_analyzed/files.json") + >>> scan_files = response.json() + >>> print([fs['id'] for fs in scan_files['filesets']]) + ['images', 'AnglesAndInternodes_1_0_2_0_6_0_6dd64fc595', 'TreeGraph__False_CurveSkeleton_c304a2cc71', 'CurveSkeleton__TriangleMesh_0393cb5708', 'TriangleMesh_9_most_connected_t_open3d_00e095c359', 'PointCloud_1_0_1_0_10_0_7ee836e5a9', 'Voxels___x____300__450__colmap_camera_False_2a093f0ccc', 'Masks_1__0__1__0____channel____rgb_5619aa428d', 'Colmap_True_null_SIMPLE_RADIAL_ffcef49fdc', 'Undistorted_SIMPLE_RADIAL_Colmap__a333f181b7'] """ return send_from_directory(self.db.path(), path) @@ -1720,7 +1768,6 @@ def post(self, scan_id): 201 >>> response.json() {'message': 'File path/to/data.txt received and saved'} - """ # Check the header used to pass the filename, or return '400' for "bad request": if 'Content-Disposition' not in request.headers: @@ -1965,10 +2012,25 @@ def get(self, scan_id, fileset_id, file_id): file_id : str Identifier for the specific image file. + Other Parameters + ---------------- + size : str or float + Query parameter controlling downsampling. + Accepted values: + * `'thumb'`: image max width and height to `150` (default); + * `'large'`: image max width and height to `1500`; + * `'orig'`: original image, no chache; + If an invalid string is supplied, the default 'thumb' is used. + base64 : str + Query parameter indicating whether to return the image encoded in base64. + Accepts 'true', '1', 'yes' (case‑insensitive) to enable. + Defaults to 'false', which streams the image file. + If set, returns the image in base64 under the 'image' JSON dictionary entry and mimetype under 'content-type'. + Returns ------- flask.Response - HTTP response containing the image data with 'image/jpeg' mimetype. + HTTP response containing the image data with 'content-type' mimetype. Raises ------ @@ -1980,11 +2042,6 @@ def get(self, scan_id, fileset_id, file_id): Notes ----- - All input parameters are sanitized before use. - - In the URL, you can use the `size` parameter to retrieve a resized image. - - The 'size' parameter defaults to 'thumb' if not specified and can be an integer or one of the following string values: - * `'thumb'`: image max width and height to `150`; - * `'large'`: image max width and height to `1500`; - * `'orig'`: original image, no chache; See Also -------- @@ -1999,26 +2056,37 @@ def get(self, scan_id, fileset_id, file_id): >>> from io import BytesIO >>> from PIL import Image >>> # Get the first image as a thumbnail (default): - >>> res = requests.get("http://127.0.0.1:5000/image/real_plant_analyzed/images/00000_rgb", stream=True) - >>> img = Image.open(BytesIO(res.content)) + >>> response = requests.get("http://127.0.0.1:5000/image/real_plant_analyzed/images/00000_rgb", stream=True) + >>> img = Image.open(BytesIO(response.content)) >>> np.asarray(img).shape (113, 150, 3) >>> # Get the first image in original size: - >>> res = requests.get("http://127.0.0.1:5000/image/real_plant_analyzed/images/00000_rgb", stream=True, params={"size": "orig"}) - >>> img = Image.open(BytesIO(res.content)) + >>> response = requests.get("http://127.0.0.1:5000/image/real_plant_analyzed/images/00000_rgb", stream=True, params={"size": "orig"}) + >>> img = Image.open(BytesIO(response.content)) >>> np.asarray(img).shape (1080, 1440, 3) - """ # Sanitize identifiers scan_id = sanitize_name(scan_id) fileset_id = sanitize_name(fileset_id) file_id = sanitize_name(file_id) + # Parse the `size` flag size = request.args.get('size', default='thumb', type=str) + # Parse the base64 flag (accepting true/1/yes in any case) + base64_flag = request.args.get('base64', default='false', type=str).lower() in ('true', '1', 'yes') + # Get the path to the image resource: path = webcache.image_path(self.db, scan_id, fileset_id, file_id, size) - return send_file(path) + mime_type, _ = mimetypes.guess_type(path) + + # If base64_flag is set, read the file, encode it, and return JSON + if base64_flag: + with open(path, 'rb') as f: + encoded = pybase64.b64encode(f.read()).decode('ascii') + return jsonify({'image': encoded, 'content-type': mime_type}) + # Otherwise, return the file directly + return send_file(path, mimetype=mime_type) class PointCloud(Resource): @@ -2067,6 +2135,21 @@ def get(self, scan_id, fileset_id, file_id): file_id : str Identifier for the specific point cloud file. + Other Parameters + ---------------- + size : str or float + Query parameter controlling downsampling. + Accepted values: + * 'orig' – serve the original point cloud. + * 'preview' – serve a precomputed preview (default). + * A float value – perform on‑the‑fly voxel downsampling using the specified voxel size. + If an invalid string is supplied, the default 'preview' is used. + coords : str + Query parameter indicating whether to return the point coordinates as JSON. + Accepts 'true', '1', 'yes' (case‑insensitive) to enable. + Defaults to 'false', which streams the PLY file. + If set, returns the data as list under the 'coordinates' JSON dictionary entry. + Returns ------- flask.Response @@ -2081,11 +2164,6 @@ def get(self, scan_id, fileset_id, file_id): Notes ----- - - In the URL, you can use a 'size' parameter that accepts: - * 'orig': original point cloud - * 'preview': downsampled preview version - * float value: custom voxel size for downsampling - - Defaults to 'preview' if size parameter is invalid - All input parameters are sanitized before use See Also @@ -2100,21 +2178,24 @@ def get(self, scan_id, fileset_id, file_id): >>> from plyfile import PlyData >>> from io import BytesIO >>> # Get original point cloud: - >>> res = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud") - >>> pcd_data = PlyData.read(BytesIO(res.content)) + >>> response = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud") + >>> pcd_data = PlyData.read(BytesIO(response.content)) >>> # Access point X-coordinates: >>> list(pcd_data['vertex']['x']) >>> # Get preview (downsampled) version - >>> res = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud", params={"size": "preview"}) + >>> response = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud", params={"size": "preview"}) >>> # Get custom downsampled version (voxel size 0.01) - >>> res = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud", params={"size": "0.01"}) - + >>> response = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud", params={"size": "0.01"}) + >>> # Send the coordinates (read the file on the server-side) + >>> response = requests.get("http://127.0.0.1:5000/pointcloud/real_plant_analyzed/PointCloud_1_0_1_0_10_0_7ee836e5a9/PointCloud", params={"size": "preview", 'coords': 'true'}) + >>> coordinates = np.array(response.json()['coordinates']) """ # Sanitize identifiers scan_id = sanitize_name(scan_id) fileset_id = sanitize_name(fileset_id) file_id = sanitize_name(file_id) + # Parse the `size` flag size = request.args.get('size', default='preview', type=str) # Try to convert the 'size' argument as a float: try: @@ -2127,6 +2208,9 @@ def get(self, scan_id, fileset_id, file_id): if isinstance(size, str) and size not in ['orig', 'preview']: size = 'preview' + # Parse the coords flag (accepting true/1/yes in any case) + coords_flag = request.args.get('coords', default='false', type=str).lower() in ('true', '1', 'yes') + try: # Get the path to the pointcloud resource: path = webcache.pointcloud_path(self.db, scan_id, fileset_id, file_id, size) @@ -2138,8 +2222,16 @@ def get(self, scan_id, fileset_id, file_id): return {'error': f"No '{scan_id}' scan found!"}, 400 except Exception as e: return {'error': f"Unknown error: {e}"}, 400 - else: - return send_file(path, mimetype='application/octet-stream') + + # If coords_flag is set, read the file and return JSON + if coords_flag: + import numpy as np + from open3d import io + pcd = io.read_point_cloud(path, print_progress=False) + # Convert the Open3D Vector3dVector to a plain Python list so JSON can serialize it. + return jsonify({'coordinates': np.array(pcd.points).tolist()}) + # Otherwise, return the file directly + return send_file(path, mimetype='application/octet-stream') class PointCloudGroundTruth(Resource): @@ -2180,6 +2272,21 @@ def get(self, scan_id, fileset_id, file_id): file_id : str Identifier for the specific point-cloud file. + Other Parameters + ---------------- + size : str or float + Query parameter controlling downsampling. + Accepted values: + * 'orig' – serve the original point cloud. + * 'preview' – serve a precomputed preview (default). + * A float value – perform on‑the‑fly voxel downsampling using the specified voxel size. + If an invalid string is supplied, the default 'preview' is used. + coords : str + Query parameter indicating whether to return the point coordinates as JSON. + Accepts 'true', '1', 'yes' (case‑insensitive) to enable. + Defaults to 'false', which streams the PLY file. + If set, returns the data as list under the 'coordinates' JSON dictionary entry. + Returns ------- flask.Response @@ -2202,12 +2309,12 @@ def get(self, scan_id, fileset_id, file_id): - Invalid size parameters default to 'preview' - Response mimetype is 'application/octet-stream' """ - # Sanitize identifiers scan_id = sanitize_name(scan_id) fileset_id = sanitize_name(fileset_id) file_id = sanitize_name(file_id) + # Parse the `size` flag size = request.args.get('size', default='preview', type=str) # Try to convert the 'size' argument as a float: try: @@ -2220,6 +2327,9 @@ def get(self, scan_id, fileset_id, file_id): if isinstance(size, str) and size not in ['orig', 'preview']: size = 'preview' + # Parse the coords flag (accepting true/1/yes in any case) + coords_flag = request.args.get('coords', default='false', type=str).lower() in ('true', '1', 'yes') + try: # Get the path to the pointcloud resource: path = webcache.pointcloud_path(self.db, scan_id, fileset_id, file_id, size) @@ -2231,8 +2341,16 @@ def get(self, scan_id, fileset_id, file_id): return {'error': f"No '{scan_id}' scan found!"}, 400 except Exception as e: return {'error': f"Unknown error: {e}"}, 400 - else: - return send_file(path, mimetype='application/octet-stream') + + # If coords_flag is set, read the file and return JSON + if coords_flag: + import numpy as np + from open3d import io + pcd = io.read_point_cloud(path, print_progress=False) + # Convert the Open3D Vector3dVector to a plain Python list so JSON can serialize it. + return jsonify({'coordinates': np.array(pcd.points).tolist()}) + # Otherwise, return the file directly + return send_file(path, mimetype='application/octet-stream') class Mesh(Resource): @@ -2279,6 +2397,14 @@ def get(self, scan_id, fileset_id, file_id): file_id : str Identifier for the specific mesh file. + Other Parameters + ---------------- + coords : str + Query parameter indicating whether to return the vertices coordinates and triangle IDs as JSON. + Accepts 'true', '1', 'yes' (case‑insensitive) to enable. + Defaults to 'false', which streams the PLY file. + If set, returns the data as list under the 'vertices' & 'triangles' JSON dictionary entry. + Returns ------- flask.Response @@ -2323,11 +2449,15 @@ def get(self, scan_id, fileset_id, file_id): fileset_id = sanitize_name(fileset_id) file_id = sanitize_name(file_id) + # Parse the `size` flag size = request.args.get('size', default='orig', type=str) # Make sure that the 'size' argument we got is a valid option, else default to 'orig': if not size in ['orig']: size = 'orig' + # Parse the coords flag (accepting true/1/yes in any case) + coords_flag = request.args.get('coords', default='false', type=str).lower() in ('true', '1', 'yes') + try: # Get the path to the mesh resource: path = webcache.mesh_path(self.db, scan_id, fileset_id, file_id, size) @@ -2339,8 +2469,16 @@ def get(self, scan_id, fileset_id, file_id): return {'error': f"No '{scan_id}' scan found!"}, 400 except Exception as e: return {'error': f"Unknown error: {e}"}, 400 - else: - return send_file(path, mimetype='application/octet-stream') + + # If coords_flag is set, read the file and return JSON + if coords_flag: + import numpy as np + from open3d import io + pcd = io.read_triangle_mesh(path, print_progress=False) + # Convert the Open3D Vector3dVector to a plain Python list so JSON can serialize it. + return jsonify({'vertices': np.array(pcd.vertices).tolist(), 'triangles': np.array(pcd.triangles).tolist()}) + # Otherwise, return the file directly + return send_file(path, mimetype='application/octet-stream') class CurveSkeleton(Resource): @@ -2407,25 +2545,20 @@ def get(self, scan_id): >>> # Start the REST API server >>> # Then in a Python console: >>> import requests - >>> import json - >>> >>> # Fetch skeleton data for a valid scan >>> response = requests.get("http://127.0.0.1:5000/skeleton/Col-0_E1_1") - >>> skeleton_data = json.loads(response.content) + >>> skeleton_data = response.json() >>> print(list(skeleton_data.keys())) ['angles', 'internodes', 'metadata'] - >>> >>> # Example with invalid scan ID >>> response = requests.get("http://127.0.0.1:5000/skeleton/invalid_id") >>> print(response.status_code) 400 - >>> print(json.loads(response.content)) + >>> print(response.json()) {'error': "Scan 'invalid_id' not found!"} """ - # Sanitize identifiers scan_id = sanitize_name(scan_id) - # Get the corresponding `Scan` instance try: scan = self.db.get_scan(scan_id) @@ -2534,18 +2667,15 @@ def get(self, scan_id): -------- >>> # Get all sequence data >>> import requests - >>> import json >>> response = requests.get("http://127.0.0.1:5000/sequence/real_plant_analyzed") - >>> data = json.loads(response.content.decode('utf-8')) - >>> # Expected output: {'angles': [...], 'internodes': [...], 'fruit_points': [...]} - + >>> data = response.json() # Expected output: {'angles': [...], 'internodes': [...], 'fruit_points': [...]} + >>> print(list(data)) + ['angles', 'internodes', 'fruit_points', 'manual_angles', 'manual_internodes'] >>> # Get only angles data - >>> response = requests.get( - ... "http://127.0.0.1:5000/sequence/real_plant_analyzed", - ... params={'type': 'angles'} - ... ) - >>> angles = json.loads(response.content.decode('utf-8')) - >>> # Expected output: [angle1, angle2, ...] + >>> response = requests.get("http://127.0.0.1:5000/sequence/real_plant_analyzed", params={'type': 'angles'}) + >>> angles = response.json() + >>> print(angles[:5]) + [47.13015345294241, 239.43543078022594, 311.8816488465762, 251.0289289739646, 249.56560354730826] """ # Sanitize identifiers scan_id = sanitize_name(scan_id) @@ -2620,8 +2750,7 @@ def is_within_directory(directory, target): def is_directory_in_archive(archive_path, target_dir): - """ - Check if a specific directory exists within an archive file. + """Check if a specific directory exists within an archive file. This function checks whether a given directory is present at the top level of a ZIP archive. @@ -2645,8 +2774,7 @@ def is_directory_in_archive(archive_path, target_dir): def is_valid_archive(archive_path): - """ - Validate if a given archive meets specific directory and file requirements. + """Validate if a given archive meets specific directory and file requirements. This function checks if the provided archive contains certain required directories and files, and verifies that the directory structure does not exceed a specified depth. @@ -2866,8 +2994,8 @@ def cleanup_temp_file(response): return send_file(temp_zip_path, download_name=f'{scan_id}.zip', mimetype='application/zip') - @requires_jwt @rate_limit(max_requests=5, window_seconds=60) + @add_jwt_from_header def post(self, scan_id, **kwargs): """Handle ZIP file upload and extraction for a scan dataset. @@ -3086,7 +3214,7 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): """Create a new scan in the database. @@ -3116,7 +3244,7 @@ def post(self, **kwargs): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new scan with metadata: >>> metadata = {'description': 'Test plant scan'} - >>> url = f"{plantdb_url()}/api/scan" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan" >>> response = requests.post(url, json={'name': 'test_plant', 'metadata': metadata}) >>> print(response.status_code) 201 @@ -3140,7 +3268,7 @@ def post(self, **kwargs): scan = self.db.create_scan(scan_id, **kwargs) # Set metadata if provided if metadata: - scan.set_metadata(metadata) + scan.set_metadata(metadata, **kwargs) return {'message': f"Scan '{scan_id}' created successfully."}, 201 except Exception as e: @@ -3202,13 +3330,13 @@ def get(self, scan_id): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new scan with metadata: >>> metadata = {'description': 'Test plant scan'} - >>> url = f"{plantdb_url()}/api/scan" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan" >>> response = requests.post(url, json={'name': 'test_plant', 'metadata': metadata}) >>> # Get all metadata: - >>> url = f"{plantdb_url()}/api/scan/test_plant/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan/test_plant/metadata" >>> response = requests.get(url) >>> print(response.json()) - {'metadata': {'owner': 'anonymous', 'description': 'Test plant scan'}} + {'metadata': {'owner': 'guest', 'description': 'Test plant scan'}} >>> # Get specific metadata key: >>> response = requests.get(url+"?key=description") >>> print(response.json()) @@ -3230,7 +3358,7 @@ def get(self, scan_id): self.logger.error(f'Error retrieving metadata: {str(e)}') return {'message': f'Error retrieving metadata: {str(e)}'}, 500 - @requires_jwt + @add_jwt_from_header def post(self, scan_id, **kwargs): """Update metadata for a specified scan. @@ -3263,14 +3391,14 @@ def post(self, scan_id, **kwargs): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new scan with metadata: >>> metadata = {'description': 'Test plant scan'} - >>> url = f"{plantdb_url()}/api/scan" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan" >>> response = requests.post(url, json={'name': 'test_plant', 'metadata': metadata}) >>> # Update scan metadata: - >>> url = f"{plantdb_url()}/api/scan/test_plant/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan/test_plant/metadata" >>> data = {"metadata": {"description": "Updated scan description"}} >>> response = requests.post(url, json=data) >>> print(response.json()) - {'metadata': {'owner': 'anonymous', 'description': 'Updated scan description'}} + {'metadata': {'owner': 'guest', 'description': 'Updated scan description'}} """ try: # Get request data @@ -3290,7 +3418,7 @@ def post(self, scan_id, **kwargs): return {'message': 'Scan not found'}, 404 # Update the metadata - scan.set_metadata(metadata) + scan.set_metadata(metadata, **kwargs) # TODO: make this works: # if replace: # # Replace entire metadata dictionary @@ -3355,7 +3483,7 @@ def get(self, scan_id): >>> import requests >>> from plantdb.client.rest_api import plantdb_url >>> # List filesets in a scan: - >>> url = f"{plantdb_url()}/api/scan/real_plant/filesets" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/scan/real_plant/filesets" >>> response = requests.get(url) >>> print(response.status_code) 200 @@ -3397,8 +3525,8 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt - def post(self): + @add_jwt_from_header + def post(self, **kwargs): """Create a new fileset associated with a scan. This method handles POST requests to create a new fileset. It validates the input data, @@ -3436,7 +3564,7 @@ def post(self): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new fileset with metadata: >>> metadata = {'description': 'This is a test fileset'} - >>> url = f"{plantdb_url()}/api/fileset" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset" >>> response = requests.post(url, json={'fileset_id': 'my_fileset', 'scan_id': 'real_plant', 'metadata': metadata}) >>> print(response.status_code) 201 @@ -3469,10 +3597,10 @@ def post(self): if not scan: return {'message': 'Scan not found'}, 404 # Create the fileset - fileset = scan.create_fileset(fs_id, ) + fileset = scan.create_fileset(fs_id, **kwargs) # Set metadata if provided if metadata: - fileset.set_metadata(metadata) + fileset.set_metadata(metadata, **kwargs) return { 'message': f"Fileset '{fs_id}' created successfully in '{scan.id}'.", "id": fs_id @@ -3544,10 +3672,10 @@ def get(self, scan_id, fileset_id): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new fileset with metadata: >>> metadata = {'description': 'This is a test fileset'} - >>> url = f"{plantdb_url()}/api/fileset" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset" >>> response = requests.post(url, json={'name': 'my_fileset', 'scan_id': 'real_plant', 'metadata': metadata}) >>> # Get all metadata: - >>> url = f"{plantdb_url()}/api/fileset/real_plant/my_fileset/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset/real_plant/my_fileset/metadata" >>> response = requests.get(url) >>> print(response.json()) {'metadata': {'description': 'This is a test fileset'}} @@ -3575,8 +3703,8 @@ def get(self, scan_id, fileset_id): self.logger.error(f'Error retrieving metadata: {str(e)}') return {'message': f'Error retrieving metadata: {str(e)}'}, 500 - @requires_jwt - def post(self, scan_id, fileset_id): + @add_jwt_from_header + def post(self, scan_id, fileset_id, **kwargs): """Update metadata for a specified fileset. This method handles updating metadata for a fileset within a scan. It supports both @@ -3617,11 +3745,11 @@ def post(self, scan_id, fileset_id): >>> from plantdb.client.rest_api import plantdb_url >>> # Create a new fileset with metadata: >>> metadata = {'description': 'This is a test fileset'} - >>> url = f"{plantdb_url()}/api/fileset" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset" >>> data = {'name': 'my_fileset', 'scan_id': 'real_plant', 'metadata': metadata} >>> response = requests.post(url, json=data) >>> # Get the original metadata: - >>> url = f"{plantdb_url()}/api/fileset/{data['scan_id']}/{data['name']}/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset/{data['scan_id']}/{data['name']}/metadata" >>> response = requests.get(url) >>> print(response.json()) {'metadata': {'description': 'This is a test fileset'}} @@ -3634,7 +3762,6 @@ def post(self, scan_id, fileset_id): >>> metadata_update = {"metadata": {"description": "Brand new description", "version": "2.0"}, "replace": True} >>> response = requests.post(url, json=metadata_update) >>> print(response.json()) - """ try: # Get request data @@ -3659,7 +3786,7 @@ def post(self, scan_id, fileset_id): return {'message': 'Fileset not found'}, 404 # Update the metadata - fileset.set_metadata(metadata) + fileset.set_metadata(metadata, **kwargs) # TODO: make this works: # if replace: # # Replace entire metadata dictionary @@ -3715,7 +3842,7 @@ def get(self, scan_id, fileset_id): >>> import requests >>> from plantdb.client.rest_api import plantdb_url >>> # List files in a fileset: - >>> url = f"{plantdb_url()}/api/fileset/real_plant/images/files" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/fileset/real_plant/images/files" >>> response = requests.get(url) >>> print(response.status_code) 200 @@ -3764,8 +3891,8 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt - def post(self): + @add_jwt_from_header + def post(self, **kwargs): """Create a new file in a fileset and write data to it. This method handles POST requests to create a new file with data. It expects @@ -3784,7 +3911,7 @@ def post(self): Returns ------- dict - Response containing success message or error description. + Response containing a success message or error description. If successful, also returns the created file ID under 'id' key, as sanitization may have happened. int HTTP status code (201, 400, 404, or 500) @@ -3794,14 +3921,15 @@ def post(self): >>> # Start a test REST API server first: >>> # $ fsdb_rest_api --test >>> import requests - >>> import json >>> from tempfile import NamedTemporaryFile >>> from plantdb.client.rest_api import plantdb_url >>> # Create a YAML temporary file: >>> with NamedTemporaryFile(suffix='.yaml', mode="w", delete=False) as f: f.write('name: my_file') >>> file_path = f.name + >>> login_res = requests.post(f"{plantdb_url('localhost', port=5000)}/login", json={'username': 'admin', 'password': 'admin'}) + >>> token = login_res.json()['access_token'] >>> # Create a new file with metadata in the database: - >>> url = f"{plantdb_url()}/api/file" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/file" >>> # Open the file separately for sending >>> with open(file_path, 'rb') as file_handle: ... files = { @@ -3815,7 +3943,7 @@ def post(self): ... 'fileset_id': 'images', ... 'metadata': metadata ... } - ... response = requests.post(url, files=files, data=data) + ... response = requests.post(url, files=files, data=data, headers={'Authorization': 'Bearer ' + token}) >>> print(response.status_code) 201 >>> print(response.json()) @@ -3857,7 +3985,7 @@ def post(self): try: # Get the scan - scan = self.db.get_scan(scan_id) + scan = self.db.get_scan(scan_id, **kwargs) if not scan: return {'message': 'Scan not found'}, 404 # Get the fileset @@ -3866,20 +3994,20 @@ def post(self): return {'message': 'Fileset not found'}, 404 # Create the file file_id = sanitize_name(file_id) - file = fileset.create_file(file_id) + file = fileset.create_file(file_id, **kwargs) try: # Write the file data with the specified extension if ext in ['.jpg', '.jpeg', '.png', '.tif']: - file.write_raw(file_data.read(), ext=ext[1:]) # Binary mode + file.write_raw(file_data.read(), ext=ext[1:], **kwargs) # Binary mode else: - file.write(file_data.read().decode(), ext=ext[1:]) # Text mode + file.write(file_data.read().decode(), ext=ext[1:], **kwargs) # Text mode except Exception as e: - fileset.delete_file(file_id) + fileset.delete_file(file_id, **kwargs) self.logger.error(f'Error writing file: {str(e)}') return {'message': f'Error writing file: {str(e)}'}, 500 # Set metadata if provided if metadata: - file.set_metadata(metadata) + file.set_metadata(metadata, **kwargs) return { 'message': f"File '{file_id}{ext}' created and written successfully in fileset '{fileset.id}'.", 'id': f"{file_id}", @@ -3941,7 +4069,7 @@ def get(self, scan_id, fileset_id, file_id): >>> import requests >>> from plantdb.client.rest_api import plantdb_url >>> # Get all metadata: - >>> url = f"{plantdb_url()}/api/file/test_plant/images/image_001/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/file/test_plant/images/image_001/metadata" >>> response = requests.get(url) >>> print(response.json()) {'metadata': {'description': 'Test file'}} @@ -3973,8 +4101,8 @@ def get(self, scan_id, fileset_id, file_id): self.logger.error(f'Error retrieving metadata: {str(e)}') return {'message': f'Error retrieving metadata: {str(e)}'}, 500 - @requires_jwt - def post(self, scan_id, fileset_id, file_id): + @add_jwt_from_header + def post(self, scan_id, fileset_id, file_id, **kwargs): """Update metadata for a specified file. Parameters @@ -4008,7 +4136,7 @@ def post(self, scan_id, fileset_id, file_id): >>> # $ fsdb_rest_api --test >>> import requests >>> from plantdb.client.rest_api import plantdb_url - >>> url = f"{plantdb_url()}/api/file/test_plant/images/image_001/metadata" + >>> url = f"{plantdb_url('localhost', port=5000)}/api/file/test_plant/images/image_001/metadata" >>> data = {"metadata": {"description": "Updated description"}} >>> response = requests.post(url, json=data) >>> print(response.json()) @@ -4042,7 +4170,7 @@ def post(self, scan_id, fileset_id, file_id): return {'message': 'File not found'}, 404 # Update the metadata - file.set_metadata(metadata) + file.set_metadata(metadata, **kwargs) # TODO: make this works: # if replace: # # Replace entire metadata dictionary diff --git a/src/server/pyproject.toml b/src/server/pyproject.toml index a3566cf6..401edeb5 100644 --- a/src/server/pyproject.toml +++ b/src/server/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "flask", "flask-cors", "flask-restful", + "pybase64", "requests", "toml", ]