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
|
||||
schema:
|
||||
type: string
|
||||
- name: backend_only
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
get:
|
||||
operationId: spiffworkflow_backend.routes.authentication_controller.logout
|
||||
summary: Logout authenticated user
|
||||
@ -909,11 +914,6 @@ paths:
|
||||
get:
|
||||
operationId: spiffworkflow_backend.routes.extensions_controller.extension_list
|
||||
summary: Returns the list of available extensions
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||
tags:
|
||||
- Extensions
|
||||
responses:
|
||||
@ -2530,12 +2530,12 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Workflow"
|
||||
|
||||
/messages/{message_name}:
|
||||
/messages/{modified_message_name}:
|
||||
parameters:
|
||||
- name: message_name
|
||||
- name: modified_message_name
|
||||
in: path
|
||||
required: true
|
||||
description: The unique name of the message.
|
||||
description: The message_name, modified to replace slashes (/) with colons
|
||||
schema:
|
||||
type: string
|
||||
- name: execution_mode
|
||||
@ -2556,14 +2556,125 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Workflow"
|
||||
$ref: "#/components/schemas/AwesomeUnspecifiedPayload"
|
||||
responses:
|
||||
"200":
|
||||
description: One task
|
||||
content:
|
||||
application/json:
|
||||
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}:
|
||||
parameters:
|
||||
@ -3775,9 +3886,9 @@ components:
|
||||
# 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.
|
||||
AwesomeUnspecifiedPayload:
|
||||
properties:
|
||||
anythingyouwant:
|
||||
type: string
|
||||
# we know that task_submit submits no body at all, and None is not an object, as this so helpfully tells us
|
||||
# type: "object"
|
||||
additionalProperties: {}
|
||||
ReportMetadata:
|
||||
properties:
|
||||
columns:
|
||||
|
@ -143,6 +143,7 @@ config_from_env("SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_ABSOLUTE_PATH")
|
||||
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
|
||||
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_USER_GROUP", default="everybody")
|
||||
config_from_env("SPIFFWORKFLOW_BACKEND_DEFAULT_PUBLIC_USER_GROUP", default="spiff_public")
|
||||
|
||||
### sentry
|
||||
config_from_env("SPIFFWORKFLOW_BACKEND_SENTRY_DSN", default="")
|
||||
|
@ -18,6 +18,8 @@ groups:
|
||||
users: [dan@sartography.com]
|
||||
group3:
|
||||
users: [jon@sartography.com]
|
||||
spiff_public:
|
||||
users: []
|
||||
|
||||
permissions:
|
||||
admin:
|
||||
@ -43,3 +45,8 @@ permissions:
|
||||
groups: [group3]
|
||||
actions: [read]
|
||||
uri: PG:misc
|
||||
|
||||
public_access:
|
||||
groups: [spiff_public]
|
||||
actions: [read, create]
|
||||
uri: /public/*
|
||||
|
@ -68,7 +68,7 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
|
||||
break
|
||||
|
||||
new_task = Task(
|
||||
task.task_id,
|
||||
task.task_guid,
|
||||
task.task_name,
|
||||
task.task_title,
|
||||
task.task_type,
|
||||
|
@ -65,6 +65,13 @@ class MessageInstanceModel(SpiffworkflowBaseDBModel):
|
||||
def validate_status(self, key: str, value: Any) -> Any:
|
||||
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:
|
||||
"""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]:
|
||||
return ["not_started", "running"]
|
||||
|
||||
def get_data(self) -> dict:
|
||||
"""Returns the data of the last completed task in this process instance."""
|
||||
last_completed_task = (
|
||||
def get_last_completed_task(self) -> TaskModel | None:
|
||||
last_completed_task: TaskModel | None = (
|
||||
TaskModel.query.filter_by(process_instance_id=self.id, state="COMPLETED")
|
||||
.order_by(desc(TaskModel.end_in_seconds)) # type: ignore
|
||||
.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
|
||||
return last_completed_task.json_data() # type: ignore
|
||||
return last_completed_task.json_data()
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
import string
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
@ -91,3 +93,46 @@ class UserModel(SpiffworkflowBaseDBModel):
|
||||
user_as_json_string = current_app.json.dumps(self)
|
||||
user_dict: dict[str, Any] = current_app.json.loads(user_as_json_string)
|
||||
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.models.group import SPIFF_GUEST_GROUP
|
||||
from spiffworkflow_backend.models.group import SPIFF_NO_AUTH_GROUP
|
||||
from spiffworkflow_backend.models.group import GroupModel
|
||||
from spiffworkflow_backend.models.service_account import ServiceAccountModel
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.models.user import SPIFF_GUEST_USER
|
||||
from spiffworkflow_backend.models.user import SPIFF_NO_AUTH_USER
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.services.authentication_service import AuthenticationService
|
||||
from spiffworkflow_backend.services.authorization_service import PUBLIC_AUTHENTICATION_EXCLUSION_LIST
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
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)
|
||||
elif token_info["api_key"] is not None:
|
||||
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:
|
||||
g.user = user_model
|
||||
@ -213,13 +222,17 @@ def login_api_return(code: str, state: str, session_state: str) -> str:
|
||||
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:
|
||||
redirect_url = ""
|
||||
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:
|
||||
@ -460,3 +473,26 @@ def _get_authentication_identifier_from_request() -> str:
|
||||
authentication_identifier: str = request.headers["SpiffWorkflow-Authentication-Identifier"]
|
||||
return authentication_identifier
|
||||
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
|
||||
from typing import Any
|
||||
|
||||
import flask.wrappers
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import make_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.process_instance import ProcessInstanceModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema
|
||||
@ -63,43 +59,11 @@ def message_instance_list(
|
||||
# -H 'content-type: application/json' \
|
||||
# --data-raw '{"payload":{"sure": "yes", "food": "spicy"}}'
|
||||
def message_send(
|
||||
message_name: str,
|
||||
modified_message_name: str,
|
||||
body: dict[str, Any],
|
||||
execution_mode: str | None = None,
|
||||
) -> flask.wrappers.Response:
|
||||
process_instance = None
|
||||
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
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()
|
||||
response_json = {
|
||||
"task_data": process_instance.get_data(),
|
||||
|
@ -1,18 +1,28 @@
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import flask.wrappers
|
||||
import sentry_sdk
|
||||
from flask import Blueprint
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import make_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 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.process_entity_not_found_error import ProcessEntityNotFoundError
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from spiffworkflow_backend.models.human_task import HumanTaskModel
|
||||
from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel
|
||||
from spiffworkflow_backend.models.principal import PrincipalModel
|
||||
@ -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.reference_cache import ReferenceCacheModel
|
||||
from spiffworkflow_backend.models.reference_cache import ReferenceSchema
|
||||
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
|
||||
from spiffworkflow_backend.services.authentication_service import AuthenticationService # noqa: F401
|
||||
from spiffworkflow_backend.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.jinja_service import JinjaService
|
||||
from spiffworkflow_backend.services.process_caller_service import ProcessCallerService
|
||||
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.task_service import TaskModelError
|
||||
from spiffworkflow_backend.services.task_service import TaskService
|
||||
|
||||
process_api_blueprint = Blueprint("process_api", __name__)
|
||||
|
||||
@ -357,3 +374,186 @@ def _get_process_model_for_instantiation(
|
||||
status_code=400,
|
||||
)
|
||||
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 os
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Generator
|
||||
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.util.task import TaskState # type: ignore
|
||||
from sqlalchemy import and_
|
||||
from sqlalchemy import asc
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import aliased
|
||||
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.exceptions.api_error import ApiError
|
||||
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 ProcessInstanceTaskDataCannotBeUpdatedError
|
||||
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 TaskModel
|
||||
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_for_me_or_raise
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _prepare_form_data
|
||||
from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared
|
||||
from spiffworkflow_backend.services.authorization_service import AuthorizationService
|
||||
from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService
|
||||
from spiffworkflow_backend.services.file_system_service import FileSystemService
|
||||
from spiffworkflow_backend.services.git_service import GitCommandError
|
||||
from spiffworkflow_backend.services.git_service import GitService
|
||||
from spiffworkflow_backend.services.jinja_service import JinjaService
|
||||
from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor
|
||||
from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError
|
||||
@ -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_model_service import ProcessModelService
|
||||
from spiffworkflow_backend.services.spec_file_service import SpecFileService
|
||||
from spiffworkflow_backend.services.task_service import TaskModelError
|
||||
from spiffworkflow_backend.services.task_service import TaskService
|
||||
|
||||
|
||||
@ -557,7 +550,12 @@ def task_submit(
|
||||
execution_mode: str | None = None,
|
||||
) -> flask.wrappers.Response:
|
||||
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(
|
||||
@ -567,7 +565,7 @@ def process_instance_progress(
|
||||
process_instance = _find_process_instance_for_me_or_raise(process_instance_id, include_actions=True)
|
||||
|
||||
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:
|
||||
response["task"] = HumanTaskModel.to_task(next_human_task_assigned_to_me)
|
||||
# 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(
|
||||
processes_started_by_user: bool = True,
|
||||
has_lane_assignment_id: bool = True,
|
||||
@ -1069,71 +963,6 @@ def _get_tasks(
|
||||
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
|
||||
def _update_form_schema_with_task_data_as_needed(in_dict: dict, task_data: dict) -> None:
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
if form_ui_schema is None:
|
||||
return
|
||||
@ -1269,14 +1072,3 @@ def _get_task_model_from_guid_or_raise(task_guid: str, process_instance_id: int)
|
||||
status_code=400,
|
||||
)
|
||||
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 re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from flask import current_app
|
||||
@ -77,27 +78,33 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [
|
||||
{"path": "/task-data", "relevant_permissions": ["read", "update"]},
|
||||
]
|
||||
|
||||
AUTHENTICATION_EXCLUSION_LIST = {
|
||||
"authentication_begin": "spiffworkflow_backend.routes.service_tasks_controller",
|
||||
"authentication_callback": "spiffworkflow_backend.routes.service_tasks_controller",
|
||||
"authentication_options": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"github_webhook_receive": "spiffworkflow_backend.routes.webhooks_controller",
|
||||
"login": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"login_api_return": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"login_return": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"login_with_access_token": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"logout": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"logout_return": "spiffworkflow_backend.routes.authentication_controller",
|
||||
"status": "spiffworkflow_backend.routes.health_controller",
|
||||
"task_allows_guest": "spiffworkflow_backend.routes.tasks_controller",
|
||||
"test_raise_error": "spiffworkflow_backend.routes.debug_controller",
|
||||
"url_info": "spiffworkflow_backend.routes.debug_controller",
|
||||
"webhook": "spiffworkflow_backend.routes.webhooks_controller",
|
||||
AUTHENTICATION_EXCLUSION_LIST = [
|
||||
"spiffworkflow_backend.routes.authentication_controller.authentication_options",
|
||||
"spiffworkflow_backend.routes.authentication_controller.login",
|
||||
"spiffworkflow_backend.routes.authentication_controller.login_api_return",
|
||||
"spiffworkflow_backend.routes.authentication_controller.login_return",
|
||||
"spiffworkflow_backend.routes.authentication_controller.login_with_access_token",
|
||||
"spiffworkflow_backend.routes.authentication_controller.logout",
|
||||
"spiffworkflow_backend.routes.authentication_controller.logout_return",
|
||||
"spiffworkflow_backend.routes.debug_controller.test_raise_error",
|
||||
"spiffworkflow_backend.routes.debug_controller.url_info",
|
||||
"spiffworkflow_backend.routes.health_controller.status",
|
||||
"spiffworkflow_backend.routes.service_tasks_controller.authentication_begin",
|
||||
"spiffworkflow_backend.routes.service_tasks_controller.authentication_callback",
|
||||
"spiffworkflow_backend.routes.tasks_controller.task_allows_guest",
|
||||
"spiffworkflow_backend.routes.webhooks_controller.github_webhook_receive",
|
||||
"spiffworkflow_backend.routes.webhooks_controller.webhook",
|
||||
# swagger api calls
|
||||
"console_ui_home": "connexion.apis.flask_api",
|
||||
"console_ui_static_files": "connexion.apis.flask_api",
|
||||
"get_json_spec": "connexion.apis.flask_api",
|
||||
}
|
||||
"connexion.apis.flask_api.console_ui_home",
|
||||
"connexion.apis.flask_api.console_ui_static_files",
|
||||
"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:
|
||||
@ -250,6 +257,17 @@ class AuthorizationService:
|
||||
db.session.commit()
|
||||
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
|
||||
def should_disable_auth_for_request(cls) -> bool:
|
||||
if request.method == "OPTIONS":
|
||||
@ -262,17 +280,10 @@ class AuthorizationService:
|
||||
if not request.endpoint:
|
||||
return True
|
||||
|
||||
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
|
||||
api_function_full_path, module = cls.get_fully_qualified_api_function_from_request()
|
||||
if (
|
||||
api_function_name
|
||||
and (
|
||||
api_function_name in AUTHENTICATION_EXCLUSION_LIST
|
||||
and controller_name
|
||||
and controller_name in AUTHENTICATION_EXCLUSION_LIST[api_function_name]
|
||||
)
|
||||
api_function_full_path
|
||||
and (api_function_full_path in AUTHENTICATION_EXCLUSION_LIST)
|
||||
or (module == openid_blueprint or module == scaffold) # don't check permissions for static assets
|
||||
):
|
||||
return True
|
||||
@ -292,6 +303,22 @@ class AuthorizationService:
|
||||
|
||||
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
|
||||
def check_for_permission(cls, decoded_token: dict | None) -> None:
|
||||
if cls.should_disable_auth_for_request():
|
||||
@ -308,19 +335,7 @@ class AuthorizationService:
|
||||
if cls.request_allows_guest_access(decoded_token):
|
||||
return None
|
||||
|
||||
permission_string = cls.get_permission_from_http_method(request.method)
|
||||
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}",
|
||||
)
|
||||
cls.check_permission_for_request()
|
||||
|
||||
@classmethod
|
||||
def request_is_excluded_from_permission_check(cls) -> bool:
|
||||
|
@ -38,12 +38,12 @@ class JinjaHelpers:
|
||||
|
||||
class JinjaService:
|
||||
@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."""
|
||||
if extensions is None:
|
||||
if isinstance(task, TaskModel):
|
||||
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
|
||||
if extensions and "instructionsForEndUser" in extensions:
|
||||
if extensions["instructionsForEndUser"]:
|
||||
|
@ -1,5 +1,7 @@
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from flask import g
|
||||
from SpiffWorkflow.bpmn import BpmnEvent # type: ignore
|
||||
from SpiffWorkflow.bpmn.specs.event_definitions.message import CorrelationProperty # 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,
|
||||
)
|
||||
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.models.db import db
|
||||
from spiffworkflow_backend.models.message_instance import MessageInstanceModel
|
||||
@ -145,40 +148,6 @@ class MessageService:
|
||||
|
||||
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
|
||||
def process_message_receive(
|
||||
process_instance_receive: ProcessInstanceModel,
|
||||
@ -221,3 +190,113 @@ class MessageService:
|
||||
message_instance_receive.status = MessageStatuses.completed.value
|
||||
db.session.add(message_instance_receive)
|
||||
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.task import Task as SpiffTask # 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 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 db
|
||||
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 JsonDataModel
|
||||
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
|
||||
@ -707,6 +709,17 @@ class TaskService:
|
||||
def get_name_for_display(cls, entity: TaskDefinitionModel | BpmnProcessDefinitionModel) -> str:
|
||||
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
|
||||
def _task_subprocess(cls, spiff_task: SpiffTask) -> tuple[str | None, BpmnWorkflow | None]:
|
||||
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")
|
||||
|
||||
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
|
||||
def logged_in_headers(
|
||||
user: UserModel, _redirect_url: str = "http://some/frontend/url", extra_token_payload: dict | None = None
|
||||
) -> dict[str, str]:
|
||||
def logged_in_headers(user: UserModel, extra_token_payload: dict | None = None) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer " + user.encode_auth_token(extra_token_payload)}
|
||||
|
||||
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 tests.spiffworkflow_backend.helpers.base_test import BaseTest
|
||||
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
|
||||
|
||||
|
||||
class TestAuthentication(BaseTest):
|
||||
@ -152,3 +153,51 @@ class TestAuthentication(BaseTest):
|
||||
assert response.status_code == 500
|
||||
assert response.json is not None
|
||||
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 React from 'react';
|
||||
|
||||
import { createBrowserRouter, Outlet, RouterProvider } from 'react-router-dom';
|
||||
import { AbilityContext } from './contexts/Can';
|
||||
import APIErrorProvider from './contexts/APIErrorContext';
|
||||
import ContainerForExtensions from './ContainerForExtensions';
|
||||
import PublicRoutes from './routes/PublicRoutes';
|
||||
|
||||
export default function App() {
|
||||
const ability = defineAbility(() => {});
|
||||
return (
|
||||
<div className="cds--white">
|
||||
<APIErrorProvider>
|
||||
<AbilityContext.Provider value={ability}>
|
||||
<ContainerForExtensions />
|
||||
</AbilityContext.Provider>
|
||||
</APIErrorProvider>
|
||||
</div>
|
||||
);
|
||||
const routeComponents = () => {
|
||||
return [
|
||||
{ path: 'public/*', element: <PublicRoutes /> },
|
||||
{
|
||||
path: '*',
|
||||
element: <ContainerForExtensions />,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
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 { createBrowserRouter, RouterProvider, Outlet } from 'react-router-dom';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
@ -100,15 +100,17 @@ export default function ContainerForExtensions() {
|
||||
]);
|
||||
|
||||
const routeComponents = () => {
|
||||
return [
|
||||
{
|
||||
path: '*',
|
||||
element: <BaseRoutes extensionUxElements={extensionUxElements} />,
|
||||
},
|
||||
{ path: 'editor/*', element: <EditorRoutes /> },
|
||||
{ path: 'extensions/:page_identifier', element: <Extension /> },
|
||||
{ path: 'login', element: <Login /> },
|
||||
];
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="*"
|
||||
element={<BaseRoutes extensionUxElements={extensionUxElements} />}
|
||||
/>
|
||||
<Route path="editor/*" element={<EditorRoutes />} />
|
||||
<Route path="extensions/:page_identifier" element={<Extension />} />
|
||||
<Route path="login" element={<Login />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
const backendIsDownPage = () => {
|
||||
@ -120,31 +122,20 @@ export default function ContainerForExtensions() {
|
||||
return [];
|
||||
}
|
||||
if (backendIsUp) {
|
||||
return <Outlet />;
|
||||
return routeComponents();
|
||||
}
|
||||
return backendIsDownPage();
|
||||
};
|
||||
|
||||
const layout = () => {
|
||||
return (
|
||||
<>
|
||||
<NavigationBar extensionUxElements={extensionUxElements} />
|
||||
<Content className={contentClassName}>
|
||||
<ScrollToTop />
|
||||
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
||||
{innerComponents()}
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '*',
|
||||
Component: layout,
|
||||
children: routeComponents(),
|
||||
},
|
||||
]);
|
||||
return <RouterProvider router={router} />;
|
||||
return (
|
||||
<>
|
||||
<NavigationBar extensionUxElements={extensionUxElements} />
|
||||
<Content className={contentClassName}>
|
||||
<ScrollToTop />
|
||||
<ErrorBoundary FallbackComponent={ErrorBoundaryFallback}>
|
||||
{innerComponents()}
|
||||
</ErrorBoundary>
|
||||
</Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,8 @@ import validator from '@rjsf/validator-ajv8';
|
||||
import { ReactNode } from 'react';
|
||||
import { RegistryFieldsType } from '@rjsf/utils';
|
||||
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 DateRangePickerWidget from '../rjsf/custom_widgets/DateRangePicker/DateRangePickerWidget';
|
||||
import TypeaheadWidget from '../rjsf/custom_widgets/TypeaheadWidget/TypeaheadWidget';
|
||||
@ -29,6 +30,7 @@ type OwnProps = {
|
||||
noValidate?: boolean;
|
||||
restrictedWidth?: boolean;
|
||||
submitButtonText?: string;
|
||||
reactJsonSchemaForm?: string;
|
||||
};
|
||||
|
||||
export default function CustomForm({
|
||||
@ -43,6 +45,7 @@ export default function CustomForm({
|
||||
noValidate = false,
|
||||
restrictedWidth = false,
|
||||
submitButtonText,
|
||||
reactJsonSchemaForm = 'carbon',
|
||||
}: OwnProps) {
|
||||
// set in uiSchema using the "ui:widget" key for a property
|
||||
const rjsfWidgets = {
|
||||
@ -444,24 +447,32 @@ export default function CustomForm({
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
formData={formData}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
schema={schema}
|
||||
uiSchema={uiSchema}
|
||||
widgets={rjsfWidgets}
|
||||
validator={validator}
|
||||
customValidate={customValidate}
|
||||
noValidate={noValidate}
|
||||
fields={rjsfFields}
|
||||
templates={rjsfTemplates}
|
||||
omitExtraData
|
||||
>
|
||||
{childrenToUse}
|
||||
</Form>
|
||||
);
|
||||
const formProps = {
|
||||
id,
|
||||
disabled,
|
||||
formData,
|
||||
onChange,
|
||||
onSubmit,
|
||||
schema,
|
||||
uiSchema,
|
||||
widgets: rjsfWidgets,
|
||||
validator,
|
||||
customValidate,
|
||||
noValidate,
|
||||
fields: rjsfFields,
|
||||
templates: rjsfTemplates,
|
||||
omitExtraData: true,
|
||||
};
|
||||
if (reactJsonSchemaForm === 'carbon') {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
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) => {
|
||||
if (obj === null || obj === undefined) {
|
||||
return newValue;
|
||||
|
@ -46,12 +46,14 @@ a.cds--header__menu-item {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
h1 {
|
||||
h1,
|
||||
h1.MuiTypography-h1 {
|
||||
font-weight: 400;
|
||||
font-size: 28px;
|
||||
line-height: 36px;
|
||||
color: #161616;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.00938em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
@ -503,3 +503,15 @@ export interface ElementForArray {
|
||||
key: string;
|
||||
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({
|
||||
displayLocation: 'routes',
|
||||
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) {
|
||||
if (onUnauthorized) {
|
||||
onUnauthorized(result);
|
||||
} else if (UserService.isPublicUser()) {
|
||||
window.location.href = '/public/sign_out';
|
||||
} else {
|
||||
// Hopefully we can make this service a hook and use the error message context directly
|
||||
// eslint-disable-next-line no-alert
|
||||
|
@ -43,6 +43,30 @@ const checkPathForTaskShowParams = (
|
||||
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 = (
|
||||
authenticationOption?: AuthenticationOption,
|
||||
redirectUrl?: string | null
|
||||
@ -64,21 +88,6 @@ const doLogin = (
|
||||
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 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.
|
||||
if (idToken === null) {
|
||||
logoutRedirectUrl = SIGN_IN_PATH;
|
||||
} else if (isPublicUser()) {
|
||||
logoutRedirectUrl += '&backend_only=true';
|
||||
}
|
||||
|
||||
window.location.href = logoutRedirectUrl;
|
||||
@ -169,6 +180,7 @@ const UserService = {
|
||||
getPreferredUsername,
|
||||
getUserEmail,
|
||||
isLoggedIn,
|
||||
isPublicUser,
|
||||
loginIfNeeded,
|
||||
onlyGuestTaskCompletion,
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user