diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml
index 84df4260..8ea813be 100755
--- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml
@@ -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:
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py
index 7a005f1e..6cb121b1 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py
@@ -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="")
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml
index 6398883d..df7cbb95 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/local_development.yml
@@ -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/*
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py
index fee793c8..13d0ca34 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task.py
@@ -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,
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py
index 3081ecd3..c9c7d57c 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/message_instance.py
@@ -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.
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py
index 57e08d58..04cc8129 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py
@@ -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 {}
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py
index 9f2f7884..b711867e 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py
index 4ed292fa..96b75943 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py
@@ -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()
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py
index e867e26e..595a0c48 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py
@@ -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(),
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py
index e7bef567..68564b82 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py
new file mode 100644
index 00000000..dc501787
--- /dev/null
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/public_controller.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py
index 644fe544..515985ca 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py
index ad92e467..a049ba18 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py
@@ -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:
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py
index eee981be..2026ad9b 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/jinja_service.py
@@ -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"]:
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py
index 7fa2bbd0..8a389164 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py
index 15ecb230..88542c67 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/task_service.py
@@ -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
diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py
index 76ef5d0f..85279cef 100644
--- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py
+++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py
@@ -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
diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json
new file mode 100644
index 00000000..9bf6b006
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-schema.json
@@ -0,0 +1,11 @@
+{
+ "title": "Form for message start event",
+ "type": "object",
+ "required": ["firstName"],
+ "properties": {
+ "firstName": {
+ "type": "string",
+ "title": "First name"
+ }
+ }
+}
diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/entry-form-uischema.json
@@ -0,0 +1 @@
+{}
diff --git a/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn b/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn
new file mode 100644
index 00000000..7fa5969d
--- /dev/null
+++ b/spiffworkflow-backend/tests/data/message-start-event-with-form/message-start-event-with-form.bpmn
@@ -0,0 +1,68 @@
+
+