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 @@
+ Go to{' '} + + Home + +
+ > + ); + } + + if (confirmationMessage) { + return ( +