From a2ccc64654a8683d0f859ba6c02b39f4a1147d50 Mon Sep 17 00:00:00 2001 From: Daan Rosendal Date: Sat, 4 Nov 2023 16:50:20 +0100 Subject: [PATCH] feat(rest): add endpoint to share workflows (#552) Adds a new endpoint to share a workflow with a user. Closes reanahub/reana-client#680 --- AUTHORS.md | 1 + docs/openapi.json | 105 ++++++ reana_workflow_controller/config.py | 2 + reana_workflow_controller/rest/workflows.py | 207 ++++++++++- tests/test_views.py | 383 +++++++++++++++++++- 5 files changed, 682 insertions(+), 16 deletions(-) diff --git a/AUTHORS.md b/AUTHORS.md index fd7fce0b..f3b9bb24 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -7,6 +7,7 @@ The list of contributors in alphabetical order: - [Anton Khodak](https://orcid.org/0000-0003-3263-4553) - [Audrius Mecionis](https://orcid.org/0000-0002-3759-1663) - [Camila Diaz](https://orcid.org/0000-0001-5543-797X) +- [Daan Rosendal](https://orcid.org/0000-0002-3447-9000) - [Diego Rodriguez](https://orcid.org/0000-0003-0649-2002) - [Dinos Kousidis](https://orcid.org/0000-0002-4914-4289) - [Giuseppe Steduto](https://orcid.org/0009-0002-1258-8553) diff --git a/docs/openapi.json b/docs/openapi.json index a28dbab4..8ad42401 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -993,6 +993,111 @@ "summary": "Get the retention rules of a workflow." } }, + "/api/workflows/{workflow_id_or_name}/share": { + "post": { + "description": "This resource allows to share a workflow with other users.", + "operationId": "share_workflow", + "parameters": [ + { + "description": "Required. UUID of workflow owner.", + "in": "query", + "name": "user", + "required": true, + "type": "string" + }, + { + "description": "Required. Analysis UUID or name.", + "in": "path", + "name": "workflow_id_or_name", + "required": true, + "type": "string" + }, + { + "description": "JSON object with details of the share.", + "in": "body", + "name": "share_details", + "required": true, + "schema": { + "properties": { + "message": { + "description": "Optional. Message to include when sharing the workflow.", + "type": "string" + }, + "user_email_to_share_with": { + "description": "User to share the workflow with.", + "type": "string" + }, + "valid_until": { + "description": "Optional. Date when access to the workflow will expire (format YYYY-MM-DD).", + "type": "string" + } + }, + "required": [ + "user_email_to_share_with" + ], + "type": "object" + } + } + ], + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Request succeeded. The workflow has been shared with the user.", + "examples": { + "application/json": { + "message": "The workflow has been shared with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + }, + "schema": { + "properties": { + "message": { + "type": "string" + }, + "workflow_id": { + "type": "string" + }, + "workflow_name": { + "type": "string" + } + }, + "type": "object" + } + }, + "400": { + "description": "Request failed. The incoming data seems malformed." + }, + "404": { + "description": "Request failed. Workflow does not exist or user does not exist.", + "examples": { + "application/json": { + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does not exist" + } + } + }, + "409": { + "description": "Request failed. The workflow is already shared with the user.", + "examples": { + "application/json": { + "message": "The workflow is already shared with the user." + } + } + }, + "500": { + "description": "Request failed. Internal controller error.", + "examples": { + "application/json": { + "message": "Internal controller error." + } + } + } + }, + "summary": "Share a workflow with other users." + } + }, "/api/workflows/{workflow_id_or_name}/status": { "get": { "description": "This resource reports the status of workflow.", diff --git a/reana_workflow_controller/config.py b/reana_workflow_controller/config.py index fc271ff2..62859127 100644 --- a/reana_workflow_controller/config.py +++ b/reana_workflow_controller/config.py @@ -275,3 +275,5 @@ def _parse_interactive_sessions_environments(env_var): CONTAINER_IMAGE_ALIAS_PREFIXES = ["docker.io/", "docker.io/library/", "library/"] """Prefixes that can be removed from container image references to generate valid image aliases.""" + +MAX_WORKFLOW_SHARING_MESSAGE_LENGTH = 5000 diff --git a/reana_workflow_controller/rest/workflows.py b/reana_workflow_controller/rest/workflows.py index 618d6215..6c259bf2 100644 --- a/reana_workflow_controller/rest/workflows.py +++ b/reana_workflow_controller/rest/workflows.py @@ -1,30 +1,38 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2020, 2021, 2022 CERN. +# Copyright (C) 2020, 2021, 2022, 2023 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. """REANA Workflow Controller workflows REST API.""" +import datetime import json import logging +import re from typing import Optional from uuid import uuid4 from flask import Blueprint, jsonify, request -from reana_commons.config import WORKFLOW_TIME_FORMAT -from reana_db.database import Session -from reana_db.utils import build_workspace_path -from reana_db.models import User, Workflow, RunStatus, WorkflowResource -from reana_db.utils import _get_workflow_with_uuid_or_name, get_default_quota_resource -from sqlalchemy import and_, nullslast +from sqlalchemy import and_, nullslast, or_ +from sqlalchemy.orm import aliased +from sqlalchemy.exc import IntegrityError from webargs import fields from webargs.flaskparser import use_args, use_kwargs - - -from reana_workflow_controller.config import DEFAULT_NAME_FOR_WORKFLOWS +from reana_commons.config import WORKFLOW_TIME_FORMAT +from reana_db.database import Session +from reana_db.models import RunStatus, User, UserWorkflow, Workflow, WorkflowResource +from reana_db.utils import ( + _get_workflow_with_uuid_or_name, + build_workspace_path, + get_default_quota_resource, +) +from reana_workflow_controller.config import ( + DEFAULT_NAME_FOR_WORKFLOWS, + MAX_WORKFLOW_SHARING_MESSAGE_LENGTH, +) from reana_workflow_controller.errors import ( REANAWorkflowControllerError, REANAWorkflowNameError, @@ -39,7 +47,6 @@ use_paginate_args, ) - START = "start" STOP = "stop" DELETED = "deleted" @@ -888,3 +895,181 @@ def get_workflow_retention_rules(workflow_id_or_name: str, user: str): except Exception as e: logging.exception(str(e)) return jsonify({"message": str(e)}), 500 + + +@blueprint.route("/workflows//share", methods=["POST"]) +@use_kwargs({"user": fields.Str(required=True)}, location="query") +def share_workflow(workflow_id_or_name: str, user: str): + r"""Share a workflow with other users. + + --- + post: + summary: Share a workflow with other users. + description: >- + This resource allows to share a workflow with other users. + operationId: share_workflow + produces: + - application/json + parameters: + - name: user + in: query + description: Required. UUID of workflow owner. + required: true + type: string + - name: workflow_id_or_name + in: path + description: Required. Analysis UUID or name. + required: true + type: string + - name: share_details + in: body + description: JSON object with details of the share. + required: true + schema: + type: object + properties: + user_email_to_share_with: + type: string + description: User to share the workflow with. + message: + type: string + description: Optional. Message to include when sharing the workflow. + valid_until: + type: string + description: Optional. Date when access to the workflow will expire (format YYYY-MM-DD). + required: [user_email_to_share_with] + responses: + 200: + description: >- + Request succeeded. The workflow has been shared with the user. + schema: + type: object + properties: + message: + type: string + workflow_id: + type: string + workflow_name: + type: string + examples: + application/json: + { + "message": "The workflow has been shared with the user.", + "workflow_id": "cdcf48b1-c2f3-4693-8230-b066e088c6ac", + "workflow_name": "mytest.1" + } + 400: + description: >- + Request failed. The incoming data seems malformed. + 404: + description: >- + Request failed. Workflow does not exist or user does not exist. + examples: + application/json: + { + "message": "Workflow cdcf48b1-c2f3-4693-8230-b066e088c6ac does + not exist", + } + 409: + description: >- + Request failed. The workflow is already shared with the user. + examples: + application/json: + { + "message": "The workflow is already shared with the user.", + } + 500: + description: >- + Request failed. Internal controller error. + examples: + application/json: + { + "message": "Internal controller error.", + } + """ + try: + sharer = User.query.filter(User.id_ == user).first() + if not sharer: + return ( + jsonify({"message": f"User with id '{user}' does not exist."}), + 404, + ) + + share_details = request.json + user_email_to_share_with = share_details.get("user_email_to_share_with") + message = share_details.get("message", None) + valid_until = share_details.get("valid_until", None) + + if sharer.email == user_email_to_share_with: + raise ValueError("Unable to share a workflow with yourself.") + + user_to_share_with = ( + Session.query(User) + .filter(User.email == user_email_to_share_with) + .one_or_none() + ) + + if not user_to_share_with: + return ( + jsonify( + { + "message": f"User with email '{user_email_to_share_with}' does not exist." + } + ), + 404, + ) + + if valid_until: + try: + datetime.date.fromisoformat(valid_until) + except ValueError as e: + raise ValueError( + f"Date format is not valid ({str(e)}). Please use YYYY-MM-DD format." + ) + + # check if date is in the future + if datetime.date.fromisoformat(valid_until) < datetime.date.today(): + raise ValueError("The 'valid_until' date cannot be in the past.") + + if message and len(message) > MAX_WORKFLOW_SHARING_MESSAGE_LENGTH: + raise ValueError( + "Message is too long. Please keep it under " + + str(MAX_WORKFLOW_SHARING_MESSAGE_LENGTH) + + " characters." + ) + + workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, sharer.id_) + + try: + Session.add( + UserWorkflow( + user_id=user_to_share_with.id_, + workflow_id=workflow.id_, + message=message, + valid_until=valid_until, + ) + ) + Session.commit() + except IntegrityError: + Session.rollback() + return ( + jsonify( + { + "message": f"{workflow.get_full_workflow_name()} is already shared with {user_email_to_share_with}." + } + ), + 409, + ) + + response = { + "message": "The workflow has been shared with the user.", + "workflow_id": workflow.id_, + "workflow_name": workflow.get_full_workflow_name(), + } + return jsonify(response), 200 + except ValueError as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 400 + except Exception as e: + logging.exception(str(e)) + return jsonify({"message": str(e)}), 500 diff --git a/tests/test_views.py b/tests/test_views.py index cb9d0bed..1e5a275f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2024 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -18,20 +18,20 @@ import pytest from flask import url_for from reana_db.models import ( + InteractiveSession, Job, JobCache, - Workflow, RunStatus, - InteractiveSession, + Workflow, + UserWorkflow, ) -from werkzeug.utils import secure_filename - from reana_workflow_controller.rest.utils import ( create_workflow_workspace, delete_workflow, ) from reana_workflow_controller.rest.workflows_status import START, STOP from reana_workflow_controller.workflow_run_manager import WorkflowRunManager +from werkzeug.utils import secure_filename status_dict = { START: RunStatus.pending, @@ -1650,3 +1650,376 @@ def test_get_workflow_retention_rules_invalid_user(app, sample_serial_workflow_i query_string={"user": uuid.uuid4()}, ) assert res.status_code == 404 + + +def test_share_workflow( + app, session, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + session.query(UserWorkflow).filter_by(user_id=user2.id_).delete() + + +def test_share_workflow_with_message_and_valid_until( + app, session, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a message and a valid until date.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + "message": "This is a shared workflow with a message.", + "valid_until": "2999-12-31", + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + session.query(UserWorkflow).filter_by(user_id=user2.id_).delete() + + +def test_share_workflow_invalid_email( + app, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with invalid email format.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + invalid_emails = [ + "invalid_email", + "invalid_email@", + "@invalid_email.com", + "invalid_email.com", + "invalid@ email.com", # Contains a space + "invalid email@domain.com", # Contains a space + "invalid_email@.com", # Empty domain + "invalid_email@com.", # Empty top-level domain + "invalid_email@com", # Missing top-level domain + "invalid_email@com.", # Extra dot in top-level domain + ] + + with app.test_client() as client: + for invalid_email in invalid_emails: + share_details = { + "user_email_to_share_with": invalid_email, + } + + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user2.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 404 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User with email '{invalid_email}' does not exist." + ) + + +def test_share_workflow_with_valid_email_but_unexisting_user( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with valid email but unexisting user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + valid_emails = [ + "valid_email@example.com", + "another_valid_email@test.org", + "john.doe@email-domain.net", + "alice.smith@sub.domain.co.uk", + "user2234@gmail.com", + "admin@company.com", + "support@website.org", + "marketing@example.net", + "jane_doe@sub.example.co", + "user.name@sub.domain.co.uk", + ] + + with app.test_client() as client: + for valid_email in valid_emails: + share_details = { + "user_email_to_share_with": valid_email, + } + + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 404 + response_data = res.get_json() + assert ( + response_data["message"] + == f"User with email '{valid_email}' does not exist." + ) + + +def test_share_workflow_with_invalid_date_format( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with an invalid date format for 'valid_until'.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + "valid_until": "2023/12/31", # Invalid format + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == "Date format is not valid (Invalid isoformat string: '2023/12/31'). Please use YYYY-MM-DD format." + ) + + +def test_share_non_existent_workflow(app, user1, user2): + """Test sharing a non-existent workflow.""" + non_existent_workflow_id = "non_existent_workflow_id" + share_details = { + "user_email_to_share_with": user2.email, + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=non_existent_workflow_id, + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == f"REANA_WORKON is set to {non_existent_workflow_id}, but that workflow does not exist. Please set your REANA_WORKON environment variable appropriately." + ) + + +def test_share_workflow_with_self( + app, user1, sample_serial_workflow_in_db_owned_by_user1 +): + """Test attempting to share a workflow with yourself.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user1.email, + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 400 + response_data = res.get_json() + assert response_data["message"] == "Unable to share a workflow with yourself." + + +def test_share_workflow_already_shared( + app, session, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test attempting to share a workflow that is already shared with the user.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + } + with app.test_client() as client: + client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + + # Attempt to share the same workflow again + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 409 + response_data = res.get_json() + assert ( + response_data["message"] + == f"{workflow.get_full_workflow_name()} is already shared with {user2.email}." + ) + + session.query(UserWorkflow).filter_by(user_id=user2.id_).delete() + + +def test_share_workflow_with_past_valid_until_date( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a 'valid_until' date in the past.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + "valid_until": "2021-01-01", # A date in the past + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] == "The 'valid_until' date cannot be in the past." + ) + + +def test_share_workflow_with_long_message( + app, user1, user2, sample_serial_workflow_in_db_owned_by_user1 +): + """Test share workflow with a message exceeding 5000 characters.""" + workflow = sample_serial_workflow_in_db_owned_by_user1 + long_message = "A" * 5001 # A message exceeding the 5000-character limit + share_details = { + "user_email_to_share_with": user2.email, + "message": long_message, + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 400 + response_data = res.get_json() + assert ( + response_data["message"] + == "Message is too long. Please keep it under 5000 characters." + ) + + +def test_share_multiple_workflows( + app, + session, + user1, + user2, + sample_serial_workflow_in_db_owned_by_user1, + sample_yadage_workflow_in_db_owned_by_user1, +): + """Test sharing multiple workflows with different users.""" + workflow1 = sample_serial_workflow_in_db_owned_by_user1 + share_details = { + "user_email_to_share_with": user2.email, + } + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow1.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + workflow2 = sample_yadage_workflow_in_db_owned_by_user1 + with app.test_client() as client: + res = client.post( + url_for( + "workflows.share_workflow", + workflow_id_or_name=str(workflow2.id_), + ), + query_string={ + "user": str(user1.id_), + }, + content_type="application/json", + data=json.dumps(share_details), + ) + assert res.status_code == 200 + response_data = res.get_json() + assert response_data["message"] == "The workflow has been shared with the user." + + session.query(UserWorkflow).filter_by(user_id=user2.id_).delete()