From cf0167da5e0766f6301631ecfcf3a98cec58748b Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 10:26:37 +0100 Subject: [PATCH 01/18] ENH: Health check route --- dservercore/config_routes.py | 13 ++++++++++++- dservercore/schemas.py | 4 ++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/dservercore/config_routes.py b/dservercore/config_routes.py index 2191a91..8c9de8d 100644 --- a/dservercore/config_routes.py +++ b/dservercore/config_routes.py @@ -13,7 +13,7 @@ import dservercore import dservercore.utils_auth from dservercore.blueprint import Blueprint -from dservercore.schemas import ConfigSchema, VersionSchema +from dservercore.schemas import ConfigSchema, HealthSchema, VersionSchema from dservercore.utils import versions_to_dict, obj_to_lowercase_key_dict @@ -45,3 +45,14 @@ def server_versions(): This does not require authorization.""" return jsonify({"versions": versions_to_dict()}) + + +@bp.route("/health", methods=["GET"]) +@bp.response(200, HealthSchema) +def health(): + """Health check endpoint for container orchestration. + + Returns a simple status indicating the service is running. + This does not require authorization.""" + + return jsonify({"status": "healthy"}), 200 diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 12df225..451e775 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -13,6 +13,10 @@ ) +class HealthSchema(Schema): + status = String() + + class ConfigSchema(Schema): config = Dict(keys=String(), values=Raw()) From d98351682ef979f32dd03049e619fa9a5e023dfa Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 15:47:18 +0100 Subject: [PATCH 02/18] ENH: API routes for modifying tags --- dservercore/tags_routes.py | 107 +++++++++++++++++++++++++++++++++---- dservercore/utils.py | 38 +++++++++++++ 2 files changed, 136 insertions(+), 9 deletions(-) diff --git a/dservercore/tags_routes.py b/dservercore/tags_routes.py index fb4a807..92d6760 100644 --- a/dservercore/tags_routes.py +++ b/dservercore/tags_routes.py @@ -1,41 +1,42 @@ -"""Route for retrieving tags of a dataset""" +"""Routes for retrieving and modifying tags of a dataset""" from flask import ( abort, jsonify, - current_app + current_app, + request ) from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import UnknownURIError, AuthorizationError from dservercore.blueprint import Blueprint from dservercore.schemas import TagSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_tags_from_uri_by_user + get_tags_from_uri_by_user, + set_tags_for_uri_by_user ) bp = Blueprint("tags", __name__, url_prefix="/tags") + @bp.route("/", methods=["GET"]) @bp.response(200, TagSchema) -@bp.alt_response(1, description=2) +@bp.alt_response(401, description="Unauthorized") @bp.alt_response(403, description="No permissions") @bp.alt_response(404, description="Not found") @jwt_required() -def manifest(uri): - """Request the dataset manifest.""" +def get_tags(uri): + """Request the dataset tags.""" username = get_jwt_identity() if not dservercore.utils_auth.user_exists(username): - # Unregistered users should see 401. abort(401) uri = url_suffix_to_uri(uri) if not dservercore.utils_auth.may_access(username, uri): - # Authorization errors should return 400. abort(403) try: @@ -47,3 +48,91 @@ def manifest(uri): return {"tags": tags} +@bp.route("/", methods=["PUT"]) +@bp.arguments(TagSchema) +@bp.response(200, TagSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_tags(data, uri): + """Set the dataset tags (replaces all existing tags).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + tags = set_tags_for_uri_by_user(username, uri, data.get("tags", [])) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"tags": tags} + + +@bp.route("//", methods=["POST"]) +@bp.response(200, TagSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def add_tag(uri, tag): + """Add a single tag to the dataset.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + # Get existing tags + existing_tags = get_tags_from_uri_by_user(username, uri) + # Add new tag if not already present + if tag not in existing_tags: + existing_tags.append(tag) + # Set updated tags + tags = set_tags_for_uri_by_user(username, uri, existing_tags) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"tags": tags} + + +@bp.route("//", methods=["DELETE"]) +@bp.response(200, TagSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def delete_tag(uri, tag): + """Remove a single tag from the dataset.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + # Get existing tags + existing_tags = get_tags_from_uri_by_user(username, uri) + # Remove tag if present + if tag in existing_tags: + existing_tags.remove(tag) + # Set updated tags + tags = set_tags_for_uri_by_user(username, uri, existing_tags) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"tags": tags} + + diff --git a/dservercore/utils.py b/dservercore/utils.py index 6d296f2..b1e9c3c 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1068,3 +1068,41 @@ def get_annotations_from_uri_by_user(username, uri): raise (AuthorizationError()) return current_app.retrieve.get_annotations(uri) + + +def set_tags_for_uri_by_user(username, uri, tags): + """Set tags for a dataset. + + :param username: username + :param uri: dataset URI + :param tags: list of tags to set + :returns: updated list of tags + :raises: AuthenticationError if user is invalid. + AuthorizationError if the user has not got permissions to modify + content in the base URI + UnknownBaseURIError if the base URI has not been registered. + UnknownURIError if the URI is not available to the user. + """ + user = get_user_obj(username) + + base_uri_str = uri.rsplit("/", 1)[0] + base_uri = _get_base_uri_obj(base_uri_str) + if base_uri is None: + raise (UnknownBaseURIError()) + + # Check if user has register permissions (write access) for this base URI + if base_uri not in user.register_base_uris: + raise (AuthorizationError()) + + # Update tags in both search and retrieve plugins + if hasattr(current_app.search, "set_tags"): + current_app.search.set_tags(uri, tags) + else: + logger.warning("Search plugin has no method 'set_tags'") + + if hasattr(current_app.retrieve, "set_tags"): + current_app.retrieve.set_tags(uri, tags) + else: + logger.warning("Retrieve plugin has no method 'set_tags'") + + return tags From b0a5aaf10024e13745e0e975fa1664c90cba64ba Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 15:59:47 +0100 Subject: [PATCH 03/18] ENH: Annotation routes --- dservercore/annotations_routes.py | 105 +++++++++++++++++++++++++++--- dservercore/schemas.py | 6 +- dservercore/utils.py | 86 ++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 11 deletions(-) diff --git a/dservercore/annotations_routes.py b/dservercore/annotations_routes.py index 886d82d..f005b46 100644 --- a/dservercore/annotations_routes.py +++ b/dservercore/annotations_routes.py @@ -1,21 +1,25 @@ -"""Route for retrieving dataset annotations by URI.""" +"""Routes for retrieving and modifying dataset annotations by URI.""" from flask import ( abort, jsonify, - current_app + current_app, + request ) from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import UnknownURIError, AuthorizationError from dservercore.blueprint import Blueprint -from dservercore.schemas import AnnotationSchema +from dservercore.schemas import AnnotationSchema, SingleAnnotationSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_annotations_from_uri_by_user + get_annotations_from_uri_by_user, + set_annotations_for_uri_by_user, + set_annotation_for_uri_by_user, + delete_annotation_for_uri_by_user ) bp = Blueprint("annotations", __name__, url_prefix="/annotations") @@ -23,21 +27,19 @@ @bp.route("/", methods=["GET"]) @bp.response(200, AnnotationSchema) -@bp.alt_response(401, description="Not registered") +@bp.alt_response(401, description="Unauthorized") @bp.alt_response(403, description="No permissions") -@bp.alt_response(400, description="Unknown URI") +@bp.alt_response(404, description="Not found") @jwt_required() -def annotations(uri): +def get_annotations(uri): """Request the dataset annotations.""" username = get_jwt_identity() if not dservercore.utils_auth.user_exists(username): - # Unregistered users should see 401. abort(401) uri = url_suffix_to_uri(uri) if not dservercore.utils_auth.may_access(username, uri): - # Authorization errors should return 403. abort(403) try: @@ -46,4 +48,87 @@ def annotations(uri): current_app.logger.info("UnknownURIError") abort(404) + return {"annotations": annotations} + + +@bp.route("/", methods=["PUT"]) +@bp.arguments(AnnotationSchema) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_annotations(data, uri): + """Set all dataset annotations (replaces existing annotations).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = set_annotations_for_uri_by_user( + username, uri, data.get("annotations", {}) + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"annotations": annotations} + + +@bp.route("//", methods=["PUT"]) +@bp.arguments(SingleAnnotationSchema) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_annotation(data, uri, annotation_name): + """Set a single annotation (creates or updates).""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = set_annotation_for_uri_by_user( + username, uri, annotation_name, data.get("value") + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"annotations": annotations} + + +@bp.route("//", methods=["DELETE"]) +@bp.response(200, AnnotationSchema) +@bp.alt_response(401, description="Unauthorized") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def delete_annotation(uri, annotation_name): + """Delete a single annotation.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + + try: + annotations = delete_annotation_for_uri_by_user( + username, uri, annotation_name + ) + except AuthorizationError: + abort(403) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + return {"annotations": annotations} \ No newline at end of file diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 451e775..15c0130 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -44,7 +44,11 @@ class ManifestSchema(Schema): # Define a schema for the response class AnnotationSchema(Schema): - annotations = Dict(keys=String(), values=String()) + annotations = Dict(keys=String(), values=Raw()) + + +class SingleAnnotationSchema(Schema): + value = Raw() class TagSchema(Schema): diff --git a/dservercore/utils.py b/dservercore/utils.py index b1e9c3c..799b187 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1106,3 +1106,89 @@ def set_tags_for_uri_by_user(username, uri, tags): logger.warning("Retrieve plugin has no method 'set_tags'") return tags + + +def set_annotations_for_uri_by_user(username, uri, annotations): + """Set all annotations for a dataset (replaces existing annotations). + + :param username: username + :param uri: dataset URI + :param annotations: dictionary of annotations to set + :returns: updated annotations dictionary + :raises: AuthenticationError if user is invalid. + AuthorizationError if the user has not got permissions to modify + content in the base URI + UnknownBaseURIError if the base URI has not been registered. + UnknownURIError if the URI is not available to the user. + """ + user = get_user_obj(username) + + base_uri_str = uri.rsplit("/", 1)[0] + base_uri = _get_base_uri_obj(base_uri_str) + if base_uri is None: + raise (UnknownBaseURIError()) + + # Check if user has register permissions (write access) for this base URI + if base_uri not in user.register_base_uris: + raise (AuthorizationError()) + + # Update annotations in both search and retrieve plugins + if hasattr(current_app.search, "set_annotations"): + current_app.search.set_annotations(uri, annotations) + else: + logger.warning("Search plugin has no method 'set_annotations'") + + if hasattr(current_app.retrieve, "set_annotations"): + current_app.retrieve.set_annotations(uri, annotations) + else: + logger.warning("Retrieve plugin has no method 'set_annotations'") + + return annotations + + +def set_annotation_for_uri_by_user(username, uri, annotation_name, value): + """Set a single annotation for a dataset. + + :param username: username + :param uri: dataset URI + :param annotation_name: name of the annotation + :param value: value to set for the annotation + :returns: updated annotations dictionary + :raises: AuthenticationError if user is invalid. + AuthorizationError if the user has not got permissions to modify + content in the base URI + UnknownBaseURIError if the base URI has not been registered. + UnknownURIError if the URI is not available to the user. + """ + # Get existing annotations + existing_annotations = get_annotations_from_uri_by_user(username, uri) + + # Update the specific annotation + existing_annotations[annotation_name] = value + + # Set all annotations + return set_annotations_for_uri_by_user(username, uri, existing_annotations) + + +def delete_annotation_for_uri_by_user(username, uri, annotation_name): + """Delete a single annotation from a dataset. + + :param username: username + :param uri: dataset URI + :param annotation_name: name of the annotation to delete + :returns: updated annotations dictionary + :raises: AuthenticationError if user is invalid. + AuthorizationError if the user has not got permissions to modify + content in the base URI + UnknownBaseURIError if the base URI has not been registered. + UnknownURIError if the URI is not available to the user. + """ + # Get existing annotations + existing_annotations = get_annotations_from_uri_by_user(username, uri) + + # Remove the annotation if it exists + if annotation_name in existing_annotations: + del existing_annotations[annotation_name] + + # Set all annotations + return set_annotations_for_uri_by_user(username, uri, existing_annotations) From 6b24cf75fde2e0b0c6f0efc8d44b20ed1d3ae24b Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 20:31:05 +0100 Subject: [PATCH 04/18] MAINT: Removed unused legacy code --- MANIFEST.in | 1 - dservercore/templates/index.html | 9 --------- 2 files changed, 10 deletions(-) delete mode 100644 dservercore/templates/index.html diff --git a/MANIFEST.in b/MANIFEST.in index 2fdd34e..a5021c6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,2 @@ include README.rst include LICENSE -include dserver/templates/* diff --git a/dservercore/templates/index.html b/dservercore/templates/index.html deleted file mode 100644 index 7be1599..0000000 --- a/dservercore/templates/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - dserver - - -

dservercore

-

{{ num_datasets }} registered datasets.

- - From 57c09ca978a1cb3a8d7fd378371d7797fe770806 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 21:01:13 +0100 Subject: [PATCH 05/18] BUG: Update tags and annotations in storage --- dservercore/utils.py | 113 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/dservercore/utils.py b/dservercore/utils.py index 799b187..49b3d3d 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -12,6 +12,7 @@ from flask_smorest.pagination import PaginationParameters from sqlalchemy.sql import exists +import dtoolcore import dtoolcore.utils from dservercore import ( @@ -1070,6 +1071,108 @@ def get_annotations_from_uri_by_user(username, uri): return current_app.retrieve.get_annotations(uri) +def _update_tags_in_storage(uri, tags): + """Update tags in the actual storage backend using dtoolcore. + + :param uri: dataset URI + :param tags: list of tags to set + """ + try: + # Load the dataset + dataset = dtoolcore.DataSet.from_uri(uri) + storage_broker = dataset._storage_broker + + # Check if storage broker supports tag operations + if not hasattr(storage_broker, 'put_text') or not hasattr(storage_broker, 'delete_key'): + logger.debug(f"Storage broker for {uri} does not support tag operations") + return + + # Get the dataset UUID from the URI + uuid = uri.rsplit("/", 1)[1] + prefix = uuid + "/" + + # Get existing tags from storage + existing_tags = set() + try: + if hasattr(storage_broker, 'list_tags'): + existing_tags = set(storage_broker.list_tags()) + except Exception: + pass # No existing tags or method not available + + new_tags = set(tags) + + # Delete tags that are no longer present + for tag in existing_tags - new_tags: + logger.debug(f"Deleting tag '{tag}' from storage for {uri}") + try: + storage_broker.delete_key(prefix + "tags/" + tag) + except Exception as e: + logger.warning(f"Failed to delete tag '{tag}': {e}") + + # Add new tags + for tag in new_tags - existing_tags: + logger.debug(f"Adding tag '{tag}' to storage for {uri}") + storage_broker.put_text(prefix + "tags/" + tag, "") + + logger.info(f"Updated tags in storage for {uri}") + + except Exception as e: + # Log but don't fail - database update succeeded + logger.warning(f"Failed to update tags in storage for {uri}: {e}") + + +def _update_annotations_in_storage(uri, annotations): + """Update annotations in the actual storage backend using dtoolcore. + + :param uri: dataset URI + :param annotations: dictionary of annotations to set + """ + try: + # Load the dataset + dataset = dtoolcore.DataSet.from_uri(uri) + storage_broker = dataset._storage_broker + + # Check if storage broker supports annotation operations + if not hasattr(storage_broker, 'put_text') or not hasattr(storage_broker, 'delete_key'): + logger.debug(f"Storage broker for {uri} does not support annotation operations") + return + + # Get the dataset UUID from the URI + uuid = uri.rsplit("/", 1)[1] + prefix = uuid + "/" + + # Get existing annotation names from storage + existing_annotations = set() + try: + if hasattr(storage_broker, 'list_annotation_names'): + existing_annotations = set(storage_broker.list_annotation_names()) + except Exception: + pass # No existing annotations or method not available + + new_annotations = set(annotations.keys()) + + # Delete annotations that are no longer present + for annotation_name in existing_annotations - new_annotations: + logger.debug(f"Deleting annotation '{annotation_name}' from storage for {uri}") + try: + storage_broker.delete_key(prefix + "annotations/" + annotation_name + ".json") + except Exception as e: + logger.warning(f"Failed to delete annotation '{annotation_name}': {e}") + + # Add/update annotations + for annotation_name, value in annotations.items(): + logger.debug(f"Setting annotation '{annotation_name}' in storage for {uri}") + storage_broker.put_text( + prefix + "annotations/" + annotation_name + ".json", + json.dumps(value, indent=2) + ) + + logger.info(f"Updated annotations in storage for {uri}") + + except Exception as e: + logger.warning(f"Failed to update annotations in storage for {uri}: {e}") + + def set_tags_for_uri_by_user(username, uri, tags): """Set tags for a dataset. @@ -1094,7 +1197,7 @@ def set_tags_for_uri_by_user(username, uri, tags): if base_uri not in user.register_base_uris: raise (AuthorizationError()) - # Update tags in both search and retrieve plugins + # Update tags in both search and retrieve plugins (database) if hasattr(current_app.search, "set_tags"): current_app.search.set_tags(uri, tags) else: @@ -1105,6 +1208,9 @@ def set_tags_for_uri_by_user(username, uri, tags): else: logger.warning("Retrieve plugin has no method 'set_tags'") + # Update tags in actual storage backend + _update_tags_in_storage(uri, tags) + return tags @@ -1132,7 +1238,7 @@ def set_annotations_for_uri_by_user(username, uri, annotations): if base_uri not in user.register_base_uris: raise (AuthorizationError()) - # Update annotations in both search and retrieve plugins + # Update annotations in both search and retrieve plugins (database) if hasattr(current_app.search, "set_annotations"): current_app.search.set_annotations(uri, annotations) else: @@ -1143,6 +1249,9 @@ def set_annotations_for_uri_by_user(username, uri, annotations): else: logger.warning("Retrieve plugin has no method 'set_annotations'") + # Update annotations in actual storage backend + _update_annotations_in_storage(uri, annotations) + return annotations From 3e99eecbf22bd29e321fe3a34f6d7a0c8ddc3e6a Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 21:09:56 +0100 Subject: [PATCH 06/18] MAINT: Don't use storage broker directly for setting tags and annotations --- dservercore/utils.py | 51 +++++++------------------------------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/dservercore/utils.py b/dservercore/utils.py index 49b3d3d..0038a10 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1080,39 +1080,23 @@ def _update_tags_in_storage(uri, tags): try: # Load the dataset dataset = dtoolcore.DataSet.from_uri(uri) - storage_broker = dataset._storage_broker - - # Check if storage broker supports tag operations - if not hasattr(storage_broker, 'put_text') or not hasattr(storage_broker, 'delete_key'): - logger.debug(f"Storage broker for {uri} does not support tag operations") - return - - # Get the dataset UUID from the URI - uuid = uri.rsplit("/", 1)[1] - prefix = uuid + "/" # Get existing tags from storage - existing_tags = set() - try: - if hasattr(storage_broker, 'list_tags'): - existing_tags = set(storage_broker.list_tags()) - except Exception: - pass # No existing tags or method not available - + existing_tags = set(dataset.list_tags()) new_tags = set(tags) # Delete tags that are no longer present for tag in existing_tags - new_tags: logger.debug(f"Deleting tag '{tag}' from storage for {uri}") try: - storage_broker.delete_key(prefix + "tags/" + tag) + dataset.delete_tag(tag) except Exception as e: logger.warning(f"Failed to delete tag '{tag}': {e}") - # Add new tags + # Add new tags (put_tag includes name validation) for tag in new_tags - existing_tags: logger.debug(f"Adding tag '{tag}' to storage for {uri}") - storage_broker.put_text(prefix + "tags/" + tag, "") + dataset.put_tag(tag) logger.info(f"Updated tags in storage for {uri}") @@ -1130,42 +1114,23 @@ def _update_annotations_in_storage(uri, annotations): try: # Load the dataset dataset = dtoolcore.DataSet.from_uri(uri) - storage_broker = dataset._storage_broker - - # Check if storage broker supports annotation operations - if not hasattr(storage_broker, 'put_text') or not hasattr(storage_broker, 'delete_key'): - logger.debug(f"Storage broker for {uri} does not support annotation operations") - return - - # Get the dataset UUID from the URI - uuid = uri.rsplit("/", 1)[1] - prefix = uuid + "/" # Get existing annotation names from storage - existing_annotations = set() - try: - if hasattr(storage_broker, 'list_annotation_names'): - existing_annotations = set(storage_broker.list_annotation_names()) - except Exception: - pass # No existing annotations or method not available - + existing_annotations = set(dataset.list_annotation_names()) new_annotations = set(annotations.keys()) # Delete annotations that are no longer present for annotation_name in existing_annotations - new_annotations: logger.debug(f"Deleting annotation '{annotation_name}' from storage for {uri}") try: - storage_broker.delete_key(prefix + "annotations/" + annotation_name + ".json") + dataset.delete_annotation(annotation_name) except Exception as e: logger.warning(f"Failed to delete annotation '{annotation_name}': {e}") - # Add/update annotations + # Add/update annotations (put_annotation includes name validation) for annotation_name, value in annotations.items(): logger.debug(f"Setting annotation '{annotation_name}' in storage for {uri}") - storage_broker.put_text( - prefix + "annotations/" + annotation_name + ".json", - json.dumps(value, indent=2) - ) + dataset.put_annotation(annotation_name, value) logger.info(f"Updated annotations in storage for {uri}") From 839f4e91f9d2d9bfe2455fdfdcf161f31c55cf8a Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 22:26:08 +0100 Subject: [PATCH 07/18] ENH: Modifying README --- dservercore/readme_routes.py | 40 ++++++++++++++++++++--- dservercore/schemas.py | 4 +++ dservercore/utils.py | 62 ++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/dservercore/readme_routes.py b/dservercore/readme_routes.py index 784bac4..82ea882 100644 --- a/dservercore/readme_routes.py +++ b/dservercore/readme_routes.py @@ -1,8 +1,9 @@ -"""Route for retrieving the readme of a dataset""" +"""Routes for retrieving and updating the readme of a dataset""" from flask import ( abort, jsonify, - current_app + current_app, + request, ) from dservercore.utils_auth import ( jwt_required, @@ -11,11 +12,12 @@ from dservercore import UnknownURIError from dservercore.blueprint import Blueprint -from dservercore.schemas import ReadmeSchema +from dservercore.schemas import ReadmeSchema, ReadmeRequestSchema import dservercore.utils_auth from dservercore.utils import ( url_suffix_to_uri, - get_readme_from_uri_by_user + get_readme_from_uri_by_user, + set_readme_for_uri_by_user, ) bp = Blueprint("readmes", __name__, url_prefix="/readmes") @@ -45,4 +47,32 @@ def readme(uri): current_app.logger.info("UnknownURIError") abort(404) - return {"readme": readme} \ No newline at end of file + return {"readme": readme} + + +@bp.route("/", methods=["PUT"]) +@bp.arguments(ReadmeRequestSchema) +@bp.response(200, ReadmeSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def set_readme(request_data, uri): + """Update the dataset readme.""" + username = get_jwt_identity() + if not dservercore.utils_auth.user_exists(username): + abort(401) + + uri = url_suffix_to_uri(uri) + if not dservercore.utils_auth.may_access(username, uri): + abort(403) + + readme_content = request_data.get("readme", "") + + try: + set_readme_for_uri_by_user(username, uri, readme_content) + except UnknownURIError: + current_app.logger.info("UnknownURIError") + abort(404) + + return {"readme": readme_content} \ No newline at end of file diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 15c0130..8628370 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -36,6 +36,10 @@ class ReadmeSchema(Schema): readme = String() +class ReadmeRequestSchema(Schema): + readme = String(required=True) + + class ManifestSchema(Schema): items = Dict(keys=String, values=Nested(ItemSchema)) hash_function = String() diff --git a/dservercore/utils.py b/dservercore/utils.py index 0038a10..5fa5474 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1266,3 +1266,65 @@ def delete_annotation_for_uri_by_user(username, uri, annotation_name): # Set all annotations return set_annotations_for_uri_by_user(username, uri, existing_annotations) + + +def _update_readme_in_storage(uri, content): + """Update README in the actual storage backend using dtoolcore. + + :param uri: dataset URI + :param content: README content string + """ + try: + # Load the dataset + dataset = dtoolcore.DataSet.from_uri(uri) + + # Update the README using put_readme + logger.debug(f"Updating README in storage for {uri}") + dataset.put_readme(content) + + logger.info(f"Updated README in storage for {uri}") + + except Exception as e: + # Log but don't fail - database update succeeded + logger.warning(f"Failed to update README in storage for {uri}: {e}") + + +def set_readme_for_uri_by_user(username, uri, content): + """Set README content for a dataset. + + :param username: username + :param uri: dataset URI + :param content: README content string + :returns: updated README content + :raises: AuthenticationError if user is invalid. + AuthorizationError if the user has not got permissions to modify + content in the base URI + UnknownBaseURIError if the base URI has not been registered. + UnknownURIError if the URI is not available to the user. + """ + user = get_user_obj(username) + + base_uri_str = uri.rsplit("/", 1)[0] + base_uri = _get_base_uri_obj(base_uri_str) + if base_uri is None: + raise (UnknownBaseURIError()) + + # Check if user has register permissions (write access) for this base URI + if base_uri not in user.register_base_uris: + raise (AuthorizationError()) + + # Update README in both search and retrieve plugins (database) + if hasattr(current_app.search, "set_readme"): + current_app.search.set_readme(uri, content) + else: + logger.warning("Search plugin has no method 'set_readme'") + + if hasattr(current_app.retrieve, "set_readme"): + current_app.retrieve.set_readme(uri, content) + else: + logger.warning("Retrieve plugin has no method 'set_readme'") + + # Update README in actual storage backend + _update_readme_in_storage(uri, content) + + return content From 924a8669fc6ae7196e40b83517c52685d1a9d3c6 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 7 Dec 2025 23:25:46 +0100 Subject: [PATCH 08/18] BUILD: Switched build system to flit --- pyproject.toml | 57 ++++++++++++++++++++++++++++---------------------- setup.cfg | 10 --------- 2 files changed, 32 insertions(+), 35 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 58b339c..2058751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,32 +1,32 @@ [build-system] -requires = ["setuptools>=42", "setuptools_scm[toml]>=6.3"] -build-backend = "setuptools.build_meta" +requires = ["flit_scm"] +build-backend = "flit_scm:buildapi" [project] name = "dservercore" description = "Web API to register/lookup/search for dtool dataset metadata" readme = "README.rst" -license = {file = "LICENSE"} +license = {text = "MIT"} authors = [ {name = "Tjelvar Olsson", email = "tjelvar.olsson@gmail.com"} ] dynamic = ["version"] +requires-python = ">=3.8" dependencies = [ - "setuptools", - "flask<3", - "pymongo", - "alembic", - "flask-sqlalchemy", - "flask-migrate", - "flask-pymongo", - "flask-marshmallow", - "flask-smorest", - "marshmallow-sqlalchemy", - "flask-cors", - "dtoolcore>=3.18.0", - "flask-jwt-extended[asymmetric_crypto]>=4.6.0", - "pyyaml" - ] + "flask<3", + "pymongo", + "alembic", + "flask-sqlalchemy", + "flask-migrate", + "flask-pymongo", + "flask-marshmallow", + "flask-smorest", + "marshmallow-sqlalchemy", + "flask-cors", + "dtoolcore>=3.18.0", + "flask-jwt-extended[asymmetric_crypto]>=4.6.0", + "pyyaml" +] [project.optional-dependencies] test = [ @@ -45,16 +45,23 @@ Documentation = "https://dservercore.readthedocs.io" Repository = "https://github.com/jic-dtool/dservercore" Changelog = "https://github.com/jic-dtool/dservercore/blob/main/CHANGELOG.rst" +[project.entry-points."flask.commands"] +base_uri = "dservercore.cli:base_uri_cli" +user = "dservercore.cli:user_cli" +config = "dservercore.cli:config_cli" +dataset = "dservercore.cli:dataset_cli" + +[tool.flit.module] +name = "dservercore" + [tool.setuptools_scm] version_scheme = "guess-next-dev" local_scheme = "no-local-version" write_to = "dservercore/version.py" -[tool.setuptools] -packages = ["dservercore"] +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--cov=dservercore --cov-report=term-missing" -[project.entry-points."flask.commands"] -"base_uri" = "dservercore.cli:base_uri_cli" -"user" = "dservercore.cli:user_cli" -"config" = "dservercore.cli:config_cli" -"dataset" = "dservercore.cli:dataset_cli" +[tool.flake8] +exclude = ["venv*", "env*", ".tox", ".git", "*.egg", "build", "docs", "migrations", "jwt-spike"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 52056ef..0000000 --- a/setup.cfg +++ /dev/null @@ -1,10 +0,0 @@ -[flake8] -exclude=venv*,env*,.tox,.git,*.egg,build,docs,migrations,jwt-spike - -[tool:pytest] -testpaths = tests -#addopts = --cov=dservercore --cov-report=term-missing --disable-warnings -addopts = --cov=dservercore --cov-report=term-missing - -[cov:run] -source = dserver From 7d4ac82e4861fc2c240b8bab2a1637ce7b62f305 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Mon, 8 Dec 2025 09:10:30 +0100 Subject: [PATCH 09/18] DOC: Updated CHANGELOG and README --- CHANGELOG.rst | 40 ++++++++++++++++++++++++++++++++ README.rst | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b01752b..0f78d62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,46 @@ CHANGELOG This project uses `semantic versioning `_. This change log uses principles from `keep a changelog `_. +[0.23.0] - 2025-12-08 +--------------------- + +Added +^^^^^ + +- Health check endpoint ``GET /config/health`` for container orchestration + (does not require authorization) +- Tag manipulation routes: + + - ``GET /tags/`` - Get dataset tags + - ``PUT /tags/`` - Set all dataset tags (replaces existing) + - ``POST /tags//`` - Add a single tag + - ``DELETE /tags//`` - Remove a single tag + +- Annotation manipulation routes: + + - ``GET /annotations/`` - Get dataset annotations + - ``PUT /annotations/`` - Set all annotations (replaces existing) + - ``PUT /annotations//`` - Set a single annotation + - ``DELETE /annotations//`` - Delete a single annotation + +- README manipulation routes: + + - ``GET /readmes/`` - Get dataset README + - ``PUT /readmes/`` - Update dataset README + +Changed +^^^^^^^ + +- Switched build system to flit +- Tags and annotations are now updated both in the database and in storage + (previously only updated in database) + +Removed +^^^^^^^ + +- Removed unused legacy code + + [0.22.0] ------------ diff --git a/README.rst b/README.rst index be992f7..41bcfd8 100644 --- a/README.rst +++ b/README.rst @@ -504,6 +504,57 @@ URI ``s3://dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db``:: http://localhost:5000/manifests/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db +Modifying dataset tags +~~~~~~~~~~~~~~~~~~~~~~ + +Add a single tag to a dataset:: + + $ curl -H "$HEADER" -X POST \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/new-tag + +Replace all tags on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"tags": ["tag1", "tag2"]}' \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + +Remove a tag from a dataset:: + + $ curl -H "$HEADER" -X DELETE \ + http://localhost:5000/tags/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/old-tag + + +Modifying dataset annotations +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Set a single annotation on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"value": "some value"}' \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation + +Replace all annotations on a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"annotations": {"key1": "value1", "key2": 42}}' \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + +Delete an annotation from a dataset:: + + $ curl -H "$HEADER" -X DELETE \ + http://localhost:5000/annotations/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db/my-annotation + + +Modifying dataset README +~~~~~~~~~~~~~~~~~~~~~~~~ + +Update the README of a dataset:: + + $ curl -H "$HEADER" -H "Content-Type: application/json" \ + -X PUT -d '{"readme": "---\ndescription: Updated README content\n"}' \ + http://localhost:5000/readmes/s3/dtool-demo/ba92a5fa-d3b4-4f10-bcb9-947f62e652db + + Getting information about one's own permissions ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -727,6 +778,19 @@ and extension plugins with their versions, i.e.:: This request does not require any authorization. +The request:: + + $ curl http://localhost:5000/config/health + +will return a simple health check status for container orchestration:: + + { + "status": "healthy" + } + +This request does not require any authorization and can be used for +Kubernetes liveness/readiness probes or Docker health checks. + Creating a plugin ----------------- From 35c55eb4ef41bdd93e92449e6d497f760d31422a Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 14 Dec 2025 19:23:12 +0100 Subject: [PATCH 10/18] ENH: User management endpoints --- dservercore/user_routes.py | 152 ++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/dservercore/user_routes.py b/dservercore/user_routes.py index 0ff1ddd..799e120 100644 --- a/dservercore/user_routes.py +++ b/dservercore/user_routes.py @@ -16,7 +16,13 @@ from dservercore.sort import SortParameters, ASCENDING, DESCENDING from dservercore.schemas import SummarySchema from dservercore.sql_models import User, UserSchema, UserWithPermissionsSchema -from dservercore.utils import register_user, delete_user, summary_of_datasets_by_user +from dservercore.utils import ( + register_user, + delete_user, + summary_of_datasets_by_user, + get_permission_info, + register_permissions, +) bp = Blueprint("users", __name__, url_prefix="/users") @@ -166,4 +172,146 @@ def user_summary_get(username): abort(404) summary = summary_of_datasets_by_user(username) - return summary \ No newline at end of file + return summary + + +@bp.route("//search/", methods=["POST"]) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="User or base URI not found") +@bp.alt_response(409, description="Permission already exists") +@jwt_required() +def user_search_permission_post(username, base_uri): + """Grant search permission to a user on a base URI. + + The user in the Authorization token needs to be admin. + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + if not dservercore.utils_auth.has_admin_rights(identity): + abort(403) + + if not dservercore.utils.base_uri_exists(base_uri): + abort(404) + + if not dservercore.utils_auth.user_exists(username): + abort(404) + + permissions = get_permission_info(base_uri) + + if username in permissions["users_with_search_permissions"]: + abort(409) + + permissions["users_with_search_permissions"].append(username) + register_permissions(base_uri, permissions) + + return dservercore.utils.get_user_info(username) + + +@bp.route("//search/", methods=["DELETE"]) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="User or base URI not found") +@jwt_required() +def user_search_permission_delete(username, base_uri): + """Revoke search permission from a user on a base URI. + + The user in the Authorization token needs to be admin. + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + if not dservercore.utils_auth.has_admin_rights(identity): + abort(403) + + if not dservercore.utils.base_uri_exists(base_uri): + abort(404) + + if not dservercore.utils_auth.user_exists(username): + abort(404) + + permissions = get_permission_info(base_uri) + + if username in permissions["users_with_search_permissions"]: + permissions["users_with_search_permissions"].remove(username) + register_permissions(base_uri, permissions) + + return dservercore.utils.get_user_info(username) + + +@bp.route("//register/", methods=["POST"]) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="User or base URI not found") +@bp.alt_response(409, description="Permission already exists") +@jwt_required() +def user_register_permission_post(username, base_uri): + """Grant register permission to a user on a base URI. + + The user in the Authorization token needs to be admin. + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + if not dservercore.utils_auth.has_admin_rights(identity): + abort(403) + + if not dservercore.utils.base_uri_exists(base_uri): + abort(404) + + if not dservercore.utils_auth.user_exists(username): + abort(404) + + permissions = get_permission_info(base_uri) + + if username in permissions["users_with_register_permissions"]: + abort(409) + + permissions["users_with_register_permissions"].append(username) + register_permissions(base_uri, permissions) + + return dservercore.utils.get_user_info(username) + + +@bp.route("//register/", methods=["DELETE"]) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="User or base URI not found") +@jwt_required() +def user_register_permission_delete(username, base_uri): + """Revoke register permission from a user on a base URI. + + The user in the Authorization token needs to be admin. + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + if not dservercore.utils_auth.has_admin_rights(identity): + abort(403) + + if not dservercore.utils.base_uri_exists(base_uri): + abort(404) + + if not dservercore.utils_auth.user_exists(username): + abort(404) + + permissions = get_permission_info(base_uri) + + if username in permissions["users_with_register_permissions"]: + permissions["users_with_register_permissions"].remove(username) + register_permissions(base_uri, permissions) + + return dservercore.utils.get_user_info(username) \ No newline at end of file From 9460ab74d149893b82649aadeb93e1860f077af5 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sun, 14 Dec 2025 19:39:44 +0100 Subject: [PATCH 11/18] TST: Tests for user routes --- tests/conftest.py | 30 +++-- tests/test_user_routes.py | 270 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1468e5c..b649446 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,10 +93,10 @@ def tmp_app(request): "SECRET_KEY": "secret", "FLASK_ENV": "development", "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", - "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/", + "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "RETRIEVE_MONGO_DB": tmp_mongo_db_name, "RETRIEVE_MONGO_COLLECTION": "datasets", - "SEARCH_MONGO_URI": "mongodb://localhost:27017/", + "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "SEARCH_MONGO_DB": tmp_mongo_db_name, "SEARCH_MONGO_COLLECTION": "datasets", "SQLALCHEMY_TRACK_MODIFICATIONS": False, @@ -105,6 +105,9 @@ def tmp_app(request): "JWT_TOKEN_LOCATION": "headers", "JWT_HEADER_NAME": "Authorization", "JWT_HEADER_TYPE": "Bearer", + "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), + "MONGO_DB": tmp_mongo_db_name, + "MONGO_COLLECTION": "dependencies", } app = create_app(config) @@ -151,10 +154,10 @@ def tmp_app_with_users(request): "SECRET_KEY": "secret", "FLASK_ENV": "development", "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", - "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/", + "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "RETRIEVE_MONGO_DB": tmp_mongo_db_name, "RETRIEVE_MONGO_COLLECTION": "datasets", - "SEARCH_MONGO_URI": "mongodb://localhost:27017/", + "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "SEARCH_MONGO_DB": tmp_mongo_db_name, "SEARCH_MONGO_COLLECTION": "datasets", "SQLALCHEMY_TRACK_MODIFICATIONS": False, @@ -163,6 +166,9 @@ def tmp_app_with_users(request): "JWT_TOKEN_LOCATION": "headers", "JWT_HEADER_NAME": "Authorization", "JWT_HEADER_TYPE": "Bearer", + "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), + "MONGO_DB": tmp_mongo_db_name, + "MONGO_COLLECTION": "dependencies", } app = create_app(config) @@ -224,10 +230,10 @@ def tmp_app_with_data(request): "OPENAPI_VERSION": '3.0.2', "FLASK_ENV": "development", "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", - "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/", + "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "RETRIEVE_MONGO_DB": tmp_mongo_db_name, "RETRIEVE_MONGO_COLLECTION": "datasets", - "SEARCH_MONGO_URI": "mongodb://localhost:27017/", + "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "SEARCH_MONGO_DB": tmp_mongo_db_name, "SEARCH_MONGO_COLLECTION": "datasets", "SQLALCHEMY_TRACK_MODIFICATIONS": False, @@ -236,6 +242,9 @@ def tmp_app_with_data(request): "JWT_TOKEN_LOCATION": "headers", "JWT_HEADER_NAME": "Authorization", "JWT_HEADER_TYPE": "Bearer", + "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), + "MONGO_DB": tmp_mongo_db_name, + "MONGO_COLLECTION": "dependencies", } app = create_app(config) @@ -348,14 +357,17 @@ def tmp_cli_runner(request): "OPENAPI_VERSION": '3.0.2', "FLASK_ENV": "development", "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:", - "RETRIEVE_MONGO_URI": "mongodb://localhost:27017/", + "RETRIEVE_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "RETRIEVE_MONGO_DB": tmp_mongo_db_name, "RETRIEVE_MONGO_COLLECTION": "datasets", - "SEARCH_MONGO_URI": "mongodb://localhost:27017/", + "SEARCH_MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), "SEARCH_MONGO_DB": tmp_mongo_db_name, "SEARCH_MONGO_COLLECTION": "datasets", "SQLALCHEMY_TRACK_MODIFICATIONS": False, - "SECRET_KEY": "dev" + "SECRET_KEY": "dev", + "MONGO_URI": os.environ.get("TEST_MONGO_URI", "mongodb://localhost:27017/"), + "MONGO_DB": tmp_mongo_db_name, + "MONGO_COLLECTION": "dependencies", } app = create_app(config) diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py index 326be66..ad4d4ee 100644 --- a/tests/test_user_routes.py +++ b/tests/test_user_routes.py @@ -489,3 +489,273 @@ def test_dataset_summary_route( headers=headers ) assert r.status_code == 404 + + +def test_grant_search_permission_route( + tmp_app_with_users_client, + snowwhite_token, + grumpy_token, + noone_token, + sleepy_token): + """Test granting search permission via POST.""" + + from dservercore.sql_models import UserWithPermissionsSchema + + # 1 - Admin grants search permission to sleepy on s3://snow-white + # sleepy already has search permission, so we need to first check + # the initial state then test with a new base_uri + headers = dict(Authorization="Bearer " + snowwhite_token) + + # First, register a new base URI for testing + from dservercore.utils import register_base_uri + register_base_uri("s3://test-bucket") + + # Grant search permission - should succeed with 200 + r = tmp_app_with_users_client.post( + "/users/sleepy/search/s3://test-bucket", + headers=headers, + json={} + ) + assert r.status_code == 200 + + user_response = r.json + assert len(UserWithPermissionsSchema().validate(user_response)) == 0 + assert "s3://test-bucket" in user_response["search_permissions_on_base_uris"] + + # 2 - Try to grant the same permission again - should return 409 Conflict + r = tmp_app_with_users_client.post( + "/users/sleepy/search/s3://test-bucket", + headers=headers, + json={} + ) + assert r.status_code == 409 + + # 3 - Non-admin (grumpy) tries to grant permission - should return 403 + headers = dict(Authorization="Bearer " + grumpy_token) + r = tmp_app_with_users_client.post( + "/users/sleepy/search/s3://test-bucket", + headers=headers, + json={} + ) + assert r.status_code == 403 + + # 4 - Unregistered user (noone) tries to grant permission - should return 401 + headers = dict(Authorization="Bearer " + noone_token) + r = tmp_app_with_users_client.post( + "/users/sleepy/search/s3://test-bucket", + headers=headers, + json={} + ) + assert r.status_code == 401 + + # 5 - Admin tries to grant permission to non-existent user - should return 404 + headers = dict(Authorization="Bearer " + snowwhite_token) + r = tmp_app_with_users_client.post( + "/users/nonexistent/search/s3://test-bucket", + headers=headers, + json={} + ) + assert r.status_code == 404 + + # 6 - Admin tries to grant permission on non-existent base URI - should return 404 + r = tmp_app_with_users_client.post( + "/users/sleepy/search/s3://nonexistent-bucket", + headers=headers, + json={} + ) + assert r.status_code == 404 + + +def test_revoke_search_permission_route( + tmp_app_with_users_client, + snowwhite_token, + grumpy_token, + noone_token, + sleepy_token): + """Test revoking search permission via DELETE.""" + + from dservercore.sql_models import UserWithPermissionsSchema + + # Initial state: grumpy and sleepy have search permissions on s3://snow-white + headers = dict(Authorization="Bearer " + snowwhite_token) + + # 1 - Admin revokes search permission from sleepy + r = tmp_app_with_users_client.delete( + "/users/sleepy/search/s3://snow-white", + headers=headers + ) + assert r.status_code == 200 + + user_response = r.json + assert len(UserWithPermissionsSchema().validate(user_response)) == 0 + assert "s3://snow-white" not in user_response["search_permissions_on_base_uris"] + + # 2 - Revoking permission that doesn't exist should still succeed (idempotent) + r = tmp_app_with_users_client.delete( + "/users/sleepy/search/s3://snow-white", + headers=headers + ) + assert r.status_code == 200 + + # 3 - Non-admin (grumpy) tries to revoke permission - should return 403 + headers = dict(Authorization="Bearer " + grumpy_token) + r = tmp_app_with_users_client.delete( + "/users/sleepy/search/s3://snow-white", + headers=headers + ) + assert r.status_code == 403 + + # 4 - Unregistered user (noone) tries to revoke permission - should return 401 + headers = dict(Authorization="Bearer " + noone_token) + r = tmp_app_with_users_client.delete( + "/users/sleepy/search/s3://snow-white", + headers=headers + ) + assert r.status_code == 401 + + # 5 - Admin tries to revoke permission from non-existent user - should return 404 + headers = dict(Authorization="Bearer " + snowwhite_token) + r = tmp_app_with_users_client.delete( + "/users/nonexistent/search/s3://snow-white", + headers=headers + ) + assert r.status_code == 404 + + # 6 - Admin tries to revoke permission on non-existent base URI - should return 404 + r = tmp_app_with_users_client.delete( + "/users/sleepy/search/s3://nonexistent-bucket", + headers=headers + ) + assert r.status_code == 404 + + +def test_grant_register_permission_route( + tmp_app_with_users_client, + snowwhite_token, + grumpy_token, + noone_token, + sleepy_token): + """Test granting register permission via POST.""" + + from dservercore.sql_models import UserWithPermissionsSchema + + headers = dict(Authorization="Bearer " + snowwhite_token) + + # 1 - Admin grants register permission to sleepy on s3://snow-white + # sleepy does not have register permission initially + r = tmp_app_with_users_client.post( + "/users/sleepy/register/s3://snow-white", + headers=headers, + json={} + ) + assert r.status_code == 200 + + user_response = r.json + assert len(UserWithPermissionsSchema().validate(user_response)) == 0 + assert "s3://snow-white" in user_response["register_permissions_on_base_uris"] + + # 2 - Try to grant the same permission again - should return 409 Conflict + r = tmp_app_with_users_client.post( + "/users/sleepy/register/s3://snow-white", + headers=headers, + json={} + ) + assert r.status_code == 409 + + # 3 - Non-admin (grumpy) tries to grant permission - should return 403 + headers = dict(Authorization="Bearer " + grumpy_token) + r = tmp_app_with_users_client.post( + "/users/sleepy/register/s3://snow-white", + headers=headers, + json={} + ) + assert r.status_code == 403 + + # 4 - Unregistered user (noone) tries to grant permission - should return 401 + headers = dict(Authorization="Bearer " + noone_token) + r = tmp_app_with_users_client.post( + "/users/sleepy/register/s3://snow-white", + headers=headers, + json={} + ) + assert r.status_code == 401 + + # 5 - Admin tries to grant permission to non-existent user - should return 404 + headers = dict(Authorization="Bearer " + snowwhite_token) + r = tmp_app_with_users_client.post( + "/users/nonexistent/register/s3://snow-white", + headers=headers, + json={} + ) + assert r.status_code == 404 + + # 6 - Admin tries to grant permission on non-existent base URI - should return 404 + r = tmp_app_with_users_client.post( + "/users/sleepy/register/s3://nonexistent-bucket", + headers=headers, + json={} + ) + assert r.status_code == 404 + + +def test_revoke_register_permission_route( + tmp_app_with_users_client, + snowwhite_token, + grumpy_token, + noone_token, + sleepy_token): + """Test revoking register permission via DELETE.""" + + from dservercore.sql_models import UserWithPermissionsSchema + + # Initial state: grumpy has register permission on s3://snow-white + headers = dict(Authorization="Bearer " + snowwhite_token) + + # 1 - Admin revokes register permission from grumpy + r = tmp_app_with_users_client.delete( + "/users/grumpy/register/s3://snow-white", + headers=headers + ) + assert r.status_code == 200 + + user_response = r.json + assert len(UserWithPermissionsSchema().validate(user_response)) == 0 + assert "s3://snow-white" not in user_response["register_permissions_on_base_uris"] + + # 2 - Revoking permission that doesn't exist should still succeed (idempotent) + r = tmp_app_with_users_client.delete( + "/users/grumpy/register/s3://snow-white", + headers=headers + ) + assert r.status_code == 200 + + # 3 - Non-admin (sleepy) tries to revoke permission - should return 403 + headers = dict(Authorization="Bearer " + sleepy_token) + r = tmp_app_with_users_client.delete( + "/users/grumpy/register/s3://snow-white", + headers=headers + ) + assert r.status_code == 403 + + # 4 - Unregistered user (noone) tries to revoke permission - should return 401 + headers = dict(Authorization="Bearer " + noone_token) + r = tmp_app_with_users_client.delete( + "/users/grumpy/register/s3://snow-white", + headers=headers + ) + assert r.status_code == 401 + + # 5 - Admin tries to revoke permission from non-existent user - should return 404 + headers = dict(Authorization="Bearer " + snowwhite_token) + r = tmp_app_with_users_client.delete( + "/users/nonexistent/register/s3://snow-white", + headers=headers + ) + assert r.status_code == 404 + + # 6 - Admin tries to revoke permission on non-existent base URI - should return 404 + r = tmp_app_with_users_client.delete( + "/users/grumpy/register/s3://nonexistent-bucket", + headers=headers + ) + assert r.status_code == 404 From 17d76c4956a09c02d1b2582f8144d67ef47c4d90 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Mon, 15 Dec 2025 07:35:15 +0100 Subject: [PATCH 12/18] MAINT: Changed user permissions getting and setting --- dservercore/sql_models.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/dservercore/sql_models.py b/dservercore/sql_models.py index 794ba99..5497043 100644 --- a/dservercore/sql_models.py +++ b/dservercore/sql_models.py @@ -145,8 +145,38 @@ class Meta: class UserWithPermissionsSchema(UserSchema): - register_permissions_on_base_uris = fields.List(fields.String) - search_permissions_on_base_uris = fields.List(fields.String) + """User schema including permission fields. + + This schema handles two cases: + - User model objects: returned directly from paginated queries (e.g., GET /users) + - Dict objects: returned from user.as_dict() via get_user_info() (e.g., GET /users/) + + The User model stores permissions as relationships (search_base_uris, register_base_uris) + to BaseURI objects, while the API returns them as lists of base URI strings. The as_dict() + method performs this conversion, but raw model objects need explicit field serialization. + """ + search_permissions_on_base_uris = fields.Method("get_search_permissions") + register_permissions_on_base_uris = fields.Method("get_register_permissions") + + def get_search_permissions(self, obj): + """Serialize search permissions as list of base URI strings.""" + if isinstance(obj, User): + # Raw User model from paginated query - extract from relationship + return [u.base_uri for u in obj.search_base_uris] + elif isinstance(obj, dict): + # Dict from user.as_dict() - field already converted + return obj.get("search_permissions_on_base_uris", []) + return [] + + def get_register_permissions(self, obj): + """Serialize register permissions as list of base URI strings.""" + if isinstance(obj, User): + # Raw User model from paginated query - extract from relationship + return [u.base_uri for u in obj.register_base_uris] + elif isinstance(obj, dict): + # Dict from user.as_dict() - field already converted + return obj.get("register_permissions_on_base_uris", []) + return [] class DatasetSchema(ma.SQLAlchemyAutoSchema): From 321f41701e81090357457206ef57e719bfdea010 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Mon, 15 Dec 2025 14:19:45 +0100 Subject: [PATCH 13/18] ENH: Added display_name to user --- dservercore/me_routes.py | 37 +++++++++++++++++++++++++----- dservercore/schemas.py | 7 +++++- dservercore/sql_models.py | 45 ++++++++++++++++++++---------------- dservercore/user_routes.py | 47 +++++++++++++++++++++++++++++++++++++- dservercore/utils.py | 15 ++++++++---- dservercore/utils_auth.py | 9 ++++++++ 6 files changed, 127 insertions(+), 33 deletions(-) diff --git a/dservercore/me_routes.py b/dservercore/me_routes.py index 79274ee..c889925 100644 --- a/dservercore/me_routes.py +++ b/dservercore/me_routes.py @@ -1,19 +1,18 @@ """Routes for me (information on currently authenticated user)""" -from flask import ( - abort, - jsonify, -) +from flask import abort + from dservercore.utils_auth import ( jwt_required, get_jwt_identity, ) +import dservercore import dservercore.utils import dservercore.utils_auth from dservercore.blueprint import Blueprint -from dservercore.sql_models import UserSchema, UserWithPermissionsSchema -from dservercore.schemas import SummarySchema +from dservercore.sql_models import User, UserWithPermissionsSchema +from dservercore.schemas import SummarySchema, MeUpdateSchema from dservercore.utils import summary_of_datasets_by_user @@ -34,6 +33,32 @@ def me_get(): return dservercore.utils.get_user_info(identity) +@bp.route("", methods=["PATCH"]) +@bp.arguments(MeUpdateSchema) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@jwt_required() +def me_patch(data: MeUpdateSchema): + """Update the current user's profile. + + Currently supports updating: + - display_name: string (can be null to clear) + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + user = User.query.filter_by(username=identity).first() + + if "display_name" in data: + user.display_name = data.get("display_name") + + dservercore.sql_db.session.commit() + + return dservercore.utils.get_user_info(identity) + + @bp.route("/summary", methods=["GET"]) @bp.response(200, SummarySchema) @bp.alt_response(401, description="Not registered") diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 8628370..0d36e9d 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -96,4 +96,9 @@ class SummarySchema(Schema): size_in_bytes_per_base_uri = Dict(keys=String, values=Integer) tags = List(String) datasets_per_tag = Dict(keys=String, values=Integer) - size_in_bytes_per_tag = Dict(keys=String, values=Integer) \ No newline at end of file + size_in_bytes_per_tag = Dict(keys=String, values=Integer) + + +class MeUpdateSchema(Schema): + """Schema for updating the current user's profile (PATCH /me).""" + display_name = String(load_default=None, allow_none=True) \ No newline at end of file diff --git a/dservercore/sql_models.py b/dservercore/sql_models.py index 5497043..e9674f7 100644 --- a/dservercore/sql_models.py +++ b/dservercore/sql_models.py @@ -1,6 +1,6 @@ """Database models and derived schemas""" import datetime -from marshmallow import fields +from marshmallow import fields, pre_dump import dtoolcore.utils from dservercore import ma from dservercore import sql_db as db @@ -39,6 +39,7 @@ def _deserialize(self, value, attr, data, **kwargs): class User(db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), index=True, unique=True) + display_name = db.Column(db.String(255), nullable=True) # Optional human-readable name is_admin = db.Column(db.Boolean(), nullable=False, default=False) search_base_uris = db.relationship( "BaseURI", secondary=search_permissions, back_populates="search_users" @@ -54,6 +55,7 @@ def as_dict(self): """Return user using dictionary representation.""" return { "username": self.username, + "display_name": self.display_name, "is_admin": self.is_admin, "search_permissions_on_base_uris": [ u.base_uri for u in self.search_base_uris @@ -144,6 +146,12 @@ class Meta: exclude = ("id",) +class UserUpdateSchema(ma.Schema): + """Schema for partial user updates (PATCH).""" + is_admin = fields.Boolean(load_default=None) + display_name = fields.String(load_default=None, allow_none=True) + + class UserWithPermissionsSchema(UserSchema): """User schema including permission fields. @@ -154,29 +162,26 @@ class UserWithPermissionsSchema(UserSchema): The User model stores permissions as relationships (search_base_uris, register_base_uris) to BaseURI objects, while the API returns them as lists of base URI strings. The as_dict() method performs this conversion, but raw model objects need explicit field serialization. + + The @pre_dump hook converts User model objects to dicts before serialization, ensuring + the permission relationships are properly transformed to string lists. """ - search_permissions_on_base_uris = fields.Method("get_search_permissions") - register_permissions_on_base_uris = fields.Method("get_register_permissions") + search_permissions_on_base_uris = fields.List(fields.String()) + register_permissions_on_base_uris = fields.List(fields.String()) - def get_search_permissions(self, obj): - """Serialize search permissions as list of base URI strings.""" - if isinstance(obj, User): - # Raw User model from paginated query - extract from relationship - return [u.base_uri for u in obj.search_base_uris] - elif isinstance(obj, dict): - # Dict from user.as_dict() - field already converted - return obj.get("search_permissions_on_base_uris", []) - return [] + @pre_dump + def convert_user_to_dict(self, obj, **kwargs): + """Convert User model objects to dicts before serialization. + + This is needed because User model objects have permission relationships + (search_base_uris, register_base_uris) that point to BaseURI objects, + not the string lists expected by the API response. - def get_register_permissions(self, obj): - """Serialize register permissions as list of base URI strings.""" + Dict objects (from user.as_dict()) pass through unchanged. + """ if isinstance(obj, User): - # Raw User model from paginated query - extract from relationship - return [u.base_uri for u in obj.register_base_uris] - elif isinstance(obj, dict): - # Dict from user.as_dict() - field already converted - return obj.get("register_permissions_on_base_uris", []) - return [] + return obj.as_dict() + return obj class DatasetSchema(ma.SQLAlchemyAutoSchema): diff --git a/dservercore/user_routes.py b/dservercore/user_routes.py index 799e120..f2d94be 100644 --- a/dservercore/user_routes.py +++ b/dservercore/user_routes.py @@ -9,13 +9,14 @@ ) from flask_smorest.pagination import PaginationParameters +import dservercore import dservercore.utils import dservercore.utils_auth from dservercore.blueprint import Blueprint from dservercore.sort import SortParameters, ASCENDING, DESCENDING from dservercore.schemas import SummarySchema -from dservercore.sql_models import User, UserSchema, UserWithPermissionsSchema +from dservercore.sql_models import User, UserSchema, UserUpdateSchema, UserWithPermissionsSchema from dservercore.utils import ( register_user, delete_user, @@ -124,6 +125,50 @@ def user_put(data: UserSchema, username): return "", success_code +@bp.route("/", methods=["PATCH"]) +@bp.arguments(UserUpdateSchema) +@bp.response(200, UserWithPermissionsSchema) +@bp.alt_response(401, description="Not registered") +@bp.alt_response(403, description="No permissions") +@bp.alt_response(404, description="Not found") +@jwt_required() +def user_patch(data: UserUpdateSchema, username): + """Partially update a user in dserver. + + Only provided fields will be updated. Supports updating: + - is_admin: boolean + - display_name: string (can be null to clear) + + The user in the Authorization token needs to be admin. + """ + identity = get_jwt_identity() + + if not dservercore.utils_auth.user_exists(identity): + abort(401) + + if not dservercore.utils_auth.has_admin_rights(identity): + abort(403) + + if not dservercore.utils_auth.user_exists(username): + abort(404) + + # Get the user object and update only provided fields + user = User.query.filter_by(username=username).first() + + if data.get("is_admin") is not None: + user.is_admin = data["is_admin"] + + if "display_name" in data and data.get("display_name") is not None: + user.display_name = data["display_name"] + elif data.get("display_name") is None and "display_name" in data: + # Explicitly setting to None/null clears the display_name + user.display_name = None + + dservercore.sql_db.session.commit() + + return dservercore.utils.get_user_info(username) + + @bp.route("/", methods=["DELETE"]) @bp.response(200) @bp.alt_response(401, description="Not registered") diff --git a/dservercore/utils.py b/dservercore/utils.py index 5fa5474..f196c6a 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -223,12 +223,13 @@ def register_user(username, data): Example input structure:: - {"is_admin": True}, + {"is_admin": True, "display_name": "John Doe"}, If a user is missing in the system it is skipped. The ``is_admin`` status - defaults to False. + defaults to False. The ``display_name`` is optional. """ is_admin = data.get("is_admin", False) + display_name = data.get("display_name") # User already exists, update if sql_db.session.query(exists().where(User.username == username)).scalar(): @@ -236,8 +237,10 @@ def register_user(username, data): sql_db.session.query(User).filter_by(username=username).all() ): sqlalch_user_obj.is_admin = is_admin + if "display_name" in data: + sqlalch_user_obj.display_name = display_name else: # user does not exist yet, create - user = User(username=username, is_admin=is_admin) + user = User(username=username, is_admin=is_admin, display_name=display_name) sql_db.session.add(user) sql_db.session.commit() @@ -326,14 +329,14 @@ def update_users(users): Example input structure:: [ - {"username": "magic.mirror", "is_admin": True}, + {"username": "magic.mirror", "is_admin": True, "display_name": "Magic Mirror"}, {"username": "snow.white", "is_admin": False}, {"username": "dopey"}, {"username": "sleepy"}, ] If a user is missing in the system it is skipped. The ``is_admin`` status - defaults to False. + defaults to False. The ``display_name`` is only updated if provided. """ for user in users: username = user["username"] @@ -343,6 +346,8 @@ def update_users(users): sql_db.session.query(User).filter_by(username=username).all() ): # NOQA sqlalch_user_obj.is_admin = is_admin + if "display_name" in user: + sqlalch_user_obj.display_name = user.get("display_name") sql_db.session.commit() diff --git a/dservercore/utils_auth.py b/dservercore/utils_auth.py index 88e9f10..81dbf58 100644 --- a/dservercore/utils_auth.py +++ b/dservercore/utils_auth.py @@ -11,6 +11,7 @@ from flask_jwt_extended import jwt_required as flask_jwt_required from flask_jwt_extended import get_jwt_identity as flask_get_jwt_identity +from flask_jwt_extended import get_jwt as flask_get_jwt def jwt_required(*jwt_required_args, **jwt_required_kwargs): @@ -34,6 +35,14 @@ def get_jwt_identity(): return flask_get_jwt_identity() +def get_jwt(): + """Return JWT payload or empty dict if JWT authorisation disabled.""" + if current_app.config.get("DISABLE_JWT_AUTHORISATION"): + return {} + else: + return flask_get_jwt() + + def _get_user_obj(username): return User.query.filter_by(username=username).first() From c871ee46c7ca3d72f542707adfd4fce64a795794 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Wed, 10 Jun 2026 22:14:20 +0200 Subject: [PATCH 14/18] BUG: UUID search filter matched BSON Binary instead of stored strings; readme PUT now 403s without register permission; widen URI columns (with migration); timezone-safe datetime helpers; update tests for display_name API --- dservercore/date_utils.py | 18 ++++++-- dservercore/manifest_routes.py | 2 +- dservercore/readme_routes.py | 4 +- dservercore/schemas.py | 17 ++++++- dservercore/sql_models.py | 9 ++-- tests/test_cli.py | 4 ++ tests/test_me_routes.py | 6 ++- tests/test_readme_write_authorization.py | 27 +++++++++++ tests/test_sorting.py | 30 ++++++------ tests/test_uri_search_by_uuid.py | 56 +++++++++++++++++++++++ tests/test_user_routes.py | 42 ++++++++++++----- tests/test_utils_permission_management.py | 6 +++ tests/test_utils_user_management.py | 14 ++++++ 13 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 tests/test_readme_write_authorization.py create mode 100644 tests/test_uri_search_by_uuid.py diff --git a/dservercore/date_utils.py b/dservercore/date_utils.py index c27e9ee..370154d 100644 --- a/dservercore/date_utils.py +++ b/dservercore/date_utils.py @@ -1,6 +1,16 @@ """Validation utility functions.""" -from datetime import datetime +from datetime import datetime, timezone + + +def _naive_utc_from_timestamp(timestamp): + """Return a naive UTC datetime for a Unix timestamp. + + Naive UTC datetimes are used throughout dservercore because + dtoolcore.utils.timestamp() only accepts naive datetimes. + """ + return datetime.fromtimestamp( + float(timestamp), timezone.utc).replace(tzinfo=None) def extract_created_at_as_datetime(admin_metadata): @@ -12,11 +22,9 @@ def extract_created_at_as_datetime(admin_metadata): created_at = admin_metadata["created_at"] except KeyError: created_at = admin_metadata["frozen_at"] - created_at = float(created_at) - return datetime.utcfromtimestamp(created_at) + return _naive_utc_from_timestamp(created_at) def extract_frozen_at_as_datetime(admin_metadata): frozen_at = admin_metadata["frozen_at"] - frozen_at = float(frozen_at) - return datetime.utcfromtimestamp(frozen_at) + return _naive_utc_from_timestamp(frozen_at) diff --git a/dservercore/manifest_routes.py b/dservercore/manifest_routes.py index d7af6c3..f5ddd68 100644 --- a/dservercore/manifest_routes.py +++ b/dservercore/manifest_routes.py @@ -22,7 +22,7 @@ @bp.route("/", methods=["GET"]) @bp.response(200, ManifestSchema) -@bp.alt_response(1, description=2) +@bp.alt_response(401, description="Not registered") @bp.alt_response(403, description="No permissions") @bp.alt_response(404, description="Not found") @jwt_required() diff --git a/dservercore/readme_routes.py b/dservercore/readme_routes.py index 82ea882..ecd9a13 100644 --- a/dservercore/readme_routes.py +++ b/dservercore/readme_routes.py @@ -10,7 +10,7 @@ get_jwt_identity, ) -from dservercore import UnknownURIError +from dservercore import AuthorizationError, UnknownURIError from dservercore.blueprint import Blueprint from dservercore.schemas import ReadmeSchema, ReadmeRequestSchema import dservercore.utils_auth @@ -71,6 +71,8 @@ def set_readme(request_data, uri): try: set_readme_for_uri_by_user(username, uri, readme_content) + except AuthorizationError: + abort(403) except UnknownURIError: current_app.logger.info("UnknownURIError") abort(404) diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 0d36e9d..0811cfd 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -13,6 +13,19 @@ ) +class UUIDString(UUID): + """UUID-validated field that deserializes to a lowercase string. + + Dataset UUIDs are stored as strings (in SQL and in the Mongo search + and retrieve indexes). The plain marshmallow UUID field deserializes + to uuid.UUID objects, which pymongo encodes as BSON Binary - those + never match the stored strings, silently breaking UUID queries. + """ + + def _deserialize(self, value, attr, data, **kwargs): + return str(super()._deserialize(value, attr, data, **kwargs)) + + class HealthSchema(Schema): status = String() @@ -60,7 +73,7 @@ class TagSchema(Schema): class RegisterDatasetSchema(Schema): - uuid = UUID() + uuid = UUIDString() base_uri = String() uri = String() # dtoolcore_version should be included when storing (based on current version) but not required on the request @@ -81,7 +94,7 @@ class SearchDatasetSchema(Schema): free_text = String() creator_usernames = List(String) base_uris = List(String) - uuids = List(UUID) + uuids = List(UUIDString) tags = List(String) diff --git a/dservercore/sql_models.py b/dservercore/sql_models.py index e9674f7..f74847a 100644 --- a/dservercore/sql_models.py +++ b/dservercore/sql_models.py @@ -33,7 +33,10 @@ def _serialize(self, value, attr, obj, **kwargs): def _deserialize(self, value, attr, data, **kwargs): if value is None: return None - return datetime.utcfromtimestamp(float(value)) + # Naive UTC datetime: dtoolcore.utils.timestamp() in _serialize + # only accepts naive datetimes. + return datetime.datetime.fromtimestamp( + float(value), datetime.timezone.utc).replace(tzinfo=None) class User(db.Model): @@ -72,7 +75,7 @@ def as_dict(self): class BaseURI(db.Model): __tablename__ = "base_uri" id = db.Column(db.Integer, primary_key=True) - base_uri = db.Column(db.String(255), index=True, unique=True) + base_uri = db.Column(db.String(1024), index=True, unique=True) search_users = db.relationship( "User", secondary=search_permissions, back_populates="search_base_uris" ) @@ -101,7 +104,7 @@ def as_dict(self): class Dataset(db.Model): id = db.Column(db.Integer, primary_key=True) base_uri_id = db.Column(db.Integer, db.ForeignKey("base_uri.id"), nullable=False) - uri = db.Column(db.String(255), index=True, unique=True, nullable=False) + uri = db.Column(db.String(1024), index=True, unique=True, nullable=False) uuid = db.Column(db.String(36), index=True, nullable=False) name = db.Column(db.String(80), index=True, nullable=False) base_uri = db.relationship("BaseURI", back_populates="datasets") diff --git a/tests/test_cli.py b/tests/test_cli.py index 71c4d61..25e08f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -19,6 +19,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA new_user = get_user_obj("admin") expected_content = { "username": "admin", + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -29,6 +30,7 @@ def test_cli_register_user(tmp_cli_runner): # NOQA new_user = get_user_obj("dopey") expected_content = { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -55,12 +57,14 @@ def test_cli_list_users(tmp_cli_runner): # NOQA expected_content = [ { "username": "admin", + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "grumpy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] diff --git a/tests/test_me_routes.py b/tests/test_me_routes.py index 8a5f146..a22dde0 100644 --- a/tests/test_me_routes.py +++ b/tests/test_me_routes.py @@ -22,7 +22,8 @@ def test_me_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'snow-white' + 'username': 'snow-white', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -45,7 +46,8 @@ def test_me_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) headers = dict(Authorization="Bearer " + grumpy_token) diff --git a/tests/test_readme_write_authorization.py b/tests/test_readme_write_authorization.py new file mode 100644 index 0000000..9e7c502 --- /dev/null +++ b/tests/test_readme_write_authorization.py @@ -0,0 +1,27 @@ +"""Audit regression test: PUT /readmes must map missing register +permission to 403, not crash with an uncaught AuthorizationError (500).""" + +from urllib.parse import quote + + +def test_set_readme_without_register_permission_403( + tmp_app_with_users_client, sleepy_token): # NOQA + """sleepy has search but not register permission on s3://snow-white.""" + uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_users_client.put( + f"/readmes/{quote(uri, safe='')}", + json={"readme": "---\nproject: hijack"}, + headers={"Authorization": f"Bearer {sleepy_token}"}, + ) + assert response.status_code == 403 + + +def test_set_readme_unregistered_user_401( + tmp_app_with_users_client, noone_token): # NOQA + uri = "s3://snow-white/af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_users_client.put( + f"/readmes/{quote(uri, safe='')}", + json={"readme": "---\nproject: x"}, + headers={"Authorization": f"Bearer {noone_token}"}, + ) + assert response.status_code == 401 diff --git a/tests/test_sorting.py b/tests/test_sorting.py index 22c0b29..dcf3da2 100644 --- a/tests/test_sorting.py +++ b/tests/test_sorting.py @@ -212,9 +212,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': False, 'username': 'grumpy'} - assert data[1] == {'is_admin': False, 'username': 'sleepy'} - assert data[2] == {'is_admin': True, 'username': 'snow-white'} + assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} # order 2 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -226,9 +226,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': False, 'username': 'sleepy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} # order 3 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -240,9 +240,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'sleepy'} - assert data[2] == {'is_admin': False, 'username': 'grumpy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} # order 4 response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -254,9 +254,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': False, 'username': 'sleepy'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': True, 'username': 'snow-white'} + assert data[0] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} # order 6 - default response = tmp_app_with_dummy_sort_blueprint_client.get( @@ -268,9 +268,9 @@ def test_sort_parameters(tmp_app_with_dummy_sort_blueprint_client): assert len(data) == 3 - assert data[0] == {'is_admin': True, 'username': 'snow-white'} - assert data[1] == {'is_admin': False, 'username': 'grumpy'} - assert data[2] == {'is_admin': False, 'username': 'sleepy'} + assert data[0] == {'display_name': None, 'is_admin': True, 'username': 'snow-white'} + assert data[1] == {'display_name': None, 'is_admin': False, 'username': 'grumpy'} + assert data[2] == {'display_name': None, 'is_admin': False, 'username': 'sleepy'} @pytest.mark.parametrize("header_name", ("X-Dummy-Name", None)) diff --git a/tests/test_uri_search_by_uuid.py b/tests/test_uri_search_by_uuid.py new file mode 100644 index 0000000..cab6163 --- /dev/null +++ b/tests/test_uri_search_by_uuid.py @@ -0,0 +1,56 @@ +"""Audit regression test: searching by UUID through the API must match +datasets stored in the search index. + +The marshmallow UUID field used to deserialize query UUIDs into +uuid.UUID objects, which pymongo encodes as BSON Binary - these never +matched the uuid *strings* stored in the search index, so a uuids +filter silently returned no results. +""" + +import json + + +def test_search_uris_by_uuid(tmp_app_with_data_client, grumpy_token): # NOQA + """tmp_app_with_data registers datasets with this UUID for grumpy.""" + uuid = "af6727bf-29c7-43dd-b42f-a5d7ede28337" + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": [uuid]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + hits = response.get_json() + assert len(hits) > 0 + assert all(hit["uuid"] == uuid for hit in hits) + + +def test_search_uris_by_uuid_uppercase_normalized( + tmp_app_with_data_client, grumpy_token): # NOQA + """UUIDs are case-insensitive; queries must normalize.""" + uuid = "AF6727BF-29C7-43DD-B42F-A5D7EDE28337" + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": [uuid]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 200 + assert len(response.get_json()) > 0 + + +def test_search_uris_by_invalid_uuid_rejected( + tmp_app_with_data_client, grumpy_token): # NOQA + response = tmp_app_with_data_client.post( + "/uris", + data=json.dumps({"uuids": ["not-a-uuid"]}), + headers={ + "Authorization": "Bearer {}".format(grumpy_token), + "Content-Type": "application/json", + }, + ) + assert response.status_code == 422 diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py index ad4d4ee..74a4183 100644 --- a/tests/test_user_routes.py +++ b/tests/test_user_routes.py @@ -19,7 +19,8 @@ def test_put_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) headers = dict(Authorization="Bearer " + snowwhite_token) @@ -52,7 +53,8 @@ def test_put_user_route( 'is_admin': True, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -71,7 +73,8 @@ def test_put_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.put( @@ -117,7 +120,8 @@ def test_put_user_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'dopey' + 'username': 'dopey', + 'display_name': None }) assert user_response == expected_response @@ -162,7 +166,8 @@ def test_get_user_route( 'is_admin': True, 'register_permissions_on_base_uris': [], 'search_permissions_on_base_uris': [], - 'username': 'snow-white' + 'username': 'snow-white', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -185,7 +190,8 @@ def test_get_user_route( 'is_admin': False, 'register_permissions_on_base_uris': ['s3://snow-white'], 'search_permissions_on_base_uris': ['s3://snow-white'], - 'username': 'grumpy' + 'username': 'grumpy', + 'display_name': None }) r = tmp_app_with_users_client.get( @@ -321,9 +327,15 @@ def test_list_user_route( data = r.json assert data == [ - {'is_admin': False, 'username': 'grumpy'}, - {'is_admin': False, 'username': 'sleepy'}, - {'is_admin': True, 'username': 'snow-white'} + {'display_name': None, 'is_admin': False, 'username': 'grumpy', + 'register_permissions_on_base_uris': ['s3://snow-white'], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': False, 'username': 'sleepy', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': True, 'username': 'snow-white', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': []} ] headers = dict(Authorization="Bearer " + snowwhite_token) @@ -336,9 +348,15 @@ def test_list_user_route( data = r.json assert data == [ - {'is_admin': True, 'username': 'snow-white'}, - {'is_admin': False, 'username': 'sleepy'}, - {'is_admin': False, 'username': 'grumpy'} + {'display_name': None, 'is_admin': True, 'username': 'snow-white', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': []}, + {'display_name': None, 'is_admin': False, 'username': 'sleepy', + 'register_permissions_on_base_uris': [], + 'search_permissions_on_base_uris': ['s3://snow-white']}, + {'display_name': None, 'is_admin': False, 'username': 'grumpy', + 'register_permissions_on_base_uris': ['s3://snow-white'], + 'search_permissions_on_base_uris': ['s3://snow-white']} ] # Only admins allowed. diff --git a/tests/test_utils_permission_management.py b/tests/test_utils_permission_management.py index 57a8c01..2bdcc62 100644 --- a/tests/test_utils_permission_management.py +++ b/tests/test_utils_permission_management.py @@ -32,18 +32,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": "snow-white", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": ["s3://snow-white"] }, { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": [] }, { "username": "sleepy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": ["s3://snow-white"], "register_permissions_on_base_uris": [] @@ -70,18 +73,21 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": "snow-white", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "dopey", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": "sleepy", + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] diff --git a/tests/test_utils_user_management.py b/tests/test_utils_user_management.py index 1428a49..88f783c 100644 --- a/tests/test_utils_user_management.py +++ b/tests/test_utils_user_management.py @@ -25,6 +25,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(admin_username) expected_content = { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -34,6 +35,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(data_champion_username) expected_content = { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -43,6 +45,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(standard_user_username) expected_content = { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -59,6 +62,7 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA user_info = get_user_info(new_username) expected_content = { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -69,24 +73,28 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -100,12 +108,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA users_to_delete = [ { "username": standard_user_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": new_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -116,12 +126,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] @@ -142,12 +154,14 @@ def test_user_management_helper_functions(tmp_app_client): # NOQA expected_content = [ { "username": admin_username, + "display_name": None, "is_admin": False, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] }, { "username": data_champion_username, + "display_name": None, "is_admin": True, "search_permissions_on_base_uris": [], "register_permissions_on_base_uris": [] From 4d0c4821807cb7f39aacad8329d766fa6a5627f0 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Wed, 10 Jun 2026 23:21:25 +0200 Subject: [PATCH 15/18] ENH: Record authenticated uploader on registration (uploaded_by/uploaded_at), searchable filter and summary facets; harden summary against missing size_in_bytes --- dservercore/schemas.py | 4 ++ dservercore/sql_models.py | 15 ++++- dservercore/utils.py | 51 ++++++++++++++-- tests/test_me_routes.py | 13 ++++- tests/test_sql_dataset_utils.py | 2 + tests/test_sql_list_datasets_by_user.py | 4 ++ tests/test_summary_of_datasets_by_user.py | 3 + tests/test_uri_routes.py | 71 +++++++++++++++-------- tests/test_user_routes.py | 13 ++++- tests/test_utils_register_dataset.py | 21 ++++++- 10 files changed, 160 insertions(+), 37 deletions(-) diff --git a/dservercore/schemas.py b/dservercore/schemas.py index 0811cfd..6c935dd 100644 --- a/dservercore/schemas.py +++ b/dservercore/schemas.py @@ -96,6 +96,7 @@ class SearchDatasetSchema(Schema): base_uris = List(String) uuids = List(UUIDString) tags = List(String) + uploaded_by = List(String) class SummarySchema(Schema): @@ -110,6 +111,9 @@ class SummarySchema(Schema): tags = List(String) datasets_per_tag = Dict(keys=String, values=Integer) size_in_bytes_per_tag = Dict(keys=String, values=Integer) + uploaders = List(String) + datasets_per_uploader = Dict(keys=String, values=Integer) + size_in_bytes_per_uploader = Dict(keys=String, values=Integer) class MeUpdateSchema(Schema): diff --git a/dservercore/sql_models.py b/dservercore/sql_models.py index f74847a..bd68881 100644 --- a/dservercore/sql_models.py +++ b/dservercore/sql_models.py @@ -113,6 +113,12 @@ class Dataset(db.Model): created_at = db.Column(db.DateTime(), nullable=False) number_of_items = db.Column(db.Integer) size_in_bytes = db.Column(db.BigInteger) + # Server-asserted registration provenance: the authenticated identity + # that registered the dataset (e.g. ORCID), as opposed to the + # client-claimed creator_username. NULL for registrations performed + # outside an authenticated request (CLI, indexer, webhooks). + uploaded_by = db.Column(db.String(255), index=True, nullable=True) + uploaded_at = db.Column(db.DateTime(), nullable=True) def __repr__(self): return "".format(self.uri) @@ -129,6 +135,10 @@ def as_dict(self): "created_at": dtoolcore.utils.timestamp(self.created_at), "number_of_items": self.number_of_items, "size_in_bytes": self.size_in_bytes, + "uploaded_by": self.uploaded_by, + "uploaded_at": ( + dtoolcore.utils.timestamp(self.uploaded_at) + if self.uploaded_at is not None else None), } @@ -199,11 +209,14 @@ class Meta: 'uri', 'uuid', 'number_of_items', - 'size_in_bytes') + 'size_in_bytes', + 'uploaded_by', + 'uploaded_at') base_uri = fields.Method("get_base_uri_string") frozen_at = FloatDateTimeField() created_at = FloatDateTimeField() + uploaded_at = FloatDateTimeField(allow_none=True) def get_base_uri_string(self, obj): """Always serialize base_uri as string, no matter whether object is Dataset or simple dict""" diff --git a/dservercore/utils.py b/dservercore/utils.py index f196c6a..bf35c1d 100644 --- a/dservercore/utils.py +++ b/dservercore/utils.py @@ -1,6 +1,6 @@ """Utility functions.""" -from datetime import datetime, date +from datetime import datetime, date, timezone import importlib import json import logging @@ -443,21 +443,25 @@ def summary_of_datasets_by_user(username): datasets_per_creator = {} datasets_per_base_uri = {} datasets_per_tag = {} + datasets_per_uploader = {} size_in_bytes_per_creator = {} size_in_bytes_per_base_uri = {} size_in_bytes_per_tag = {} + size_in_bytes_per_uploader = {} total_size_in_bytes = 0 for ds in datasets: user = ds["creator_username"] uri = ds["base_uri"] + # size_in_bytes is optional on registration; treat missing as 0. + size_in_bytes = ds.get("size_in_bytes") or 0 datasets_per_creator[user] = datasets_per_creator.get(user, 0) + 1 datasets_per_base_uri[uri] = datasets_per_base_uri.get(uri, 0) + 1 - size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + ds["size_in_bytes"] - size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + ds["size_in_bytes"] + size_in_bytes_per_creator[user] = size_in_bytes_per_creator.get(user, 0) + size_in_bytes + size_in_bytes_per_base_uri[uri] = size_in_bytes_per_base_uri.get(uri, 0) + size_in_bytes # All datasets should have the "tags" key. However, it could be the # case that a dataset in the database prior to version 0.14.0 fails @@ -467,9 +471,18 @@ def summary_of_datasets_by_user(username): if "tags" in ds: for tag in ds["tags"]: datasets_per_tag[tag] = datasets_per_tag.get(tag, 0) + 1 - size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + ds["size_in_bytes"] + size_in_bytes_per_tag[tag] = size_in_bytes_per_tag.get(tag, 0) + size_in_bytes - total_size_in_bytes += ds["size_in_bytes"] + # Server-asserted registration provenance; datasets registered + # outside an authenticated request have no uploader. + uploader = ds.get("uploaded_by") + if uploader is not None: + datasets_per_uploader[uploader] = \ + datasets_per_uploader.get(uploader, 0) + 1 + size_in_bytes_per_uploader[uploader] = \ + size_in_bytes_per_uploader.get(uploader, 0) + size_in_bytes + + total_size_in_bytes += size_in_bytes summary = { "number_of_datasets": len(datasets), @@ -482,7 +495,10 @@ def summary_of_datasets_by_user(username): "size_in_bytes_per_base_uri": size_in_bytes_per_base_uri, "tags": sorted(datasets_per_tag.keys()), "datasets_per_tag": datasets_per_tag, - "size_in_bytes_per_tag": size_in_bytes_per_tag + "size_in_bytes_per_tag": size_in_bytes_per_tag, + "uploaders": sorted(datasets_per_uploader.keys()), + "datasets_per_uploader": datasets_per_uploader, + "size_in_bytes_per_uploader": size_in_bytes_per_uploader } return summary @@ -806,6 +822,8 @@ def create_dataset_obj_from_admin_metadata(admin_metadata): created_at=created_at, number_of_items=number_of_items, size_in_bytes=size_in_bytes, + uploaded_by=admin_metadata.get("uploaded_by"), + uploaded_at=admin_metadata.get("uploaded_at"), ) return dataset @@ -844,6 +862,19 @@ def delete_dataset_admin_metadata(uri): return uri +def _get_authenticated_identity(): + """Return the JWT identity of the current request, or None. + + Registrations can also happen outside an authenticated request + context (Flask CLI, indexer, webhooks); those yield None. + """ + try: + from flask_jwt_extended import get_jwt_identity + return get_jwt_identity() + except Exception: + return None + + def register_dataset(dataset_info): """Put-update a dataset in the lookup server. Put is idempotent.""" @@ -854,6 +885,14 @@ def register_dataset(dataset_info): if not base_uri_exists(base_uri): raise (ValidationError("Base URI is not registered: {}".format(base_uri))) # NOQA + # Server-asserted registration provenance. Unlike the client-claimed + # creator_username, uploaded_by reflects the identity that actually + # authenticated this registration; it is never accepted from clients. + dataset_info = dict(dataset_info) + dataset_info["uploaded_by"] = _get_authenticated_identity() + dataset_info["uploaded_at"] = datetime.now( + timezone.utc).replace(tzinfo=None) + # Take a copy as register_dataset_descriptive_metadata makes # changes to the dictionary, in particular it changes the # types of the dates to datetime objects. diff --git a/tests/test_me_routes.py b/tests/test_me_routes.py index a22dde0..7f9cc85 100644 --- a/tests/test_me_routes.py +++ b/tests/test_me_routes.py @@ -104,7 +104,10 @@ def test_me_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -129,6 +132,9 @@ def test_me_summary_route( "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -149,7 +155,10 @@ def test_me_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) diff --git a/tests/test_sql_dataset_utils.py b/tests/test_sql_dataset_utils.py index 83e367e..59363f7 100644 --- a/tests/test_sql_dataset_utils.py +++ b/tests/test_sql_dataset_utils.py @@ -26,6 +26,8 @@ def test_sql_dataset_helper_functions(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 47, "size_in_bytes": 5741810, + "uploaded_by": None, + "uploaded_at": None, } # BaseURI not registered yet. diff --git a/tests/test_sql_list_datasets_by_user.py b/tests/test_sql_list_datasets_by_user.py index 5bd3369..84f1fb4 100644 --- a/tests/test_sql_list_datasets_by_user.py +++ b/tests/test_sql_list_datasets_by_user.py @@ -35,6 +35,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 7283, "size_in_bytes": 5741810, + "uploaded_by": None, + "uploaded_at": None, } admin_metadata_2 = { "base_uri": base_uri_2, @@ -46,6 +48,8 @@ def test_list_datasets_by_user(tmp_app_client): # NOQA "created_at": 1536236399.19497, "number_of_items": 392, "size_in_bytes": 574181, + "uploaded_by": None, + "uploaded_at": None, } register_dataset_admin_metadata(admin_metadata_1) register_dataset_admin_metadata(admin_metadata_2) diff --git a/tests/test_summary_of_datasets_by_user.py b/tests/test_summary_of_datasets_by_user.py index d9ef2a0..eb5c630 100644 --- a/tests/test_summary_of_datasets_by_user.py +++ b/tests/test_summary_of_datasets_by_user.py @@ -19,5 +19,8 @@ def test_summary_of_datasets_by_user(tmp_app_with_data_client): # NOQA "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert summary == exected_output diff --git a/tests/test_uri_routes.py b/tests/test_uri_routes.py index ddf391e..faa428f 100644 --- a/tests/test_uri_routes.py +++ b/tests/test_uri_routes.py @@ -5,6 +5,18 @@ from dservercore.utils import uri_to_url_suffix + +def _strip_registration_provenance(obj): + """Remove the server-stamped uploaded_by/uploaded_at fields (dynamic + timestamp) from API responses before comparing against static + expectations.""" + entries = obj if isinstance(obj, list) else [obj] + for entry in entries: + entry.pop("uploaded_by", None) + entry.pop("uploaded_at", None) + return obj + + def test_list_uri_route( tmp_app_with_data_client, grumpy_token, @@ -64,7 +76,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.get( @@ -78,7 +90,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.get( @@ -92,7 +104,7 @@ def test_list_uri_route( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.get( @@ -106,7 +118,7 @@ def test_list_uri_route( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.get( "/uris", @@ -119,7 +131,7 @@ def test_list_uri_route( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # check response for others r = tmp_app_with_data_client.get( @@ -164,7 +176,8 @@ def test_get_dataset_by_uri_route( headers=headers ) assert r.status_code == 200 - assert json.loads(r.data.decode("utf-8")) == bad_apples_on_mr_men + assert _strip_registration_provenance( + json.loads(r.data.decode("utf-8"))) == bad_apples_on_mr_men # user has no search permission on base uri r = tmp_app_with_data_client.get( @@ -283,7 +296,11 @@ def test_put_dataset_by_uri_route( "number_of_items": 1, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 @@ -338,7 +355,11 @@ def test_put_dataset_by_uri_route( # in retrieve plugin, and the implementation of a register_dataset method # is not enforced on a plugin # assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 # URI suffix and dataset uri attribute disagree @@ -450,7 +471,11 @@ def test_put_dataset_route_when_created_at_is_string( "number_of_items": 1, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped provenance: registered via the API as grumpy. + assert admin_metadata.pop("uploaded_by") == "grumpy" + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert len(lookup_datasets_by_user_and_uuid("grumpy", uuid)) == 1 @@ -578,7 +603,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Make sure that timestamps are returned as float. first_entry = hits[0] @@ -657,7 +682,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.get( @@ -671,7 +696,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.get( @@ -685,7 +710,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.get( @@ -699,7 +724,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.get( "/uris", @@ -712,7 +737,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for apples (in README). headers = dict(Authorization="Bearer " + grumpy_token) @@ -729,7 +754,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for U00096 (in manifest). headers = dict(Authorization="Bearer " + grumpy_token) @@ -746,7 +771,7 @@ def test_uris_get_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for crazystuff (in annotaitons). headers = dict(Authorization="Bearer " + grumpy_token) @@ -762,7 +787,7 @@ def test_uris_get_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order def test_uris_post_route_with_query( @@ -867,7 +892,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_mr_men, oranges_on_snow_white, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by name and base uri r = tmp_app_with_data_client.post( @@ -883,7 +908,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_snow_white, bad_apples_on_mr_men, oranges_on_snow_white, ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri r = tmp_app_with_data_client.post( @@ -899,7 +924,7 @@ def test_uris_post_route_with_query( expected_order = [ oranges_on_snow_white, bad_apples_on_snow_white, bad_apples_on_mr_men ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # sorting by uuid and uri with pagination r = tmp_app_with_data_client.post( @@ -915,7 +940,7 @@ def test_uris_post_route_with_query( expected_order = [ bad_apples_on_mr_men, bad_apples_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order r = tmp_app_with_data_client.post( "/uris", @@ -930,7 +955,7 @@ def test_uris_post_route_with_query( expected_order = [ oranges_on_snow_white ] - assert hits == expected_order + assert _strip_registration_provenance(hits) == expected_order # Search for apples (in README). headers = dict(Authorization="Bearer " + grumpy_token) diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py index 74a4183..32f44b7 100644 --- a/tests/test_user_routes.py +++ b/tests/test_user_routes.py @@ -403,7 +403,10 @@ def test_dataset_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -428,6 +431,9 @@ def test_dataset_summary_route( "tags": ["evil", "fruit", "good"], "datasets_per_tag": {"good": 1, "evil": 2, "fruit": 3}, "size_in_bytes_per_tag": {"evil": 11483620, "fruit": 11483620, "good": 0}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) @@ -457,7 +463,10 @@ def test_dataset_summary_route( "size_in_bytes_per_base_uri": {}, "tags": [], "datasets_per_tag": {}, - "size_in_bytes_per_tag": {} + "size_in_bytes_per_tag": {}, + "uploaders": [], + "datasets_per_uploader": {}, + "size_in_bytes_per_uploader": {}, } assert expected_content == json.loads(r.data.decode("utf-8")) diff --git a/tests/test_utils_register_dataset.py b/tests/test_utils_register_dataset.py index 1fe9197..fffffb1 100644 --- a/tests/test_utils_register_dataset.py +++ b/tests/test_utils_register_dataset.py @@ -72,7 +72,12 @@ def test_register_dataset(tmp_app_client): # NOQA "number_of_items": 9876, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): @@ -148,7 +153,12 @@ def test_register_dataset_without_created_at(tmp_app_client): # NOQA "number_of_items": 1232, "size_in_bytes": 5741810, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): @@ -222,7 +232,12 @@ def test_register_dataset_without_created_at_and_size_in_bytes(tmp_app_client): "number_of_items": None, "size_in_bytes": None, } - assert get_admin_metadata_from_uri(uri) == expected_content + admin_metadata = get_admin_metadata_from_uri(uri) + # Server-stamped registration provenance: dynamic timestamp, no + # authenticated identity outside a request context. + assert admin_metadata.pop("uploaded_by") is None + assert isinstance(admin_metadata.pop("uploaded_at"), float) + assert admin_metadata == expected_content assert get_readme_from_uri_by_user("sleepy", uri) == dataset_info["readme"] with pytest.raises(ValidationError): From 977cb555269f2d52305b0dde402b6037aa2ac7fb Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sat, 13 Jun 2026 21:23:05 +0200 Subject: [PATCH 16/18] Added test for uploaded_by --- tests/test_uploaded_by.py | 135 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 tests/test_uploaded_by.py diff --git a/tests/test_uploaded_by.py b/tests/test_uploaded_by.py new file mode 100644 index 0000000..fec4210 --- /dev/null +++ b/tests/test_uploaded_by.py @@ -0,0 +1,135 @@ +"""Registration provenance: the server records which authenticated user +registered a dataset (uploaded_by/uploaded_at), as a server-asserted fact +separate from the client-claimed creator_username.""" + +import json +from urllib.parse import quote + +UUID = "11111111-2222-4333-8444-555555555555" +BASE_URI = "s3://snow-white" +URI = f"{BASE_URI}/{UUID}" + + +def dataset_payload(**overrides): + payload = { + "uuid": UUID, + "base_uri": BASE_URI, + "uri": URI, + "name": "provenance-test", + "type": "dataset", + "readme": "---\ndescription: provenance test", + "manifest": { + "dtoolcore_version": "3.18.0", + "hash_function": "md5sum_hexdigest", + "items": {}, + }, + "creator_username": "fr_lp1029", # noisy machine-local claim + "frozen_at": "1536238185.881941", + "annotations": {}, + "tags": [], + } + payload.update(overrides) + return payload + + +def register(client, token, payload): + return client.put( + f"/uris/{quote(URI, safe='')}", + data=json.dumps(payload), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + ) + + +def test_register_via_api_records_authenticated_uploader( + tmp_app_with_users_client, grumpy_token): # NOQA + from dservercore.sql_models import Dataset + + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + dataset = Dataset.query.filter_by(uri=URI).first() + assert dataset is not None + # creator_username keeps the client-side claim ... + assert dataset.creator_username == "fr_lp1029" + # ... while the server records who actually registered it. + assert dataset.uploaded_by == "grumpy" + assert dataset.uploaded_at is not None + + # Exposed in API responses (list datasets via empty search). + r = tmp_app_with_users_client.post( + "/uris", + data=json.dumps({}), + headers={ + "Authorization": f"Bearer {grumpy_token}", + "Content-Type": "application/json", + }, + ) + hits = [d for d in r.get_json() if d["uri"] == URI] + assert len(hits) == 1 + assert hits[0]["uploaded_by"] == "grumpy" + assert hits[0]["uploaded_at"] is not None + + +def test_register_without_request_context_leaves_uploader_empty( + tmp_app_with_users): # NOQA + """CLI/indexer registrations run outside a JWT request context.""" + from dservercore.sql_models import Dataset + from dservercore.utils import register_dataset + + register_dataset(dataset_payload(name="cli-registered")) + + dataset = Dataset.query.filter_by(uri=URI).first() + assert dataset is not None + assert dataset.uploaded_by is None + + +def test_client_cannot_forge_uploaded_by( + tmp_app_with_users_client, grumpy_token): # NOQA + """uploaded_by is server-asserted; the register schema must reject it.""" + payload = dataset_payload(uploaded_by="mallory") + r = register(tmp_app_with_users_client, grumpy_token, payload) + assert r.status_code == 422 + + +def test_search_filter_by_uploaded_by( + tmp_app_with_users_client, grumpy_token): # NOQA + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + def search(query): + r = tmp_app_with_users_client.post( + "/uris", + data=json.dumps(query), + headers={ + "Authorization": f"Bearer {grumpy_token}", + "Content-Type": "application/json", + }, + ) + assert r.status_code == 200 + return r.get_json() + + hits = search({"uploaded_by": ["grumpy"]}) + assert len(hits) == 1 + assert hits[0]["uri"] == URI + assert hits[0]["uploaded_by"] == "grumpy" + + assert search({"uploaded_by": ["someone-else"]}) == [] + + +def test_summary_includes_uploader_facets( + tmp_app_with_users_client, grumpy_token): # NOQA + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) + assert r.status_code == 201 + + r = tmp_app_with_users_client.get( + "/me/summary", + headers={"Authorization": f"Bearer {grumpy_token}"}, + ) + assert r.status_code == 200 + summary = r.get_json() + assert "grumpy" in summary["uploaders"] + assert summary["datasets_per_uploader"]["grumpy"] == 1 + assert summary["size_in_bytes_per_uploader"]["grumpy"] == 0 From eaa2ae226d38b55a04534a7180f78f7bced6ce59 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sat, 13 Jun 2026 21:23:24 +0200 Subject: [PATCH 17/18] Drop Python 3.7 and 3.8 --- .github/workflows/test.yml | 2 +- dservercore/__init__.py | 28 ++++++++++------------------ pyproject.toml | 5 +++-- 3 files changed, 14 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1899652..76f4c46 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] mongodb-version: ['5.0', '6.0', '7.0', '8.0'] dserver-search-plugin-mongo-version: ['0.4.2'] dserver-retrieve-plugin-mongo-version: ['0.4.2'] diff --git a/dservercore/__init__.py b/dservercore/__init__.py index 89c17d5..ce72781 100644 --- a/dservercore/__init__.py +++ b/dservercore/__init__.py @@ -42,24 +42,16 @@ __version__ = None -# Python version-dependent treatment of entry points -if sys.version_info >= (3, 8): - from importlib.metadata import entry_points - eps = entry_points() - if sys.version_info >= (3, 10): - search_entrypoints_iterator = eps.select(group="dservercore.search") - retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve") - extension_entrypoints_iterator = eps.select(group="dservercore.extension") - else: - search_entrypoints_iterator = eps.get("dservercore.search", []) - retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", []) - extension_entrypoints_iterator = eps.get("dservercore.extension", []) -else: # Python version < 3.8 - from pkg_resources import iter_entry_points - - search_entrypoints_iterator = iter_entry_points("dservercore.search") - retrieve_entrypoints_iterator = iter_entry_points("dservercore.retrieve") - extension_entrypoints_iterator = iter_entry_points("dservercore.extension") +from importlib.metadata import entry_points +eps = entry_points() +if sys.version_info >= (3, 10): + search_entrypoints_iterator = eps.select(group="dservercore.search") + retrieve_entrypoints_iterator = eps.select(group="dservercore.retrieve") + extension_entrypoints_iterator = eps.select(group="dservercore.extension") +else: + search_entrypoints_iterator = eps.get("dservercore.search", []) + retrieve_entrypoints_iterator = eps.get("dservercore.retrieve", []) + extension_entrypoints_iterator = eps.get("dservercore.extension", []) class ValidationError(ValueError): diff --git a/pyproject.toml b/pyproject.toml index 2058751..543317b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ {name = "Tjelvar Olsson", email = "tjelvar.olsson@gmail.com"} ] dynamic = ["version"] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "flask<3", "pymongo", @@ -25,7 +25,8 @@ dependencies = [ "flask-cors", "dtoolcore>=3.18.0", "flask-jwt-extended[asymmetric_crypto]>=4.6.0", - "pyyaml" + "pyyaml", + "marshmallow<4.0.0" ] [project.optional-dependencies] From f6c3ee363e81ceaf3145efbe1c39781d6c443f27 Mon Sep 17 00:00:00 2001 From: Lars Pastewka Date: Sat, 13 Jun 2026 21:41:16 +0200 Subject: [PATCH 18/18] Check if search plugin supports uploaded_by --- tests/test_uploaded_by.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_uploaded_by.py b/tests/test_uploaded_by.py index fec4210..dbe9f30 100644 --- a/tests/test_uploaded_by.py +++ b/tests/test_uploaded_by.py @@ -96,6 +96,19 @@ def test_client_cannot_forge_uploaded_by( def test_search_filter_by_uploaded_by( tmp_app_with_users_client, grumpy_token): # NOQA + # Check if the search plugin supports filtering by uploaded_by. + search_plugin_supports_uploaded_by = True + try: + from dserver_search_plugin_mongo.utils_search import VALID_MONGO_QUERY_KEYS + if "uploaded_by" not in VALID_MONGO_QUERY_KEYS: + search_plugin_supports_uploaded_by = False + except ImportError: + pass + + if not search_plugin_supports_uploaded_by: + import pytest + pytest.skip("Search plugin does not support uploaded_by filter") + r = register(tmp_app_with_users_client, grumpy_token, dataset_payload()) assert r.status_code == 201