From 4a2e181f2fc1c632d620072360a10f10583a1b3e Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 16:41:02 +0100 Subject: [PATCH 01/48] Add JWT to request headers after login - Update `session.headers` to include Authorization header with JWT token upon successful login. --- src/client/plantdb/client/plantdb_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/client/plantdb/client/plantdb_client.py b/src/client/plantdb/client/plantdb_client.py index b63e376..01f47cc 100644 --- a/src/client/plantdb/client/plantdb_client.py +++ b/src/client/plantdb/client/plantdb_client.py @@ -186,6 +186,8 @@ def login(self, username: str, password: str) -> bool: 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') From de0a7afd84163734ccf22e72742aacb9be3fecf6 Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 16:49:57 +0100 Subject: [PATCH 02/48] Implemented enhanced REST API options: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added `size` and `base64` query parameters for image endpoints, allowing thumbnail, large, or original images with optional Base64 JSON output. - Extended point cloud and ground‑truth endpoints with `size` (preview/orig/voxel) and `coords` flags to return point coordinates as JSON. - Added mesh endpoint support for `coords` flag to expose vertices and triangles in JSON. - Updated MIME type handling with `mimetypes` and incorporated `pybase64` for Base64 encoding. - Refactored documentation and cleaned up deprecated comments. --- src/server/plantdb/server/rest_api.py | 135 ++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 21 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 8bcd7c5..0301c54 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 @@ -41,6 +41,7 @@ from tempfile import mkstemp from zipfile import ZipFile +import pybase64 from flask import Response from flask import after_this_request from flask import jsonify @@ -49,7 +50,6 @@ from flask import send_file from flask import send_from_directory from flask_restful import Resource - from plantdb.commons.fsdb.exceptions import FileNotFoundError from plantdb.commons.fsdb.exceptions import FilesetNotFoundError from plantdb.commons.fsdb.exceptions import ScanNotFoundError @@ -1965,10 +1965,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 +1995,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 -------- @@ -2015,10 +2025,22 @@ 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='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 +2089,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 +2118,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 @@ -2108,6 +2140,9 @@ def get(self, scan_id, fileset_id, file_id): >>> 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"}) >>> # 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"}) + >>> # Send the coordinates (read the file on the server-side) + >>> 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", 'coords': 'true'}) + >>> coordinates = np.array(res.json()['coordinates']) """ # Sanitize identifiers @@ -2115,6 +2150,7 @@ 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='preview', type=str) # Try to convert the 'size' argument as a float: try: @@ -2127,6 +2163,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 +2177,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 +2227,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 +2264,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 +2282,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 +2296,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 +2352,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 +2404,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 +2424,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): From efa9877037f8e5d351036036a82dc5369c8f1292 Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 16:52:25 +0100 Subject: [PATCH 03/48] Add pybase64 to server dependencies - Include `pybase64` in `src/server/pyproject.toml` to support Base64 image encoding. --- src/server/pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/server/pyproject.toml b/src/server/pyproject.toml index a3566cf..401edeb 100644 --- a/src/server/pyproject.toml +++ b/src/server/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "flask", "flask-cors", "flask-restful", + "pybase64", "requests", "toml", ] From 9299acdd5d75f4d44374a049e536e1379b62aac3 Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 17:01:29 +0100 Subject: [PATCH 04/48] Add missing 'token' kwargs to creation REST API methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update POST handler signatures to accept `**kwargs` and forward them to underlying DB operations. - Pass `**kwargs` to `scan.set_metadata`, `scan.create_fileset`, `fileset.create_file`, `fileset.delete_file`, and `file.set_metadata` calls. - Adjust method definitions for file‑level and scan‑level metadata updates to propagate `**kwargs` to the appropriate underlying functions. --- src/server/plantdb/server/rest_api.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 0301c54..68854a8 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -3233,7 +3233,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: @@ -3383,7 +3383,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 @@ -3491,7 +3491,7 @@ def __init__(self, db, logger): self.logger = logger @requires_jwt - def post(self): + 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, @@ -3562,10 +3562,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 @@ -3669,7 +3669,7 @@ def get(self, scan_id, fileset_id): return {'message': f'Error retrieving metadata: {str(e)}'}, 500 @requires_jwt - def post(self, scan_id, fileset_id): + 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 @@ -3752,7 +3752,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 @@ -3858,7 +3858,7 @@ def __init__(self, db, logger): self.logger = logger @requires_jwt - def post(self): + 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 @@ -3959,7 +3959,7 @@ 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']: @@ -3967,12 +3967,12 @@ def post(self): else: file.write(file_data.read().decode(), ext=ext[1:]) # 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}", @@ -4067,7 +4067,7 @@ def get(self, scan_id, fileset_id, file_id): return {'message': f'Error retrieving metadata: {str(e)}'}, 500 @requires_jwt - def post(self, scan_id, fileset_id, file_id): + def post(self, scan_id, fileset_id, file_id, **kwargs): """Update metadata for a specified file. Parameters @@ -4135,7 +4135,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 From 32ae1845a1131fb00f9053183629f4a80ed1a860 Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 18:05:14 +0100 Subject: [PATCH 05/48] Add `has_role`, guest user docstring, and `can_create_user` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement `has_role` to verify a user’s role membership - Add explanatory docstring to `get_guest_user` - Introduce `can_create_user` that checks the `MANAGE_USERS` permission for user creation privileges --- src/commons/plantdb/commons/auth/rbac.py | 42 ++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index dea1c32..e562f73 100644 --- a/src/commons/plantdb/commons/auth/rbac.py +++ b/src/commons/plantdb/commons/auth/rbac.py @@ -219,6 +219,24 @@ def has_permission(self, user: User, permission: Permission) -> bool: user_permissions = self.get_user_permissions(user) return permission in user_permissions + 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. + """ + return role in user.roles + def is_guest_user(self, user: User) -> bool: """ Check if the given user is the guest user. @@ -236,8 +254,32 @@ def is_guest_user(self, user: User) -> bool: return user.username == self.users.GUEST_USERNAME def get_guest_user(self) -> User: + """ + Retrieves the guest user from the user repository. + + Returns + ------- + User + The user object corresponding to the guest username. + """ 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 + ---------- + user : User + The user to check. + + Returns + ------- + bool + True if the user can create new users, False otherwise. + """ + return self.has_permission(user, Permission.MANAGE_USERS) + def can_manage_groups(self, user: User) -> bool: """ Check if a user can manage groups (create/delete groups). From 7fa611af67e3b4096fdcfead991934d20746b6f9 Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 18:12:23 +0100 Subject: [PATCH 06/48] Fix misleading comment in auth manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update comment to correctly describe username availability check instead of “login” in user creation logic. --- src/commons/plantdb/commons/auth/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commons/plantdb/commons/auth/manager.py b/src/commons/plantdb/commons/auth/manager.py index 26c79b9..471f3bf 100644 --- a/src/commons/plantdb/commons/auth/manager.py +++ b/src/commons/plantdb/commons/auth/manager.py @@ -261,7 +261,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: From 640cd195cb10f62f7a5b841c11778e73ea55b7bc Mon Sep 17 00:00:00 2001 From: Jonathan LEGRAND Date: Tue, 27 Jan 2026 18:12:56 +0100 Subject: [PATCH 07/48] Improve metadata handling and logging for scans, filesets, and files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Always set owner, timestamps, and creator on new scans/filesets - Add authentication context and permission checks to user‑creation, group deletion, and metadata modification - Enrich error messages with resource identifiers - Wrap all metadata updates, file creations, deletions, and imports in exclusive locks - Log detailed operation summaries including user and resource IDs - Propagate **kwargs to REST‑API helpers for proper user lookup and permissions - Update logging and error handling to be consistent across scan, fileset, and file APIs --- src/commons/plantdb/commons/fsdb/core.py | 156 ++++++++++++++--------- 1 file changed, 98 insertions(+), 58 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 41f1a61..0c1c7d1 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -685,13 +685,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: @@ -988,7 +987,7 @@ def logout(self, **kwargs): return False @require_authentication - def create_user(self, username, fullname, password, roles=None) -> None: + def create_user(self, username, fullname, password, roles=None, **kwargs) -> None: """ Create a new user with the specified details. @@ -1007,6 +1006,16 @@ def create_user(self, username, fullname, password, roles=None) -> None: -------- RBACManager.users.create : Method used to actually create the user. """ + current_user = self.get_user_data(**kwargs) + if not current_user: + raise PermissionError("No authenticated user!") + + # 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}'") + + # TODO: add a maximum role given the current user role + return self.rbac_manager.users.create(username, fullname, password, roles) def get_guest_user(self): @@ -1184,7 +1193,7 @@ def delete_group(self, group_name, **kwargs): 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 @@ -1615,7 +1624,7 @@ def set_metadata(self, data, value=None, **kwargs): # 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 +1636,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 scan '{self.id}' metadata by user '{current_user.username}'...") 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 +1647,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 '{self.id}' metadata!") return @require_authentication @@ -1696,7 +1705,7 @@ def create_fileset(self, fs_id, metadata=None, **kwargs): raise IOError(f"Invalid fileset identifier '{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}' from scan '{self.id}'...") 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): @@ -1720,8 +1729,7 @@ 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 '{fs_id}' fileset!") return fileset def store(self): @@ -1756,7 +1764,7 @@ def delete_fileset(self, fs_id, **kwargs): # Check ownership if self.owner != current_user.username: - raise PermissionError(f"Only the owner can delete filesets from scan '{self.id}'") + raise PermissionError(f"Only the owner can delete fileset '{fs_id}' from scan '{self.id}'") # Use exclusive lock for fileset deletion with self.db.lock_manager.acquire_lock(self.id, LockType.EXCLUSIVE, current_user.username): @@ -1769,7 +1777,7 @@ def delete_fileset(self, fs_id, **kwargs): 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"Deleted fileset '{fs_id}' from scan '{self.id}' by user '{current_user.username}'") return def path(self) -> pathlib.Path: @@ -2042,12 +2050,15 @@ def set_metadata(self, data, value=None, **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can set fileset metadata in scan/fileset '{self.scan.id}/{self.id}'") + + 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) - _set_metadata(self.metadata, data, value) - # Ensure modification timestamp - self.metadata['last_modified'] = iso_date_now() - _store_fileset_metadata(self) + self.logger.info(f"Set fileset '{self.id}' metadata in '{self.scan.id}' by user '{current_user.username}'") return @require_authentication @@ -2090,7 +2101,7 @@ def create_file(self, f_id, metadata=None, **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can create a file in scan/fileset '{self.scan.id}/{self.id}'") # Verify if the given `fs_id` is valid if not _is_valid_id(f_id): @@ -2119,9 +2130,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"Created new file '{f_id}' in '{self.scan.id}/{self.id}' for user '{current_user.username}'") return file @require_authentication @@ -2156,17 +2165,22 @@ def delete_file(self, f_id, **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can delete file in scan/fileset '{self.scan.id}/{self.id}'") # 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 - 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``) + # Use exclusive lock for fileset creation + self.logger.info(f"Deleting file '{f_id}' from scan/fileset '{self.scan.id}/{self.id}'") + 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``) + + self.logger.info(f"Deleted file '{f_id}' from scan/fileset '{self.scan.id}/{self.id}' by user '{current_user.username}'") return def store(self): @@ -2302,7 +2316,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 +2345,22 @@ 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 ownership + if self.owner != current_user.username: + raise PermissionError(f"Only the owner can set file metadata for scan/fileset/file '{self.scan.id}/{self.fileset.id}/{self.id}'") + + # Use exclusive lock for this operation + 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"Updated file '{self.id}' metadata in '{self.scan.id}/{self.fileset.id}' by user '{current_user.username}'.") return @require_authentication @@ -2365,21 +2392,26 @@ def import_file(self, path, **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can create file in scan/fileset '{self.scan.id}/{self.fileset.id}'") # 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 + + # Use exclusive lock for this operation + 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"Imported file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") return def store(self): @@ -2451,13 +2483,17 @@ def write_raw(self, data, ext="", **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can write file in scan/fileset '{self.scan.id}/{self.fileset.id}'") - 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 + 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"Wrote file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") return def read(self): @@ -2529,13 +2565,17 @@ def write(self, data, ext="", **kwargs): # Check ownership if self.scan.owner != current_user.username: - raise PermissionError(f"Only the owner can create filesets in scan '{self.id}'") + raise PermissionError(f"Only the owner can write file in scan/fileset '{self.scan.id}/{self.fileset.id}'") - 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 + 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"Wrote file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") return def path(self) -> pathlib.Path: From cca5db0f06bc5e30775d0d02598aa663ab4c631d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Tue, 27 Jan 2026 22:29:15 +0100 Subject: [PATCH 08/48] Refactor RBAC Manager and clean up docstrings - Standardize docstring formatting for all methods in `rbac.py`. - Remove redundant and inconsistent comments for better clarity. - Reorganize `can_create_group` logic to align with `can_manage_groups` permission. - Simplify admin role checks in group management functions (`can_add_to_group`, `can_delete_group`). - Ensure `create_group` uses `can_manage_groups` for permission validation. - Update role/permission summaries to include additional context like ownership and shared groups. --- src/commons/plantdb/commons/auth/rbac.py | 142 ++++++++++------------- 1 file changed, 61 insertions(+), 81 deletions(-) diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index e562f73..f4d39ab 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 @@ -190,8 +187,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. @@ -220,8 +216,7 @@ def has_permission(self, user: User, permission: Permission) -> bool: return permission in user_permissions def has_role(self, user: User, role: Role) -> bool: - """ - Check if a user has a specific role. + """Check if a user has a specific role. Parameters ---------- @@ -238,8 +233,7 @@ def has_role(self, user: User, role: Role) -> bool: return role in user.roles def is_guest_user(self, user: User) -> bool: - """ - Check if the given user is the guest user. + """Check if the given user is the guest user. Parameters ---------- @@ -254,19 +248,17 @@ def is_guest_user(self, user: User) -> bool: return user.username == self.users.GUEST_USERNAME def get_guest_user(self) -> User: - """ - Retrieves the guest user from the user repository. + """Retrieves the guest user from the user repository. Returns ------- User - The user object corresponding to the guest username. + The User object corresponding to the guest username. """ 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. + """Check if a user can create new users. Parameters ---------- @@ -281,8 +273,9 @@ def can_create_user(self, user: User) -> bool: return self.has_permission(user, Permission.MANAGE_USERS) def can_manage_groups(self, user: User) -> bool: - """ - Check if a user can manage groups (create/delete groups). + """Check if a user can manage groups (create/delete groups). + + Any user with a role that has a ``MANAGE_GROUPS`` permission can create groups. Parameters ---------- @@ -296,27 +289,8 @@ def can_manage_groups(self, user: User) -> bool: """ return self.has_permission(user, Permission.MANAGE_GROUPS) - def can_create_group(self, user: User) -> bool: - """ - Check if a user can create groups. - - Any user with CONTRIBUTOR role or higher can create groups. - - Parameters - ---------- - user : User - The user to check. - - Returns - ------- - bool - True if the user can create groups, False otherwise. - """ - return self.has_permission(user, Permission.CREATE) - 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 @@ -335,16 +309,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. @@ -358,12 +348,15 @@ 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, 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 ---------- @@ -386,14 +379,13 @@ def create_group(self, user: User, name: str, users: Optional[Set[str]] = None, ValueError If a group with the same name already exists. """ - if not self.can_create_group(user): + if not self.can_manage_groups(user): return None return self.groups.create_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 ---------- @@ -415,8 +407,7 @@ def add_user_to_group(self, user: User, group_name: str, username_to_add: str) - 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 ---------- @@ -438,8 +429,7 @@ def remove_user_from_group(self, user: User, group_name: str, username_to_remove 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 ---------- @@ -459,8 +449,7 @@ def delete_group(self, user: User, group_name: str) -> bool: 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 ---------- @@ -475,8 +464,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 ---------- @@ -493,8 +481,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) @@ -554,8 +541,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. @@ -576,8 +562,7 @@ 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: - Owner-based access (owners get CONTRIBUTOR role for their scans) @@ -603,8 +588,7 @@ def can_access_scan(self, user: User, scan_metadata: dict, operation: Permission 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. + """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. @@ -628,8 +612,7 @@ def can_access_scan_by_owner(self, user: User, scan_owner: str, operation: Permi 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. @@ -648,8 +631,7 @@ def can_modify_scan_owner(self, user: User, scan_metadata: dict) -> bool: 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). @@ -669,8 +651,7 @@ def can_modify_scan_sharing(self, user: User, scan_metadata: dict) -> bool: 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. @@ -708,8 +689,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. @@ -722,7 +702,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() @@ -730,8 +710,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 ---------- @@ -749,8 +728,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. @@ -779,8 +757,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. @@ -795,9 +772,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) From f66ba97b1215912db29479180704fece182b022d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Tue, 27 Jan 2026 22:29:55 +0100 Subject: [PATCH 09/48] Update terminology from `JWT` to `JSON Web Token` in session management - Standardize references throughout `session.py` from `JWT` to `JSON Web Token` for clarity. - Update variable names, comments, log messages, and docstrings to reflect the terminology change. - Refine function signatures and parameters, replacing `jwt_token` with `token` for consistency. - Correct minor grammatical inconsistencies in comments for enhanced readability and uniformity. --- src/commons/plantdb/commons/auth/session.py | 84 ++++++++++----------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/src/commons/plantdb/commons/auth/session.py b/src/commons/plantdb/commons/auth/session.py index b47f9ad..117d078 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 @@ -476,7 +476,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 @@ -499,10 +499,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 +512,29 @@ 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: + def _payload_from_token(self, token: str) -> dict: """ - Decode the payload from a JWT token. + 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 +543,21 @@ 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]]: + def validate_session(self, token: str) -> Optional[Dict[str, Any]]: """ - Validate a JWT token and return user information. + 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 +572,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 +605,14 @@ 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]: + 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 +621,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 +648,38 @@ def cleanup_expired_sessions(self) -> None: del self.sessions[jti] return - def session_username(self, jwt_token: str) -> Optional[str]: + def session_username(self, token: str) -> Optional[str]: """ - Extract username from JWT token. + 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]: + def refresh_session(self, token: str) -> Optional[str]: """ - Refresh a JWT token if it's still valid. + 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 +687,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) From 05255184a36accfccbe193a185071450d732cdc8 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Tue, 27 Jan 2026 22:30:05 +0100 Subject: [PATCH 10/48] Add `requires_jwt` decorator and improve `**kwargs` propagation for REST API methods - Add `requires_jwt` decorator to `post` methods for enhanced security and token validation. - Update method signatures in `rest_api.py` to include `**kwargs` and propagate them to relevant database operations (`create_user`, `logout`, `get_user_data`). - Replace `jwt_token` with `token` in function calls for consistency. - Refactor logout logic to rely on `kwargs` and use `db.logout` for session invalidation. - Clean up redundant comments and improve clarity in error handling and docstrings. --- src/server/plantdb/server/rest_api.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 68854a8..9b4c3fd 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -832,7 +832,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): + @requires_jwt + 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. @@ -881,7 +882,6 @@ def post(self): """ # 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 +891,12 @@ def post(self): }, 400 try: - # Attempt to create new user in database + # Attempt to create new user in the database self.db.create_user( username=data['username'], fullname=data['fullname'], - password=data['password'] + password=data['password'], + **kwargs ) # Return success response if user creation succeeds return { @@ -1044,7 +1045,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', @@ -1074,14 +1075,11 @@ def __init__(self, db, logger): @requires_jwt 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.""" 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!") @@ -1143,11 +1141,8 @@ def __init__(self, db, logger): @requires_jwt def post(self, **kwargs): """Handle token validation.""" - # Get token from keyword arguments (from decorator) - jwt_token = kwargs.get('token', None) - 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: From 08d6bbbeefce1e39e9dad4b03416ad6fac707f95 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 00:53:00 +0100 Subject: [PATCH 11/48] Refactor RBAC permission logic and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Updated `can_modify_scan_owner` to require ``Permission.MANAGE_USERS`` instead of admin‑only. - Removed legacy `can_access_scan_by_owner` method that lacked group sharing support. - Fixed grammar and clarified docstrings for `can_modify_scan_sharing`, `can_modify_scan`, and related methods. --- src/commons/plantdb/commons/auth/rbac.py | 40 +++++------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index f4d39ab..fbc16cd 100644 --- a/src/commons/plantdb/commons/auth/rbac.py +++ b/src/commons/plantdb/commons/auth/rbac.py @@ -564,7 +564,7 @@ def get_scan_permissions(self, user: User, scan_metadata: dict) -> Set[Permissio 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. - 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) @@ -587,34 +587,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. - Only admins can modify scan ownership. + Only users with permission to ``MANAGE_USERS`` can modify scan ownership. Parameters ---------- @@ -626,7 +602,7 @@ 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) @@ -634,7 +610,7 @@ def can_modify_scan_sharing(self, user: User, scan_metadata: dict) -> bool: """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 ---------- @@ -646,7 +622,7 @@ 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) @@ -668,9 +644,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) @@ -678,7 +654,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', []) From e5f0d4e026ac706867ae0798a665d4b6b47d79d1 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 01:04:54 +0100 Subject: [PATCH 12/48] Warn on `None` metadata values instead of raising - In `src/commons/plantdb/commons/fsdb/metadata.py`, the `metadata` helper now logs a warning when a key `data` is set to `None` instead of raising an `IOError`. - The warning message is generated by `logger.warning(f"Metadata key '{data}' was set to `None`!")`, preserving the operation flow while alerting developers to the missing value. --- src/commons/plantdb/commons/fsdb/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commons/plantdb/commons/fsdb/metadata.py b/src/commons/plantdb/commons/fsdb/metadata.py index e2433ff..6da8f9c 100644 --- a/src/commons/plantdb/commons/fsdb/metadata.py +++ b/src/commons/plantdb/commons/fsdb/metadata.py @@ -266,7 +266,7 @@ def _set_metadata(metadata, data, value): """ 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): From 6b078595980204a80e513d464de0f262185d9f1d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 01:08:40 +0100 Subject: [PATCH 13/48] Add type hints to metadata helper functions and tighten docstrings - Import `Any` from `typing` and add it to relevant type annotations. - Annotate all metadata loader and storer helpers with explicit return types. - Update the warning logic for `None` values remains unchanged, but the function signatures now reflect their return types. --- src/commons/plantdb/commons/fsdb/metadata.py | 34 ++++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/metadata.py b/src/commons/plantdb/commons/fsdb/metadata.py index 6da8f9c..08dcf37 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,7 +262,6 @@ 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): From 0ad81e602fd46f451e02bfde9baf1c3159e897ea Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 01:10:43 +0100 Subject: [PATCH 14/48] Refactor core file system database logic - Introduced improved error messaging with resource-specific identifiers for better debugging. - Updated permission validation logic for scan creation and deletion, ensuring stricter checks on user roles. - Made docstrings consistent with refined terminology and updated type hints across core functions. - Improved locking mechanisms by wrapping metadata updates, scans, and fileset operations in exclusive locks. - Refactored RBAC methods for better clarity and alignment with security best practices. --- src/commons/plantdb/commons/fsdb/core.py | 621 +++++++++++++---------- 1 file changed, 366 insertions(+), 255 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 0c1c7d1..8662fdc 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -106,7 +106,9 @@ 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 User from plantdb.commons.auth.rbac import RBACManager from plantdb.commons.auth.session import JWTSessionManager from plantdb.commons.auth.session import SessionManager @@ -232,9 +234,9 @@ def wrapper(self, *args, **kwargs): jwt_token = kwargs.pop('token', None) # Get username from JWT if isinstance(self, (Scan, Fileset, File)): - username = self.db.get_user(jwt_token) + username = self.db.get_username(jwt_token) else: - username = self.get_user(jwt_token) + username = self.get_username(jwt_token) kwargs['username'] = username else: kwargs['username'] = 'guest' @@ -245,9 +247,9 @@ def wrapper(self, *args, **kwargs): token = kwargs.pop('token', None) # Get username from session token if isinstance(self, (Scan, Fileset, File)): - username = self.db.get_user(token) + username = self.db.get_username(token) else: - username = self.get_user(token) + username = self.get_username(token) kwargs['username'] = username else: kwargs['username'] = 'guest' @@ -431,8 +433,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. @@ -550,7 +551,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: @@ -613,7 +614,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 +643,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 +671,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 +682,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) @@ -701,39 +711,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 @@ -741,14 +735,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 -------- @@ -768,39 +772,31 @@ 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 ---------- @@ -813,11 +809,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 -------- @@ -850,8 +846,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 ---------- @@ -867,8 +862,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 @@ -896,9 +890,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() @@ -906,8 +899,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 ------- @@ -947,19 +939,19 @@ def validate_user(self, username: str, password: str) -> bool: 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) -> 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. """ if self.validate_user(username, password): @@ -976,8 +968,8 @@ def login(self, username: str, password: str) -> Union[str, None]: return None @require_token - def logout(self, **kwargs): - """Logout user and by invalidating its session.""" + def logout(self, **kwargs) -> bool: + """Logout a user by invalidating its session.""" success, username = self.session_manager.invalidate_session(kwargs.get('token', None)) if success: self.logger.info(f"User {username} logged out successfully") @@ -988,8 +980,7 @@ def logout(self, **kwargs): @require_authentication def create_user(self, username, fullname, password, roles=None, **kwargs) -> None: - """ - Create a new user with the specified details. + """Create a new user with the specified details. Parameters ---------- @@ -1002,6 +993,12 @@ def create_user(self, username, fullname, password, roles=None, **kwargs) -> Non 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. @@ -1018,33 +1015,17 @@ def create_user(self, username, fullname, password, roles=None, **kwargs) -> Non return self.rbac_manager.users.create(username, fullname, password, roles) - def get_guest_user(self): - """ - Retrieve the guest user information from the RBAC manager. - - Returns the guest user object containing all relevant data. - - Parameters - ---------- - None + 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 -------- @@ -1052,62 +1033,82 @@ def get_guest_user(self): """ 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. """ - 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. """ 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. """ - 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!") return self.rbac_manager.create_group(current_user, name, users, description) @@ -1118,23 +1119,31 @@ 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. """ - 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") @@ -1147,30 +1156,38 @@ 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. """ - 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 @@ -1178,82 +1195,105 @@ 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. """ - 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(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. """ 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. """ - 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 @@ -1262,18 +1302,18 @@ def get_scan_access_summary(self, scan_id, **kwargs): Raises ------ PermissionError - If no authenticated user + If no user is authenticated. """ - 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}") @@ -1281,7 +1321,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; @@ -1385,7 +1425,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 = {} @@ -1394,27 +1434,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) @@ -1516,21 +1572,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 @@ -1545,7 +1601,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): @@ -1584,7 +1640,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 @@ -1608,7 +1665,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: @@ -1618,7 +1675,7 @@ 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 @@ -1639,7 +1696,7 @@ def set_metadata(self, data, value=None, **kwargs): raise PermissionError(f"Insufficient permissions to modify scan '{self.id}' metadata.") # Use exclusive lock for metadata updates - self.logger.info(f"Updating scan '{self.id}' metadata by user '{current_user.username}'...") + 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) @@ -1647,7 +1704,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"Done updating the scan '{self.id}' metadata!") + self.logger.info(f"Done updating the scan metadata.") return @require_authentication @@ -1668,14 +1725,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 -------- @@ -1697,20 +1758,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 a 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 @@ -1720,7 +1781,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 @@ -1729,16 +1790,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"Done creating the '{fs_id}' fileset!") + 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 @@ -1746,6 +1802,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 @@ -1762,22 +1830,28 @@ 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 fileset '{fs_id}' 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: @@ -1829,7 +1903,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; @@ -1971,7 +2045,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) @@ -2028,6 +2102,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 @@ -2048,17 +2128,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 set fileset metadata in scan/fileset '{self.scan.id}/{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!") + # 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"Set fileset '{self.id}' metadata in '{self.scan.id}' by user '{current_user.username}'") + self.logger.info(f"Done editing the fileset metadata.") return @require_authentication @@ -2075,6 +2157,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 @@ -2099,20 +2195,20 @@ 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 a file in scan/fileset '{self.scan.id}/{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 @@ -2121,7 +2217,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 @@ -2130,7 +2226,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 @@ -2142,6 +2238,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 @@ -2163,24 +2271,23 @@ 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 delete file in scan/fileset '{self.scan.id}/{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 scan/fileset '{self.scan.id}/{self.id}'") + 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``) - self.logger.info(f"Deleted file '{f_id}' from scan/fileset '{self.scan.id}/{self.id}' by user '{current_user.username}'") + self.logger.info(f"Done deleting file.") return def store(self): @@ -2349,18 +2456,19 @@ def set_metadata(self, data, value=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 set file metadata for scan/fileset/file '{self.scan.id}/{self.fileset.id}/{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.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"Updated file '{self.id}' metadata in '{self.scan.id}/{self.fileset.id}' by user '{current_user.username}'.") + self.logger.info(f"Done editing the file metadata.") return @require_authentication @@ -2390,17 +2498,18 @@ 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 file in scan/fileset '{self.scan.id}/{self.fileset.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.") + 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:] @@ -2411,7 +2520,7 @@ def import_file(self, path, **kwargs): copyfile(path, newpath) self.store() # register it to the scan main JSON FILE - self.logger.info(f"Imported file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") + self.logger.info(f"Done importing file.") return def store(self): @@ -2481,11 +2590,12 @@ 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 write file in scan/fileset '{self.scan.id}/{self.fileset.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!") # 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) @@ -2493,7 +2603,7 @@ def write_raw(self, data, ext="", **kwargs): f.write(data) self.store() - self.logger.info(f"Wrote file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") + self.logger.info(f"Done writing raw file.") return def read(self): @@ -2563,11 +2673,12 @@ 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 write file in scan/fileset '{self.scan.id}/{self.fileset.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!") # 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) @@ -2575,7 +2686,7 @@ def write(self, data, ext="", **kwargs): f.write(data) self.store() - self.logger.info(f"Wrote file '{self.id}' in scan/fileset '{self.scan.id}/{self.fileset.id}' for user '{current_user.username}'") + self.logger.info(f"Done writing file.") return def path(self) -> pathlib.Path: From 4a08136e46aeefbf8ce7143c72d65a9fb19ecb4d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 01:11:37 +0100 Subject: [PATCH 15/48] Improve comment wording in `plantdb_client.py` --- src/client/plantdb/client/plantdb_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/client/plantdb/client/plantdb_client.py b/src/client/plantdb/client/plantdb_client.py index 01f47cc..1b6104f 100644 --- a/src/client/plantdb/client/plantdb_client.py +++ b/src/client/plantdb/client/plantdb_client.py @@ -750,8 +750,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, @@ -777,10 +777,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 = { From dee0f81d4e0c53447aa2fe711c0b2177c7b8ca93 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Wed, 28 Jan 2026 01:30:56 +0100 Subject: [PATCH 16/48] Update example URLs to use explicit host/port and include auth token - In **`src/server/plantdb/server/rest_api.py`** replace all `plantdb_url()` calls in docstring examples with `plantdb_url('localhost', port=5000)`. - Add a login request example that obtains an `access_token` and stores it in a `token` variable. - Show how to include `headers={'Authorization': 'Bearer ' + token}` in the file upload example. - Update calls that previously used `self.db.get_scan(scan_id)` and `file.write_raw(...)` to pass `**kwargs` (e.g., `self.db.get_scan(scan_id, **kwargs)` and `file.write_raw(..., **kwargs)`). - Adjust related example URLs for metadata retrieval and updates to use the new host/port form. --- src/server/plantdb/server/rest_api.py | 40 ++++++++++++++------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 9b4c3fd..23d9ae3 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -3204,7 +3204,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 @@ -3290,10 +3290,10 @@ 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'}} @@ -3351,10 +3351,10 @@ 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()) @@ -3443,7 +3443,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 @@ -3524,7 +3524,7 @@ def post(self, **kwargs): >>> 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 @@ -3632,10 +3632,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'}} @@ -3705,11 +3705,11 @@ def post(self, scan_id, fileset_id, **kwargs): >>> 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'}} @@ -3803,7 +3803,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 @@ -3888,8 +3888,10 @@ def post(self, **kwargs): >>> # 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 = { @@ -3903,7 +3905,7 @@ def post(self, **kwargs): ... '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()) @@ -3945,7 +3947,7 @@ def post(self, **kwargs): 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 @@ -3958,9 +3960,9 @@ def post(self, **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, **kwargs) self.logger.error(f'Error writing file: {str(e)}') @@ -4029,7 +4031,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'}} @@ -4096,7 +4098,7 @@ def post(self, scan_id, fileset_id, file_id, **kwargs): >>> # $ 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()) From 5c6b439f716db124bdfddb7c3b9420d21bcbaccb Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 18:17:04 +0100 Subject: [PATCH 17/48] Add role hierarchy and assignment checks - Introduced `rank` property on `Role` to expose a numeric hierarchy (READER=1, CONTRIBUTOR=2, ADMIN=3) - Added `can_assign(target_role)` method to determine if a role can assign another role based on rank comparison - Updated docstrings in `src/commons/plantdb/commons/auth/models.py` with usage examples for `rank` and `can_assign` --- src/commons/plantdb/commons/auth/models.py | 57 ++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/commons/plantdb/commons/auth/models.py b/src/commons/plantdb/commons/auth/models.py index fb22934..3eeb1e0 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: { From e9aa3ab0689a04a0fc3e202b2c1bed944c9e9c4f Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 18:17:21 +0100 Subject: [PATCH 18/48] Add unit tests for auth models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tests for `Permission` constants and string values - Add tests for `Role` constants, permissions set, rank ordering, and `can_assign` logic - Add tests for `User` serialization (`to_dict`, `from_dict`), JSON conversion, lock state checks, and failed‑attempt tracking - Add tests for `Group` add/remove user functionality, duplicate prevention, and `has_user` checks - Create new test file `src/commons/tests/test_auth_models.py` containing all tests above. --- src/commons/tests/test_auth_models.py | 243 ++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 src/commons/tests/test_auth_models.py diff --git a/src/commons/tests/test_auth_models.py b/src/commons/tests/test_auth_models.py new file mode 100644 index 0000000..9641f6a --- /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")) From 6d42eb9c3a0ed2e76293e95f543148e219f42bff Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 18:17:54 +0100 Subject: [PATCH 19/48] Remove refactored auth tests from `test_auth.py` - Delete extensive test suite for `Permission`, `Role`, `User`, and `Group` that was previously in `src/commons/tests/test_auth.py`. - Consolidate all auth model tests into `src/commons/tests/test_auth_models.py`. --- src/commons/tests/test_auth.py | 206 --------------------------------- 1 file changed, 206 deletions(-) diff --git a/src/commons/tests/test_auth.py b/src/commons/tests/test_auth.py index 5c56770..731bb2d 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""" From d5c5fb863bf9883db1a1e032986d729b8db51346 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 20:39:47 +0100 Subject: [PATCH 20/48] Add session_token helper and refine session_username MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify active‑session check by using a truthy `username` instead of `is not None`. - Expand the docstring for `session_username` to explain validation, parameters, and return value. - Introduce new method `session_token(username)` that cleans up expired sessions and returns the active session id for the given `username` or `None` if no active session exists. --- src/commons/plantdb/commons/auth/session.py | 45 ++++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/src/commons/plantdb/commons/auth/session.py b/src/commons/plantdb/commons/auth/session.py index 117d078..6238ddd 100644 --- a/src/commons/plantdb/commons/auth/session.py +++ b/src/commons/plantdb/commons/auth/session.py @@ -102,7 +102,7 @@ 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 @@ -270,10 +270,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 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``. + """ + 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. From 11aa7a90a328af3e2dd7714e478ee2083424dbb4 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 20:48:34 +0100 Subject: [PATCH 21/48] Update User model docs and add tracking fields - Revise `User` class docstring for clarity and consistency. - Add new optional attributes ``last_failed_attempt`` and ``password_last_change`` with defaults. - Update type hints for ``permissions``, ``last_login``, ``is_active``, ``failed_attempts``, and ``locked_until``. - Update docstrings for ``__eq__``, ``to_dict``, ``from_dict``, ``to_json``, and ``from_json`` methods. - Adjust `Group` class docstring formatting and role mention. --- src/commons/plantdb/commons/auth/models.py | 78 ++++++++++------------ 1 file changed, 35 insertions(+), 43 deletions(-) diff --git a/src/commons/plantdb/commons/auth/models.py b/src/commons/plantdb/commons/auth/models.py index 3eeb1e0..247ca2f 100644 --- a/src/commons/plantdb/commons/auth/models.py +++ b/src/commons/plantdb/commons/auth/models.py @@ -208,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 ---------- @@ -226,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 -------- @@ -275,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 ---------- @@ -296,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, @@ -321,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 ---------- @@ -332,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 ------ @@ -390,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 ---------- @@ -415,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 ------ @@ -470,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 ---------- @@ -482,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. @@ -512,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 ---------- @@ -531,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 ---------- @@ -550,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 ---------- From 08ff72ba2ac41868d4af0b0575a0f523f089d97d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 20:49:44 +0100 Subject: [PATCH 22/48] Clean up auth manager docstrings and minor text tweaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert multiline docstrings in `src/commons/plantdb/commons/auth/manager.py` to single‑line format for consistency. - Update the welcome log message to ``f"Welcome {fullname}, please log in...'``. - Adjust the wording in the `_ensure_admin_user` docstring to read “used internally in the class”. - Remove extraneous blank lines and normalize comment spacing throughout the file. - No changes to runtime behavior, only documentation formatting. --- src/commons/plantdb/commons/auth/manager.py | 90 ++++++++------------- 1 file changed, 32 insertions(+), 58 deletions(-) diff --git a/src/commons/plantdb/commons/auth/manager.py b/src/commons/plantdb/commons/auth/manager.py index 471f3bf..46f3b69 100644 --- a/src/commons/plantdb/commons/auth/manager.py +++ b/src/commons/plantdb/commons/auth/manager.py @@ -60,8 +60,7 @@ class UserManager(): - """ - UserManager class for managing user data. + """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,8 +152,7 @@ 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. @@ -194,8 +191,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 +210,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 ---------- @@ -285,12 +280,11 @@ def create(self, username: str, fullname: str, password: str, roles: Union[Role, 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 +308,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 +339,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 +358,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 ---------- @@ -385,8 +377,7 @@ def is_locked_out(self, username) -> bool: 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 +395,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. @@ -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 @@ -461,8 +450,7 @@ def _record_failed_attempt(self, username: str, max_failed_attempts: int, lockou 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,8 +475,7 @@ 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 ---------- @@ -500,8 +487,7 @@ def unlock_user(self, user: User) -> None: return def activate(self, user: User) -> None: - """ - Activates a user. + """Activates a user. Parameters ---------- @@ -520,8 +506,7 @@ def activate(self, user: User) -> None: return def deactivate(self, user: User) -> None: - """ - Deactivates a user. + """Deactivates a user. Parameters ---------- @@ -577,8 +562,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 ---------- @@ -611,8 +595,7 @@ 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. @@ -626,8 +609,7 @@ class GroupManager: """ def __init__(self, groups_file: str = "groups.json"): - """ - Initialize the GroupManager. + """Initialize the GroupManager. Parameters ---------- @@ -691,8 +673,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 +681,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 @@ -736,8 +717,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 ---------- @@ -752,8 +732,7 @@ def get_group(self, name: str) -> Optional[Group]: return self.groups.get(name) def delete_group(self, name: str) -> bool: - """ - Delete a group. + """Delete a group. Parameters ---------- @@ -773,8 +752,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 ---------- @@ -798,8 +776,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 ---------- @@ -823,8 +800,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 +819,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 +829,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 ---------- From 3bce9d7d8dc98c449b6d7b3fa7a1d4d51513fedd Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 21:08:21 +0100 Subject: [PATCH 23/48] Clean up documentation and docstring formatting --- src/client/plantdb/client/plantdb_client.py | 1 - src/commons/plantdb/commons/auth/manager.py | 41 ++++++++++----------- src/commons/plantdb/commons/auth/rbac.py | 23 ++++++------ src/server/plantdb/server/rest_api.py | 5 --- 4 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/client/plantdb/client/plantdb_client.py b/src/client/plantdb/client/plantdb_client.py index 1b6104f..087a06f 100644 --- a/src/client/plantdb/client/plantdb_client.py +++ b/src/client/plantdb/client/plantdb_client.py @@ -741,7 +741,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 diff --git a/src/commons/plantdb/commons/auth/manager.py b/src/commons/plantdb/commons/auth/manager.py index 46f3b69..619bd1a 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,7 +59,7 @@ ph = PasswordHasher() -class UserManager(): +class UserManager(object): """UserManager class for managing user data. The UserManager class provides methods to create, load, @@ -155,7 +155,8 @@ def _save_users(self) -> None: """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 ------ @@ -225,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 @@ -276,7 +277,7 @@ 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: @@ -410,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 @@ -437,14 +437,13 @@ 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 @@ -479,7 +478,7 @@ def unlock_user(self, user: User) -> None: Parameters ---------- - username : plantdb.commons.auth.User + user : plantdb.commons.auth.User The user to unlock. """ user.locked_until = None @@ -571,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: @@ -602,18 +601,18 @@ class GroupManager: 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"): + 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) @@ -699,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) @@ -727,7 +726,7 @@ 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) @@ -742,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 @@ -764,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: @@ -788,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: diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index fbc16cd..3ff7ab8 100644 --- a/src/commons/plantdb/commons/auth/rbac.py +++ b/src/commons/plantdb/commons/auth/rbac.py @@ -156,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 @@ -168,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: diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 23d9ae3..bada1f6 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -1380,7 +1380,6 @@ def get(self): ['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() - """ query = request.args.get('filterQuery', None) fuzzy = request.args.get('fuzzy', False, type=bool) @@ -1715,7 +1714,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: @@ -2013,7 +2011,6 @@ def get(self, scan_id, fileset_id, file_id): >>> img = Image.open(BytesIO(res.content)) >>> np.asarray(img).shape (1080, 1440, 3) - """ # Sanitize identifiers scan_id = sanitize_name(scan_id) @@ -2138,7 +2135,6 @@ def get(self, scan_id, fileset_id, file_id): >>> # Send the coordinates (read the file on the server-side) >>> 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", 'coords': 'true'}) >>> coordinates = np.array(res.json()['coordinates']) - """ # Sanitize identifiers scan_id = sanitize_name(scan_id) @@ -3722,7 +3718,6 @@ def post(self, scan_id, fileset_id, **kwargs): >>> 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 From d3ff7787cdf79433c6a3643b09a8d02861a3c9be Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 22:24:29 +0100 Subject: [PATCH 24/48] Clean up temporary directories for dummy databases on disconnect - In `FSDB.disconnect` remove the temporary database directory when the instance was created by `dummy_db` (flagged by `_is_dummy`). - Add a cleanup block that uses `shutil.rmtree` and logs success or warning. - In `test_database.py` set `db._is_dummy = True` for dummy DBs so the cleanup logic is exercised. - Update `dummy_db` documentation to show that it logs in as the `admin` user and to verify that `db.path().exists()` is `False` after disconnect. --- src/commons/plantdb/commons/fsdb/core.py | 13 +++++++++++++ src/commons/plantdb/commons/test_database.py | 12 +++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 8662fdc..bd0d84c 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -444,14 +444,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 diff --git a/src/commons/plantdb/commons/test_database.py b/src/commons/plantdb/commons/test_database.py index 609f150..65b166f 100644 --- a/src/commons/plantdb/commons/test_database.py +++ b/src/commons/plantdb/commons/test_database.py @@ -591,15 +591,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 +632,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') From 9e764893c3e1a3ab51dda8abc46a3cd4ff556bd6 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Thu, 29 Jan 2026 22:25:05 +0100 Subject: [PATCH 25/48] Rename group creation parameter and enhance RBAC logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `create_group` signature from `name: str` to `group_name: str` and update all docstring references accordingly. - Add informative logs for permission checks and actions: error logs when permission is denied, info logs for successful creations, and warning logs for removals and deletions. - Update return‑value documentation to use code fences and clarify that `None` indicates a denied permission. - Adjust `add_user_to_group` and `remove_user_from_group` docstrings to list ``True`` and ``False`` outcomes with proper formatting. - Ensure all log messages reference the correct `group_name` variable and user names consistently. --- src/commons/plantdb/commons/auth/rbac.py | 35 ++++++++++++++---------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/commons/plantdb/commons/auth/rbac.py b/src/commons/plantdb/commons/auth/rbac.py index 3ff7ab8..5b631b6 100644 --- a/src/commons/plantdb/commons/auth/rbac.py +++ b/src/commons/plantdb/commons/auth/rbac.py @@ -194,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. @@ -353,7 +353,7 @@ def can_delete_group(self, user: User) -> bool: 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. @@ -361,17 +361,17 @@ def create_group(self, user: User, name: str, users: Optional[Set[str]] = None, ---------- 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 ------ @@ -379,9 +379,10 @@ def create_group(self, user: User, name: str, users: Optional[Set[str]] = None, If a group with the same name already exists. """ 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. @@ -398,11 +399,13 @@ 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: @@ -420,11 +423,13 @@ 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: @@ -440,11 +445,13 @@ 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]: From 15e42e8b14cc70dd271fb30b1792da248db8b7fe Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:13:49 +0100 Subject: [PATCH 26/48] Introduce `get_logged_username` helper and refine authentication flow - Added `get_logged_username` function to determine the current user from `SingleSessionManager`, `JWTSessionManager`, or `SessionManager`. - Updated `require_authentication` decorator to call the new helper and inject the username into the wrapped method. - Refactored `login` to block concurrent logins under a `SingleSessionManager`, log informative messages, and use clearer wording. - Renamed `create_user` parameter from `username` to ``new_username`` to avoid shadowing. - Adjusted related docstrings and log messages in `src/commons/plantdb/commons/fsdb/core.py`. --- src/commons/plantdb/commons/fsdb/core.py | 202 ++++++++++++++++------- 1 file changed, 145 insertions(+), 57 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index bd0d84c..9243c10 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -108,6 +108,7 @@ 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 @@ -198,64 +199,95 @@ 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`. - 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 + 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. + + 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 + """ + 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_username(jwt_token) - else: - username = self.get_username(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_username(token) - else: - username = self.get_username(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, token=kwargs.pop('token', None), **kwargs) return method(self, *args, **kwargs) @@ -952,7 +984,7 @@ def validate_user(self, username: str, password: str) -> bool: return self.rbac_manager.users.validate(username, password) @require_connected_db - def login(self, username: str, password: str) -> Optional[str]: + def login(self, username: str, password: str, **kwargs) -> Optional[str]: """Authenticate user and create session. Parameters @@ -968,16 +1000,29 @@ def login(self, username: str, password: str) -> Optional[str]: Returns the user session ID if successful, ``None`` otherwise. """ 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 @@ -985,19 +1030,19 @@ def logout(self, **kwargs) -> bool: """Logout a user by invalidating its session.""" 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, **kwargs) -> None: + 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. @@ -1024,9 +1069,7 @@ def create_user(self, username, fullname, password, roles=None, **kwargs) -> Non if not self.rbac_manager.can_create_user(current_user): raise PermissionError(f"Insufficient permissions to create new user with user '{current_user.username}'") - # TODO: add a maximum role given the current user role - - return self.rbac_manager.users.create(username, fullname, password, roles) + 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. @@ -1076,6 +1119,11 @@ def get_user_data(self, username=None, token=None) -> Optional[User]: Optional[User] The User object corresponding to the currently authenticated user, if any, ``None`` otherwise. """ + 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 token: @@ -1123,6 +1171,11 @@ def create_group(self, name, users=None, description=None, **kwargs) -> Optional if not current_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) @require_authentication @@ -1281,6 +1334,17 @@ def get_user_groups(self, user=None, **kwargs) -> list[Group]: ------ PermissionError 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) if not current_user: @@ -1316,6 +1380,30 @@ def get_scan_access_summary(self, scan_id, **kwargs): ------ PermissionError 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) if not current_user: From 24a3aea7c2dd79f0245a6c9ae8f9abce1d688ea9 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:14:53 +0100 Subject: [PATCH 27/48] Add example usage to authentication and group methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add doctest-style examples to `validate_user`, `login`, `logout`, `create_user`, `get_guest_user`, `get_username`, `get_user_data`. - Add examples to group‑management functions `create_group`, `add_user_to_group`, `remove_user_from_group`, `delete_group`, `list_groups`. - Show how to use `dummy_db()` for testing, including login/logout logs and expected return values. --- src/commons/plantdb/commons/fsdb/core.py | 157 ++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 9243c10..7219a3f 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -980,6 +980,14 @@ 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) @@ -998,6 +1006,19 @@ def login(self, username: str, password: str, **kwargs) -> Optional[str]: ------- 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): @@ -1027,7 +1048,18 @@ def login(self, username: str, password: str, **kwargs) -> Optional[str]: @require_token def logout(self, **kwargs) -> bool: - """Logout a user by invalidating its session.""" + """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"Successfully logged out from '{username}'.") @@ -1060,6 +1092,19 @@ def create_user(self, new_username, fullname, password, roles=None, **kwargs) -> See Also -------- RBACManager.users.create : Method used to actually create the user. + + 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() """ current_user = self.get_user_data(**kwargs) if not current_user: @@ -1086,6 +1131,14 @@ def get_guest_user(self) -> User: 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() @@ -1101,6 +1154,18 @@ def get_username(self, token) -> Optional[str]: ------- 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(token) @@ -1118,6 +1183,25 @@ def get_user_data(self, username=None, token=None) -> Optional[User]: ------- 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!") @@ -1166,6 +1250,17 @@ def create_group(self, name, users=None, description=None, **kwargs) -> Optional If the user lacks permission to create groups. ValueError 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) if not current_user: @@ -1206,6 +1301,21 @@ def add_user_to_group(self, group_name, user, **kwargs): PermissionError 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) if not current_user: @@ -1243,6 +1353,20 @@ def remove_user_from_group(self, group_name, user, **kwargs): PermissionError 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) if not current_user: @@ -1278,6 +1402,26 @@ def delete_group(self, group_name, **kwargs) -> bool: PermissionError 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) if not current_user: @@ -1300,6 +1444,17 @@ def list_groups(self, **kwargs) -> list[Group]: ------ PermissionError 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: From 3a384c8e484a4fdf906b7086bd65721a70a8be2f Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:15:35 +0100 Subject: [PATCH 28/48] Improve documentation and formatting in FSDB core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cleaned up and tightened docstring comments, adding backticks around variable names and file references - Re‑formatted error messages and permission checks for consistency and clarity - Adjusted directory structure description to use the correct `${FSDB.basedir}` reference - Fixed minor punctuation and spacing issues in the `require_connected_db` decorator and related methods - Updated logger messages to use multi‑line strings where appropriate and to correctly reference usernames and IDs - Minor text corrections in `FSDB.disconnect` and other permission‑related error messages - Overall: no functional changes, only cosmetic and readability improvements. --- src/commons/plantdb/commons/fsdb/core.py | 49 ++++++++++++++---------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 7219a3f..5101682 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -147,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. @@ -297,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 ---------- @@ -310,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 @@ -408,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() @@ -620,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 ---------- @@ -631,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 ------- @@ -2088,7 +2087,8 @@ def delete_fileset(self, fs_id, **kwargs) -> None: # 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!") + 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): @@ -2453,7 +2453,8 @@ def create_file(self, f_id, metadata=None, **kwargs): # 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!") + 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): @@ -2529,7 +2530,8 @@ def delete_file(self, f_id, **kwargs): # 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!") + 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): @@ -2714,7 +2716,8 @@ def set_metadata(self, data, value=None, **kwargs): # 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!") + 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...") @@ -2756,7 +2759,8 @@ def import_file(self, path, **kwargs): # 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!") + 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): @@ -2765,7 +2769,8 @@ def import_file(self, path, **kwargs): 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}'...") + 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:] @@ -2848,10 +2853,12 @@ def write_raw(self, data, ext="", **kwargs): # 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!") + raise PermissionError( + f"Insufficient permissions to write raw '{self.filename}' file in '{self.scan.id}/{self.fileset.id}' as '{current_user.username}' user!") # 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}'...") + 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) @@ -2931,10 +2938,12 @@ def write(self, data, ext="", **kwargs): # 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!") + raise PermissionError( + f"Insufficient permissions to write '{self.filename}' file in '{self.scan.id}/{self.fileset.id}' as '{current_user.username}' user!") # 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}'...") + 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) From 76effc7d3e290341b06c59d480c94c71120e80e9 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:16:07 +0100 Subject: [PATCH 29/48] Cleanup stray whitespace and error formatting in FSDB core --- src/commons/plantdb/commons/fsdb/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index 5101682..a7e358f 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -285,7 +285,6 @@ def require_authentication(method): """ def wrapper(self, *args, **kwargs): - kwargs['username'] = get_logged_username(self, token=kwargs.pop('token', None), **kwargs) return method(self, *args, **kwargs) @@ -824,7 +823,8 @@ def delete_scan(self, scan_id, **kwargs) -> bool: # Check DELETE permission for this specific scan scan = self.scans[scan_id] 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!") + 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...") @@ -1205,7 +1205,7 @@ def get_user_data(self, username=None, token=None) -> Optional[User]: 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 + username = None if username: return self.rbac_manager.users.get_user(username) From d0a4d2e589632107e1691907db6b5168cb7750ab Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:17:29 +0100 Subject: [PATCH 30/48] Improve documentation and formatting in auth session No functional changes. --- src/commons/plantdb/commons/auth/session.py | 66 +++++++-------------- 1 file changed, 22 insertions(+), 44 deletions(-) diff --git a/src/commons/plantdb/commons/auth/session.py b/src/commons/plantdb/commons/auth/session.py index 6238ddd..729bb1b 100644 --- a/src/commons/plantdb/commons/auth/session.py +++ b/src/commons/plantdb/commons/auth/session.py @@ -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 ---------- @@ -109,8 +106,7 @@ def _user_has_session(self, username) -> bool: 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. @@ -183,8 +177,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 +215,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 +242,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,8 +261,7 @@ def cleanup_expired_sessions(self) -> None: return def session_username(self, session_id: str) -> Optional[str]: - """ - Retrieve the username associated with a given session ID. + """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; @@ -292,8 +282,7 @@ def session_username(self, session_id: str) -> Optional[str]: return session_data['username'] if session_data else None def session_token(self, username) -> Optional[str]: - """ - Retrieve the active session token, if any, for a given username. + """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. @@ -316,8 +305,7 @@ def session_token(self, username) -> Optional[str]: return None def refresh_session(self, session_id: str) -> Optional[str]: - """ - Refresh a session if it's still valid. + """Refresh a session if it's still valid. Parameters ---------- @@ -342,8 +330,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` @@ -407,8 +394,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 @@ -435,8 +421,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 ---------- @@ -454,8 +439,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 @@ -499,8 +483,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. @@ -557,8 +540,7 @@ def create_session(self, username: str) -> Union[str, None]: return jwt_token def _payload_from_token(self, token: str) -> dict: - """ - Decode the payload from a JSON Web Token. + """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. @@ -592,8 +574,7 @@ def _payload_from_token(self, token: str) -> dict: ) def validate_session(self, token: str) -> Optional[Dict[str, Any]]: - """ - Validate a JSON Web Token and return user information. + """Validate a JSON Web Token and return user information. Parameters ---------- @@ -647,8 +628,7 @@ def validate_session(self, token: str) -> Optional[Dict[str, Any]]: } def invalidate_session(self, token: str = None, jti: str = None) -> Tuple[bool, str | None]: - """ - Invalidate a session by removing it from tracking. + """Invalidate a session by removing it from tracking. Parameters ---------- @@ -690,8 +670,7 @@ def cleanup_expired_sessions(self) -> None: return def session_username(self, token: str) -> Optional[str]: - """ - Extract username from JSON Web Token. + """Extract username from JSON Web Token. Parameters ---------- @@ -707,8 +686,7 @@ def session_username(self, token: str) -> Optional[str]: return session_data['username'] if session_data else None def refresh_session(self, token: str) -> Optional[str]: - """ - Refresh a JSON Web Token if it's still valid. + """Refresh a JSON Web Token if it's still valid. Parameters ---------- From 46c31153c1522745ef8a4ab961bdb3bc6356e05a Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:18:21 +0100 Subject: [PATCH 31/48] Add optional `session_manager` to `test_database` - Allow `session_manager` keyword argument in `dummy_db` and forward it to `FSDB` constructor. - Default to `None` to preserve backward compatibility with existing calls. --- src/commons/plantdb/commons/test_database.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commons/plantdb/commons/test_database.py b/src/commons/plantdb/commons/test_database.py index 65b166f..5c0b56b 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): From dbd196c68cf373fb53ea7e7ae53c9da052b16132 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:19:31 +0100 Subject: [PATCH 32/48] Add JWT session manager to test database setup - Pass a `JWTSessionManager` initialized with `jwt_key` to the `test_database` function for both empty and populated test database scenarios. - Update `test_database` calls to include the new `session_manager` keyword argument, enabling JWT authenticated sessions during testing. --- src/server/plantdb/server/cli/fsdb_rest_api.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server/plantdb/server/cli/fsdb_rest_api.py b/src/server/plantdb/server/cli/fsdb_rest_api.py index b2e66b3..0ff0093 100644 --- a/src/server/plantdb/server/cli/fsdb_rest_api.py +++ b/src/server/plantdb/server/cli/fsdb_rest_api.py @@ -266,9 +266,13 @@ def _setup_test_database(empty: bool, models: bool, db_path: Optional[Union[str, Path 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) From a33b4d211ba54767fa2bf7f1d77da4a6695b248a Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:20:50 +0100 Subject: [PATCH 33/48] Standardize JWT terminology and improve wording in docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace “JWT token” with “JSON Web Token” in `rest_api.py` and CLI documentation. - Minor wording fixes in docstrings and log messages (e.g., “A logger instance…” and “The path to the created test database.”). - No functional changes. --- src/server/plantdb/server/cli/fsdb_rest_api.py | 6 +++--- src/server/plantdb/server/cli/wsgi.py | 2 +- src/server/plantdb/server/rest_api.py | 13 +++++-------- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/server/plantdb/server/cli/fsdb_rest_api.py b/src/server/plantdb/server/cli/fsdb_rest_api.py index 0ff0093..35bd826 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,12 +259,12 @@ 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: diff --git a/src/server/plantdb/server/cli/wsgi.py b/src/server/plantdb/server/cli/wsgi.py index d306f66..0831d78 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 bada1f6..50d4367 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -706,7 +706,7 @@ def requires_jwt(f): @wraps(f) def decorated_function(*args, **kwargs): - # Try to get JWT token from request header + # Try to get JSON Web Token from 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: @@ -1096,7 +1096,7 @@ class TokenValidation(Resource): """ 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 @@ -1157,8 +1157,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 @@ -2704,8 +2703,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. @@ -2729,8 +2727,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. From 25b56603757da4d3fbd3c28c42634f7e7f466d8b Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:21:52 +0100 Subject: [PATCH 34/48] Update doctest examples to use admin/guest - Change login doctest to use `admin`/`admin` credentials instead of `anonymous`/`AlanMoore`. - Update missing credentials example to reflect the new default user `admin`. - Adjust metadata examples to show owner as `guest` instead of `anonymous`. - Reflect the updated owner in the POST metadata doctest. --- src/server/plantdb/server/rest_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 50d4367..f9a54b7 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -1018,13 +1018,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) @@ -3289,7 +3289,7 @@ def get(self, scan_id): >>> 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()) @@ -3351,7 +3351,7 @@ def post(self, scan_id, **kwargs): >>> 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 From cc44eb308b755e05097e803f88d6755cd6ab9d3f Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:23:18 +0100 Subject: [PATCH 35/48] Fix Register resource - Update `src/server/plantdb/server/rest_api.py` to call `self.db.create_user` with `new_username`, `fullname`, and `password` extracted via `data.pop`. - Adjust the inline comment to match the new wording for clarity. --- src/server/plantdb/server/rest_api.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index f9a54b7..4a09fb2 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -891,11 +891,11 @@ def post(self, **kwargs): }, 400 try: - # Attempt to create new user in the 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 From 22984e68edc389cb1dc9bff934fddd337a9e8f97 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:24:10 +0100 Subject: [PATCH 36/48] Add example usage for user registration, login, logout, and token validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update doctest examples in `src/server/plantdb/server/rest_api.py` for registering a user, logging in, and checking user existence. - Add a step‑by‑step example for admin login, user creation, and subsequent login. - Extend the `Logout` endpoint docstring with a full usage example. - Enhance the `TokenValidation` endpoint docstring to show how to validate a JWT. - Adjust inline comments and wording for clarity across the auth‑related endpoints. --- src/server/plantdb/server/rest_api.py | 68 +++++++++++++++++---------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 4a09fb2..08f9a53 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -870,15 +870,20 @@ def post(self, **kwargs): >>> # 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() @@ -963,9 +968,9 @@ def get(self): >>> 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()) @@ -1075,7 +1080,23 @@ def __init__(self, db, logger): @requires_jwt def post(self, **kwargs): - """Handle user logout.""" + """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 'token' in kwargs: # Invalidate session @@ -1093,8 +1114,7 @@ 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 JSON Web Token, verifies its validity against the database session manager, and returns the authenticated @@ -1115,22 +1135,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,7 +1144,21 @@ def __init__(self, db, logger): @requires_jwt def post(self, **kwargs): - """Handle token validation.""" + """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(**kwargs) except Exception as e: From 014a85e39b5cf11ecb76d630346165d112d10dc2 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:24:55 +0100 Subject: [PATCH 37/48] Improve TokenRefresh initialization and documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update `TokenRefresh.__init__` in `rest_api.py` to accept a `db` parameter and assign it to `self.db` (previously `None`). - Extend the `post` method docstring to describe refreshing a **JSON Web Token** and add doctest examples demonstrating a full token‑refresh workflow. - Replace the brief “Refresh JWT token.” comment with a detailed description and example usage section. - Preserve the `requires_jwt` decorator behavior and token extraction logic. --- src/server/plantdb/server/rest_api.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 08f9a53..8eb4ec3 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -1195,13 +1195,32 @@ class TokenRefresh(Resource): """ - def __init__(self): + def __init__(self, db): """Initialize the TokenRefresh resource.""" - self.db = None + self.db = db @requires_jwt 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) From 96f9fd43a68e75063bb4cc479eef2a6246d96927 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:29:35 +0100 Subject: [PATCH 38/48] Remove redundant active session checks from session creation and JWT refresh - In `src/commons/plantdb/commons/auth/session.py`, the guard that logged a warning and returned `None` when `self._user_has_session(username)` was true has been removed from `create_session`. - The same guard has been removed from `refresh_session` (`_validate_jwt`), allowing token refresh attempts without an early exit. - The code now relies on the existing concurrency limit check to enforce session policy. --- src/commons/plantdb/commons/auth/session.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/commons/plantdb/commons/auth/session.py b/src/commons/plantdb/commons/auth/session.py index 729bb1b..04a8182 100644 --- a/src/commons/plantdb/commons/auth/session.py +++ b/src/commons/plantdb/commons/auth/session.py @@ -154,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})") @@ -508,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})") From 9e980aa40c2b8d76dd4618ae57e51f9806a2c60b Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:31:22 +0100 Subject: [PATCH 39/48] Improve documentation and formatting in client rest_api No functional changes. --- src/client/plantdb/client/rest_api.py | 30 +++++++++------------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/client/plantdb/client/rest_api.py b/src/client/plantdb/client/rest_api.py index 8464d77..72d8bac 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. @@ -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 ---------- @@ -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 ---------- @@ -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 ---------- @@ -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 ---------- @@ -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 @@ -843,8 +837,7 @@ def request_login(host, username, password, **kwargs): 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 @@ -892,8 +885,7 @@ def request_check_username(host, username, **kwargs): 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 @@ -940,8 +932,7 @@ def request_logout(host, **kwargs): 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 @@ -1749,8 +1740,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 From 97d775b0a4d331f8b349be6ee08042c8d06f6828 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:33:33 +0100 Subject: [PATCH 40/48] Improve documentation and formatting in client rest_api No functional changes. --- src/client/plantdb/client/rest_api.py | 192 +++++++++++++------------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/src/client/plantdb/client/rest_api.py b/src/client/plantdb/client/rest_api.py index 72d8bac..a4dfe3b 100644 --- a/src/client/plantdb/client/rest_api.py +++ b/src/client/plantdb/client/rest_api.py @@ -120,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 ------- @@ -164,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 ------- @@ -202,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 ------- @@ -240,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 ------- @@ -279,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 ------- @@ -315,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 ------- @@ -357,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 @@ -424,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 @@ -463,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 @@ -500,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 ------- @@ -542,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 ------- @@ -572,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 ------- @@ -607,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 ------- @@ -650,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 ------- @@ -795,17 +795,17 @@ 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 ------- @@ -853,11 +853,11 @@ 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 ------- @@ -899,11 +899,11 @@ 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. @@ -953,11 +953,11 @@ 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. @@ -1000,11 +1000,11 @@ 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. @@ -1032,11 +1032,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. @@ -1072,11 +1072,11 @@ 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``. Returns ------- @@ -1130,11 +1130,11 @@ 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``. Returns ------- @@ -1172,11 +1172,11 @@ 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``. Returns ------- @@ -1224,7 +1224,7 @@ 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``. @@ -1277,7 +1277,7 @@ 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``. @@ -1351,7 +1351,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 @@ -1440,11 +1440,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. @@ -1521,11 +1521,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 ------- @@ -1570,11 +1570,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 ------- @@ -1831,11 +1831,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 ------- @@ -1905,11 +1905,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 ------- @@ -1942,11 +1942,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 ------- @@ -1979,11 +1979,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 ------- @@ -2016,11 +2016,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 ------- From 1e3a61a7e9d62950af4b0688ddc49ee042237526 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:42:12 +0100 Subject: [PATCH 41/48] Add user registration endpoint to client API - Introduce `create_user` function in `src/client/plantdb/client/api_endpoints.py` that returns the registration URL path `/register`. - Include comprehensive docstring and example usage. --- src/client/plantdb/client/api_endpoints.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/client/plantdb/client/api_endpoints.py b/src/client/plantdb/client/api_endpoints.py index 70e9c01..4624c31 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" From 8471dea497ec5800b8a235e6d26e55c5fb5d1dd4 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 00:42:37 +0100 Subject: [PATCH 42/48] Add user registration support and unify response checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented `create_user` in `plantdb_client.py` to post user data to the `/register` endpoint, returning a boolean success flag and detailed error logging. - Replaced explicit `status_code == 200` checks with the more idiomatic `response.ok` in authentication, logout, and token refresh methods. - Updated imports to place `api_endpoints` after other imports for clarity. - Added comprehensive docstrings, error handling for non‑OK responses, and exception logging in `create_user`. - Adjusted file `plantdb_client.py` accordingly. --- src/client/plantdb/client/plantdb_client.py | 34 +++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/client/plantdb/client/plantdb_client.py b/src/client/plantdb/client/plantdb_client.py index 087a06f..ad9affb 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,7 +182,7 @@ 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 @@ -210,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') @@ -219,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: @@ -235,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') From db848ecff04dac5671c654ce4606e5c1fe9ca63c Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 01:22:24 +0100 Subject: [PATCH 43/48] Use username in account lockout log message - Update logger in `manager.py` to reference the `username` variable instead of the `user` object when reporting a locked account. --- src/commons/plantdb/commons/auth/manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commons/plantdb/commons/auth/manager.py b/src/commons/plantdb/commons/auth/manager.py index 619bd1a..3354b9d 100644 --- a/src/commons/plantdb/commons/auth/manager.py +++ b/src/commons/plantdb/commons/auth/manager.py @@ -374,7 +374,7 @@ 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: From a08915a7363906cccbb538b6008803270200e6f5 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 01:51:55 +0100 Subject: [PATCH 44/48] =?UTF-8?q?Add=20JWT=20protection=20to=20scan?= =?UTF-8?q?=E2=80=91related=20REST=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply `@requires_jwt` to all `get` and `post` handlers that access or modify scans. - Extend method signatures to accept `**kwargs` and forward them to `self.db.get_scan`, `self.db.get_scan`, and `self.db.create_scan`. - Re‑order decorators for the ZIP‑upload `post` method to keep `@requires_jwt` after `@rate_limit`. --- src/server/plantdb/server/rest_api.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index 8eb4ec3..a6ab384 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -1374,7 +1374,8 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - def get(self): + @requires_jwt + def get(self, **kwargs): """Retrieve a list of scan dataset information. This method handles GET requests to retrieve scan information. It supports @@ -1425,7 +1426,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 @@ -1467,7 +1468,8 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - def get(self, scan_id): + @requires_jwt + def get(self, scan_id, **kwargs): """Retrieve detailed information about a specific scan dataset. Parameters @@ -1513,12 +1515,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): + @requires_jwt + def post(self, scan_id, **kwargs): """Create a new scan dataset. Parameters @@ -1551,7 +1554,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}") @@ -2984,8 +2987,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) + @requires_jwt def post(self, scan_id, **kwargs): """Handle ZIP file upload and extraction for a scan dataset. From ac23fa0651230a8bfdaa712280d3b18c909e9b1d Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 01:52:26 +0100 Subject: [PATCH 45/48] Adjust client REST API to return raw Response objects and update examples - Change return types of `request_login`, `request_check_username`, `request_logout`, `request_new_user`, `request_scan_names_list`, and `request_scans_info` from `dict`/`bool` to `requests.Response`. - Update docstrings and examples to show callers using `.json()` or `.ok` on the returned `Response`. - Remove redundant `.json()` and `.ok` checks from the functions; the caller now handles the response. - Adjust example code in `rest_api.py` to match new behavior and demonstrate correct usage. --- src/client/plantdb/client/rest_api.py | 58 +++++++++++++-------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/src/client/plantdb/client/rest_api.py b/src/client/plantdb/client/rest_api.py index a4dfe3b..c47f072 100644 --- a/src/client/plantdb/client/rest_api.py +++ b/src/client/plantdb/client/rest_api.py @@ -718,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 @@ -809,9 +809,8 @@ def request_login(host, username, password, **kwargs): 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 ----- @@ -825,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) @@ -833,7 +832,7 @@ 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): @@ -861,9 +860,8 @@ def request_check_username(host, username, **kwargs): 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 ----- @@ -878,10 +876,11 @@ 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): @@ -909,8 +908,8 @@ def request_logout(host, **kwargs): Returns ------- - bool - Indicate if the logout was successful. + requests.Response + The response from the API. Notes ----- @@ -923,12 +922,13 @@ 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): @@ -963,8 +963,8 @@ def request_new_user(host, username, password, fullname, **kwargs): Returns ------- - bool - Indicate if the logout was successful. + requests.Response + The response from the API. Notes ----- @@ -978,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): @@ -1010,20 +1010,19 @@ def request_scan_names_list(host, **kwargs): 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): @@ -1050,10 +1049,11 @@ 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] From ff96930560d80e13342462474f77a6031af6bf53 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 03:24:42 +0100 Subject: [PATCH 46/48] Add session_token parameter to scan-related REST API functions Include a `session_token` argument and description in `request_scan_data`, `request_scans_info`, `request_scans_meta`, `request_scans_download`, and `request_scans_data`. --- src/client/plantdb/client/rest_api.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/client/plantdb/client/rest_api.py b/src/client/plantdb/client/rest_api.py index c47f072..ba26e96 100644 --- a/src/client/plantdb/client/rest_api.py +++ b/src/client/plantdb/client/rest_api.py @@ -1055,8 +1055,7 @@ def request_scans_info(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): @@ -1077,6 +1076,8 @@ def request_scan_data(host, scan_id, **kwargs): 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``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1135,6 +1136,8 @@ def request_scan_image(host, scan_id, fileset_id, file_id, size='orig', **kwargs 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``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1177,6 +1180,8 @@ def request_scan_tasks_fileset(host, scan_id, **kwargs): 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``. + session_token : str + The PlantDB REST API session token of the user. Returns ------- @@ -1227,6 +1232,8 @@ def request_refresh(host, scan_id=None, **kwargs): 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 ------- @@ -1280,6 +1287,8 @@ def request_archive_download(host, scan_id, out_dir=None, **kwargs): 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 ------- From ded451febe3d58ec8f4d943df6dd0d9677b4a919 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 03:25:51 +0100 Subject: [PATCH 47/48] Add default_user support to wrapped database methods - Introduce `default_user` keyword argument in the wrapper for `get_logged_username` - Pass `default_user` and `token` to `get_logged_username` in the correct order - Update the `core.py` docstring to show usage of `default_user='guest'` - Minor refactor to kwargs handling for clarity. --- src/commons/plantdb/commons/fsdb/core.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commons/plantdb/commons/fsdb/core.py b/src/commons/plantdb/commons/fsdb/core.py index a7e358f..b116c03 100644 --- a/src/commons/plantdb/commons/fsdb/core.py +++ b/src/commons/plantdb/commons/fsdb/core.py @@ -245,6 +245,7 @@ def get_logged_username(fsdb, default_user=None, token=None, **kwargs): >>> 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): @@ -285,8 +286,8 @@ def require_authentication(method): """ def wrapper(self, *args, **kwargs): - kwargs['username'] = get_logged_username(self, token=kwargs.pop('token', None), **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 From 2310571618a8db323279c0c93f63c99545b022f1 Mon Sep 17 00:00:00 2001 From: Jonathan Legrand Date: Fri, 30 Jan 2026 03:31:32 +0100 Subject: [PATCH 48/48] Rename `requires_jwt` decorator and update its usage - Added new decorator `add_jwt_from_header` that extracts the JWT from the request header and passes it via `kwargs['token']`. - Replaced all `@requires_jwt` annotations in `rest_api.py` with `@add_jwt_from_header`. - Simplified decorator logic: token presence and validity are now handled by the database layer. - Updated docstrings and example code to use `response.json()` instead of manual `json.loads` and removed redundant `import json` statements. --- src/server/plantdb/server/rest_api.py | 138 ++++++++++++++------------ 1 file changed, 72 insertions(+), 66 deletions(-) diff --git a/src/server/plantdb/server/rest_api.py b/src/server/plantdb/server/rest_api.py index a6ab384..dfd185e 100644 --- a/src/server/plantdb/server/rest_api.py +++ b/src/server/plantdb/server/rest_api.py @@ -39,6 +39,7 @@ from math import radians from pathlib import Path from tempfile import mkstemp +from typing import Optional from zipfile import ZipFile import pybase64 @@ -50,6 +51,7 @@ from flask import send_file from flask import send_from_directory from flask_restful import Resource + from plantdb.commons.fsdb.exceptions import FileNotFoundError from plantdb.commons.fsdb.exceptions import FilesetNotFoundError from plantdb.commons.fsdb.exceptions import ScanNotFoundError @@ -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 JSON Web 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,7 @@ def __init__(self, db): self.db = db @rate_limit(max_requests=5, window_seconds=60) # maximum of 1 requests per minute - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): """Handle HTTP POST request to register a new user. @@ -966,7 +984,6 @@ 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=admin") >>> print(response.json()) @@ -1078,7 +1095,7 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): """Handle user logout. @@ -1142,7 +1159,7 @@ def __init__(self, db, logger): self.db = db self.logger = logger - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): """Handle JSON Web Token validation. @@ -1199,7 +1216,7 @@ def __init__(self, db): """Initialize the TokenRefresh resource.""" self.db = db - @requires_jwt + @add_jwt_from_header def post(self, **kwargs): """Refresh JSON Web Token. @@ -1347,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 """ @@ -1374,7 +1390,7 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - @requires_jwt + @add_jwt_from_header def get(self, **kwargs): """Retrieve a list of scan dataset information. @@ -1408,15 +1424,14 @@ def get(self, **kwargs): >>> # 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) @@ -1468,7 +1483,7 @@ def __init__(self, db, logger): self.logger = logger @rate_limit(max_requests=120, window_seconds=60) - @requires_jwt + @add_jwt_from_header def get(self, scan_id, **kwargs): """Retrieve detailed information about a specific scan dataset. @@ -1501,10 +1516,9 @@ def get(self, scan_id, **kwargs): >>> # 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 @@ -1520,7 +1534,7 @@ def get(self, scan_id, **kwargs): return {'message': f"Scan id '{scan_id}' does not exist"}, 404 @rate_limit(max_requests=15, window_seconds=60) - @requires_jwt + @add_jwt_from_header def post(self, scan_id, **kwargs): """Create a new scan dataset. @@ -1643,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) @@ -2041,13 +2056,13 @@ 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) """ @@ -2163,17 +2178,17 @@ 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) - >>> 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", 'coords': 'true'}) - >>> coordinates = np.array(res.json()['coordinates']) + >>> 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) @@ -2530,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) @@ -2657,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) @@ -2988,7 +2995,7 @@ def cleanup_temp_file(response): return send_file(temp_zip_path, download_name=f'{scan_id}.zip', mimetype='application/zip') @rate_limit(max_requests=5, window_seconds=60) - @requires_jwt + @add_jwt_from_header def post(self, scan_id, **kwargs): """Handle ZIP file upload and extraction for a scan dataset. @@ -3207,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. @@ -3351,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. @@ -3518,7 +3525,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 fileset associated with a scan. @@ -3696,7 +3703,7 @@ 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 + @add_jwt_from_header def post(self, scan_id, fileset_id, **kwargs): """Update metadata for a specified fileset. @@ -3884,7 +3891,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 file in a fileset and write data to it. @@ -3904,7 +3911,7 @@ def post(self, **kwargs): 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) @@ -3914,7 +3921,6 @@ def post(self, **kwargs): >>> # 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: @@ -4095,7 +4101,7 @@ 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 + @add_jwt_from_header def post(self, scan_id, fileset_id, file_id, **kwargs): """Update metadata for a specified file.