diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 8ea813be7..9c3220839 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -2652,6 +2652,18 @@ paths: enum: - synchronous - asynchronous + get: + tags: + - Tasks + operationId: spiffworkflow_backend.routes.public_controller.form_show + summary: Gets one task that a user wants to complete + responses: + "200": + description: One task + content: + application/json: + schema: + $ref: "#/components/schemas/Task" put: tags: - Tasks diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml index bcd387a00..228b1f5ad 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/acceptance_tests.yml @@ -8,9 +8,16 @@ users: groups: admin: users: [ciadmin1@spiffworkflow.org] + spiff_public: + users: [] permissions: admin: groups: [admin] actions: [create, read, update, delete] uri: /* + + public_access: + groups: [spiff_public] + actions: [create, read, update] + uri: /public/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml index df7cbb95a..606989020 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml @@ -48,5 +48,5 @@ permissions: public_access: groups: [spiff_public] - actions: [read, create] + actions: [read, create, update] uri: /public/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py index 96b759432..eb5e5295e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py @@ -16,12 +16,10 @@ from spiffworkflow_backend.exceptions.error import InvalidRedirectUrlError from spiffworkflow_backend.exceptions.error import MissingAccessTokenError from spiffworkflow_backend.exceptions.error import TokenExpiredError from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX -from spiffworkflow_backend.models.group import SPIFF_GUEST_GROUP from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.service_account import ServiceAccountModel from spiffworkflow_backend.models.task import TaskModel # noqa: F401 -from spiffworkflow_backend.models.user import SPIFF_GUEST_USER from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authentication_service import AuthenticationService @@ -130,15 +128,6 @@ def login( ) return redirect(redirect_url) - if process_instance_id and task_guid and TaskModel.task_guid_allows_guest(task_guid, process_instance_id): - AuthenticationService.create_guest_token( - username=SPIFF_GUEST_USER, - group_identifier=SPIFF_GUEST_GROUP, - auth_token_properties={"only_guest_task_completion": True, "process_instance_id": process_instance_id}, - authentication_identifier=authentication_identifier, - ) - return redirect(redirect_url) - state = AuthenticationService.generate_state(redirect_url, authentication_identifier) login_redirect_url = AuthenticationService().get_login_redirect_url( state.decode("UTF-8"), authentication_identifier=authentication_identifier @@ -298,27 +287,6 @@ def _clear_auth_tokens_from_thread_local_data() -> None: delattr(tld, "user_has_logged_out") -def _force_logout_user_if_necessary(user_model: UserModel | None, decoded_token: dict) -> bool: - """Logs out a guest user if certain criteria gets met. - - * if the user is a no auth guest and we have auth enabled - * if the user is a guest and goes somewhere else that does not allow guests - """ - if user_model is not None: - if ( - not current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTHENTICATION_DISABLED") - and user_model.username == SPIFF_NO_AUTH_USER - and user_model.service_id == "spiff_guest_service_id" - ) or ( - user_model.username == SPIFF_GUEST_USER - and user_model.service_id == "spiff_guest_service_id" - and not AuthorizationService.request_allows_guest_access(decoded_token) - ): - AuthenticationService.set_user_has_logged_out() - return True - return False - - def _find_token_from_request(token: str | None) -> dict[str, str | None]: api_key = None if not token and "Authorization" in request.headers: @@ -356,10 +324,6 @@ def _get_user_model_from_token(decoded_token: dict) -> UserModel | None: user_model = _get_user_from_decoded_internal_token(decoded_token) except Exception as e: current_app.logger.error(f"Exception in verify_token getting user from decoded internal token. {e}") - - # if the user is forced logged out then stop processing the token - if _force_logout_user_if_necessary(user_model, decoded_token): - return None else: user_info = None authentication_identifier = _get_authentication_identifier_from_request() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py index 68564b828..aee1c7ee9 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -1,6 +1,8 @@ import json +import os import uuid from typing import Any +from typing import TypedDict from uuid import UUID import flask.wrappers @@ -21,19 +23,23 @@ from spiffworkflow_backend.background_processing.celery_tasks.process_instance_t ) from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError +from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError +from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_instance_file_data import ProcessInstanceFileDataModel from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel from spiffworkflow_backend.models.reference_cache import ReferenceSchema -from spiffworkflow_backend.models.task import TaskModel # noqa: F401 -from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401 +from spiffworkflow_backend.models.task import TaskModel from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.jinja_service import JinjaService @@ -42,12 +48,24 @@ from spiffworkflow_backend.services.process_instance_processor import ProcessIns from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_model_service import ProcessModelService +from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.task_service import TaskModelError from spiffworkflow_backend.services.task_service import TaskService process_api_blueprint = Blueprint("process_api", __name__) +class TaskDataSelectOption(TypedDict): + value: str + label: str + + +class ReactJsonSchemaSelectOption(TypedDict): + type: str + title: str + enum: list[str] + + def permissions_check(body: dict[str, dict[str, list[str]]]) -> flask.wrappers.Response: if "requests_to_check" not in body: raise ( @@ -557,3 +575,211 @@ def _get_spiff_task_from_processor( ) ) return spiff_task + + +def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) -> TaskModel: + task_model: TaskModel | None = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first() + if task_model is None: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'", + status_code=400, + ) + return task_model + + +def _get_task_model_for_request( + process_instance_id: int, + task_guid: str = "next", + with_form_data: bool = False, +) -> TaskModel: + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + + if process_instance.status == ProcessInstanceStatus.suspended.value: + raise ApiError( + error_code="error_suspended", + message="The process instance is suspended", + status_code=400, + ) + + process_model = _get_process_model( + process_instance.process_model_identifier, + ) + + task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id) + task_definition = task_model.task_definition + + can_complete = False + try: + AuthorizationService.assert_user_can_complete_task(process_instance.id, task_model.guid, g.user) + can_complete = True + except ( + HumanTaskNotFoundError, + UserDoesNotHaveAccessToTaskError, + HumanTaskAlreadyCompletedError, + ): + can_complete = False + + task_model.process_model_display_name = process_model.display_name + task_model.process_model_identifier = process_model.id + task_model.typename = task_definition.typename + task_model.can_complete = can_complete + task_model.name_for_display = TaskService.get_name_for_display(task_definition) + extensions = TaskService.get_extensions_from_task_model(task_model) + + if with_form_data: + task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier + process_model_with_form = process_model + + refs = SpecFileService.get_references_for_process(process_model_with_form) + all_processes = [i.identifier for i in refs] + if task_process_identifier not in all_processes: + top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model) + bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier( + top_bpmn_process.bpmn_process_definition.bpmn_identifier + ) + relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path()) + process_model_relative_path = os.path.dirname(relative_path) + process_model_with_form = ProcessModelService.get_process_model_from_relative_path(process_model_relative_path) + + form_schema_file_name = "" + form_ui_schema_file_name = "" + task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id, task_model.guid) + + if "properties" in extensions: + properties = extensions["properties"] + if "formJsonSchemaFilename" in properties: + form_schema_file_name = properties["formJsonSchemaFilename"] + if "formUiSchemaFilename" in properties: + form_ui_schema_file_name = properties["formUiSchemaFilename"] + + task_draft_data = TaskService.task_draft_data_from_task_model(task_model) + + saved_form_data = None + if task_draft_data is not None: + saved_form_data = task_draft_data.get_saved_form_data() + + task_model.data = task_model.get_data() + task_model.saved_form_data = saved_form_data + if task_definition.typename == "UserTask": + if not form_schema_file_name: + raise ( + ApiError( + error_code="missing_form_file", + message=f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid: {task_guid}", + status_code=400, + ) + ) + + form_dict = _prepare_form_data( + form_file=form_schema_file_name, + task_model=task_model, + process_model=process_model_with_form, + revision=process_instance.bpmn_version_control_identifier, + ) + _update_form_schema_with_task_data_as_needed(form_dict, task_model.data) + task_model.form_schema = form_dict + + if form_ui_schema_file_name: + ui_form_contents = _prepare_form_data( + form_file=form_ui_schema_file_name, + task_model=task_model, + process_model=process_model_with_form, + revision=process_instance.bpmn_version_control_identifier, + ) + task_model.form_ui_schema = ui_form_contents + else: + task_model.form_ui_schema = {} + _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model.form_ui_schema, task_model.data) + + # it should be safe to add instructions to the task spec here since we are never commiting it back to the db + extensions["instructionsForEndUser"] = JinjaService.render_instructions_for_end_user(task_model, extensions) + + task_model.extensions = extensions + return task_model + + +# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches +def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None: + for k, value in in_dict.items(): + if "anyOf" == k: + # value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"] + if isinstance(value, list): + if len(value) == 1: + first_element_in_value_list = value[0] + if isinstance(first_element_in_value_list, str): + if first_element_in_value_list.startswith("options_from_task_data_var:"): + task_data_var = first_element_in_value_list.replace("options_from_task_data_var:", "") + + if task_data_var not in task_data: + message = ( + "Error building form. Attempting to create a selection list with options from" + f" variable '{task_data_var}' but it doesn't exist in the Task Data." + ) + raise ApiError( + error_code="missing_task_data_var", + message=message, + status_code=500, + ) + + select_options_from_task_data = task_data.get(task_data_var) + if select_options_from_task_data == []: + raise ApiError( + error_code="invalid_form_data", + message=( + "This form depends on variables, but at least one variable was empty. The" + f" variable '{task_data_var}' must be a list with at least one element." + ), + status_code=500, + ) + if isinstance(select_options_from_task_data, str): + raise ApiError( + error_code="invalid_form_data", + message=( + "This form depends on enum variables, but at least one variable was a string." + f" The variable '{task_data_var}' must be a list with at least one element." + ), + status_code=500, + ) + if isinstance(select_options_from_task_data, list): + if all("value" in d and "label" in d for d in select_options_from_task_data): + + def map_function( + task_data_select_option: TaskDataSelectOption, + ) -> ReactJsonSchemaSelectOption: + return { + "type": "string", + "enum": [task_data_select_option["value"]], + "title": task_data_select_option["label"], + } + + options_for_react_json_schema_form = list( + map( + map_function, + select_options_from_task_data, + ) + ) + + in_dict[k] = options_for_react_json_schema_form + elif isinstance(value, dict): + _update_form_schema_with_task_data_as_needed(value, task_data) + elif isinstance(value, list): + for o in value: + if isinstance(o, dict): + _update_form_schema_with_task_data_as_needed(o, task_data) + + +def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None: + if form_ui_schema is None: + return + if task_data and "form_ui_hidden_fields" in task_data: + hidden_fields = task_data["form_ui_hidden_fields"] + for hidden_field in hidden_fields: + hidden_field_parts = hidden_field.split(".") + relevant_depth_of_ui_schema = form_ui_schema + for ii, hidden_field_part in enumerate(hidden_field_parts): + if hidden_field_part not in relevant_depth_of_ui_schema: + relevant_depth_of_ui_schema[hidden_field_part] = {} + relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part] + if len(hidden_field_parts) == ii + 1: + relevant_depth_of_ui_schema["ui:widget"] = "hidden" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py index dc5017870..25d4d9551 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py @@ -8,11 +8,14 @@ from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.models.human_task import HumanTaskModel +from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.task import TaskModel -from spiffworkflow_backend.models.task_definition import TaskDefinitionModel # noqa: F401 +from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_for_request from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared from spiffworkflow_backend.services.jinja_service import JinjaService @@ -52,9 +55,15 @@ def message_form_show( process_model = ProcessModelService.get_process_model(message_triggerable_process_model.process_model_identifier) extensions = matching_start_tasks[0].task_spec.extensions - response_body = _get_form_and_prepare_data(extensions=extensions, process_model=process_model) + form = _get_form_and_prepare_data(extensions=extensions, process_model=process_model) - return make_response(jsonify(response_body), 200) + response_json = { + "form": form, + "task_guid": None, + "process_instance_id": None, + "confirmation_message_markdown": None, + } + return make_response(jsonify(response_json), 200) def message_form_submit( @@ -107,13 +116,21 @@ def form_submit( next_form_contents = None next_task_guid = None + + next_task_assigned_to_me = None if "next_task_assigned_to_me" in response_item: next_task_assigned_to_me = response_item["next_task_assigned_to_me"] + elif "next_task" in response_item: + task_model = TaskModel.query.filter_by(guid=str(response_item["next_task"].id)).first() + if _assign_task_if_guest(task_model): + next_task_assigned_to_me = response_item["next_task"] + + if next_task_assigned_to_me is not None: process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() - next_task_guid = next_task_assigned_to_me.id + next_task_guid = str(next_task_assigned_to_me.id) process_model = ProcessModelService.get_process_model(process_instance.process_model_identifier) next_form_contents = _get_form_and_prepare_data( - process_model=process_model, task_guid=next_task_assigned_to_me.task_guid, process_instance=process_instance + process_model=process_model, task_guid=next_task_guid, process_instance=process_instance ) response_json = { @@ -125,6 +142,45 @@ def form_submit( return make_response(jsonify(response_json), 200) +def form_show( + process_instance_id: int, + task_guid: str, +) -> flask.wrappers.Response: + task_model = _get_task_model_for_request( + process_instance_id=process_instance_id, + task_guid=task_guid, + with_form_data=True, + ) + if task_model is None or not task_model.allows_guest(task_model.process_instance_id): + raise ( + ApiError( + error_code="task_not_found", + message=f"Could not find completable task for {task_guid} in process_instance {process_instance_id}.", + status_code=404, + ) + ) + + _assign_task_if_guest(task_model) + + instructions_for_end_user = None + if task_model.extensions: + instructions_for_end_user = task_model.extensions["instructionsForEndUser"] + + form = { + "form_schema": task_model.form_schema, + "form_ui_schema": task_model.form_ui_schema, + "instructions_for_end_user": instructions_for_end_user, + } + + response_json = { + "form": form, + "task_guid": task_guid, + "process_instance_id": process_instance_id, + "confirmation_message_markdown": None, + } + return make_response(jsonify(response_json), 200) + + def _get_form_and_prepare_data( process_model: ProcessModelInfo, extensions: dict | None = None, @@ -170,3 +226,34 @@ def _get_form_and_prepare_data( extension_list["instructionsForEndUser"], task_data=task_data ) return form_contents + + +def _assign_task_if_guest(task_model: TaskModel) -> bool: + if not task_model.allows_guest(task_model.process_instance_id): + return False + + human_task_user = ( + HumanTaskUserModel.query.filter_by(user_id=g.user.id) + .join(HumanTaskModel, HumanTaskModel.id == HumanTaskUserModel.human_task_id) + .filter(HumanTaskModel.task_guid == task_model.guid) + .first() + ) + if human_task_user is None: + human_task = HumanTaskModel.query.filter_by( + task_guid=task_model.guid, process_instance_id=task_model.process_instance_id + ).first() + if human_task is None: + raise ( + ApiError( + error_code="completable_task_not_found", + message=( + f"Could not find completable task for {task_model.guid} in process_instance" + f" {task_model.process_instance_id}." + ), + status_code=400, + ) + ) + human_task_user = HumanTaskUserModel(user_id=g.user.id, human_task=human_task) + db.session.add(human_task_user) + db.session.commit() + return True diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 515985cab..1427d5003 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -1,9 +1,7 @@ import json -import os from collections import OrderedDict from collections.abc import Generator from typing import Any -from typing import TypedDict import flask.wrappers import sentry_sdk @@ -27,15 +25,12 @@ from sqlalchemy.orm.util import AliasedClass from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError -from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError -from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel -from spiffworkflow_backend.models.json_data import JsonDataDict # noqa: F401 -from spiffworkflow_backend.models.json_data import JsonDataModel # noqa: F401 +from spiffworkflow_backend.models.json_data import JsonDataModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus @@ -52,33 +47,22 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model -from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data +from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_for_request +from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_from_guid_or_raise +from spiffworkflow_backend.routes.process_api_blueprint import _munge_form_ui_schema_based_on_hidden_fields_in_task_data from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared +from spiffworkflow_backend.routes.process_api_blueprint import _update_form_schema_with_task_data_as_needed from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService -from spiffworkflow_backend.services.file_system_service import FileSystemService from spiffworkflow_backend.services.jinja_service import JinjaService from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService -from spiffworkflow_backend.services.process_model_service import ProcessModelService -from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.task_service import TaskService -class TaskDataSelectOption(TypedDict): - value: str - label: str - - -class ReactJsonSchemaSelectOption(TypedDict): - type: str - title: str - enum: list[str] - - def task_allows_guest( process_instance_id: int, task_guid: str, @@ -436,110 +420,11 @@ def task_show( task_guid: str = "next", with_form_data: bool = False, ) -> flask.wrappers.Response: - process_instance = _find_process_instance_by_id_or_raise(process_instance_id) - - if process_instance.status == ProcessInstanceStatus.suspended.value: - raise ApiError( - error_code="error_suspended", - message="The process instance is suspended", - status_code=400, - ) - - process_model = _get_process_model( - process_instance.process_model_identifier, + task_model = _get_task_model_for_request( + process_instance_id=process_instance_id, + task_guid=task_guid, + with_form_data=with_form_data, ) - - task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id) - task_definition = task_model.task_definition - - can_complete = False - try: - AuthorizationService.assert_user_can_complete_task(process_instance.id, task_model.guid, g.user) - can_complete = True - except ( - HumanTaskNotFoundError, - UserDoesNotHaveAccessToTaskError, - HumanTaskAlreadyCompletedError, - ): - can_complete = False - - task_model.process_model_display_name = process_model.display_name - task_model.process_model_identifier = process_model.id - task_model.typename = task_definition.typename - task_model.can_complete = can_complete - task_model.name_for_display = TaskService.get_name_for_display(task_definition) - extensions = TaskService.get_extensions_from_task_model(task_model) - - if with_form_data: - task_process_identifier = task_model.bpmn_process.bpmn_process_definition.bpmn_identifier - process_model_with_form = process_model - - refs = SpecFileService.get_references_for_process(process_model_with_form) - all_processes = [i.identifier for i in refs] - if task_process_identifier not in all_processes: - top_bpmn_process = TaskService.bpmn_process_for_called_activity_or_top_level_process(task_model) - bpmn_file_full_path = ProcessInstanceProcessor.bpmn_file_full_path_from_bpmn_process_identifier( - top_bpmn_process.bpmn_process_definition.bpmn_identifier - ) - relative_path = os.path.relpath(bpmn_file_full_path, start=FileSystemService.root_path()) - process_model_relative_path = os.path.dirname(relative_path) - process_model_with_form = ProcessModelService.get_process_model_from_relative_path(process_model_relative_path) - - form_schema_file_name = "" - form_ui_schema_file_name = "" - task_model.signal_buttons = TaskService.get_ready_signals_with_button_labels(process_instance_id, task_model.guid) - - if "properties" in extensions: - properties = extensions["properties"] - if "formJsonSchemaFilename" in properties: - form_schema_file_name = properties["formJsonSchemaFilename"] - if "formUiSchemaFilename" in properties: - form_ui_schema_file_name = properties["formUiSchemaFilename"] - - task_draft_data = TaskService.task_draft_data_from_task_model(task_model) - - saved_form_data = None - if task_draft_data is not None: - saved_form_data = task_draft_data.get_saved_form_data() - - task_model.data = task_model.get_data() - task_model.saved_form_data = saved_form_data - if task_definition.typename == "UserTask": - if not form_schema_file_name: - raise ( - ApiError( - error_code="missing_form_file", - message=f"Cannot find a form file for process_instance_id: {process_instance_id}, task_guid: {task_guid}", - status_code=400, - ) - ) - - form_dict = _prepare_form_data( - form_file=form_schema_file_name, - task_model=task_model, - process_model=process_model_with_form, - revision=process_instance.bpmn_version_control_identifier, - ) - _update_form_schema_with_task_data_as_needed(form_dict, task_model.data) - task_model.form_schema = form_dict - - if form_ui_schema_file_name: - ui_form_contents = _prepare_form_data( - form_file=form_ui_schema_file_name, - task_model=task_model, - process_model=process_model_with_form, - revision=process_instance.bpmn_version_control_identifier, - ) - task_model.form_ui_schema = ui_form_contents - else: - task_model.form_ui_schema = {} - _munge_form_ui_schema_based_on_hidden_fields_in_task_data(task_model.form_ui_schema, task_model.data) - - # it should be safe to add instructions to the task spec here since we are never commiting it back to the db - extensions["instructionsForEndUser"] = JinjaService.render_instructions_for_end_user(task_model, extensions) - - task_model.extensions = extensions - return make_response(jsonify(task_model), 200) @@ -963,76 +848,6 @@ def _get_tasks( return make_response(jsonify(response_json), 200) -# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches -def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None: - for k, value in in_dict.items(): - if "anyOf" == k: - # value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"] - if isinstance(value, list): - if len(value) == 1: - first_element_in_value_list = value[0] - if isinstance(first_element_in_value_list, str): - if first_element_in_value_list.startswith("options_from_task_data_var:"): - task_data_var = first_element_in_value_list.replace("options_from_task_data_var:", "") - - if task_data_var not in task_data: - message = ( - "Error building form. Attempting to create a selection list with options from" - f" variable '{task_data_var}' but it doesn't exist in the Task Data." - ) - raise ApiError( - error_code="missing_task_data_var", - message=message, - status_code=500, - ) - - select_options_from_task_data = task_data.get(task_data_var) - if select_options_from_task_data == []: - raise ApiError( - error_code="invalid_form_data", - message=( - "This form depends on variables, but at least one variable was empty. The" - f" variable '{task_data_var}' must be a list with at least one element." - ), - status_code=500, - ) - if isinstance(select_options_from_task_data, str): - raise ApiError( - error_code="invalid_form_data", - message=( - "This form depends on enum variables, but at least one variable was a string." - f" The variable '{task_data_var}' must be a list with at least one element." - ), - status_code=500, - ) - if isinstance(select_options_from_task_data, list): - if all("value" in d and "label" in d for d in select_options_from_task_data): - - def map_function( - task_data_select_option: TaskDataSelectOption, - ) -> ReactJsonSchemaSelectOption: - return { - "type": "string", - "enum": [task_data_select_option["value"]], - "title": task_data_select_option["label"], - } - - options_for_react_json_schema_form = list( - map( - map_function, - select_options_from_task_data, - ) - ) - - in_dict[k] = options_for_react_json_schema_form - elif isinstance(value, dict): - _update_form_schema_with_task_data_as_needed(value, task_data) - elif isinstance(value, list): - for o in value: - if isinstance(o, dict): - _update_form_schema_with_task_data_as_needed(o, task_data) - - def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any: potential_owner_usernames_from_group_concat_or_similar = func.group_concat(assigned_user.username.distinct()).label( "potential_owner_usernames" @@ -1045,30 +860,3 @@ def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any: ) return potential_owner_usernames_from_group_concat_or_similar - - -def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None: - if form_ui_schema is None: - return - if task_data and "form_ui_hidden_fields" in task_data: - hidden_fields = task_data["form_ui_hidden_fields"] - for hidden_field in hidden_fields: - hidden_field_parts = hidden_field.split(".") - relevant_depth_of_ui_schema = form_ui_schema - for ii, hidden_field_part in enumerate(hidden_field_parts): - if hidden_field_part not in relevant_depth_of_ui_schema: - relevant_depth_of_ui_schema[hidden_field_part] = {} - relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part] - if len(hidden_field_parts) == ii + 1: - relevant_depth_of_ui_schema["ui:widget"] = "hidden" - - -def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int) -> TaskModel: - task_model: TaskModel | None = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first() - if task_model is None: - raise ApiError( - error_code="task_not_found", - message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'", - status_code=400, - ) - return task_model diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_url_for_task_with_bpmn_identifier.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_url_for_task_with_bpmn_identifier.py index 9e61db206..13d1f0103 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_url_for_task_with_bpmn_identifier.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_url_for_task_with_bpmn_identifier.py @@ -39,7 +39,14 @@ class GetUrlForTaskWithBpmnIdentifier(Script): " therefore it does not have a url to retrieve" ) + public = True + public_segment = "" + if "public" in kwargs: + public = kwargs["public"] + if public is True: + public_segment = "/public" + guid = str(desired_spiff_task.id) fe_url = current_app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"] - url = f"{fe_url}/tasks/{script_attributes_context.process_instance_id}/{guid}" + url = f"{fe_url}{public_segment}/tasks/{script_attributes_context.process_instance_id}/{guid}" return url diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index a049ba187..16a6b7174 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -31,7 +31,6 @@ from spiffworkflow_backend.models.human_task import HumanTaskModel from spiffworkflow_backend.models.permission_assignment import PermissionAssignmentModel from spiffworkflow_backend.models.permission_target import PermissionTargetModel from spiffworkflow_backend.models.principal import PrincipalModel -from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.service_account import SPIFF_SERVICE_ACCOUNT_AUTH_SERVICE from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import SPIFF_GUEST_USER @@ -102,6 +101,7 @@ AUTHENTICATION_EXCLUSION_LIST = [ # these are api calls that are allowed to generate a public jwt when called PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [ + "spiffworkflow_backend.routes.public_controller.form_show", "spiffworkflow_backend.routes.public_controller.message_form_show", "spiffworkflow_backend.routes.public_controller.message_form_submit", ] @@ -332,9 +332,6 @@ class AuthorizationService: if cls.request_is_excluded_from_permission_check(): return None - if cls.request_allows_guest_access(decoded_token): - return None - cls.check_permission_for_request() @classmethod @@ -345,31 +342,6 @@ class AuthorizationService: return True return False - @classmethod - def request_allows_guest_access(cls, decoded_token: dict | None) -> bool: - if cls.request_is_excluded_from_permission_check(): - return True - - api_view_function = current_app.view_functions[request.endpoint] - if api_view_function.__name__ in ["task_show", "task_submit", "task_save_draft"]: - process_instance_id = int(request.path.split("/")[3]) - task_guid = request.path.split("/")[4] - if TaskModel.task_guid_allows_guest(task_guid, process_instance_id): - return True - - if ( - decoded_token is not None - and "process_instance_id" in decoded_token - and "only_guest_task_completion" in decoded_token - and decoded_token["only_guest_task_completion"] is True - and api_view_function.__name__ == "typeahead" - and api_view_function.__module__ == "spiffworkflow_backend.routes.connector_proxy_controller" - ): - process_instance = ProcessInstanceModel.query.filter_by(id=decoded_token["process_instance_id"]).first() - if process_instance is not None and not process_instance.has_terminal_status(): - return True - return False - @staticmethod def assert_user_can_complete_task( process_instance_id: int, @@ -817,6 +789,8 @@ class AuthorizationService: for group in group_permissions: group_identifier = group["name"] UserService.find_or_create_group(group_identifier) + if group_identifier == current_app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"]: + unique_user_group_identifiers.add(group_identifier) if not group_permissions_only: for username in group["users"]: if user_model and username != user_model.username: diff --git a/spiffworkflow-backend/tests/data/test-allow-guest/test_allow_guest.bpmn b/spiffworkflow-backend/tests/data/test-allow-guest/test_allow_guest.bpmn index f5ad4a500..24ba3dcbf 100644 --- a/spiffworkflow-backend/tests/data/test-allow-guest/test_allow_guest.bpmn +++ b/spiffworkflow-backend/tests/data/test-allow-guest/test_allow_guest.bpmn @@ -14,6 +14,7 @@ true You have completed the task. + We have instructions. Flow_14w7df0 Flow_02dvhev diff --git a/spiffworkflow-backend/tests/data/test-get-url-for-task-with-bpmn-identifier/test-get-url-for-task-with-bpmn-identifier-non-public.bpmn b/spiffworkflow-backend/tests/data/test-get-url-for-task-with-bpmn-identifier/test-get-url-for-task-with-bpmn-identifier-non-public.bpmn new file mode 100644 index 000000000..8f9d80c92 --- /dev/null +++ b/spiffworkflow-backend/tests/data/test-get-url-for-task-with-bpmn-identifier/test-get-url-for-task-with-bpmn-identifier-non-public.bpmn @@ -0,0 +1,51 @@ + + + + + Flow_1sk03xw + + + + Flow_1xw30wr + + + + Flow_1sk03xw + Flow_1ckjs49 + url = get_url_for_task_with_bpmn_identifier("manual_task", public=False) + + + Flow_1ckjs49 + Flow_1xw30wr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py index da05aba1f..147faeaf9 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_public_controller.py @@ -1,9 +1,11 @@ import json +import re from flask import Flask from flask.testing import FlaskClient from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict @@ -36,9 +38,14 @@ class TestPublicController(BaseTest): response = client.get(url) assert response.status_code == 200 assert response.json is not None - assert "form_schema" in response.json - assert "form_ui_schema" in response.json - assert response.json["form_schema"]["title"] == "Form for message start event" + assert "form" in response.json + assert "confirmation_message_markdown" in response.json + assert "task_guid" in response.json + assert "form_schema" in response.json["form"] + assert "form_ui_schema" in response.json["form"] + assert response.json["form"]["form_schema"]["title"] == "Form for message start event" + assert response.json["confirmation_message_markdown"] is None + assert response.json["task_guid"] is None def test_can_submit_to_public_message_submit( self, @@ -161,3 +168,102 @@ class TestPublicController(BaseTest): process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() assert process_instance.status == ProcessInstanceStatus.user_input_required.value + + def test_can_complete_complete_a_guest_task( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + admin_user = self.find_or_create_user("admin") + group_info: list[GroupPermissionsDict] = [ + { + "users": [], + "name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"], + "permissions": [{"actions": ["create", "read", "update"], "uri": "/public/*"}], + }, + { + "users": [admin_user.username], + "name": "admin", + "permissions": [{"actions": ["all"], "uri": "/*"}], + }, + ] + AuthorizationService.refresh_permissions(group_info) + + process_group_id = "my_process_group" + process_model_id = "test-allow-guest" + bpmn_file_location = "test-allow-guest" + process_model = self.create_group_and_model_with_bpmn( + client, + admin_user, + process_group_id=process_group_id, + process_model_id=process_model_id, + bpmn_file_location=bpmn_file_location, + ) + + admin_headers = self.logged_in_headers(admin_user) + response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, admin_headers) + assert response.json is not None + process_instance_id = response.json["id"] + + response = client.post( + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run", + headers=admin_headers, + ) + assert response.status_code == 200 + + task_model = TaskModel.query.filter_by(process_instance_id=process_instance_id, state="READY").first() + assert task_model is not None + first_task_guid = task_model.guid + + response = client.get( + f"/v1.0/public/tasks/{process_instance_id}/{first_task_guid}", + ) + assert response.status_code == 200 + assert response.json is not None + assert response.json["form"] == {"form_schema": None, "form_ui_schema": None, "instructions_for_end_user": ""} + assert response.json["confirmation_message_markdown"] is None + assert response.json["task_guid"] == first_task_guid + assert response.json["process_instance_id"] == process_instance_id + + headers_dict = dict(response.headers) + assert "Set-Cookie" in headers_dict + cookie = headers_dict["Set-Cookie"] + cookie_split = cookie.split(";") + access_token = [cookie for cookie in cookie_split if cookie.startswith("access_token=")][0] + assert access_token is not None + re_result = re.match(r"^access_token=[\w_\.-]+$", access_token) + assert re_result is not None + user_header = {"Authorization": "Bearer " + access_token.split("=")[1]} + + response = client.put( + f"/v1.0/public/tasks/{process_instance_id}/{first_task_guid}?execution_mode=synchronous", + data="{}", + content_type="application/json", + headers=user_header, + ) + assert response.status_code == 200 + assert response.json is not None + assert response.json["form"] == {"instructions_for_end_user": "We have instructions."} + assert response.json["confirmation_message_markdown"] is None + assert response.json["task_guid"] is not None + assert response.json["task_guid"] != first_task_guid + assert response.json["process_instance_id"] == process_instance_id + + second_task_guid = response.json["task_guid"] + response = client.put( + f"/v1.0/public/tasks/{process_instance_id}/{second_task_guid}?execution_mode=synchronous", + data="{}", + content_type="application/json", + headers=user_header, + ) + assert response.status_code == 200 + assert response.json is not None + assert response.json["form"] is None + assert response.json["confirmation_message_markdown"] == "You have completed the task." + assert response.json["task_guid"] is None + assert response.json["process_instance_id"] == process_instance_id + + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() + assert process_instance is not None + assert process_instance.status == ProcessInstanceStatus.complete.value diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py index 7896e10ef..22063aaee 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_tasks_controller.py @@ -441,86 +441,6 @@ class TestTasksController(BaseTest): assert response.json["saved_form_data"] is None assert response.json["data"]["HEY"] == draft_data["HEY"] - def test_can_complete_complete_a_guest_task( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - process_group_id = "my_process_group" - process_model_id = "test-allow-guest" - bpmn_file_location = "test-allow-guest" - process_model = self.create_group_and_model_with_bpmn( - client, - with_super_admin_user, - process_group_id=process_group_id, - process_model_id=process_model_id, - bpmn_file_location=bpmn_file_location, - ) - - headers = self.logged_in_headers(with_super_admin_user) - response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, headers) - assert response.json is not None - process_instance_id = response.json["id"] - - response = client.post( - f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run", - headers=self.logged_in_headers(with_super_admin_user), - ) - assert response.status_code == 200 - - task_model = TaskModel.query.filter_by(process_instance_id=process_instance_id, state="READY").first() - assert task_model is not None - task_guid = task_model.guid - - # log in a guest user to complete the tasks - redirect_url = f"{app.config['SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND']}/test-redirect-dne" - response = client.get( - f"/v1.0/login?process_instance_id={process_instance_id}&task_guid={task_guid}&redirect_url={redirect_url}&authentication_identifier=DOES_NOT_MATTER", - ) - assert response.status_code == 302 - assert response.location == redirect_url - headers_dict = dict(response.headers) - assert "Set-Cookie" in headers_dict - cookie = headers_dict["Set-Cookie"] - access_token = cookie.split(";")[0].split("=")[1] - assert access_token is not None - - # ensure guest user can get and complete both guest manual tasks - response = client.get( - f"/v1.0/tasks/{process_instance_id}/{task_guid}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == 200 - response = client.put( - f"/v1.0/tasks/{process_instance_id}/{task_guid}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == 200 - assert response.json is not None - task_guid = response.json["id"] - assert task_guid is not None - response = client.put( - f"/v1.0/tasks/{process_instance_id}/{task_guid}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == 200 - assert response.json is not None - assert "guest_confirmation" in response.json - - # ensure user gets logged out when they try to go anywhere else - response = client.get( - "/v1.0/tasks", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == 403 - headers_dict = dict(response.headers) - assert "Set-Cookie" in headers_dict - cookie = headers_dict["Set-Cookie"] - access_token = cookie.split(";")[0].split("=")[1] - assert access_token == "" - def test_task_instance_list( self, app: Flask, diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_url_for_task_with_bpmn_identifier.py b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_url_for_task_with_bpmn_identifier.py index 05eb86b42..ea512c96c 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_url_for_task_with_bpmn_identifier.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/scripts/test_get_url_for_task_with_bpmn_identifier.py @@ -21,6 +21,40 @@ class TestGetUrlForTaskWithBpmnIdentifier(BaseTest): process_model = load_test_spec( process_model_id="misc/test-get-url-for-task-with-bpmn-identifier", process_model_source_directory="test-get-url-for-task-with-bpmn-identifier", + bpmn_file_name="test-get-url-for-task-with-bpmn-identifier.bpmn", + ) + process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + + assert len(process_instance.active_human_tasks) == 1 + human_task = process_instance.active_human_tasks[0] + assert len(human_task.potential_owners) == 1 + assert human_task.potential_owners[0] == initiator_user + + spiff_task = processor.__class__.get_task_by_bpmn_identifier(human_task.task_name, processor.bpmn_process_instance) + ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task) + assert process_instance.status == ProcessInstanceStatus.complete.value + assert spiff_task is not None + assert "url" in spiff_task.data + + fe_url = app.config["SPIFFWORKFLOW_BACKEND_URL_FOR_FRONTEND"] + expected_url = f"{fe_url}/public/tasks/{process_instance.id}/{str(spiff_task.id)}" + assert spiff_task.data["url"] == expected_url + + def test_get_url_for_task_can_get_non_public_url( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + initiator_user = self.find_or_create_user("initiator_user") + assert initiator_user.principal is not None + AuthorizationService.import_permissions_from_yaml_file() + + process_model = load_test_spec( + process_model_id="misc/test-get-url-for-task-with-bpmn-identifier", + process_model_source_directory="test-get-url-for-task-with-bpmn-identifier", + bpmn_file_name="test-get-url-for-task-with-bpmn-identifier-non-public.bpmn", ) process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) processor = ProcessInstanceProcessor(process_instance) diff --git a/spiffworkflow-frontend/.gitignore b/spiffworkflow-frontend/.gitignore index b425f33a8..5bba7ec0d 100644 --- a/spiffworkflow-frontend/.gitignore +++ b/spiffworkflow-frontend/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* cypress/videos cypress/screenshots +cypress/downloads # i keep accidentally committing these /test*.json diff --git a/spiffworkflow-frontend/cypress/e2e/tasks.cy.js b/spiffworkflow-frontend/cypress/e2e/tasks.cy.js index 3aefc5935..f0ece168d 100644 --- a/spiffworkflow-frontend/cypress/e2e/tasks.cy.js +++ b/spiffworkflow-frontend/cypress/e2e/tasks.cy.js @@ -43,19 +43,6 @@ describe('tasks', () => { submitInputIntoFormField('get_form_num_two', '#root_form_num_2', 3); cy.contains('Task: get_form_num_three'); - // TODO: remove this if we decide to completely kill form navigation - // cy.getBySel('form-nav-form2').click(); - // checkFormFieldIsReadOnly( - // 'get_form_num_two', - // '#root_form_num_2' - // ); - // cy.getBySel('form-nav-form1').click(); - // checkFormFieldIsReadOnly( - // 'get_form_num_one', - // '#root_form_num_1' - // ); - // - // cy.getBySel('form-nav-form3').click(); submitInputIntoFormField('get_form_num_three', '#root_form_num_3', 4); cy.contains('Task: get_form_num_four'); @@ -98,17 +85,48 @@ describe('tasks', () => { cy.contains('Process Instance Id: '); cy.get('.process-instance-status').contains('complete'); }); - - // we no longer have a tasks table so these are actually covered in the process_instances test - // it('can paginate items', () => { - // // make sure we have some tasks - // kickOffModelWithForm(); - // kickOffModelWithForm(); - // kickOffModelWithForm(); - // kickOffModelWithForm(); - // kickOffModelWithForm(); - // - // cy.navigateToHome(); - // cy.basicPaginationTest('process-instance-show-link-id'); - // }); +}); + +describe('public_tasks', () => { + it('can start process from message form', () => { + // login and log out to ensure permissions are set correctly + cy.login(); + cy.logout(); + + cy.visit('public/misc:bounty_start_multiple_forms'); + cy.get('#root_firstName').type('MyFirstName'); + cy.contains('Submit').click(); + cy.get('#root_lastName').type('MyLastName'); + cy.contains('Submit').click(); + cy.contains('We hear you. Your name is MyFirstName MyLastName.'); + }); + + it('can complete a guest task', () => { + cy.login(); + const groupDisplayName = 'Shared Resources'; + const modelDisplayName = 'task-with-guest-form'; + cy.navigateToProcessModel(groupDisplayName, modelDisplayName); + cy.runPrimaryBpmnFile(false, false, false); + + cy.get('[data-qa="metadata-value-first_task_url"] a') + .invoke('attr', 'href') + .then((hrefValue) => { + cy.logout(); + cy.visit(hrefValue); + // form 1 + cy.contains('Submit').click(); + // form 2 + cy.contains('Submit').click(); + cy.contains('You are done. Yay!'); + cy.visit(hrefValue); + cy.contains('Error retrieving content.'); + cy.getBySel('public-home-link').click(); + cy.getBySel('public-sign-out').click(); + if (Cypress.env('SPIFFWORKFLOW_FRONTEND_AUTH_WITH_KEYCLOAK') === true) { + cy.contains('Sign in to your account'); + } else { + cy.get('#spiff-login-button').should('exist'); + } + }); + }); }); diff --git a/spiffworkflow-frontend/cypress/support/commands.js b/spiffworkflow-frontend/cypress/support/commands.js index 6143b9abd..063659002 100644 --- a/spiffworkflow-frontend/cypress/support/commands.js +++ b/spiffworkflow-frontend/cypress/support/commands.js @@ -107,7 +107,11 @@ Cypress.Commands.add('createModel', (groupId, modelId, modelDisplayName) => { // Intended to be run from the process model show page Cypress.Commands.add( 'runPrimaryBpmnFile', - (expectAutoRedirectToHumanTask = false, returnToProcessModelShow = true) => { + ( + expectAutoRedirectToHumanTask = false, + returnToProcessModelShow = true, + processInstanceExpectedToBeComplete = true + ) => { cy.getBySel('start-process-instance').click(); if (expectAutoRedirectToHumanTask) { // the url changes immediately, so also make sure we get some content from the next page, "Task:", @@ -117,7 +121,9 @@ Cypress.Commands.add( } else { cy.url().should('include', `/process-instances`); cy.contains('Process Instance Id'); - cy.contains('complete'); + if (processInstanceExpectedToBeComplete) { + cy.contains('complete'); + } if (returnToProcessModelShow) { cy.getBySel('process-model-breadcrumb-link').click(); cy.getBySel('process-model-show-permissions-loaded').should('exist'); diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index 8a9f46e93..39b4d0750 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -24,7 +24,7 @@ export default function App() {
- ; +
diff --git a/spiffworkflow-frontend/src/components/NavigationBar.tsx b/spiffworkflow-frontend/src/components/NavigationBar.tsx index 0659de415..9560151d4 100644 --- a/spiffworkflow-frontend/src/components/NavigationBar.tsx +++ b/spiffworkflow-frontend/src/components/NavigationBar.tsx @@ -312,7 +312,7 @@ export default function NavigationBar({ extensionUxElements }: OwnProps) { ); } - if (activeKey && ability && !UserService.onlyGuestTaskCompletion()) { + if (activeKey && ability) { return ( ( diff --git a/spiffworkflow-frontend/src/helpers.test.tsx b/spiffworkflow-frontend/src/helpers.test.tsx index 08e08e28e..4b2fff35a 100644 --- a/spiffworkflow-frontend/src/helpers.test.tsx +++ b/spiffworkflow-frontend/src/helpers.test.tsx @@ -3,6 +3,7 @@ import { slugifyString, underscorizeString, recursivelyChangeNullAndUndefined, + isURL, } from './helpers'; test('it can slugify a string', () => { @@ -95,3 +96,24 @@ test('it can replace null values in object with undefined', () => { expect(result.contacts.awesome).toEqual(false); expect(result.contacts.info).toEqual(''); }); + +test('it can identify urls', () => { + const urls = [ + 'http://localhost:7001/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233', + 'http://localhost/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233', + 'https://www.google.com', + ]; + urls.forEach((url: string) => { + const result = isURL(url); + expect(result).toBe(true); + }); + const badUrls = [ + 'localhost:7001/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233', + 'localhost/public/tasks/94/61efeb05-7278-4de8-979d-b4580cfc0233', + 'www.google.com', + ]; + badUrls.forEach((url: string) => { + const result = isURL(url); + expect(result).toBe(false); + }); +}); diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index 477b41e3d..1b0f2376d 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -299,7 +299,7 @@ export const parseTaskShowUrl = (url: string) => { export const isURL = (str: string) => { const urlRegex = // eslint-disable-next-line max-len - /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:[/?#]\S*)?$/i; + /^(?:(?:(?:https?|ftp):)?\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))?)(?::\d{2,5})?(?:[/?#]\S*)?$/i; return urlRegex.test(str); }; diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index e94da2afa..3bd181b86 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -509,7 +509,7 @@ export interface PublicTaskForm { form_ui_schema: any; instructions_for_end_user?: string; } -export interface PublicTaskSubmitResponse { +export interface PublicTask { form: PublicTaskForm; task_guid: string; process_instance_id: number; diff --git a/spiffworkflow-frontend/src/routes/Login.tsx b/spiffworkflow-frontend/src/routes/Login.tsx index 58366c5d6..26b75e361 100644 --- a/spiffworkflow-frontend/src/routes/Login.tsx +++ b/spiffworkflow-frontend/src/routes/Login.tsx @@ -5,7 +5,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { AuthenticationOption } from '../interfaces'; import HttpService from '../services/HttpService'; import UserService from '../services/UserService'; -import { parseTaskShowUrl } from '../helpers'; export default function Login() { const navigate = useNavigate(); @@ -13,9 +12,6 @@ export default function Login() { const [authenticationOptions, setAuthenticationOptions] = useState< AuthenticationOption[] | null >(null); - const [allowsGuestLogin, setAllowsGuestLogin] = useState( - null - ); const originalUrl = searchParams.get('original_url'); const getOriginalUrl = useCallback(() => { @@ -30,16 +26,6 @@ export default function Login() { path: '/authentication-options', successCallback: setAuthenticationOptions, }); - const pathSegments = parseTaskShowUrl(getOriginalUrl() || ''); - if (pathSegments) { - HttpService.makeCallToBackend({ - path: `/tasks/${pathSegments[1]}/${pathSegments[2]}/allows-guest`, - successCallback: (result: any) => - setAllowsGuestLogin(result.allows_guest), - }); - } else { - setAllowsGuestLogin(false); - } }, [getOriginalUrl]); const authenticationOptionButtons = () => { @@ -112,8 +98,8 @@ export default function Login() { ); } - if (authenticationOptions !== null && allowsGuestLogin !== null) { - if (allowsGuestLogin || authenticationOptions.length === 1) { + if (authenticationOptions !== null) { + if (authenticationOptions.length === 1) { UserService.doLogin(authenticationOptions[0], getOriginalUrl()); return null; } diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index 14f5b8b0d..34082c4a8 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -444,11 +444,11 @@ export default function ProcessInstanceShow({ variant }: OwnProps) { }); }; - const formatMetadataValue = (value: string) => { + const formatMetadataValue = (key: string, value: string) => { if (isURL(value)) { return ( - {value} + {key} link ); } @@ -560,7 +560,12 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
{truncateString(processInstanceMetadata.key, 50)}:
-
{formatMetadataValue(processInstanceMetadata.value)}
+
+ {formatMetadataValue( + processInstanceMetadata.key, + processInstanceMetadata.value + )} +
) )} diff --git a/spiffworkflow-frontend/src/routes/PublicRoutes.tsx b/spiffworkflow-frontend/src/routes/PublicRoutes.tsx index c32b50a87..3dfc96f24 100644 --- a/spiffworkflow-frontend/src/routes/PublicRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/PublicRoutes.tsx @@ -1,6 +1,6 @@ import { Content } from '@carbon/react'; import { Route, Routes } from 'react-router-dom'; -import MessageStartEventForm from './public/MessageStartEventForm'; +import PublicForm from './public/PublicForm'; import SignOut from './public/SignOut'; export default function PublicRoutes() { @@ -8,10 +8,11 @@ export default function PublicRoutes() { } + path="/tasks/:process_instance_id/:task_guid" + element={} /> - } /> + } /> + } /> ); diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 9c5088dd9..dda2471c4 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -24,8 +24,6 @@ import { import CustomForm from '../components/CustomForm'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import InstructionsForEndUser from '../components/InstructionsForEndUser'; -import UserService from '../services/UserService'; -import MarkdownRenderer from '../components/MarkdownRenderer'; export default function TaskShow() { // get a basic task which doesn't get the form data so we can load @@ -46,10 +44,6 @@ export default function TaskShow() { const navigate = useNavigate(); const [formButtonsDisabled, setFormButtonsDisabled] = useState(false); - const [guestConfirmationText, setGuestConfirmationText] = useState< - string | null - >(null); - const [taskData, setTaskData] = useState(null); const [autosaveOnFormChanges, setAutosaveOnFormChanges] = useState(true); @@ -62,15 +56,11 @@ export default function TaskShow() { // always work for them so use that since it will work in all cases const navigateToInterstitial = useCallback( (myTask: BasicTask) => { - if (UserService.onlyGuestTaskCompletion()) { - setGuestConfirmationText('Thank you!'); - } else { - navigate( - `/process-instances/for-me/${modifyProcessIdentifierForPathParam( - myTask.process_model_identifier - )}/${myTask.process_instance_id}/interstitial` - ); - } + navigate( + `/process-instances/for-me/${modifyProcessIdentifierForPathParam( + myTask.process_model_identifier + )}/${myTask.process_instance_id}/interstitial` + ); }, [navigate] ); @@ -190,12 +180,6 @@ export default function TaskShow() { removeError(); if (result.ok) { navigate(`/tasks`); - } else if ('guest_confirmation' in result) { - if (result.guest_confirmation) { - setGuestConfirmationText(result.guest_confirmation); - } else { - setGuestConfirmationText('Form submitted successfully'); - } } else if (result.process_instance_id) { if (result.can_complete) { navigate(`/tasks/${result.process_instance_id}/${result.id}`); @@ -361,10 +345,7 @@ export default function TaskShow() { if (taskWithTaskData.state === 'READY') { const submitButtonText = getSubmitButtonText(formUiSchema); let closeButton = null; - if ( - taskWithTaskData.typename === 'UserTask' && - !UserService.onlyGuestTaskCompletion() - ) { + if (taskWithTaskData.typename === 'UserTask') { closeButton = ( diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index 82d094856..86c6485d2 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -97,7 +97,7 @@ backendCallProps) => { if (onUnauthorized) { onUnauthorized(result); } else if (UserService.isPublicUser()) { - window.location.href = '/public/sign_out'; + window.location.href = '/public/sign-out'; } else { // Hopefully we can make this service a hook and use the error message context directly // eslint-disable-next-line no-alert diff --git a/spiffworkflow-frontend/src/services/UserService.ts b/spiffworkflow-frontend/src/services/UserService.ts index 41332748b..ac27a518c 100644 --- a/spiffworkflow-frontend/src/services/UserService.ts +++ b/spiffworkflow-frontend/src/services/UserService.ts @@ -122,15 +122,6 @@ const authenticationDisabled = () => { return false; }; -const onlyGuestTaskCompletion = () => { - const idToken = getIdToken(); - if (idToken) { - const idObject = jwt(idToken); - return (idObject as any).only_guest_task_completion; - } - return false; -}; - /** * Return prefered username * Somehow if using Google as the OpenID provider, the field `preferred_username` is not returned @@ -182,7 +173,6 @@ const UserService = { isLoggedIn, isPublicUser, loginIfNeeded, - onlyGuestTaskCompletion, }; export default UserService;