mirror of
https://github.com/sartography/spiff-arena.git
synced 2025-01-11 18:14:20 +00:00
Unauthed endpoint support (#1210)
* some basic updates for unauthed endpoints and the start of a test w/ burnettk * added logic to create public access token if appropriate w/ burnettk * updated message_form_show to return the rjs form w/ burnettk * pyl w/ burnettk * WIP: adding public routes to frontend w/ burnettk * added public message form page to start a process instance w/ burnettk * added api endpoint to submit message task data w/ burnettk * allow switching rjsf themes in customform w/ burnettk * we can submit a public message form w/ burnettk * add message start submit to public exclusion list w/ burnettk * run message submit in synchronous mode w/ burnettk * a little refactoring to get ready for submitting unauthed tasks w/ burnettk * created public controller w/ burnettk * added api endpoint to submit additional public forms w/ burnettk * added ability to submit a second form from the public web ui w/ burnettk * some clean up and show markdown confirmation messages w/ burnettk * added support for instructions and added a logout page for public users w/ burnettk * support instructions for end user on the start message event as well w/ burnettk * minor tweaks to public logout page w/ burnettk * pyl w/ burnettk * log unsupported form in custom form w/ burnettk --------- Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
parent
66067a89da
commit
9acd2954bb
@ -91,6 +91,11 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: backend_only
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.authentication_controller.logout
|
operationId: spiffworkflow_backend.routes.authentication_controller.logout
|
||||||
summary: Logout authenticated user
|
summary: Logout authenticated user
|
||||||
@ -909,11 +914,6 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: spiffworkflow_backend.routes.extensions_controller.extension_list
|
operationId: spiffworkflow_backend.routes.extensions_controller.extension_list
|
||||||
summary: Returns the list of available extensions
|
summary: Returns the list of available extensions
|
||||||
requestBody:
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
|
||||||
tags:
|
tags:
|
||||||
- Extensions
|
- Extensions
|
||||||
responses:
|
responses:
|
||||||
@ -2530,12 +2530,12 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Workflow"
|
$ref: "#/components/schemas/Workflow"
|
||||||
|
|
||||||
/messages/{message_name}:
|
/messages/{modified_message_name}:
|
||||||
parameters:
|
parameters:
|
||||||
- name: message_name
|
- name: modified_message_name
|
||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
description: The unique name of the message.
|
description: The message_name, modified to replace slashes (/) with colons
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
- name: execution_mode
|
- name: execution_mode
|
||||||
@ -2556,14 +2556,125 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Workflow"
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: One task
|
description: One task
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Workflow"
|
properties:
|
||||||
|
task_data:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
process_instance:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
|
||||||
|
/public/messages/form/{modified_message_name}:
|
||||||
|
parameters:
|
||||||
|
- name: modified_message_name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The message_name, modified to replace slashes (/) with colons
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Messages
|
||||||
|
operationId: spiffworkflow_backend.routes.public_controller.message_form_show
|
||||||
|
summary: Gets the form associated with the given message name.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: The json schema form.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
|
||||||
|
/public/messages/submit/{modified_message_name}:
|
||||||
|
parameters:
|
||||||
|
- name: modified_message_name
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The message_name, modified to replace slashes (/) with colons
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: execution_mode
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Either run in "synchronous" or "asynchronous" mode.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- synchronous
|
||||||
|
- asynchronous
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Messages
|
||||||
|
operationId: spiffworkflow_backend.routes.public_controller.message_form_submit
|
||||||
|
summary: Instantiate and run a given process model with a message start event matching given name
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: One task
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
task_data:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
process_instance:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
|
||||||
|
/public/tasks/{process_instance_id}/{task_guid}:
|
||||||
|
parameters:
|
||||||
|
- name: task_guid
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The unique id of an existing process group.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: process_instance_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
description: The unique id of an existing process instance.
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: execution_mode
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: Either run in "synchronous" or "asynchronous" mode.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- synchronous
|
||||||
|
- asynchronous
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- Tasks
|
||||||
|
operationId: spiffworkflow_backend.routes.public_controller.form_submit
|
||||||
|
summary: Update the form data for a tasks
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: One task
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Task"
|
||||||
|
"202":
|
||||||
|
description: "ok: true"
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/OkTrue"
|
||||||
|
|
||||||
/logs/{modified_process_model_identifier}/{process_instance_id}:
|
/logs/{modified_process_model_identifier}/{process_instance_id}:
|
||||||
parameters:
|
parameters:
|
||||||
@ -3775,9 +3886,9 @@ components:
|
|||||||
# it will fail validation and not pass the request to the controller. that is generally not desirable
|
# it will fail validation and not pass the request to the controller. that is generally not desirable
|
||||||
# until we take a closer look at the schemas in here.
|
# until we take a closer look at the schemas in here.
|
||||||
AwesomeUnspecifiedPayload:
|
AwesomeUnspecifiedPayload:
|
||||||
properties:
|
# we know that task_submit submits no body at all, and None is not an object, as this so helpfully tells us
|
||||||
anythingyouwant:
|
# type: "object"
|
||||||
type: string
|
additionalProperties: {}
|
||||||
ReportMetadata:
|
ReportMetadata:
|
||||||
properties:
|
properties:
|
||||||
columns:
|
columns:
|
||||||
|
@ -143,6 +143,7 @@ config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_ABSOLUTE_PATH")
|
|||||||
config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME")
|
config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME")
|
||||||
# FIXME: do not default this but we will need to coordinate release of it since it is a breaking change
|
# FIXME: do not default this but we will need to coordinate release of it since it is a breaking change
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_USER_GROUP", default="everybody")
|
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_USER_GROUP", default="everybody")
|
||||||
|
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP", default="spiff_public")
|
||||||
|
|
||||||
### sentry
|
### sentry
|
||||||
config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="")
|
config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="")
|
||||||
|
@ -18,6 +18,8 @@ groups:
|
|||||||
users: [dan@sartography.com]
|
users: [dan@sartography.com]
|
||||||
group3:
|
group3:
|
||||||
users: [jon@sartography.com]
|
users: [jon@sartography.com]
|
||||||
|
spiff_public:
|
||||||
|
users: []
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
admin:
|
admin:
|
||||||
@ -43,3 +45,8 @@ permissions:
|
|||||||
groups: [group3]
|
groups: [group3]
|
||||||
actions: [read]
|
actions: [read]
|
||||||
uri: PG:misc
|
uri: PG:misc
|
||||||
|
|
||||||
|
public_access:
|
||||||
|
groups: [spiff_public]
|
||||||
|
actions: [read, create]
|
||||||
|
uri: /public/*
|
||||||
|
@ -68,7 +68,7 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
|
|||||||
break
|
break
|
||||||
|
|
||||||
new_task = Task(
|
new_task = Task(
|
||||||
task.task_id,
|
task.task_guid,
|
||||||
task.task_name,
|
task.task_name,
|
||||||
task.task_title,
|
task.task_title,
|
||||||
task.task_type,
|
task.task_type,
|
||||||
|
@ -65,6 +65,13 @@ class MessageInstanceModel(SpiffworkflowBaseDBModel):
|
|||||||
def validate_status(self, key: str, value: Any) -> Any:
|
def validate_status(self, key: str, value: Any) -> Any:
|
||||||
return self.validate_enum_field(key, value, MessageStatuses)
|
return self.validate_enum_field(key, value, MessageStatuses)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def split_modified_message_name(cls, modified_message_name: str) -> tuple[str, str]:
|
||||||
|
message_name_array = modified_message_name.split(":")
|
||||||
|
message_name = message_name_array.pop()
|
||||||
|
process_group_identifier = "/".join(message_name_array)
|
||||||
|
return (message_name, process_group_identifier)
|
||||||
|
|
||||||
def correlates(self, other: Any, expression_engine: PythonScriptEngine) -> bool:
|
def correlates(self, other: Any, expression_engine: PythonScriptEngine) -> bool:
|
||||||
"""Returns true if the this Message correlates with the given message.
|
"""Returns true if the this Message correlates with the given message.
|
||||||
|
|
||||||
|
@ -199,15 +199,19 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
|
|||||||
def immediately_runnable_statuses(cls) -> list[str]:
|
def immediately_runnable_statuses(cls) -> list[str]:
|
||||||
return ["not_started", "running"]
|
return ["not_started", "running"]
|
||||||
|
|
||||||
def get_data(self) -> dict:
|
def get_last_completed_task(self) -> TaskModel | None:
|
||||||
"""Returns the data of the last completed task in this process instance."""
|
last_completed_task: TaskModel | None = (
|
||||||
last_completed_task = (
|
|
||||||
TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED")
|
TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED")
|
||||||
.order_by(desc(TaskModel.end_in_seconds)) # type: ignore
|
.order_by(desc(TaskModel.end_in_seconds)) # type: ignore
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
|
return last_completed_task
|
||||||
|
|
||||||
|
def get_data(self) -> dict:
|
||||||
|
"""Returns the data of the last completed task in this process instance."""
|
||||||
|
last_completed_task = self.get_last_completed_task()
|
||||||
if last_completed_task: # pragma: no cover
|
if last_completed_task: # pragma: no cover
|
||||||
return last_completed_task.json_data() # type: ignore
|
return last_completed_task.json_data()
|
||||||
else:
|
else:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import random
|
||||||
|
import string
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -91,3 +93,46 @@ class UserModel(SpiffworkflowBaseDBModel):
|
|||||||
user_as_json_string = current_app.json.dumps(self)
|
user_as_json_string = current_app.json.dumps(self)
|
||||||
user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string)
|
user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string)
|
||||||
return user_dict
|
return user_dict
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate_random_username(cls, prefix: str = "public") -> str:
|
||||||
|
adjectives = [
|
||||||
|
"fluffy",
|
||||||
|
"cuddly",
|
||||||
|
"tiny",
|
||||||
|
"joyful",
|
||||||
|
"sweet",
|
||||||
|
"gentle",
|
||||||
|
"cheerful",
|
||||||
|
"adorable",
|
||||||
|
"whiskered",
|
||||||
|
"silky",
|
||||||
|
]
|
||||||
|
animals = [
|
||||||
|
"panda",
|
||||||
|
"kitten",
|
||||||
|
"puppy",
|
||||||
|
"bunny",
|
||||||
|
"chick",
|
||||||
|
"duckling",
|
||||||
|
"chipmunk",
|
||||||
|
"hedgehog",
|
||||||
|
"lamb",
|
||||||
|
"fawn",
|
||||||
|
"otter",
|
||||||
|
"calf",
|
||||||
|
"penguin",
|
||||||
|
"koala",
|
||||||
|
"giraffe",
|
||||||
|
"monkey",
|
||||||
|
"fox",
|
||||||
|
"raccoon",
|
||||||
|
"squirrel",
|
||||||
|
"owl",
|
||||||
|
]
|
||||||
|
fuzz = "".join(random.SystemRandom().choice(string.ascii_lowercase + string.digits) for _ in range(7))
|
||||||
|
# this is not for cryptographic purposes
|
||||||
|
adjective = random.choice(adjectives) # noqa: S311
|
||||||
|
animal = random.choice(animals) # noqa: S311
|
||||||
|
username = f"{prefix}{adjective}{animal}{fuzz}"
|
||||||
|
return username
|
||||||
|
@ -18,12 +18,14 @@ from spiffworkflow_backend.exceptions.error import TokenExpiredError
|
|||||||
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
|
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_GUEST_GROUP
|
||||||
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_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.service_account import ServiceAccountModel
|
||||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
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_GUEST_USER
|
||||||
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
|
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
|
||||||
from spiffworkflow_backend.models.user import UserModel
|
from spiffworkflow_backend.models.user import UserModel
|
||||||
from spiffworkflow_backend.services.authentication_service import AuthenticationService
|
from spiffworkflow_backend.services.authentication_service import AuthenticationService
|
||||||
|
from spiffworkflow_backend.services.authorization_service import PUBLIC_AUTHENTICATION_EXCLUSION_LIST
|
||||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||||
from spiffworkflow_backend.services.user_service import UserService
|
from spiffworkflow_backend.services.user_service import UserService
|
||||||
|
|
||||||
@ -76,6 +78,13 @@ def verify_token(token: str | None = None, force_run: bool | None = False) -> di
|
|||||||
user_model = _get_user_model_from_token(decoded_token)
|
user_model = _get_user_model_from_token(decoded_token)
|
||||||
elif token_info["api_key"] is not None:
|
elif token_info["api_key"] is not None:
|
||||||
user_model = _get_user_model_from_api_key(token_info["api_key"])
|
user_model = _get_user_model_from_api_key(token_info["api_key"])
|
||||||
|
else:
|
||||||
|
# if there is no token in the request, hit the database to see if this path allows unauthed access
|
||||||
|
# we could choose to put all of the APIs that can be accessed unauthed behind a certain path.
|
||||||
|
# if we did that, we would not have to hit the db on *every* tokenless request
|
||||||
|
api_function_full_path, _ = AuthorizationService.get_fully_qualified_api_function_from_request()
|
||||||
|
if api_function_full_path and api_function_full_path in PUBLIC_AUTHENTICATION_EXCLUSION_LIST:
|
||||||
|
_check_if_request_is_public()
|
||||||
|
|
||||||
if user_model:
|
if user_model:
|
||||||
g.user = user_model
|
g.user = user_model
|
||||||
@ -213,13 +222,17 @@ def login_api_return(code: str, state: str, session_state: str) -> str:
|
|||||||
return access_token
|
return access_token
|
||||||
|
|
||||||
|
|
||||||
def logout(id_token: str, authentication_identifier: str, redirect_url: str | None) -> Response:
|
def logout(id_token: str, authentication_identifier: str, redirect_url: str | None, backend_only: bool = False) -> Response:
|
||||||
if redirect_url is None:
|
if redirect_url is None:
|
||||||
redirect_url = ""
|
redirect_url = ""
|
||||||
AuthenticationService.set_user_has_logged_out()
|
AuthenticationService.set_user_has_logged_out()
|
||||||
return AuthenticationService().logout(
|
|
||||||
redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier
|
if backend_only:
|
||||||
)
|
return redirect(redirect_url)
|
||||||
|
else:
|
||||||
|
return AuthenticationService().logout(
|
||||||
|
redirect_url=redirect_url, id_token=id_token, authentication_identifier=authentication_identifier
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def logout_return() -> Response:
|
def logout_return() -> Response:
|
||||||
@ -460,3 +473,26 @@ def _get_authentication_identifier_from_request() -> str:
|
|||||||
authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"]
|
authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"]
|
||||||
return authentication_identifier
|
return authentication_identifier
|
||||||
return "default"
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_if_request_is_public() -> None:
|
||||||
|
permission_string = AuthorizationService.get_permission_from_http_method(request.method)
|
||||||
|
if permission_string:
|
||||||
|
public_group = GroupModel.query.filter_by(
|
||||||
|
identifier=current_app.config.get("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP")
|
||||||
|
).first()
|
||||||
|
if public_group is not None:
|
||||||
|
has_permission = AuthorizationService.has_permission(
|
||||||
|
principals=[public_group.principal],
|
||||||
|
permission=permission_string,
|
||||||
|
target_uri=request.path,
|
||||||
|
)
|
||||||
|
if has_permission:
|
||||||
|
g.user = UserService.create_public_user()
|
||||||
|
g.token = g.user.encode_auth_token(
|
||||||
|
{"public": True},
|
||||||
|
)
|
||||||
|
tld = current_app.config["THREAD_LOCAL_DATA"]
|
||||||
|
tld.new_access_token = g.token
|
||||||
|
tld.new_id_token = g.token
|
||||||
|
tld.new_authentication_identifier = _get_authentication_identifier_from_request()
|
||||||
|
@ -1,15 +1,11 @@
|
|||||||
"""APIs for dealing with process groups, process models, and process instances."""
|
|
||||||
import json
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import flask.wrappers
|
import flask.wrappers
|
||||||
from flask import g
|
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
from flask.wrappers import Response
|
from flask.wrappers import Response
|
||||||
|
|
||||||
from spiffworkflow_backend import db
|
|
||||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
|
||||||
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
|
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
|
||||||
@ -63,43 +59,11 @@ def message_instance_list(
|
|||||||
# -H 'content-type: application/json' \
|
# -H 'content-type: application/json' \
|
||||||
# --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}'
|
# --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}'
|
||||||
def message_send(
|
def message_send(
|
||||||
message_name: str,
|
modified_message_name: str,
|
||||||
body: dict[str, Any],
|
body: dict[str, Any],
|
||||||
execution_mode: str | None = None,
|
execution_mode: str | None = None,
|
||||||
) -> flask.wrappers.Response:
|
) -> flask.wrappers.Response:
|
||||||
process_instance = None
|
receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode)
|
||||||
|
|
||||||
# Create the send message
|
|
||||||
message_instance = MessageInstanceModel(
|
|
||||||
message_type="send",
|
|
||||||
name=message_name,
|
|
||||||
payload=body,
|
|
||||||
user_id=g.user.id,
|
|
||||||
)
|
|
||||||
db.session.add(message_instance)
|
|
||||||
db.session.commit()
|
|
||||||
try:
|
|
||||||
receiver_message = MessageService.correlate_send_message(message_instance, execution_mode=execution_mode)
|
|
||||||
except Exception as e:
|
|
||||||
db.session.delete(message_instance)
|
|
||||||
db.session.commit()
|
|
||||||
raise e
|
|
||||||
if not receiver_message:
|
|
||||||
db.session.delete(message_instance)
|
|
||||||
db.session.commit()
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="message_not_accepted",
|
|
||||||
message=(
|
|
||||||
"No running process instances correlate with the given message"
|
|
||||||
f" name of '{message_name}'. And this message name is not"
|
|
||||||
" currently associated with any process Start Event. Nothing"
|
|
||||||
" to do."
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first()
|
process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first()
|
||||||
response_json = {
|
response_json = {
|
||||||
"task_data": process_instance.get_data(),
|
"task_data": process_instance.get_data(),
|
||||||
|
@ -1,18 +1,28 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import flask.wrappers
|
import flask.wrappers
|
||||||
|
import sentry_sdk
|
||||||
from flask import Blueprint
|
from flask import Blueprint
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from flask import g
|
from flask import g
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
from flask import make_response
|
from flask import make_response
|
||||||
from flask.wrappers import Response
|
from flask.wrappers import Response
|
||||||
|
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||||
|
from SpiffWorkflow.util.task import TaskState # type: ignore
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import (
|
||||||
|
queue_enabled_for_process_model,
|
||||||
|
)
|
||||||
|
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
|
||||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||||
from spiffworkflow_backend.exceptions.process_entity_not_found_error import ProcessEntityNotFoundError
|
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 import HumanTaskModel
|
||||||
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
||||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
from spiffworkflow_backend.models.principal import PrincipalModel
|
||||||
@ -21,12 +31,19 @@ from spiffworkflow_backend.models.process_instance_file_data import ProcessInsta
|
|||||||
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
||||||
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
|
from spiffworkflow_backend.models.reference_cache import ReferenceCacheModel
|
||||||
from spiffworkflow_backend.models.reference_cache import ReferenceSchema
|
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.services.authentication_service import AuthenticationService # noqa: F401
|
||||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||||
|
from spiffworkflow_backend.services.git_service import GitCommandError
|
||||||
from spiffworkflow_backend.services.git_service import GitService
|
from spiffworkflow_backend.services.git_service import GitService
|
||||||
|
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||||
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
||||||
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
||||||
|
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.process_model_service import ProcessModelService
|
||||||
|
from spiffworkflow_backend.services.task_service import TaskModelError
|
||||||
|
from spiffworkflow_backend.services.task_service import TaskService
|
||||||
|
|
||||||
process_api_blueprint = Blueprint("process_api", __name__)
|
process_api_blueprint = Blueprint("process_api", __name__)
|
||||||
|
|
||||||
@ -357,3 +374,186 @@ def _get_process_model_for_instantiation(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
return process_model
|
return process_model
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_form_data(
|
||||||
|
form_file: str, process_model: ProcessModelInfo, task_model: TaskModel | None = None, revision: str | None = None
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
form_contents = GitService.get_file_contents_for_revision_if_git_revision(
|
||||||
|
process_model=process_model,
|
||||||
|
revision=revision,
|
||||||
|
file_name=form_file,
|
||||||
|
)
|
||||||
|
except GitCommandError as exception:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="git_error_loading_form",
|
||||||
|
message=(
|
||||||
|
f"Could not load form schema from: {form_file}. Was git history rewritten such that revision"
|
||||||
|
f" '{revision}' no longer exists? Error was: {str(exception)}"
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
) from exception
|
||||||
|
|
||||||
|
if task_model and task_model.data is not None:
|
||||||
|
try:
|
||||||
|
form_contents = JinjaService.render_jinja_template(form_contents, task=task_model)
|
||||||
|
except TaskModelError as wfe:
|
||||||
|
wfe.add_note(f"Error in Json Form File '{form_file}'")
|
||||||
|
api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe)
|
||||||
|
api_error.file_name = form_file
|
||||||
|
raise api_error from wfe
|
||||||
|
|
||||||
|
try:
|
||||||
|
# form_contents is a str
|
||||||
|
hot_dict: dict = json.loads(form_contents)
|
||||||
|
return hot_dict
|
||||||
|
except Exception as exception:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="error_loading_form",
|
||||||
|
message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
) from exception
|
||||||
|
|
||||||
|
|
||||||
|
def _task_submit_shared(
|
||||||
|
process_instance_id: int,
|
||||||
|
task_guid: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
execution_mode: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
principal = _find_principal_or_raise()
|
||||||
|
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
||||||
|
if not process_instance.can_submit_task():
|
||||||
|
raise ApiError(
|
||||||
|
error_code="process_instance_not_runnable",
|
||||||
|
message=(
|
||||||
|
f"Process Instance ({process_instance.id}) has status "
|
||||||
|
f"{process_instance.status} which does not allow tasks to be submitted."
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# we're dequeing twice in this function.
|
||||||
|
# tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception
|
||||||
|
# in the block causes the process instance to go into an error state. for example, when
|
||||||
|
# AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler,
|
||||||
|
# and the cost is not huge given that this function is not the most common code path in the world.
|
||||||
|
with ProcessInstanceQueueService.dequeued(process_instance):
|
||||||
|
ProcessInstanceMigrator.run(process_instance)
|
||||||
|
|
||||||
|
processor = ProcessInstanceProcessor(
|
||||||
|
process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle
|
||||||
|
)
|
||||||
|
spiff_task = _get_spiff_task_from_processor(task_guid, processor)
|
||||||
|
AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user)
|
||||||
|
|
||||||
|
if spiff_task.state != TaskState.READY:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="invalid_state",
|
||||||
|
message="You may not update a task unless it is in the READY state.",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
human_task = _find_human_task_or_raise(
|
||||||
|
process_instance_id=process_instance_id,
|
||||||
|
task_guid=task_guid,
|
||||||
|
only_tasks_that_can_be_completed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with sentry_sdk.start_span(op="task", description="complete_form_task"):
|
||||||
|
with ProcessInstanceQueueService.dequeued(process_instance):
|
||||||
|
ProcessInstanceService.complete_form_task(
|
||||||
|
processor=processor,
|
||||||
|
spiff_task=spiff_task,
|
||||||
|
data=body,
|
||||||
|
user=g.user,
|
||||||
|
human_task=human_task,
|
||||||
|
execution_mode=execution_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# currently task_model has the potential to be None. This should be removable once
|
||||||
|
# we backfill the human_task table for task_guid and make that column not nullable
|
||||||
|
task_model: TaskModel | None = human_task.task_model
|
||||||
|
if task_model is None:
|
||||||
|
task_model = TaskModel.query.filter_by(guid=human_task.task_id).first()
|
||||||
|
|
||||||
|
# delete draft data when we submit a task to ensure cycling back to the task contains the
|
||||||
|
# most up-to-date data
|
||||||
|
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
|
||||||
|
if task_draft_data is not None:
|
||||||
|
db.session.delete(task_draft_data)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id)
|
||||||
|
if next_human_task_assigned_to_me:
|
||||||
|
return {"next_task_assigned_to_me": HumanTaskModel.to_task(next_human_task_assigned_to_me)}
|
||||||
|
|
||||||
|
# a guest user completed a task, it has a guest_confirmation message to display to them,
|
||||||
|
# and there is nothing else for them to do
|
||||||
|
spiff_task_extensions = spiff_task.task_spec.extensions
|
||||||
|
if "guestConfirmation" in spiff_task_extensions and spiff_task_extensions["guestConfirmation"]:
|
||||||
|
guest_confirmation = JinjaService.render_jinja_template(spiff_task_extensions["guestConfirmation"], task_model)
|
||||||
|
return {"guest_confirmation": guest_confirmation}
|
||||||
|
|
||||||
|
if processor.next_task():
|
||||||
|
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
||||||
|
task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance)
|
||||||
|
return {"next_task": task}
|
||||||
|
|
||||||
|
# next_task always returns something, even if the instance is complete, so we never get here
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"process_model_identifier": process_instance.process_model_identifier,
|
||||||
|
"process_instance_id": process_instance_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_human_task_or_raise(
|
||||||
|
process_instance_id: int,
|
||||||
|
task_guid: str,
|
||||||
|
only_tasks_that_can_be_completed: bool = False,
|
||||||
|
) -> HumanTaskModel:
|
||||||
|
if only_tasks_that_can_be_completed:
|
||||||
|
human_task_query = HumanTaskModel.query.filter_by(
|
||||||
|
process_instance_id=process_instance_id,
|
||||||
|
task_id=task_guid,
|
||||||
|
completed=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid)
|
||||||
|
|
||||||
|
human_task: HumanTaskModel = human_task_query.first()
|
||||||
|
if human_task is None:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="no_human_task",
|
||||||
|
message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return human_task
|
||||||
|
|
||||||
|
|
||||||
|
def _get_spiff_task_from_processor(
|
||||||
|
task_guid: str,
|
||||||
|
processor: ProcessInstanceProcessor,
|
||||||
|
) -> SpiffTask:
|
||||||
|
task_uuid = uuid.UUID(task_guid)
|
||||||
|
spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid)
|
||||||
|
|
||||||
|
if spiff_task is None:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="empty_task",
|
||||||
|
message="Processor failed to obtain task.",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return spiff_task
|
||||||
|
@ -0,0 +1,172 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import flask.wrappers
|
||||||
|
from flask import g
|
||||||
|
from flask import jsonify
|
||||||
|
from flask import make_response
|
||||||
|
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.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 _prepare_form_data
|
||||||
|
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
|
||||||
|
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||||
|
from spiffworkflow_backend.services.message_service import MessageService
|
||||||
|
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
||||||
|
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
||||||
|
from spiffworkflow_backend.services.task_service import TaskService
|
||||||
|
|
||||||
|
|
||||||
|
def message_form_show(
|
||||||
|
modified_message_name: str,
|
||||||
|
) -> flask.wrappers.Response:
|
||||||
|
message_triggerable_process_model = MessageService.find_message_triggerable_process_model(modified_message_name)
|
||||||
|
|
||||||
|
process_instance = ProcessInstanceModel(
|
||||||
|
status=ProcessInstanceStatus.not_started.value,
|
||||||
|
process_initiator_id=None,
|
||||||
|
process_model_identifier=message_triggerable_process_model.process_model_identifier,
|
||||||
|
persistence_level="none",
|
||||||
|
)
|
||||||
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
start_tasks = processor.bpmn_process_instance.get_tasks(spec_class=StartEventMixin)
|
||||||
|
matching_start_tasks = [
|
||||||
|
t for t in start_tasks if t.task_spec.event_definition.name == message_triggerable_process_model.message_name
|
||||||
|
]
|
||||||
|
if len(matching_start_tasks) == 0:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="message_start_event_not_found",
|
||||||
|
message=(
|
||||||
|
f"Could not find a message start event for message '{message_triggerable_process_model.message_name}' in"
|
||||||
|
f" process model '{message_triggerable_process_model.process_model_identifier}'."
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
return make_response(jsonify(response_body), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def message_form_submit(
|
||||||
|
modified_message_name: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
execution_mode: str | None = None,
|
||||||
|
) -> flask.wrappers.Response:
|
||||||
|
receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode)
|
||||||
|
process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first()
|
||||||
|
next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance.id, g.user.id)
|
||||||
|
|
||||||
|
next_form_contents = None
|
||||||
|
task_guid = None
|
||||||
|
confirmation_message_markdown = None
|
||||||
|
if next_human_task_assigned_to_me:
|
||||||
|
task_guid = next_human_task_assigned_to_me.task_guid
|
||||||
|
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_human_task_assigned_to_me.task_guid, process_instance=process_instance
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
processor = ProcessInstanceProcessor(process_instance)
|
||||||
|
start_tasks = processor.bpmn_process_instance.get_tasks(spec_class=StartEventMixin, state=TaskState.COMPLETED)
|
||||||
|
matching_start_tasks = [t for t in start_tasks if t.task_spec.event_definition.name == receiver_message.name]
|
||||||
|
if len(matching_start_tasks) > 0:
|
||||||
|
spiff_task = matching_start_tasks[0]
|
||||||
|
task_model = TaskModel.query.filter_by(guid=str(spiff_task.id)).first()
|
||||||
|
spiff_task_extensions = spiff_task.task_spec.extensions
|
||||||
|
if "guestConfirmation" in spiff_task_extensions and spiff_task_extensions["guestConfirmation"]:
|
||||||
|
confirmation_message_markdown = JinjaService.render_jinja_template(
|
||||||
|
spiff_task.task_spec.extensions["guestConfirmation"], task_model
|
||||||
|
)
|
||||||
|
|
||||||
|
response_json = {
|
||||||
|
"form": next_form_contents,
|
||||||
|
"task_guid": task_guid,
|
||||||
|
"process_instance_id": process_instance.id,
|
||||||
|
"confirmation_message_markdown": confirmation_message_markdown,
|
||||||
|
}
|
||||||
|
return make_response(jsonify(response_json), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def form_submit(
|
||||||
|
process_instance_id: int,
|
||||||
|
task_guid: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
execution_mode: str | None = None,
|
||||||
|
) -> flask.wrappers.Response:
|
||||||
|
response_item = _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode)
|
||||||
|
|
||||||
|
next_form_contents = None
|
||||||
|
next_task_guid = None
|
||||||
|
if "next_task_assigned_to_me" in response_item:
|
||||||
|
next_task_assigned_to_me = response_item["next_task_assigned_to_me"]
|
||||||
|
process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first()
|
||||||
|
next_task_guid = 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
|
||||||
|
)
|
||||||
|
|
||||||
|
response_json = {
|
||||||
|
"form": next_form_contents,
|
||||||
|
"task_guid": next_task_guid,
|
||||||
|
"process_instance_id": process_instance_id,
|
||||||
|
"confirmation_message_markdown": response_item.get("guest_confirmation"),
|
||||||
|
}
|
||||||
|
return make_response(jsonify(response_json), 200)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_form_and_prepare_data(
|
||||||
|
process_model: ProcessModelInfo,
|
||||||
|
extensions: dict | None = None,
|
||||||
|
task_guid: str | None = None,
|
||||||
|
process_instance: ProcessInstanceModel | None = None,
|
||||||
|
) -> dict:
|
||||||
|
task_model = None
|
||||||
|
extension_list = extensions
|
||||||
|
if task_guid and process_instance:
|
||||||
|
task_model = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance.id).first()
|
||||||
|
task_model.data = task_model.json_data()
|
||||||
|
extension_list = TaskService.get_extensions_from_task_model(task_model)
|
||||||
|
|
||||||
|
revision = None
|
||||||
|
if process_instance:
|
||||||
|
revision = process_instance.bpmn_version_control_identifier
|
||||||
|
|
||||||
|
form_contents: dict = {}
|
||||||
|
if extension_list:
|
||||||
|
if "properties" in extension_list:
|
||||||
|
properties = extension_list["properties"]
|
||||||
|
if "formJsonSchemaFilename" in properties:
|
||||||
|
form_schema_file_name = properties["formJsonSchemaFilename"]
|
||||||
|
form_contents["form_schema"] = _prepare_form_data(
|
||||||
|
form_file=form_schema_file_name,
|
||||||
|
task_model=task_model,
|
||||||
|
process_model=process_model,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
if "formUiSchemaFilename" in properties:
|
||||||
|
form_ui_schema_file_name = properties["formUiSchemaFilename"]
|
||||||
|
form_contents["form_ui_schema"] = _prepare_form_data(
|
||||||
|
form_file=form_ui_schema_file_name,
|
||||||
|
task_model=task_model,
|
||||||
|
process_model=process_model,
|
||||||
|
revision=revision,
|
||||||
|
)
|
||||||
|
if "instructionsForEndUser" in extension_list and extension_list["instructionsForEndUser"]:
|
||||||
|
task_data = {}
|
||||||
|
if task_model is not None and task_model.data:
|
||||||
|
task_data = task_model.data
|
||||||
|
form_contents["instructions_for_end_user"] = JinjaService.render_jinja_template(
|
||||||
|
extension_list["instructionsForEndUser"], task_data=task_data
|
||||||
|
)
|
||||||
|
return form_contents
|
@ -1,6 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -20,15 +19,11 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
|
|||||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||||
from SpiffWorkflow.util.task import TaskState # type: ignore
|
from SpiffWorkflow.util.task import TaskState # type: ignore
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
from sqlalchemy import asc
|
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlalchemy.orm.util import AliasedClass
|
from sqlalchemy.orm.util import AliasedClass
|
||||||
|
|
||||||
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import (
|
|
||||||
queue_enabled_for_process_model,
|
|
||||||
)
|
|
||||||
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
|
from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator
|
||||||
from spiffworkflow_backend.exceptions.api_error import ApiError
|
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||||
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
|
from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError
|
||||||
@ -46,7 +41,6 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSc
|
|||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceTaskDataCannotBeUpdatedError
|
||||||
from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType
|
from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType
|
||||||
from spiffworkflow_backend.models.process_model import ProcessModelInfo
|
|
||||||
from spiffworkflow_backend.models.task import Task
|
from spiffworkflow_backend.models.task import Task
|
||||||
from spiffworkflow_backend.models.task import TaskModel
|
from spiffworkflow_backend.models.task import TaskModel
|
||||||
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel
|
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel
|
||||||
@ -58,11 +52,11 @@ 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_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 _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 _get_process_model
|
||||||
|
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.authorization_service import AuthorizationService
|
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||||
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
||||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
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
|
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||||
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
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 ProcessInstanceIsAlreadyLockedError
|
||||||
@ -71,7 +65,6 @@ from spiffworkflow_backend.services.process_instance_service import ProcessInsta
|
|||||||
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
|
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
|
||||||
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
from spiffworkflow_backend.services.process_model_service import ProcessModelService
|
||||||
from spiffworkflow_backend.services.spec_file_service import SpecFileService
|
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
|
from spiffworkflow_backend.services.task_service import TaskService
|
||||||
|
|
||||||
|
|
||||||
@ -557,7 +550,12 @@ def task_submit(
|
|||||||
execution_mode: str | None = None,
|
execution_mode: str | None = None,
|
||||||
) -> flask.wrappers.Response:
|
) -> flask.wrappers.Response:
|
||||||
with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"):
|
with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"):
|
||||||
return _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode)
|
response_item = _task_submit_shared(process_instance_id, task_guid, body, execution_mode=execution_mode)
|
||||||
|
if "next_task_assigned_to_me" in response_item:
|
||||||
|
response_item = response_item["next_task_assigned_to_me"]
|
||||||
|
elif "next_task" in response_item:
|
||||||
|
response_item = response_item["next_task"]
|
||||||
|
return make_response(jsonify(response_item), 200)
|
||||||
|
|
||||||
|
|
||||||
def process_instance_progress(
|
def process_instance_progress(
|
||||||
@ -567,7 +565,7 @@ def process_instance_progress(
|
|||||||
process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True)
|
process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True)
|
||||||
|
|
||||||
principal = _find_principal_or_raise()
|
principal = _find_principal_or_raise()
|
||||||
next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id)
|
next_human_task_assigned_to_me = TaskService.next_human_task_for_user(process_instance_id, principal.user_id)
|
||||||
if next_human_task_assigned_to_me:
|
if next_human_task_assigned_to_me:
|
||||||
response["task"] = HumanTaskModel.to_task(next_human_task_assigned_to_me)
|
response["task"] = HumanTaskModel.to_task(next_human_task_assigned_to_me)
|
||||||
# this may not catch all times we should redirect to instance show page
|
# this may not catch all times we should redirect to instance show page
|
||||||
@ -869,110 +867,6 @@ def task_save_draft(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _task_submit_shared(
|
|
||||||
process_instance_id: int,
|
|
||||||
task_guid: str,
|
|
||||||
body: dict[str, Any],
|
|
||||||
execution_mode: str | None = None,
|
|
||||||
) -> flask.wrappers.Response:
|
|
||||||
principal = _find_principal_or_raise()
|
|
||||||
process_instance = _find_process_instance_by_id_or_raise(process_instance_id)
|
|
||||||
if not process_instance.can_submit_task():
|
|
||||||
raise ApiError(
|
|
||||||
error_code="process_instance_not_runnable",
|
|
||||||
message=(
|
|
||||||
f"Process Instance ({process_instance.id}) has status "
|
|
||||||
f"{process_instance.status} which does not allow tasks to be submitted."
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# we're dequeing twice in this function.
|
|
||||||
# tried to wrap the whole block in one dequeue, but that has the confusing side-effect that every exception
|
|
||||||
# in the block causes the process instance to go into an error state. for example, when
|
|
||||||
# AuthorizationService.assert_user_can_complete_task raises. this would have been solvable, but this seems simpler,
|
|
||||||
# and the cost is not huge given that this function is not the most common code path in the world.
|
|
||||||
with ProcessInstanceQueueService.dequeued(process_instance):
|
|
||||||
ProcessInstanceMigrator.run(process_instance)
|
|
||||||
|
|
||||||
processor = ProcessInstanceProcessor(
|
|
||||||
process_instance, workflow_completed_handler=ProcessInstanceService.schedule_next_process_model_cycle
|
|
||||||
)
|
|
||||||
spiff_task = _get_spiff_task_from_process_instance(task_guid, process_instance, processor=processor)
|
|
||||||
AuthorizationService.assert_user_can_complete_task(process_instance.id, str(spiff_task.id), principal.user)
|
|
||||||
|
|
||||||
if spiff_task.state != TaskState.READY:
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="invalid_state",
|
|
||||||
message="You may not update a task unless it is in the READY state.",
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
human_task = _find_human_task_or_raise(
|
|
||||||
process_instance_id=process_instance_id,
|
|
||||||
task_guid=task_guid,
|
|
||||||
only_tasks_that_can_be_completed=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
with sentry_sdk.start_span(op="task", description="complete_form_task"):
|
|
||||||
with ProcessInstanceQueueService.dequeued(process_instance):
|
|
||||||
ProcessInstanceService.complete_form_task(
|
|
||||||
processor=processor,
|
|
||||||
spiff_task=spiff_task,
|
|
||||||
data=body,
|
|
||||||
user=g.user,
|
|
||||||
human_task=human_task,
|
|
||||||
execution_mode=execution_mode,
|
|
||||||
)
|
|
||||||
|
|
||||||
# currently task_model has the potential to be None. This should be removable once
|
|
||||||
# we backfill the human_task table for task_guid and make that column not nullable
|
|
||||||
task_model: TaskModel | None = human_task.task_model
|
|
||||||
if task_model is None:
|
|
||||||
task_model = TaskModel.query.filter_by(guid=human_task.task_id).first()
|
|
||||||
|
|
||||||
# delete draft data when we submit a task to ensure cycling back to the task contains the
|
|
||||||
# most up-to-date data
|
|
||||||
task_draft_data = TaskService.task_draft_data_from_task_model(task_model)
|
|
||||||
if task_draft_data is not None:
|
|
||||||
db.session.delete(task_draft_data)
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
next_human_task_assigned_to_me = _next_human_task_for_user(process_instance_id, principal.user_id)
|
|
||||||
if next_human_task_assigned_to_me:
|
|
||||||
return make_response(jsonify(HumanTaskModel.to_task(next_human_task_assigned_to_me)), 200)
|
|
||||||
|
|
||||||
# a guest user completed a task, it has a guest_confirmation message to display to them,
|
|
||||||
# and there is nothing else for them to do
|
|
||||||
spiff_task_extensions = spiff_task.task_spec.extensions
|
|
||||||
if (
|
|
||||||
"allowGuest" in spiff_task_extensions
|
|
||||||
and spiff_task_extensions["allowGuest"] == "true"
|
|
||||||
and "guestConfirmation" in spiff_task.task_spec.extensions
|
|
||||||
):
|
|
||||||
return make_response(jsonify({"guest_confirmation": spiff_task.task_spec.extensions["guestConfirmation"]}), 200)
|
|
||||||
|
|
||||||
if processor.next_task():
|
|
||||||
task = ProcessInstanceService.spiff_task_to_api_task(processor, processor.next_task())
|
|
||||||
task.process_model_uses_queued_execution = queue_enabled_for_process_model(process_instance)
|
|
||||||
return make_response(jsonify(task), 200)
|
|
||||||
|
|
||||||
# next_task always returns something, even if the instance is complete, so we never get here
|
|
||||||
return Response(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"process_model_identifier": process_instance.process_model_identifier,
|
|
||||||
"process_instance_id": process_instance_id,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
status=202,
|
|
||||||
mimetype="application/json",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_tasks(
|
def _get_tasks(
|
||||||
processes_started_by_user: bool = True,
|
processes_started_by_user: bool = True,
|
||||||
has_lane_assignment_id: bool = True,
|
has_lane_assignment_id: bool = True,
|
||||||
@ -1069,71 +963,6 @@ def _get_tasks(
|
|||||||
return make_response(jsonify(response_json), 200)
|
return make_response(jsonify(response_json), 200)
|
||||||
|
|
||||||
|
|
||||||
def _prepare_form_data(
|
|
||||||
form_file: str, task_model: TaskModel, process_model: ProcessModelInfo, revision: str | None = None
|
|
||||||
) -> dict:
|
|
||||||
if task_model.data is None:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
file_contents = GitService.get_file_contents_for_revision_if_git_revision(
|
|
||||||
process_model=process_model,
|
|
||||||
revision=revision,
|
|
||||||
file_name=form_file,
|
|
||||||
)
|
|
||||||
except GitCommandError as exception:
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="git_error_loading_form",
|
|
||||||
message=(
|
|
||||||
f"Could not load form schema from: {form_file}. Was git history rewritten such that revision"
|
|
||||||
f" '{revision}' no longer exists? Error was: {str(exception)}"
|
|
||||||
),
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
) from exception
|
|
||||||
|
|
||||||
try:
|
|
||||||
form_contents = JinjaService.render_jinja_template(file_contents, task=task_model)
|
|
||||||
except TaskModelError as wfe:
|
|
||||||
wfe.add_note(f"Error in Json Form File '{form_file}'")
|
|
||||||
api_error = ApiError.from_workflow_exception("instructions_error", str(wfe), exp=wfe)
|
|
||||||
api_error.file_name = form_file
|
|
||||||
raise api_error from wfe
|
|
||||||
|
|
||||||
try:
|
|
||||||
# form_contents is a str
|
|
||||||
hot_dict: dict = json.loads(form_contents)
|
|
||||||
return hot_dict
|
|
||||||
except Exception as exception:
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="error_loading_form",
|
|
||||||
message=f"Could not load form schema from: {form_file}. Error was: {str(exception)}",
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
) from exception
|
|
||||||
|
|
||||||
|
|
||||||
def _get_spiff_task_from_process_instance(
|
|
||||||
task_guid: str,
|
|
||||||
process_instance: ProcessInstanceModel,
|
|
||||||
processor: ProcessInstanceProcessor,
|
|
||||||
) -> SpiffTask:
|
|
||||||
task_uuid = uuid.UUID(task_guid)
|
|
||||||
spiff_task = processor.bpmn_process_instance.get_task_from_id(task_uuid)
|
|
||||||
|
|
||||||
if spiff_task is None:
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="empty_task",
|
|
||||||
message="Processor failed to obtain task.",
|
|
||||||
status_code=500,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return spiff_task
|
|
||||||
|
|
||||||
|
|
||||||
# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches
|
# 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:
|
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
|
||||||
for k, value in in_dict.items():
|
for k, value in in_dict.items():
|
||||||
@ -1218,32 +1047,6 @@ def _get_potential_owner_usernames(assigned_user: AliasedClass) -> Any:
|
|||||||
return potential_owner_usernames_from_group_concat_or_similar
|
return potential_owner_usernames_from_group_concat_or_similar
|
||||||
|
|
||||||
|
|
||||||
def _find_human_task_or_raise(
|
|
||||||
process_instance_id: int,
|
|
||||||
task_guid: str,
|
|
||||||
only_tasks_that_can_be_completed: bool = False,
|
|
||||||
) -> HumanTaskModel:
|
|
||||||
if only_tasks_that_can_be_completed:
|
|
||||||
human_task_query = HumanTaskModel.query.filter_by(
|
|
||||||
process_instance_id=process_instance_id,
|
|
||||||
task_id=task_guid,
|
|
||||||
completed=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
human_task_query = HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, task_id=task_guid)
|
|
||||||
|
|
||||||
human_task: HumanTaskModel = human_task_query.first()
|
|
||||||
if human_task is None:
|
|
||||||
raise (
|
|
||||||
ApiError(
|
|
||||||
error_code="no_human_task",
|
|
||||||
message=f"Cannot find a task to complete for task id '{task_guid}' and process instance {process_instance_id}.",
|
|
||||||
status_code=500,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return human_task
|
|
||||||
|
|
||||||
|
|
||||||
def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: dict | None, task_data: dict) -> None:
|
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:
|
if form_ui_schema is None:
|
||||||
return
|
return
|
||||||
@ -1269,14 +1072,3 @@ def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int)
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
return task_model
|
return task_model
|
||||||
|
|
||||||
|
|
||||||
def _next_human_task_for_user(process_instance_id: int, user_id: int) -> HumanTaskModel | None:
|
|
||||||
next_human_task: HumanTaskModel | None = (
|
|
||||||
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False)
|
|
||||||
.order_by(asc(HumanTaskModel.id)) # type: ignore
|
|
||||||
.join(HumanTaskUserModel)
|
|
||||||
.filter_by(user_id=user_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
return next_human_task
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
@ -77,27 +78,33 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [
|
|||||||
{"path": "/task-data", "relevant_permissions": ["read", "update"]},
|
{"path": "/task-data", "relevant_permissions": ["read", "update"]},
|
||||||
]
|
]
|
||||||
|
|
||||||
AUTHENTICATION_EXCLUSION_LIST = {
|
AUTHENTICATION_EXCLUSION_LIST = [
|
||||||
"authentication_begin": "spiffworkflow_backend.routes.service_tasks_controller",
|
"spiffworkflow_backend.routes.authentication_controller.authentication_options",
|
||||||
"authentication_callback": "spiffworkflow_backend.routes.service_tasks_controller",
|
"spiffworkflow_backend.routes.authentication_controller.login",
|
||||||
"authentication_options": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.authentication_controller.login_api_return",
|
||||||
"github_webhook_receive": "spiffworkflow_backend.routes.webhooks_controller",
|
"spiffworkflow_backend.routes.authentication_controller.login_return",
|
||||||
"login": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.authentication_controller.login_with_access_token",
|
||||||
"login_api_return": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.authentication_controller.logout",
|
||||||
"login_return": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.authentication_controller.logout_return",
|
||||||
"login_with_access_token": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.debug_controller.test_raise_error",
|
||||||
"logout": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.debug_controller.url_info",
|
||||||
"logout_return": "spiffworkflow_backend.routes.authentication_controller",
|
"spiffworkflow_backend.routes.health_controller.status",
|
||||||
"status": "spiffworkflow_backend.routes.health_controller",
|
"spiffworkflow_backend.routes.service_tasks_controller.authentication_begin",
|
||||||
"task_allows_guest": "spiffworkflow_backend.routes.tasks_controller",
|
"spiffworkflow_backend.routes.service_tasks_controller.authentication_callback",
|
||||||
"test_raise_error": "spiffworkflow_backend.routes.debug_controller",
|
"spiffworkflow_backend.routes.tasks_controller.task_allows_guest",
|
||||||
"url_info": "spiffworkflow_backend.routes.debug_controller",
|
"spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive",
|
||||||
"webhook": "spiffworkflow_backend.routes.webhooks_controller",
|
"spiffworkflow_backend.routes.webhooks_controller.webhook",
|
||||||
# swagger api calls
|
# swagger api calls
|
||||||
"console_ui_home": "connexion.apis.flask_api",
|
"connexion.apis.flask_api.console_ui_home",
|
||||||
"console_ui_static_files": "connexion.apis.flask_api",
|
"connexion.apis.flask_api.console_ui_static_files",
|
||||||
"get_json_spec": "connexion.apis.flask_api",
|
"connexion.apis.flask_api.get_json_spec",
|
||||||
}
|
]
|
||||||
|
|
||||||
|
# these are api calls that are allowed to generate a public jwt when called
|
||||||
|
PUBLIC_AUTHENTICATION_EXCLUSION_LIST = [
|
||||||
|
"spiffworkflow_backend.routes.public_controller.message_form_show",
|
||||||
|
"spiffworkflow_backend.routes.public_controller.message_form_submit",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class AuthorizationService:
|
class AuthorizationService:
|
||||||
@ -250,6 +257,17 @@ class AuthorizationService:
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return permission_assignment
|
return permission_assignment
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_fully_qualified_api_function_from_request(cls) -> tuple[str | None, Any]:
|
||||||
|
api_view_function = current_app.view_functions[request.endpoint]
|
||||||
|
module = inspect.getmodule(api_view_function)
|
||||||
|
api_function_name = api_view_function.__name__ if api_view_function else None
|
||||||
|
controller_name = module.__name__ if module is not None else None
|
||||||
|
function_full_path = None
|
||||||
|
if api_function_name:
|
||||||
|
function_full_path = f"{controller_name}.{api_function_name}"
|
||||||
|
return (function_full_path, module)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def should_disable_auth_for_request(cls) -> bool:
|
def should_disable_auth_for_request(cls) -> bool:
|
||||||
if request.method == "OPTIONS":
|
if request.method == "OPTIONS":
|
||||||
@ -262,17 +280,10 @@ class AuthorizationService:
|
|||||||
if not request.endpoint:
|
if not request.endpoint:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
api_view_function = current_app.view_functions[request.endpoint]
|
api_function_full_path, module = cls.get_fully_qualified_api_function_from_request()
|
||||||
module = inspect.getmodule(api_view_function)
|
|
||||||
api_function_name = api_view_function.__name__ if api_view_function else None
|
|
||||||
controller_name = module.__name__ if module is not None else None
|
|
||||||
if (
|
if (
|
||||||
api_function_name
|
api_function_full_path
|
||||||
and (
|
and (api_function_full_path in AUTHENTICATION_EXCLUSION_LIST)
|
||||||
api_function_name in AUTHENTICATION_EXCLUSION_LIST
|
|
||||||
and controller_name
|
|
||||||
and controller_name in AUTHENTICATION_EXCLUSION_LIST[api_function_name]
|
|
||||||
)
|
|
||||||
or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets
|
or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets
|
||||||
):
|
):
|
||||||
return True
|
return True
|
||||||
@ -292,6 +303,22 @@ class AuthorizationService:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_permission_for_request(cls) -> None:
|
||||||
|
permission_string = cls.get_permission_from_http_method(request.method)
|
||||||
|
if permission_string:
|
||||||
|
has_permission = cls.user_has_permission(
|
||||||
|
user=g.user,
|
||||||
|
permission=permission_string,
|
||||||
|
target_uri=request.path,
|
||||||
|
)
|
||||||
|
if has_permission:
|
||||||
|
return None
|
||||||
|
|
||||||
|
raise NotAuthorizedError(
|
||||||
|
f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def check_for_permission(cls, decoded_token: dict | None) -> None:
|
def check_for_permission(cls, decoded_token: dict | None) -> None:
|
||||||
if cls.should_disable_auth_for_request():
|
if cls.should_disable_auth_for_request():
|
||||||
@ -308,19 +335,7 @@ class AuthorizationService:
|
|||||||
if cls.request_allows_guest_access(decoded_token):
|
if cls.request_allows_guest_access(decoded_token):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
permission_string = cls.get_permission_from_http_method(request.method)
|
cls.check_permission_for_request()
|
||||||
if permission_string:
|
|
||||||
has_permission = AuthorizationService.user_has_permission(
|
|
||||||
user=g.user,
|
|
||||||
permission=permission_string,
|
|
||||||
target_uri=request.path,
|
|
||||||
)
|
|
||||||
if has_permission:
|
|
||||||
return None
|
|
||||||
|
|
||||||
raise NotAuthorizedError(
|
|
||||||
f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def request_is_excluded_from_permission_check(cls) -> bool:
|
def request_is_excluded_from_permission_check(cls) -> bool:
|
||||||
|
@ -38,12 +38,12 @@ class JinjaHelpers:
|
|||||||
|
|
||||||
class JinjaService:
|
class JinjaService:
|
||||||
@classmethod
|
@classmethod
|
||||||
def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask, extensions: dict | None = None) -> str:
|
def render_instructions_for_end_user(cls, task: TaskModel | SpiffTask | None = None, extensions: dict | None = None) -> str:
|
||||||
"""Assure any instructions for end user are processed for jinja syntax."""
|
"""Assure any instructions for end user are processed for jinja syntax."""
|
||||||
if extensions is None:
|
if extensions is None:
|
||||||
if isinstance(task, TaskModel):
|
if isinstance(task, TaskModel):
|
||||||
extensions = TaskService.get_extensions_from_task_model(task)
|
extensions = TaskService.get_extensions_from_task_model(task)
|
||||||
elif hasattr(task.task_spec, "extensions"):
|
elif task and hasattr(task.task_spec, "extensions"):
|
||||||
extensions = task.task_spec.extensions
|
extensions = task.task_spec.extensions
|
||||||
if extensions and "instructionsForEndUser" in extensions:
|
if extensions and "instructionsForEndUser" in extensions:
|
||||||
if extensions["instructionsForEndUser"]:
|
if extensions["instructionsForEndUser"]:
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import g
|
||||||
from SpiffWorkflow.bpmn import BpmnEvent # type: ignore
|
from SpiffWorkflow.bpmn import BpmnEvent # type: ignore
|
||||||
from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore
|
from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # type: ignore
|
||||||
from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore
|
from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin # type: ignore
|
||||||
@ -9,6 +11,7 @@ from spiffworkflow_backend.background_processing.celery_tasks.process_instance_t
|
|||||||
queue_process_instance_if_appropriate,
|
queue_process_instance_if_appropriate,
|
||||||
)
|
)
|
||||||
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import should_queue_process_instance
|
from spiffworkflow_backend.background_processing.celery_tasks.process_instance_task_producer import should_queue_process_instance
|
||||||
|
from spiffworkflow_backend.exceptions.api_error import ApiError
|
||||||
from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode
|
from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode
|
||||||
from spiffworkflow_backend.models.db import db
|
from spiffworkflow_backend.models.db import db
|
||||||
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
|
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
|
||||||
@ -145,40 +148,6 @@ class MessageService:
|
|||||||
|
|
||||||
return process_instance_receive
|
return process_instance_receive
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _cancel_non_matching_start_events(
|
|
||||||
cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel
|
|
||||||
) -> None:
|
|
||||||
"""Cancel any start event that does not match the start event that triggered this.
|
|
||||||
|
|
||||||
After that SpiffWorkflow and the WorkflowExecutionService can figure it out.
|
|
||||||
"""
|
|
||||||
start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin)
|
|
||||||
for start_task in start_tasks:
|
|
||||||
if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition):
|
|
||||||
start_task.cancel()
|
|
||||||
elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name:
|
|
||||||
start_task.cancel()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_process_instance_for_message_instance(
|
|
||||||
message_instance_receive: MessageInstanceModel,
|
|
||||||
) -> ProcessInstanceModel:
|
|
||||||
process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by(
|
|
||||||
id=message_instance_receive.process_instance_id
|
|
||||||
).first()
|
|
||||||
if process_instance_receive is None:
|
|
||||||
raise MessageServiceError(
|
|
||||||
(
|
|
||||||
(
|
|
||||||
"Process instance cannot be found for queued message:"
|
|
||||||
f" {message_instance_receive.id}. Tried with id"
|
|
||||||
f" {message_instance_receive.process_instance_id}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return process_instance_receive
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_message_receive(
|
def process_message_receive(
|
||||||
process_instance_receive: ProcessInstanceModel,
|
process_instance_receive: ProcessInstanceModel,
|
||||||
@ -221,3 +190,113 @@ class MessageService:
|
|||||||
message_instance_receive.status = MessageStatuses.completed.value
|
message_instance_receive.status = MessageStatuses.completed.value
|
||||||
db.session.add(message_instance_receive)
|
db.session.add(message_instance_receive)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def find_message_triggerable_process_model(cls, modified_message_name: str) -> MessageTriggerableProcessModel:
|
||||||
|
message_name, process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name)
|
||||||
|
potential_matches = MessageTriggerableProcessModel.query.filter_by(message_name=message_name).all()
|
||||||
|
actual_matches = []
|
||||||
|
for potential_match in potential_matches:
|
||||||
|
pgi, _ = potential_match.process_model_identifier.rsplit("/", 1)
|
||||||
|
if pgi.startswith(process_group_identifier):
|
||||||
|
actual_matches.append(potential_match)
|
||||||
|
|
||||||
|
if len(actual_matches) == 0:
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="message_triggerable_process_model_not_found",
|
||||||
|
message=(
|
||||||
|
f"Could not find a message triggerable process model for {modified_message_name} in the scope of group"
|
||||||
|
f" {process_group_identifier}"
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(actual_matches) > 1:
|
||||||
|
message_names = [f"{m.process_model_identifier} - {m.message_name}" for m in actual_matches]
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="multiple_message_triggerable_process_models_found",
|
||||||
|
message=f"Found {len(actual_matches)}. Expected 1. Found entries: {message_names}",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mtp: MessageTriggerableProcessModel = actual_matches[0]
|
||||||
|
return mtp
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def run_process_model_from_message(
|
||||||
|
cls,
|
||||||
|
modified_message_name: str,
|
||||||
|
body: dict[str, Any],
|
||||||
|
execution_mode: str | None = None,
|
||||||
|
) -> MessageInstanceModel:
|
||||||
|
message_name, _process_group_identifier = MessageInstanceModel.split_modified_message_name(modified_message_name)
|
||||||
|
|
||||||
|
# Create the send message
|
||||||
|
# TODO: support the full message id - including process group - in message instance
|
||||||
|
message_instance = MessageInstanceModel(
|
||||||
|
message_type="send",
|
||||||
|
name=message_name,
|
||||||
|
payload=body,
|
||||||
|
user_id=g.user.id,
|
||||||
|
)
|
||||||
|
db.session.add(message_instance)
|
||||||
|
db.session.commit()
|
||||||
|
try:
|
||||||
|
receiver_message = cls.correlate_send_message(message_instance, execution_mode=execution_mode)
|
||||||
|
except Exception as e:
|
||||||
|
db.session.delete(message_instance)
|
||||||
|
db.session.commit()
|
||||||
|
raise e
|
||||||
|
if not receiver_message:
|
||||||
|
db.session.delete(message_instance)
|
||||||
|
db.session.commit()
|
||||||
|
raise (
|
||||||
|
ApiError(
|
||||||
|
error_code="message_not_accepted",
|
||||||
|
message=(
|
||||||
|
"No running process instances correlate with the given message"
|
||||||
|
f" name of '{modified_message_name}'. And this message name is not"
|
||||||
|
" currently associated with any process Start Event. Nothing"
|
||||||
|
" to do."
|
||||||
|
),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return receiver_message
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _cancel_non_matching_start_events(
|
||||||
|
cls, processor_receive: ProcessInstanceProcessor, message_triggerable_process_model: MessageTriggerableProcessModel
|
||||||
|
) -> None:
|
||||||
|
"""Cancel any start event that does not match the start event that triggered this.
|
||||||
|
|
||||||
|
After that SpiffWorkflow and the WorkflowExecutionService can figure it out.
|
||||||
|
"""
|
||||||
|
start_tasks = processor_receive.bpmn_process_instance.get_tasks(spec_class=StartEventMixin)
|
||||||
|
for start_task in start_tasks:
|
||||||
|
if not isinstance(start_task.task_spec.event_definition, MessageEventDefinition):
|
||||||
|
start_task.cancel()
|
||||||
|
elif start_task.task_spec.event_definition.name != message_triggerable_process_model.message_name:
|
||||||
|
start_task.cancel()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_process_instance_for_message_instance(
|
||||||
|
message_instance_receive: MessageInstanceModel,
|
||||||
|
) -> ProcessInstanceModel:
|
||||||
|
process_instance_receive: ProcessInstanceModel = ProcessInstanceModel.query.filter_by(
|
||||||
|
id=message_instance_receive.process_instance_id
|
||||||
|
).first()
|
||||||
|
if process_instance_receive is None:
|
||||||
|
raise MessageServiceError(
|
||||||
|
(
|
||||||
|
(
|
||||||
|
"Process instance cannot be found for queued message:"
|
||||||
|
f" {message_instance_receive.id}. Tried with id"
|
||||||
|
f" {message_instance_receive.process_instance_id}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return process_instance_receive
|
||||||
|
@ -10,6 +10,7 @@ from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore
|
|||||||
from SpiffWorkflow.exceptions import WorkflowException # type: ignore
|
from SpiffWorkflow.exceptions import WorkflowException # type: ignore
|
||||||
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
|
||||||
from SpiffWorkflow.util.task import TaskState # type: ignore
|
from SpiffWorkflow.util.task import TaskState # type: ignore
|
||||||
|
from sqlalchemy import asc
|
||||||
|
|
||||||
from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel
|
from spiffworkflow_backend.models.bpmn_process import BpmnProcessModel
|
||||||
from spiffworkflow_backend.models.bpmn_process import BpmnProcessNotFoundError
|
from spiffworkflow_backend.models.bpmn_process import BpmnProcessNotFoundError
|
||||||
@ -17,6 +18,7 @@ from spiffworkflow_backend.models.bpmn_process_definition import BpmnProcessDefi
|
|||||||
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
|
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
|
||||||
from spiffworkflow_backend.models.db import db
|
from spiffworkflow_backend.models.db import db
|
||||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
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
|
from spiffworkflow_backend.models.json_data import JsonDataDict
|
||||||
from spiffworkflow_backend.models.json_data import JsonDataModel
|
from spiffworkflow_backend.models.json_data import JsonDataModel
|
||||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||||
@ -707,6 +709,17 @@ class TaskService:
|
|||||||
def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str:
|
def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str:
|
||||||
return entity.bpmn_name or entity.bpmn_identifier
|
return entity.bpmn_name or entity.bpmn_identifier
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def next_human_task_for_user(cls, process_instance_id: int, user_id: int) -> HumanTaskModel | None:
|
||||||
|
next_human_task: HumanTaskModel | None = (
|
||||||
|
HumanTaskModel.query.filter_by(process_instance_id=process_instance_id, completed=False)
|
||||||
|
.order_by(asc(HumanTaskModel.id)) # type: ignore
|
||||||
|
.join(HumanTaskUserModel)
|
||||||
|
.filter_by(user_id=user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return next_human_task
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]:
|
def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]:
|
||||||
top_level_workflow = spiff_task.workflow.top_workflow
|
top_level_workflow = spiff_task.workflow.top_workflow
|
||||||
|
@ -285,3 +285,12 @@ class UserService:
|
|||||||
user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id")
|
user = cls.create_user(username, "spiff_system_service", "spiff_system_service_id")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create_public_user(cls) -> UserModel:
|
||||||
|
username = UserModel.generate_random_username()
|
||||||
|
user = UserService.create_user(username, "spiff_public_service", username)
|
||||||
|
cls.add_user_to_group_or_add_to_waiting(
|
||||||
|
user.username, current_app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"]
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "Form for message start event",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["firstName"],
|
||||||
|
"properties": {
|
||||||
|
"firstName": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "First name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
{}
|
@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
|
||||||
|
<bpmn:collaboration id="Collaboration_1h79dxi">
|
||||||
|
<bpmn:participant id="Participant_04cn64m" processRef="Process_message_start_event_with_form_bkmtffv" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
<bpmn:process id="Process_message_start_event_with_form_bkmtffv" isExecutable="true">
|
||||||
|
<bpmn:endEvent id="EndEvent_1">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>The process instance completed successfully.</spiffworkflow:instructionsForEndUser>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_12pkbxb</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_17db3yp" sourceRef="message_start_event_one" targetRef="script_task_one" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_12pkbxb" sourceRef="script_task_one" targetRef="EndEvent_1" />
|
||||||
|
<bpmn:startEvent id="message_start_event_one" name="Message Start Event One" messageRef="[object Object]">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>## Enter your frist name</spiffworkflow:instructionsForEndUser>
|
||||||
|
<spiffworkflow:guestConfirmation># Thanks
|
||||||
|
|
||||||
|
We hear you. Your name is **{{incoming_request['firstName']}}**.</spiffworkflow:guestConfirmation>
|
||||||
|
<spiffworkflow:properties>
|
||||||
|
<spiffworkflow:property name="formJsonSchemaFilename" value="entry-form-schema.json" />
|
||||||
|
<spiffworkflow:property name="formUiSchemaFilename" value="entry-form-uischema.json" />
|
||||||
|
</spiffworkflow:properties>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:outgoing>Flow_17db3yp</bpmn:outgoing>
|
||||||
|
<bpmn:messageEventDefinition id="MessageEventDefinition_13ctnqx" messageRef="Message_1rfi4qj" />
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:scriptTask id="script_task_one" name="Script Task One">
|
||||||
|
<bpmn:incoming>Flow_17db3yp</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_12pkbxb</bpmn:outgoing>
|
||||||
|
<bpmn:script>a = 1</bpmn:script>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:message id="Message_1rfi4qj" name="bounty_start">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:messageVariable>incoming_request</spiffworkflow:messageVariable>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
</bpmn:message>
|
||||||
|
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1h79dxi">
|
||||||
|
<bpmndi:BPMNShape id="Participant_04cn64m_di" bpmnElement="Participant_04cn64m" isHorizontal="true">
|
||||||
|
<dc:Bounds x="40" y="52" width="600" height="250" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Event_14za570_di" bpmnElement="EndEvent_1">
|
||||||
|
<dc:Bounds x="432" y="159" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Event_0aasbbk_di" bpmnElement="message_start_event_one">
|
||||||
|
<dc:Bounds x="182" y="159" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel>
|
||||||
|
<dc:Bounds x="166" y="202" width="71" height="27" />
|
||||||
|
</bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_0mqmnb0_di" bpmnElement="script_task_one">
|
||||||
|
<dc:Bounds x="280" y="137" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_17db3yp_di" bpmnElement="Flow_17db3yp">
|
||||||
|
<di:waypoint x="218" y="177" />
|
||||||
|
<di:waypoint x="280" y="177" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_12pkbxb_di" bpmnElement="Flow_12pkbxb">
|
||||||
|
<di:waypoint x="380" y="177" />
|
||||||
|
<di:waypoint x="432" y="177" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"description": "",
|
||||||
|
"display_name": "message-start-event-with-form",
|
||||||
|
"exception_notification_addresses": [],
|
||||||
|
"fault_or_suspend_on_exception": "fault",
|
||||||
|
"metadata_extraction_paths": null,
|
||||||
|
"primary_file_name": "message-start-event-with-form.bpmn",
|
||||||
|
"primary_process_id": "Process_message_start_event_with_form_bkmtffv"
|
||||||
|
}
|
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"firstName": "Chuck",
|
||||||
|
"done": false
|
||||||
|
}
|
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"title": "Approval time",
|
||||||
|
"description": "Are we approving this?",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"firstName"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"done": {
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "Approved",
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"firstName": {
|
||||||
|
"ui:autofocus": true,
|
||||||
|
"ui:emptyValue": "",
|
||||||
|
"ui:placeholder": "ui:emptyValue causes this field to always be valid despite being required",
|
||||||
|
"ui:autocomplete": "family-name",
|
||||||
|
"ui:enableMarkdownInDescription": true,
|
||||||
|
"ui:description": "Make text **bold** or *italic*. Take a look at other options [here](https://probablyup.com/markdown-to-jsx/)."
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "Form for message start event",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["firstName"],
|
||||||
|
"properties": {
|
||||||
|
"firstName": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "First name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
{}
|
@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
|
||||||
|
<bpmn:collaboration id="Collaboration_1h79dxi">
|
||||||
|
<bpmn:participant id="Participant_04cn64m" processRef="Process_message_start_event_with_multiple_forms_bkmtffv" />
|
||||||
|
</bpmn:collaboration>
|
||||||
|
<bpmn:process id="Process_message_start_event_with_multiple_forms_bkmtffv" isExecutable="true">
|
||||||
|
<bpmn:laneSet id="LaneSet_0okk2le">
|
||||||
|
<bpmn:lane id="Lane_0n1tm41">
|
||||||
|
<bpmn:flowNodeRef>message_start_event_one</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>script_task_one</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>user_task_one</bpmn:flowNodeRef>
|
||||||
|
</bpmn:lane>
|
||||||
|
<bpmn:lane id="admin" name="admin">
|
||||||
|
<bpmn:flowNodeRef>Event_0c0wt46</bpmn:flowNodeRef>
|
||||||
|
<bpmn:flowNodeRef>admin_task</bpmn:flowNodeRef>
|
||||||
|
</bpmn:lane>
|
||||||
|
</bpmn:laneSet>
|
||||||
|
<bpmn:sequenceFlow id="Flow_17db3yp" sourceRef="message_start_event_one" targetRef="script_task_one" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_12pkbxb" sourceRef="script_task_one" targetRef="user_task_one" />
|
||||||
|
<bpmn:startEvent id="message_start_event_one" name="Message Start Event One" messageRef="[object Object]">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>## Enter your first name</spiffworkflow:instructionsForEndUser>
|
||||||
|
<spiffworkflow:properties>
|
||||||
|
<spiffworkflow:property name="formJsonSchemaFilename" value="entry-form-schema.json" />
|
||||||
|
<spiffworkflow:property name="formUiSchemaFilename" value="entry-form-uischema.json" />
|
||||||
|
</spiffworkflow:properties>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:outgoing>Flow_17db3yp</bpmn:outgoing>
|
||||||
|
<bpmn:messageEventDefinition id="MessageEventDefinition_13ctnqx" messageRef="Message_1rfi4qj" />
|
||||||
|
</bpmn:startEvent>
|
||||||
|
<bpmn:scriptTask id="script_task_one" name="Script Task One">
|
||||||
|
<bpmn:incoming>Flow_17db3yp</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_12pkbxb</bpmn:outgoing>
|
||||||
|
<bpmn:script>a = 1</bpmn:script>
|
||||||
|
</bpmn:scriptTask>
|
||||||
|
<bpmn:userTask id="user_task_one" name="User Task One">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:instructionsForEndUser>## Enter your last name {{incoming_request['firstName']}}</spiffworkflow:instructionsForEndUser>
|
||||||
|
<spiffworkflow:properties>
|
||||||
|
<spiffworkflow:property name="formJsonSchemaFilename" value="second-form-schema.json" />
|
||||||
|
<spiffworkflow:property name="formUiSchemaFilename" value="second-form-uischema.json" />
|
||||||
|
</spiffworkflow:properties>
|
||||||
|
<spiffworkflow:allowGuest>false</spiffworkflow:allowGuest>
|
||||||
|
<spiffworkflow:guestConfirmation># Thanks
|
||||||
|
|
||||||
|
We hear you. Your name is **{{incoming_request['firstName']}} {{lastName}}**.</spiffworkflow:guestConfirmation>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_12pkbxb</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_14h4dnh</bpmn:outgoing>
|
||||||
|
</bpmn:userTask>
|
||||||
|
<bpmn:endEvent id="Event_0c0wt46">
|
||||||
|
<bpmn:incoming>Flow_1aprws7</bpmn:incoming>
|
||||||
|
</bpmn:endEvent>
|
||||||
|
<bpmn:sequenceFlow id="Flow_1aprws7" sourceRef="admin_task" targetRef="Event_0c0wt46" />
|
||||||
|
<bpmn:sequenceFlow id="Flow_14h4dnh" sourceRef="user_task_one" targetRef="admin_task" />
|
||||||
|
<bpmn:userTask id="admin_task" name="Admin task">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:properties>
|
||||||
|
<spiffworkflow:property name="formJsonSchemaFilename" value="admin-task-schema.json" />
|
||||||
|
<spiffworkflow:property name="formUiSchemaFilename" value="admin-task-uischema.json" />
|
||||||
|
</spiffworkflow:properties>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
<bpmn:incoming>Flow_14h4dnh</bpmn:incoming>
|
||||||
|
<bpmn:outgoing>Flow_1aprws7</bpmn:outgoing>
|
||||||
|
</bpmn:userTask>
|
||||||
|
</bpmn:process>
|
||||||
|
<bpmn:message id="Message_1rfi4qj" name="bounty_start_multiple_forms">
|
||||||
|
<bpmn:extensionElements>
|
||||||
|
<spiffworkflow:messageVariable>incoming_request</spiffworkflow:messageVariable>
|
||||||
|
</bpmn:extensionElements>
|
||||||
|
</bpmn:message>
|
||||||
|
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||||
|
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Collaboration_1h79dxi">
|
||||||
|
<bpmndi:BPMNShape id="Participant_04cn64m_di" bpmnElement="Participant_04cn64m" isHorizontal="true">
|
||||||
|
<dc:Bounds x="40" y="52" width="600" height="370" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Lane_0w9549p_di" bpmnElement="admin" isHorizontal="true">
|
||||||
|
<dc:Bounds x="70" y="302" width="570" height="120" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Lane_0n1tm41_di" bpmnElement="Lane_0n1tm41" isHorizontal="true">
|
||||||
|
<dc:Bounds x="70" y="52" width="570" height="250" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Event_0aasbbk_di" bpmnElement="message_start_event_one">
|
||||||
|
<dc:Bounds x="132" y="159" width="36" height="36" />
|
||||||
|
<bpmndi:BPMNLabel>
|
||||||
|
<dc:Bounds x="116" y="202" width="71" height="27" />
|
||||||
|
</bpmndi:BPMNLabel>
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_0mqmnb0_di" bpmnElement="script_task_one">
|
||||||
|
<dc:Bounds x="230" y="137" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_0ziqn0f_di" bpmnElement="user_task_one">
|
||||||
|
<dc:Bounds x="400" y="137" width="100" height="80" />
|
||||||
|
<bpmndi:BPMNLabel />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Event_0c0wt46_di" bpmnElement="Event_0c0wt46">
|
||||||
|
<dc:Bounds x="542" y="342" width="36" height="36" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNShape id="Activity_1hiuuow_di" bpmnElement="admin_task">
|
||||||
|
<dc:Bounds x="400" y="320" width="100" height="80" />
|
||||||
|
</bpmndi:BPMNShape>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_17db3yp_di" bpmnElement="Flow_17db3yp">
|
||||||
|
<di:waypoint x="168" y="177" />
|
||||||
|
<di:waypoint x="230" y="177" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_12pkbxb_di" bpmnElement="Flow_12pkbxb">
|
||||||
|
<di:waypoint x="330" y="177" />
|
||||||
|
<di:waypoint x="400" y="177" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_1aprws7_di" bpmnElement="Flow_1aprws7">
|
||||||
|
<di:waypoint x="500" y="360" />
|
||||||
|
<di:waypoint x="542" y="360" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
<bpmndi:BPMNEdge id="Flow_14h4dnh_di" bpmnElement="Flow_14h4dnh">
|
||||||
|
<di:waypoint x="450" y="217" />
|
||||||
|
<di:waypoint x="450" y="320" />
|
||||||
|
</bpmndi:BPMNEdge>
|
||||||
|
</bpmndi:BPMNPlane>
|
||||||
|
</bpmndi:BPMNDiagram>
|
||||||
|
</bpmn:definitions>
|
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"description": "",
|
||||||
|
"display_name": "message-start-event-with-multiple-forms",
|
||||||
|
"exception_notification_addresses": [],
|
||||||
|
"fault_or_suspend_on_exception": "fault",
|
||||||
|
"metadata_extraction_paths": null,
|
||||||
|
"primary_file_name": "message-start-event-with-multiple-forms.bpmn",
|
||||||
|
"primary_process_id": "Process_message_start_event_with_multiple_forms_bkmtffv"
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"incoming_request": {
|
||||||
|
"firstName": "joe"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "Information request, part deux",
|
||||||
|
"description": "Hey, {{incoming_request['firstName']}}. Thanks for telling us who you are. Just one more field.",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"lastName": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Last name"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"lastName": {
|
||||||
|
"ui:autoFocus": true
|
||||||
|
}
|
||||||
|
}
|
@ -57,9 +57,7 @@ class BaseTest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def logged_in_headers(
|
def logged_in_headers(user: UserModel, extra_token_payload: dict | None = None) -> dict[str, str]:
|
||||||
user: UserModel, _redirect_url: str = "http://some/frontend/url", extra_token_payload: dict | None = None
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)}
|
return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)}
|
||||||
|
|
||||||
def create_group_and_model_with_bpmn(
|
def create_group_and_model_with_bpmn(
|
||||||
|
@ -14,6 +14,7 @@ from spiffworkflow_backend.services.service_account_service import ServiceAccoun
|
|||||||
from spiffworkflow_backend.services.user_service import UserService
|
from spiffworkflow_backend.services.user_service import UserService
|
||||||
|
|
||||||
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||||
|
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
|
||||||
|
|
||||||
|
|
||||||
class TestAuthentication(BaseTest):
|
class TestAuthentication(BaseTest):
|
||||||
@ -152,3 +153,51 @@ class TestAuthentication(BaseTest):
|
|||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert response.json is not None
|
assert response.json is not None
|
||||||
assert response.json["message"].startswith("InvalidRedirectUrlError:")
|
assert response.json["message"].startswith("InvalidRedirectUrlError:")
|
||||||
|
|
||||||
|
def test_can_access_public_endpoints_and_get_token(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
) -> None:
|
||||||
|
group_info: list[GroupPermissionsDict] = [
|
||||||
|
{
|
||||||
|
"users": [],
|
||||||
|
"name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"],
|
||||||
|
"permissions": [{"actions": ["create", "read"], "uri": "/public/*"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
AuthorizationService.refresh_permissions(group_info, group_permissions_only=True)
|
||||||
|
process_model = load_test_spec(
|
||||||
|
process_model_id="test_group/message-start-event-with-form",
|
||||||
|
process_model_source_directory="message-start-event-with-form",
|
||||||
|
)
|
||||||
|
process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1)
|
||||||
|
url = f"/v1.0/public/messages/form/{process_group_identifier}:bounty_start"
|
||||||
|
|
||||||
|
response = client.get(url)
|
||||||
|
assert response.status_code == 200
|
||||||
|
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
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": "Bearer " + access_token.split("=")[1]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
# make sure we do not create and set a new cookie with this request
|
||||||
|
headers_dict = dict(response.headers)
|
||||||
|
assert "Set-Cookie" not in headers_dict
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
"/v1.0/process-groups",
|
||||||
|
headers={"Authorization": "Bearer " + access_token.split("=")[1]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
@ -1,157 +0,0 @@
|
|||||||
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthorization(BaseTest):
|
|
||||||
pass
|
|
||||||
# def test_get_bearer_token(self, app: Flask) -> None:
|
|
||||||
# """Test_get_bearer_token."""
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# public_access_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# bearer_token = AuthenticationService.get_bearer_token(public_access_token)
|
|
||||||
# assert isinstance(public_access_token, str)
|
|
||||||
# assert isinstance(bearer_token, dict)
|
|
||||||
# assert "access_token" in bearer_token
|
|
||||||
# assert isinstance(bearer_token["access_token"], str)
|
|
||||||
# assert "refresh_token" in bearer_token
|
|
||||||
# assert isinstance(bearer_token["refresh_token"], str)
|
|
||||||
# assert "token_type" in bearer_token
|
|
||||||
# assert bearer_token["token_type"] == "Bearer"
|
|
||||||
# assert "scope" in bearer_token
|
|
||||||
# assert isinstance(bearer_token["scope"], str)
|
|
||||||
#
|
|
||||||
# def test_get_user_info_from_public_access_token(self, app: Flask) -> None:
|
|
||||||
# """Test_get_user_info_from_public_access_token."""
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# public_access_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# user_info = AuthenticationService.get_user_info_from_id_token(
|
|
||||||
# public_access_token
|
|
||||||
# )
|
|
||||||
# assert "sub" in user_info
|
|
||||||
# assert isinstance(user_info["sub"], str)
|
|
||||||
# assert len(user_info["sub"]) == 36
|
|
||||||
# assert "preferred_username" in user_info
|
|
||||||
# assert user_info["preferred_username"] == user_id
|
|
||||||
# assert "email" in user_info
|
|
||||||
# assert user_info["email"] == f"{user_id}@example.com"
|
|
||||||
#
|
|
||||||
# def test_introspect_token(self, app: Flask) -> None:
|
|
||||||
# """Test_introspect_token."""
|
|
||||||
# (
|
|
||||||
# keycloak_server_url,
|
|
||||||
# keycloak_client_id,
|
|
||||||
# keycloak_realm_name,
|
|
||||||
# keycloak_client_secret_key,
|
|
||||||
# ) = self.get_keycloak_constants(app)
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# basic_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# introspection = AuthenticationService.introspect_token(basic_token)
|
|
||||||
# assert isinstance(introspection, dict)
|
|
||||||
# assert introspection["typ"] == "Bearer"
|
|
||||||
# assert introspection["preferred_username"] == user_id
|
|
||||||
# assert introspection["client_id"] == "spiffworkflow-frontend"
|
|
||||||
#
|
|
||||||
# assert "resource_access" in introspection
|
|
||||||
# resource_access = introspection["resource_access"]
|
|
||||||
# assert isinstance(resource_access, dict)
|
|
||||||
#
|
|
||||||
# assert keycloak_client_id in resource_access
|
|
||||||
# client = resource_access[keycloak_client_id]
|
|
||||||
# assert "roles" in client
|
|
||||||
# roles = client["roles"]
|
|
||||||
#
|
|
||||||
# assert isinstance(roles, list)
|
|
||||||
# if user_id == "admin_1":
|
|
||||||
# assert len(roles) == 2
|
|
||||||
# for role in roles:
|
|
||||||
# assert role in ("User", "Admin")
|
|
||||||
# elif user_id == "admin_2":
|
|
||||||
# assert len(roles) == 1
|
|
||||||
# assert roles[0] == "User"
|
|
||||||
# elif user_id == "user_1" or user_id == "user_2":
|
|
||||||
# assert len(roles) == 2
|
|
||||||
# for role in roles:
|
|
||||||
# assert role in ("User", "Anonymous")
|
|
||||||
#
|
|
||||||
# def test_get_permission_by_token(self, app: Flask) -> None:
|
|
||||||
# """Test_get_permission_by_token."""
|
|
||||||
# output: dict = {}
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# output[user_id] = {}
|
|
||||||
# basic_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# permissions = AuthenticationService.get_permission_by_basic_token(
|
|
||||||
# basic_token
|
|
||||||
# )
|
|
||||||
# if isinstance(permissions, list):
|
|
||||||
# for permission in permissions:
|
|
||||||
# resource_name = permission["rsname"]
|
|
||||||
# output[user_id][resource_name] = {}
|
|
||||||
# # assert resource_name in resource_names
|
|
||||||
# # if resource_name == 'Process Groups' or resource_name == 'Process Models':
|
|
||||||
# if "scopes" in permission:
|
|
||||||
# scopes = permission["scopes"]
|
|
||||||
# output[user_id][resource_name]["scopes"] = scopes
|
|
||||||
#
|
|
||||||
# # if user_id == 'admin_1':
|
|
||||||
# # # assert len(permissions) == 3
|
|
||||||
# # for permission in permissions:
|
|
||||||
# # resource_name = permission['rsname']
|
|
||||||
# # # assert resource_name in resource_names
|
|
||||||
# # if resource_name == 'Process Groups' or resource_name == 'Process Models':
|
|
||||||
# # # assert len(permission['scopes']) == 4
|
|
||||||
# # for item in permission['scopes']:
|
|
||||||
# # # assert item in ('instantiate', 'read', 'update', 'delete')
|
|
||||||
# # ...
|
|
||||||
# # else:
|
|
||||||
# # # assert resource_name == 'Default Resource'
|
|
||||||
# # # assert 'scopes' not in permission
|
|
||||||
# # ...
|
|
||||||
# #
|
|
||||||
# # if user_id == 'admin_2':
|
|
||||||
# # # assert len(permissions) == 3
|
|
||||||
# # for permission in permissions:
|
|
||||||
# # resource_name = permission['rsname']
|
|
||||||
# # # assert resource_name in resource_names
|
|
||||||
# # if resource_name == 'Process Groups' or resource_name == 'Process Models':
|
|
||||||
# # # assert len(permission['scopes']) == 1
|
|
||||||
# # # assert permission['scopes'][0] == 'read'
|
|
||||||
# # ...
|
|
||||||
# # else:
|
|
||||||
# # # assert resource_name == 'Default Resource'
|
|
||||||
# # # assert 'scopes' not in permission
|
|
||||||
# # ...
|
|
||||||
# # else:
|
|
||||||
# # print(f"No Permissions: {permissions}")
|
|
||||||
# print("test_get_permission_by_token")
|
|
||||||
#
|
|
||||||
# def test_get_auth_status_for_resource_and_scope_by_token(self, app: Flask) -> None:
|
|
||||||
# """Test_get_auth_status_for_resource_and_scope_by_token."""
|
|
||||||
# resources = "Admin", "Process Groups", "Process Models"
|
|
||||||
# # scope = 'read'
|
|
||||||
# output: dict = {}
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# output[user_id] = {}
|
|
||||||
# basic_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# for resource in resources:
|
|
||||||
# output[user_id][resource] = {}
|
|
||||||
# for scope in "instantiate", "read", "update", "delete":
|
|
||||||
# auth_status = AuthenticationService.get_auth_status_for_resource_and_scope_by_token(
|
|
||||||
# basic_token, resource, scope
|
|
||||||
# )
|
|
||||||
# output[user_id][resource][scope] = auth_status
|
|
||||||
# print("test_get_auth_status_for_resource_and_scope_by_token")
|
|
||||||
#
|
|
||||||
# def test_get_permissions_by_token_for_resource_and_scope(self, app: Flask) -> None:
|
|
||||||
# """Test_get_permissions_by_token_for_resource_and_scope."""
|
|
||||||
# resource_names = "Default Resource", "Process Groups", "Process Models"
|
|
||||||
# output: dict = {}
|
|
||||||
# for user_id in ("user_1", "user_2", "admin_1", "admin_2"):
|
|
||||||
# output[user_id] = {}
|
|
||||||
# basic_token = self.get_public_access_token(user_id, user_id)
|
|
||||||
# for resource in resource_names:
|
|
||||||
# output[user_id][resource] = {}
|
|
||||||
# for scope in "instantiate", "read", "update", "delete":
|
|
||||||
# permissions = AuthenticationService.get_permissions_by_token_for_resource_and_scope(
|
|
||||||
# basic_token, resource, scope
|
|
||||||
# )
|
|
||||||
# output[user_id][resource][scope] = permissions
|
|
||||||
# print("test_get_permissions_by_token_for_resource_and_scope")
|
|
@ -0,0 +1,163 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
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.services.authorization_service import AuthorizationService
|
||||||
|
from spiffworkflow_backend.services.authorization_service import GroupPermissionsDict
|
||||||
|
|
||||||
|
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||||
|
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
|
||||||
|
|
||||||
|
|
||||||
|
class TestPublicController(BaseTest):
|
||||||
|
def test_can_get_a_form_from_message_start_event(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
) -> None:
|
||||||
|
group_info: list[GroupPermissionsDict] = [
|
||||||
|
{
|
||||||
|
"users": [],
|
||||||
|
"name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"],
|
||||||
|
"permissions": [{"actions": ["create", "read"], "uri": "/public/*"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
AuthorizationService.refresh_permissions(group_info, group_permissions_only=True)
|
||||||
|
process_model = load_test_spec(
|
||||||
|
process_model_id="test_group/message-start-event-with-form",
|
||||||
|
process_model_source_directory="message-start-event-with-form",
|
||||||
|
)
|
||||||
|
process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1)
|
||||||
|
url = f"/v1.0/public/messages/form/{process_group_identifier}:bounty_start"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
def test_can_submit_to_public_message_submit(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
) -> None:
|
||||||
|
group_info: list[GroupPermissionsDict] = [
|
||||||
|
{
|
||||||
|
"users": [],
|
||||||
|
"name": app.config["SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP"],
|
||||||
|
"permissions": [{"actions": ["create", "read"], "uri": "/public/*"}],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
AuthorizationService.refresh_permissions(group_info, group_permissions_only=True)
|
||||||
|
process_model = load_test_spec(
|
||||||
|
process_model_id="test_group/message-start-event-with-form",
|
||||||
|
process_model_source_directory="message-start-event-with-form",
|
||||||
|
)
|
||||||
|
process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1)
|
||||||
|
url = f"/v1.0/public/messages/submit/{process_group_identifier}:bounty_start?execution_mode=synchronous"
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
data=json.dumps(
|
||||||
|
{"firstName": "MyName"},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json is not None
|
||||||
|
assert "form" in response.json
|
||||||
|
assert "confirmation_message_markdown" in response.json
|
||||||
|
assert "task_guid" in response.json
|
||||||
|
assert response.json["form"] is None
|
||||||
|
assert response.json["confirmation_message_markdown"] == "# Thanks\n\nWe hear you. Your name is **MyName**."
|
||||||
|
assert response.json["task_guid"] is None
|
||||||
|
|
||||||
|
def test_can_submit_to_public_message_submit_and_get_and_submit_subsequent_form(
|
||||||
|
self,
|
||||||
|
app: Flask,
|
||||||
|
client: FlaskClient,
|
||||||
|
with_db_and_bpmn_file_cleanup: None,
|
||||||
|
) -> None:
|
||||||
|
user = self.find_or_create_user("testuser1")
|
||||||
|
admin_user = self.find_or_create_user("admin")
|
||||||
|
headers = self.logged_in_headers(user, extra_token_payload={"public": True})
|
||||||
|
group_info: list[GroupPermissionsDict] = [
|
||||||
|
{
|
||||||
|
"users": [user.username],
|
||||||
|
"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_model = load_test_spec(
|
||||||
|
process_model_id="test_group/message-start-event-with-multiple-forms",
|
||||||
|
process_model_source_directory="message-start-event-with-multiple-forms",
|
||||||
|
)
|
||||||
|
process_group_identifier, _ = process_model.modified_process_model_identifier().rsplit(":", 1)
|
||||||
|
initial_url = (
|
||||||
|
f"/v1.0/public/messages/submit/{process_group_identifier}:bounty_start_multiple_forms?execution_mode=synchronous"
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
initial_url,
|
||||||
|
data=json.dumps(
|
||||||
|
{"firstName": "MyName"},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json is not None
|
||||||
|
assert "form" in response.json
|
||||||
|
assert "confirmation_message_markdown" in response.json
|
||||||
|
assert "task_guid" in response.json
|
||||||
|
assert "process_instance_id" in response.json
|
||||||
|
assert response.json["form"] == {
|
||||||
|
"form_schema": {
|
||||||
|
"description": "Hey, MyName. Thanks for telling us who you are. Just one more field.",
|
||||||
|
"properties": {"lastName": {"title": "Last name", "type": "string"}},
|
||||||
|
"title": "Information request, part deux",
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"form_ui_schema": {"lastName": {"ui:autoFocus": True}},
|
||||||
|
"instructions_for_end_user": "## Enter your last name MyName",
|
||||||
|
}
|
||||||
|
assert response.json["confirmation_message_markdown"] is None
|
||||||
|
|
||||||
|
task_guid = response.json["task_guid"]
|
||||||
|
assert task_guid is not None
|
||||||
|
process_instance_id = response.json["process_instance_id"]
|
||||||
|
assert process_instance_id is not None
|
||||||
|
|
||||||
|
second_form_url = f"/v1.0/public/tasks/{process_instance_id}/{task_guid}?execution_mode=synchronous"
|
||||||
|
response = client.put(
|
||||||
|
second_form_url,
|
||||||
|
data=json.dumps(
|
||||||
|
{"lastName": "MyLastName"},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json is not None
|
||||||
|
assert "form" in response.json
|
||||||
|
assert "confirmation_message_markdown" in response.json
|
||||||
|
assert "task_guid" in response.json
|
||||||
|
assert "process_instance_id" in response.json
|
||||||
|
assert response.json["form"] is None
|
||||||
|
assert response.json["confirmation_message_markdown"] == "# Thanks\n\nWe hear you. Your name is **MyName MyLastName**."
|
||||||
|
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.status == ProcessInstanceStatus.user_input_required.value
|
@ -1,19 +1,41 @@
|
|||||||
import { defineAbility } from '@casl/ability';
|
import { defineAbility } from '@casl/ability';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';
|
||||||
import { AbilityContext } from './contexts/Can';
|
import { AbilityContext } from './contexts/Can';
|
||||||
import APIErrorProvider from './contexts/APIErrorContext';
|
import APIErrorProvider from './contexts/APIErrorContext';
|
||||||
import ContainerForExtensions from './ContainerForExtensions';
|
import ContainerForExtensions from './ContainerForExtensions';
|
||||||
|
import PublicRoutes from './routes/PublicRoutes';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const ability = defineAbility(() => {});
|
const ability = defineAbility(() => {});
|
||||||
return (
|
const routeComponents = () => {
|
||||||
<div className="cds--white">
|
return [
|
||||||
<APIErrorProvider>
|
{ path: 'public/*', element: <PublicRoutes /> },
|
||||||
<AbilityContext.Provider value={ability}>
|
{
|
||||||
<ContainerForExtensions />
|
path: '*',
|
||||||
</AbilityContext.Provider>
|
element: <ContainerForExtensions />,
|
||||||
</APIErrorProvider>
|
},
|
||||||
</div>
|
];
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const layout = () => {
|
||||||
|
return (
|
||||||
|
<div className="cds--white">
|
||||||
|
<APIErrorProvider>
|
||||||
|
<AbilityContext.Provider value={ability}>
|
||||||
|
<Outlet />;
|
||||||
|
</AbilityContext.Provider>
|
||||||
|
</APIErrorProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '*',
|
||||||
|
Component: layout,
|
||||||
|
children: routeComponents(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Content } from '@carbon/react';
|
import { Content } from '@carbon/react';
|
||||||
import { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
|
||||||
@ -100,15 +100,17 @@ export default function ContainerForExtensions() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const routeComponents = () => {
|
const routeComponents = () => {
|
||||||
return [
|
return (
|
||||||
{
|
<Routes>
|
||||||
path: '*',
|
<Route
|
||||||
element: <BaseRoutes extensionUxElements={extensionUxElements} />,
|
path="*"
|
||||||
},
|
element={<BaseRoutes extensionUxElements={extensionUxElements} />}
|
||||||
{ path: 'editor/*', element: <EditorRoutes /> },
|
/>
|
||||||
{ path: 'extensions/:page_identifier', element: <Extension /> },
|
<Route path="editor/*" element={<EditorRoutes />} />
|
||||||
{ path: 'login', element: <Login /> },
|
<Route path="extensions/:page_identifier" element={<Extension />} />
|
||||||
];
|
<Route path="login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendIsDownPage = () => {
|
const backendIsDownPage = () => {
|
||||||
@ -120,31 +122,20 @@ export default function ContainerForExtensions() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (backendIsUp) {
|
if (backendIsUp) {
|
||||||
return <Outlet />;
|
return routeComponents();
|
||||||
}
|
}
|
||||||
return backendIsDownPage();
|
return backendIsDownPage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const layout = () => {
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<NavigationBar extensionUxElements={extensionUxElements} />
|
||||||
<NavigationBar extensionUxElements={extensionUxElements} />
|
<Content className={contentClassName}>
|
||||||
<Content className={contentClassName}>
|
<ScrollToTop />
|
||||||
<ScrollToTop />
|
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
||||||
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
{innerComponents()}
|
||||||
{innerComponents()}
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</Content>
|
||||||
</Content>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: '*',
|
|
||||||
Component: layout,
|
|
||||||
children: routeComponents(),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return <RouterProvider router={router} />;
|
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,8 @@ import validator from '@rjsf/validator-ajv8';
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { RegistryFieldsType } from '@rjsf/utils';
|
import { RegistryFieldsType } from '@rjsf/utils';
|
||||||
import { Button } from '@carbon/react';
|
import { Button } from '@carbon/react';
|
||||||
import { Form } from '../rjsf/carbon_theme';
|
import { Form as MuiForm } from '@rjsf/mui';
|
||||||
|
import { Form as CarbonForm } from '../rjsf/carbon_theme';
|
||||||
import { DATE_RANGE_DELIMITER } from '../config';
|
import { DATE_RANGE_DELIMITER } from '../config';
|
||||||
import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
|
import DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
|
||||||
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
|
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
|
||||||
@ -29,6 +30,7 @@ type OwnProps = {
|
|||||||
noValidate?: boolean;
|
noValidate?: boolean;
|
||||||
restrictedWidth?: boolean;
|
restrictedWidth?: boolean;
|
||||||
submitButtonText?: string;
|
submitButtonText?: string;
|
||||||
|
reactJsonSchemaForm?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CustomForm({
|
export default function CustomForm({
|
||||||
@ -43,6 +45,7 @@ export default function CustomForm({
|
|||||||
noValidate = false,
|
noValidate = false,
|
||||||
restrictedWidth = false,
|
restrictedWidth = false,
|
||||||
submitButtonText,
|
submitButtonText,
|
||||||
|
reactJsonSchemaForm = 'carbon',
|
||||||
}: OwnProps) {
|
}: OwnProps) {
|
||||||
// set in uiSchema using the "ui:widget" key for a property
|
// set in uiSchema using the "ui:widget" key for a property
|
||||||
const rjsfWidgets = {
|
const rjsfWidgets = {
|
||||||
@ -444,24 +447,32 @@ export default function CustomForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const formProps = {
|
||||||
<Form
|
id,
|
||||||
id={id}
|
disabled,
|
||||||
disabled={disabled}
|
formData,
|
||||||
formData={formData}
|
onChange,
|
||||||
onChange={onChange}
|
onSubmit,
|
||||||
onSubmit={onSubmit}
|
schema,
|
||||||
schema={schema}
|
uiSchema,
|
||||||
uiSchema={uiSchema}
|
widgets: rjsfWidgets,
|
||||||
widgets={rjsfWidgets}
|
validator,
|
||||||
validator={validator}
|
customValidate,
|
||||||
customValidate={customValidate}
|
noValidate,
|
||||||
noValidate={noValidate}
|
fields: rjsfFields,
|
||||||
fields={rjsfFields}
|
templates: rjsfTemplates,
|
||||||
templates={rjsfTemplates}
|
omitExtraData: true,
|
||||||
omitExtraData
|
};
|
||||||
>
|
if (reactJsonSchemaForm === 'carbon') {
|
||||||
{childrenToUse}
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
</Form>
|
return <CarbonForm {...formProps}>{childrenToUse}</CarbonForm>;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (reactJsonSchemaForm === 'mui') {
|
||||||
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
|
return <MuiForm {...formProps}>{childrenToUse}</MuiForm>;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`Unsupported form type: ${reactJsonSchemaForm}`);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -55,6 +55,8 @@ export const getKeyByValue = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// NOTE: rjsf sets blanks values to undefined and JSON.stringify removes keys with undefined values
|
||||||
|
// so we convert undefined values to null recursively so that we can unset values in form fields
|
||||||
export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => {
|
export const recursivelyChangeNullAndUndefined = (obj: any, newValue: any) => {
|
||||||
if (obj === null || obj === undefined) {
|
if (obj === null || obj === undefined) {
|
||||||
return newValue;
|
return newValue;
|
||||||
|
@ -46,12 +46,14 @@ a.cds--header__menu-item {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1,
|
||||||
|
h1.MuiTypography-h1 {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
line-height: 36px;
|
line-height: 36px;
|
||||||
color: #161616;
|
color: #161616;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
letter-spacing: 0.00938em;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
|
@ -503,3 +503,15 @@ export interface ElementForArray {
|
|||||||
key: string;
|
key: string;
|
||||||
component: ReactElement | null;
|
component: ReactElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicTaskForm {
|
||||||
|
form_schema: any;
|
||||||
|
form_ui_schema: any;
|
||||||
|
instructions_for_end_user?: string;
|
||||||
|
}
|
||||||
|
export interface PublicTaskSubmitResponse {
|
||||||
|
form: PublicTaskForm;
|
||||||
|
task_guid: string;
|
||||||
|
process_instance_id: number;
|
||||||
|
confirmation_message_markdown: string;
|
||||||
|
}
|
||||||
|
@ -32,7 +32,7 @@ export default function BaseRoutes({ extensionUxElements }: OwnProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (extensionUxElements) {
|
if (extensionUxElements !== null) {
|
||||||
const extensionRoutes = ExtensionUxElementMap({
|
const extensionRoutes = ExtensionUxElementMap({
|
||||||
displayLocation: 'routes',
|
displayLocation: 'routes',
|
||||||
elementCallback,
|
elementCallback,
|
||||||
|
18
spiffworkflow-frontend/src/routes/PublicRoutes.tsx
Normal file
18
spiffworkflow-frontend/src/routes/PublicRoutes.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Content } from '@carbon/react';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import MessageStartEventForm from './public/MessageStartEventForm';
|
||||||
|
import SignOut from './public/SignOut';
|
||||||
|
|
||||||
|
export default function PublicRoutes() {
|
||||||
|
return (
|
||||||
|
<Content className="main-site-body-centered">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/:modified_message_name"
|
||||||
|
element={<MessageStartEventForm />}
|
||||||
|
/>
|
||||||
|
<Route path="/sign_out" element={<SignOut />} />
|
||||||
|
</Routes>
|
||||||
|
</Content>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,127 @@
|
|||||||
|
import { Column, Grid, Loading } from '@carbon/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import HttpService from '../../services/HttpService';
|
||||||
|
import CustomForm from '../../components/CustomForm';
|
||||||
|
import Page404 from '../Page404';
|
||||||
|
import { recursivelyChangeNullAndUndefined } from '../../helpers';
|
||||||
|
import useAPIError from '../../hooks/UseApiError';
|
||||||
|
import { PublicTaskForm, PublicTaskSubmitResponse } from '../../interfaces';
|
||||||
|
import MarkdownRenderer from '../../components/MarkdownRenderer';
|
||||||
|
import InstructionsForEndUser from '../../components/InstructionsForEndUser';
|
||||||
|
|
||||||
|
export default function MessageStartEventForm() {
|
||||||
|
const params = useParams();
|
||||||
|
const [formContents, setFormContents] = useState<PublicTaskForm | null>(null);
|
||||||
|
const [taskData, setTaskData] = useState<any>(null);
|
||||||
|
const [formButtonsDisabled, setFormButtonsDisabled] = useState(false);
|
||||||
|
const { addError, removeError } = useAPIError();
|
||||||
|
const [confirmationMessage, setConfirmationMessage] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [taskSubmitResponse, setTaskSubmitResponse] =
|
||||||
|
useState<PublicTaskSubmitResponse | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
HttpService.makeCallToBackend({
|
||||||
|
path: `/public/messages/form/${params.modified_message_name}`,
|
||||||
|
successCallback: (result: PublicTaskForm) => setFormContents(result),
|
||||||
|
failureCallback: (error: any) => {
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [params.modified_message_name]);
|
||||||
|
|
||||||
|
const processSubmitResult = (result: PublicTaskSubmitResponse) => {
|
||||||
|
removeError();
|
||||||
|
setFormButtonsDisabled(false);
|
||||||
|
setTaskSubmitResponse(result);
|
||||||
|
if (result.form) {
|
||||||
|
setFormContents(result.form);
|
||||||
|
} else if (result.confirmation_message_markdown) {
|
||||||
|
setConfirmationMessage(result.confirmation_message_markdown);
|
||||||
|
} else {
|
||||||
|
setConfirmationMessage('Thank you!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFormSubmit = (formObject: any, _event: any) => {
|
||||||
|
if (formButtonsDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataToSubmit = formObject?.formData;
|
||||||
|
|
||||||
|
setFormButtonsDisabled(true);
|
||||||
|
removeError();
|
||||||
|
|
||||||
|
// removing form contents will force the loading icon to appear.
|
||||||
|
// we could also set a state for it at some point but this seemed
|
||||||
|
// like a way to reduce states.
|
||||||
|
setFormContents(null);
|
||||||
|
|
||||||
|
recursivelyChangeNullAndUndefined(dataToSubmit, null);
|
||||||
|
let path = `/public/messages/submit/${params.modified_message_name}`;
|
||||||
|
let httpMethod = 'POST';
|
||||||
|
if (
|
||||||
|
taskSubmitResponse?.task_guid &&
|
||||||
|
taskSubmitResponse?.process_instance_id
|
||||||
|
) {
|
||||||
|
path = `/public/tasks/${taskSubmitResponse.process_instance_id}/${taskSubmitResponse.task_guid}`;
|
||||||
|
httpMethod = 'PUT';
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpService.makeCallToBackend({
|
||||||
|
path: `${path}?execution_mode=synchronous`,
|
||||||
|
successCallback: processSubmitResult,
|
||||||
|
failureCallback: (error: any) => {
|
||||||
|
addError(error);
|
||||||
|
},
|
||||||
|
httpMethod,
|
||||||
|
postBody: dataToSubmit,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (confirmationMessage) {
|
||||||
|
return (
|
||||||
|
<MarkdownRenderer linkTarget="_blank" source={confirmationMessage} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (formContents) {
|
||||||
|
if (formContents.form_schema) {
|
||||||
|
return (
|
||||||
|
<div className="fixed-width-container">
|
||||||
|
<InstructionsForEndUser
|
||||||
|
defaultMessage={formContents.instructions_for_end_user}
|
||||||
|
/>
|
||||||
|
<Grid fullWidth condensed className="megacondensed">
|
||||||
|
<Column sm={4} md={5} lg={8}>
|
||||||
|
<CustomForm
|
||||||
|
id="form-to-submit"
|
||||||
|
disabled={formButtonsDisabled}
|
||||||
|
formData={taskData}
|
||||||
|
onChange={(obj: any) => {
|
||||||
|
setTaskData(obj.formData);
|
||||||
|
}}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
schema={formContents.form_schema}
|
||||||
|
uiSchema={formContents.form_ui_schema}
|
||||||
|
restrictedWidth
|
||||||
|
reactJsonSchemaForm="mui"
|
||||||
|
/>
|
||||||
|
</Column>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Page404 />;
|
||||||
|
}
|
||||||
|
const style = { margin: '50px 0 50px 50px' };
|
||||||
|
return (
|
||||||
|
<Loading
|
||||||
|
description="Active loading indicator"
|
||||||
|
withOverlay={false}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
26
spiffworkflow-frontend/src/routes/public/SignOut.tsx
Normal file
26
spiffworkflow-frontend/src/routes/public/SignOut.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
import UserService from '../../services/UserService';
|
||||||
|
|
||||||
|
export default function SignOut() {
|
||||||
|
const logoutUser = () => {
|
||||||
|
UserService.doLogout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed-width-container">
|
||||||
|
<Typography variant="h1">Access Denied</Typography>
|
||||||
|
<Typography variant="body1">
|
||||||
|
You are currently logged in as{' '}
|
||||||
|
<strong>{UserService.getPreferredUsername()}</strong>. You do not have
|
||||||
|
access to this page. Would you like to sign out and sign in as a
|
||||||
|
different user?
|
||||||
|
</Typography>
|
||||||
|
<br />
|
||||||
|
<Button variant="contained" onClick={logoutUser}>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -96,6 +96,8 @@ backendCallProps) => {
|
|||||||
} else if (is403) {
|
} else if (is403) {
|
||||||
if (onUnauthorized) {
|
if (onUnauthorized) {
|
||||||
onUnauthorized(result);
|
onUnauthorized(result);
|
||||||
|
} else if (UserService.isPublicUser()) {
|
||||||
|
window.location.href = '/public/sign_out';
|
||||||
} else {
|
} else {
|
||||||
// Hopefully we can make this service a hook and use the error message context directly
|
// Hopefully we can make this service a hook and use the error message context directly
|
||||||
// eslint-disable-next-line no-alert
|
// eslint-disable-next-line no-alert
|
||||||
|
@ -43,6 +43,30 @@ const checkPathForTaskShowParams = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// required for logging out
|
||||||
|
const getIdToken = () => {
|
||||||
|
return getCookie('id_token');
|
||||||
|
};
|
||||||
|
const getAccessToken = () => {
|
||||||
|
return getCookie('access_token');
|
||||||
|
};
|
||||||
|
const getAuthenticationIdentifier = () => {
|
||||||
|
return getCookie('authentication_identifier');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoggedIn = () => {
|
||||||
|
return !!getAccessToken();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPublicUser = () => {
|
||||||
|
const idToken = getIdToken();
|
||||||
|
if (idToken) {
|
||||||
|
const idObject = jwt(idToken);
|
||||||
|
return (idObject as any).public;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const doLogin = (
|
const doLogin = (
|
||||||
authenticationOption?: AuthenticationOption,
|
authenticationOption?: AuthenticationOption,
|
||||||
redirectUrl?: string | null
|
redirectUrl?: string | null
|
||||||
@ -64,21 +88,6 @@ const doLogin = (
|
|||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
// required for logging out
|
|
||||||
const getIdToken = () => {
|
|
||||||
return getCookie('id_token');
|
|
||||||
};
|
|
||||||
const getAccessToken = () => {
|
|
||||||
return getCookie('access_token');
|
|
||||||
};
|
|
||||||
const getAuthenticationIdentifier = () => {
|
|
||||||
return getCookie('authentication_identifier');
|
|
||||||
};
|
|
||||||
|
|
||||||
const isLoggedIn = () => {
|
|
||||||
return !!getAccessToken();
|
|
||||||
};
|
|
||||||
|
|
||||||
const doLogout = () => {
|
const doLogout = () => {
|
||||||
const idToken = getIdToken();
|
const idToken = getIdToken();
|
||||||
|
|
||||||
@ -88,6 +97,8 @@ const doLogout = () => {
|
|||||||
// edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
|
// edge case. if the user is already logged out, just take them somewhere that will force them to sign in.
|
||||||
if (idToken === null) {
|
if (idToken === null) {
|
||||||
logoutRedirectUrl = SIGN_IN_PATH;
|
logoutRedirectUrl = SIGN_IN_PATH;
|
||||||
|
} else if (isPublicUser()) {
|
||||||
|
logoutRedirectUrl += '&backend_only=true';
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = logoutRedirectUrl;
|
window.location.href = logoutRedirectUrl;
|
||||||
@ -169,6 +180,7 @@ const UserService = {
|
|||||||
getPreferredUsername,
|
getPreferredUsername,
|
||||||
getUserEmail,
|
getUserEmail,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
isPublicUser,
|
||||||
loginIfNeeded,
|
loginIfNeeded,
|
||||||
onlyGuestTaskCompletion,
|
onlyGuestTaskCompletion,
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user