Merge remote-tracking branch 'origin/main' into feature/jinja_errors

This commit is contained in:
Dan 2023-01-25 15:38:52 -05:00
commit b59cca0212
41 changed files with 671 additions and 3976 deletions

14
.flake8
View File

@ -8,11 +8,19 @@ rst-roles = class,const,func,meth,mod,ref
rst-directives = deprecated
per-file-ignores =
# More specific globs seem to overwrite the more generic ones so we have
# to split them out by directory
# So if you have a rule like:
# tests/*: D102,D103
# and a rule like:
# tests/test_hey.py: D102
# THEN, test_hey.py will NOT be excluding D103
# asserts are ok in tests
spiffworkflow-backend/tests/*:S101
spiffworkflow-backend/tests/*:S101,D102,D103,D101
# prefer naming functions descriptively rather than forcing comments
spiffworkflow-backend/*:D102,D103
spiffworkflow-backend/src/*:D102,D103,D101
spiffworkflow-backend/bin/keycloak_test_server.py:B950,D
spiffworkflow-backend/conftest.py:S105
@ -34,4 +42,4 @@ per-file-ignores =
# TODO: fix the S issues:
# S607 Starting a process with a partial executable path
# S605 Starting a process with a shell: Seems safe, but may be changed in the future, consider rewriting without shell
spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,D103,S605
spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,S605,D102,D103,D101

View File

@ -67,7 +67,7 @@ jobs:
path: sample-process-models
- name: start_keycloak
working-directory: ./spiffworkflow-backend
run: ./keycloak/bin/start_keycloak 5
run: ./keycloak/bin/start_keycloak
- name: start_backend
working-directory: ./spiffworkflow-backend
run: ./bin/build_and_run_with_docker_compose

View File

@ -1,23 +1,16 @@
version: "3.8"
services:
spiffworkflow-db:
container_name: spiffworkflow-db
image: mysql:8.0.29
platform: linux/amd64
cap_add:
- SYS_NICE
restart: "no"
spiffworkflow-frontend:
container_name: spiffworkflow-frontend
image: ghcr.io/sartography/spiffworkflow-frontend:latest
depends_on:
spiffworkflow-backend:
condition: service_healthy
environment:
- MYSQL_DATABASE=spiffworkflow_backend_development
- MYSQL_ROOT_PASSWORD=my-secret-pw
- MYSQL_TCP_PORT=8003
APPLICATION_ROOT: "/"
PORT0: "${SPIFF_FRONTEND_PORT:-8001}"
ports:
- "8003"
healthcheck:
test: mysql --user=root --password=my-secret-pw -e 'select 1' spiffworkflow_backend_development
interval: 10s
timeout: 5s
retries: 10
- "${SPIFF_FRONTEND_PORT:-8001}:${SPIFF_FRONTEND_PORT:-8001}/tcp"
spiffworkflow-backend:
# container_name: spiffworkflow-backend
@ -28,58 +21,69 @@ services:
spiffworkflow-db:
condition: service_healthy
environment:
- APPLICATION_ROOT=/
- SPIFFWORKFLOW_BACKEND_ENV=development
- FLASK_DEBUG=0
- FLASK_SESSION_SECRET_KEY=super_secret_key
- OPEN_ID_SERVER_URL=http://localhost:8000/openid
- SPIFFWORKFLOW_FRONTEND_URL=http://localhost:8001
- SPIFFWORKFLOW_BACKEND_URL=http://localhost:8000
- SPIFFWORKFLOW_BACKEND_PORT=8000
- SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true
- SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:my-secret-pw@spiffworkflow-db:8003/spiffworkflow_backend_development
- BPMN_SPEC_ABSOLUTE_DIR=/app/process_models
- SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA=false
- SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME=example.yml
- RUN_BACKGROUND_SCHEDULER=true
- OPEN_ID_CLIENT_ID=spiffworkflow-backend
- OPEN_ID_CLIENT_SECRET_KEY=my_open_id_secret_key
APPLICATION_ROOT: "/"
SPIFFWORKFLOW_BACKEND_ENV: "development"
FLASK_DEBUG: "0"
FLASK_SESSION_SECRET_KEY: "${FLASK_SESSION_SECRET_KEY:-super_secret_key}"
OPEN_ID_SERVER_URL: "http://localhost:${SPIFF_BACKEND_PORT:-8000}/openid"
SPIFFWORKFLOW_FRONTEND_URL: "http://localhost:${SPIFF_FRONTEND_PORT:-8001}"
# WARNING: Frontend is a static site which assumes frontend port - 1 on localhost.
SPIFFWORKFLOW_BACKEND_URL: "http://localhost:${SPIFF_BACKEND_PORT:-8000}"
SPIFFWORKFLOW_BACKEND_PORT: "${SPIFF_BACKEND_PORT:-8000}"
SPIFFWORKFLOW_BACKEND_UPGRADE_DB: "true"
SPIFFWORKFLOW_BACKEND_DATABASE_URI: "mysql+mysqlconnector://root:${SPIFF_MYSQL_PASS:-my-secret-pw}@spiffworkflow-db:${SPIFF_MYSQL_PORT:-8003}/spiffworkflow_backend_development"
BPMN_SPEC_ABSOLUTE_DIR: "/app/process_models"
SPIFFWORKFLOW_BACKEND_LOAD_FIXTURE_DATA: "false"
SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME: "example.yml"
RUN_BACKGROUND_SCHEDULER: "true"
OPEN_ID_CLIENT_ID: "spiffworkflow-backend"
OPEN_ID_CLIENT_SECRET_KEY: "my_open_id_secret_key"
ports:
- "8000:8000"
- "${SPIFF_BACKEND_PORT:-8000}:${SPIFF_BACKEND_PORT:-8000}/tcp"
volumes:
- ./process_models:/app/process_models
- ./log:/app/log
healthcheck:
test: curl localhost:8000/v1.0/status --fail
test: "curl localhost:${SPIFF_BACKEND_PORT:-8000}/v1.0/status --fail"
interval: 10s
timeout: 5s
retries: 20
spiffworkflow-frontend:
container_name: spiffworkflow-frontend
image: ghcr.io/sartography/spiffworkflow-frontend
environment:
- APPLICATION_ROOT=/
- PORT0=8001
ports:
- "8001:8001"
spiffworkflow-connector:
container_name: spiffworkflow-connector
image: ghcr.io/sartography/connector-proxy-demo
image: ghcr.io/sartography/connector-proxy-demo:latest
environment:
- FLASK_ENV=${FLASK_ENV:-development}
- FLASK_DEBUG=0
- FLASK_SESSION_SECRET_KEY=${FLASK_SESSION_SECRET_KEY:-super_secret_key}
- CONNECTOR_PROXY_PORT=8004
FLASK_ENV: "${FLASK_ENV:-development}"
FLASK_DEBUG: "0"
FLASK_SESSION_SECRET_KEY: "${FLASK_SESSION_SECRET_KEY:-super_secret_key}"
CONNECTOR_PROXY_PORT: "${SPIFF_CONNECTOR_PORT:-8004}"
ports:
- "8004:8004"
- "${SPIFF_CONNECTOR_PORT:-8004}:${SPIFF_CONNECTOR_PORT:-8004}/tcp"
healthcheck:
test: curl localhost:8004/liveness --fail
test: "curl localhost:${SPIFF_CONNECTOR_PORT:-8004}/liveness --fail"
interval: 10s
timeout: 5s
retries: 20
spiffworkflow-db:
container_name: spiffworkflow-db
image: mysql:8.0.29
platform: linux/amd64
cap_add:
- SYS_NICE
restart: "no"
environment:
MYSQL_DATABASE: "spiffworkflow_backend_development"
MYSQL_ROOT_PASSWORD: "${SPIFF_MYSQL_PASS:-my-secret-pw}"
MYSQL_TCP_PORT: "${SPIFF_MYSQL_PORT:-8003}"
ports:
- "${SPIFF_MYSQL_PORT:-8003}:${SPIFF_MYSQL_PORT:-8003}/tcp"
healthcheck:
test: "mysql --user=root --password=${SPIFF_MYSQL_PASS:-my-secret-pw} -e 'select 1' spiffworkflow_backend_development"
interval: 10s
timeout: 5s
retries: 10
volumes:
spiffworkflow_backend:

View File

@ -8,11 +8,19 @@ rst-roles = class,const,func,meth,mod,ref
rst-directives = deprecated
per-file-ignores =
# More specific globs seem to overwrite the more generic ones so we have
# to split them out by directory
# So if you have a rule like:
# tests/*: D102,D103
# and a rule like:
# tests/test_hey.py: D102
# THEN, test_hey.py will NOT be excluding D103
# asserts are ok in tests
tests/*:S101
tests/*:S101,D102,D103
# prefer naming functions descriptively rather than forcing comments
*:D102
src/*:D102,D103
bin/keycloak_test_server.py:B950,D
conftest.py:S105
@ -31,4 +39,4 @@ per-file-ignores =
# and ignore long comment line
src/spiffworkflow_backend/services/logging_service.py:N802,B950
tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,D103,S605
tests/spiffworkflow_backend/integration/test_process_api.py:S607,S101,S605,D102,D103,D101

View File

@ -0,0 +1,33 @@
"""Get the bpmn process json for a given process instance id and store it in /tmp."""
import os
import sys
from spiffworkflow_backend import create_app
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.secret_service import SecretService
def main(env_file: str):
"""Main."""
os.environ["SPIFFWORKFLOW_BACKEND_ENV"] = "development"
if os.environ.get("BPMN_SPEC_ABSOLUTE_DIR") is None:
os.environ["BPMN_SPEC_ABSOLUTE_DIR"] = "hey"
flask_env_key = "FLASK_SESSION_SECRET_KEY"
os.environ[flask_env_key] = "whatevs"
app = create_app()
with app.app_context():
contents = None
with open(env_file, 'r') as f:
contents = f.readlines()
for line in contents:
key, value_raw = line.split('=')
value = value_raw.replace('"', '').rstrip()
SecretService().add_secret(key, value, UserModel.query.first().id)
if len(sys.argv) < 2:
raise Exception("env file must be specified")
main(sys.argv[1])

View File

@ -1,8 +1,8 @@
"""empty message
Revision ID: 22212a7d6505
Revision ID: 2ec4222f0012
Revises:
Create Date: 2023-01-23 10:59:17.365694
Create Date: 2023-01-24 10:31:26.693063
"""
from alembic import op
@ -10,7 +10,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '22212a7d6505'
revision = '2ec4222f0012'
down_revision = None
branch_labels = None
depends_on = None
@ -129,6 +129,8 @@ def upgrade():
sa.Column('bpmn_version_control_type', sa.String(length=50), nullable=True),
sa.Column('bpmn_version_control_identifier', sa.String(length=255), nullable=True),
sa.Column('spiff_step', sa.Integer(), nullable=True),
sa.Column('locked_by', sa.String(length=80), nullable=True),
sa.Column('locked_at_in_seconds', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['process_initiator_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
@ -204,8 +206,7 @@ def upgrade():
sa.ForeignKeyConstraint(['completed_by_user_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['lane_assignment_id'], ['group.id'], ),
sa.ForeignKeyConstraint(['process_instance_id'], ['process_instance.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('task_id', 'process_instance_id', name='human_task_unique')
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_human_task_completed'), 'human_task', ['completed'], unique=False)
op.create_table('message_correlation',

View File

@ -72,7 +72,7 @@ zookeeper = ["kazoo"]
[[package]]
name = "astroid"
version = "2.13.2"
version = "2.12.12"
description = "An abstract syntax tree for Python with inference support."
category = "main"
optional = false
@ -80,7 +80,7 @@ python-versions = ">=3.7.2"
[package.dependencies]
lazy-object-proxy = ">=1.4.0"
typing-extensions = ">=4.0.0"
typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""}
wrapt = [
{version = ">=1.11,<2", markers = "python_version < \"3.11\""},
{version = ">=1.14,<2", markers = "python_version >= \"3.11\""},
@ -430,17 +430,6 @@ calendars = ["convertdate", "convertdate", "hijri-converter"]
fasttext = ["fasttext"]
langdetect = ["langdetect"]
[[package]]
name = "dill"
version = "0.3.6"
description = "serialize all of python"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
graph = ["objgraph (>=1.7.2)"]
[[package]]
name = "distlib"
version = "0.3.6"
@ -878,20 +867,6 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "isort"
version = "5.11.4"
description = "A Python utility / library to sort Python imports."
category = "main"
optional = false
python-versions = ">=3.7.0"
[package.extras]
colors = ["colorama (>=0.4.3,<0.5.0)"]
pipfile-deprecated-finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "itsdangerous"
version = "2.1.2"
@ -1067,7 +1042,7 @@ tests = ["pytest", "pytest-lazy-fixture (>=0.6.2)"]
name = "mccabe"
version = "0.6.1"
description = "McCabe checker, plugin for flake8"
category = "main"
category = "dev"
optional = false
python-versions = "*"
@ -1174,7 +1149,7 @@ flake8 = ">=3.9.1"
name = "platformdirs"
version = "2.5.2"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "main"
category = "dev"
optional = false
python-versions = ">=3.7"
@ -1304,32 +1279,6 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte
docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"]
[[package]]
name = "pylint"
version = "2.15.10"
description = "python code static checker"
category = "main"
optional = false
python-versions = ">=3.7.2"
[package.dependencies]
astroid = ">=2.12.13,<=2.14.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.2", markers = "python_version < \"3.11\""},
{version = ">=0.3.6", markers = "python_version >= \"3.11\""},
]
isort = ">=4.2.5,<6"
mccabe = ">=0.6,<0.8"
platformdirs = ">=2.2.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
tomlkit = ">=0.10.1"
typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""}
[package.extras]
spelling = ["pyenchant (>=3.2,<4.0)"]
testutils = ["gitpython (>3)"]
[[package]]
name = "pyparsing"
version = "3.0.9"
@ -1837,8 +1786,8 @@ lxml = "*"
[package.source]
type = "git"
url = "https://github.com/sartography/SpiffWorkflow"
reference = "main"
resolved_reference = "7378639d349ed61d907a6891740760e5eee20d1a"
reference = "450ef3bcd639b6bc1c115fbe35bf3f93946cb0c7"
resolved_reference = "450ef3bcd639b6bc1c115fbe35bf3f93946cb0c7"
[[package]]
name = "SQLAlchemy"
@ -1937,14 +1886,6 @@ category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "tomlkit"
version = "0.11.6"
description = "Style preserving TOML library"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "tornado"
version = "6.2"
@ -2217,7 +2158,7 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "1.1"
python-versions = ">=3.9,<3.12"
content-hash = "681d047a32e12d71a39ff9de8cd6ceffc41048127243f427b69245d5e071419d"
content-hash = "ba797b1ccf2dd8dc50d62ff06f6667f28e241b0a26611192d53abfc75b29a415"
[metadata.files]
alabaster = [
@ -2241,8 +2182,8 @@ apscheduler = [
{file = "APScheduler-3.9.1.post1.tar.gz", hash = "sha256:b2bea0309569da53a7261bfa0ce19c67ddbfe151bda776a6a907579fdbd3eb2a"},
]
astroid = [
{file = "astroid-2.13.2-py3-none-any.whl", hash = "sha256:8f6a8d40c4ad161d6fc419545ae4b2f275ed86d1c989c97825772120842ee0d2"},
{file = "astroid-2.13.2.tar.gz", hash = "sha256:3bc7834720e1a24ca797fd785d77efb14f7a28ee8e635ef040b6e2d80ccb3303"},
{file = "astroid-2.12.12-py3-none-any.whl", hash = "sha256:72702205200b2a638358369d90c222d74ebc376787af8fb2f7f2a86f7b5cc85f"},
{file = "astroid-2.12.12.tar.gz", hash = "sha256:1c00a14f5a3ed0339d38d2e2e5b74ea2591df5861c0936bb292b84ccf3a78d83"},
]
attrs = [
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
@ -2426,10 +2367,6 @@ dateparser = [
{file = "dateparser-1.1.2-py2.py3-none-any.whl", hash = "sha256:d31659dc806a7d88e2b510b2c74f68b525ae531f145c62a57a99bd616b7f90cf"},
{file = "dateparser-1.1.2.tar.gz", hash = "sha256:3821bf191f95b2658c4abd91571c09821ce7a2bc179bf6cefd8b4515c3ccf9ef"},
]
dill = [
{file = "dill-0.3.6-py3-none-any.whl", hash = "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0"},
{file = "dill-0.3.6.tar.gz", hash = "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373"},
]
distlib = [
{file = "distlib-0.3.6-py2.py3-none-any.whl", hash = "sha256:f35c4b692542ca110de7ef0bea44d73981caeb34ca0b9b6b2e6d7790dda8f80e"},
{file = "distlib-0.3.6.tar.gz", hash = "sha256:14bad2d9b04d3a36127ac97f30b12a19268f211063d8f8ee4f47108896e11b46"},
@ -2562,7 +2499,6 @@ greenlet = [
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5b0ff9878333823226d270417f24f4d06f235cb3e54d1103b71ea537a6a86ce"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be9e0fb2ada7e5124f5282d6381903183ecc73ea019568d6d63d33f25b2a9000"},
{file = "greenlet-2.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b493db84d124805865adc587532ebad30efa68f79ad68f11b336e0a51ec86c2"},
{file = "greenlet-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:0459d94f73265744fee4c2d5ec44c6f34aa8a31017e6e9de770f7bcf29710be9"},
{file = "greenlet-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a20d33124935d27b80e6fdacbd34205732660e0a1d35d8b10b3328179a2b51a1"},
{file = "greenlet-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:ea688d11707d30e212e0110a1aac7f7f3f542a259235d396f88be68b649e47d1"},
{file = "greenlet-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:afe07421c969e259e9403c3bb658968702bc3b78ec0b6fde3ae1e73440529c23"},
@ -2571,7 +2507,6 @@ greenlet = [
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:659f167f419a4609bc0516fb18ea69ed39dbb25594934bd2dd4d0401660e8a1e"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:356e4519d4dfa766d50ecc498544b44c0249b6de66426041d7f8b751de4d6b48"},
{file = "greenlet-2.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:811e1d37d60b47cb8126e0a929b58c046251f28117cb16fcd371eed61f66b764"},
{file = "greenlet-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d38ffd0e81ba8ef347d2be0772e899c289b59ff150ebbbbe05dc61b1246eb4e0"},
{file = "greenlet-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0109af1138afbfb8ae647e31a2b1ab030f58b21dd8528c27beaeb0093b7938a9"},
{file = "greenlet-2.0.1-cp38-cp38-win32.whl", hash = "sha256:88c8d517e78acdf7df8a2134a3c4b964415b575d2840a2746ddb1cc6175f8608"},
{file = "greenlet-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:d6ee1aa7ab36475035eb48c01efae87d37936a8173fc4d7b10bb02c2d75dd8f6"},
@ -2580,7 +2515,6 @@ greenlet = [
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:505138d4fa69462447a562a7c2ef723c6025ba12ac04478bc1ce2fcc279a2db5"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce1e90dd302f45716a7715517c6aa0468af0bf38e814ad4eab58e88fc09f7f7"},
{file = "greenlet-2.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e9744c657d896c7b580455e739899e492a4a452e2dd4d2b3e459f6b244a638d"},
{file = "greenlet-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:662e8f7cad915ba75d8017b3e601afc01ef20deeeabf281bd00369de196d7726"},
{file = "greenlet-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:41b825d65f31e394b523c84db84f9383a2f7eefc13d987f308f4663794d2687e"},
{file = "greenlet-2.0.1-cp39-cp39-win32.whl", hash = "sha256:db38f80540083ea33bdab614a9d28bcec4b54daa5aff1668d7827a9fc769ae0a"},
{file = "greenlet-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b23d2a46d53210b498e5b701a1913697671988f4bf8e10f935433f6e7c332fb6"},
@ -2614,10 +2548,6 @@ iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
]
isort = [
{file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"},
{file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"},
]
itsdangerous = [
{file = "itsdangerous-2.1.2-py3-none-any.whl", hash = "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44"},
{file = "itsdangerous-2.1.2.tar.gz", hash = "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a"},
@ -2882,7 +2812,10 @@ orjson = [
{file = "orjson-3.8.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b68a42a31f8429728183c21fb440c21de1b62e5378d0d73f280e2d894ef8942e"},
{file = "orjson-3.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ff13410ddbdda5d4197a4a4c09969cb78c722a67550f0a63c02c07aadc624833"},
{file = "orjson-3.8.0-cp310-none-win_amd64.whl", hash = "sha256:2d81e6e56bbea44be0222fb53f7b255b4e7426290516771592738ca01dbd053b"},
{file = "orjson-3.8.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:200eae21c33f1f8b02a11f5d88d76950cd6fd986d88f1afe497a8ae2627c49aa"},
{file = "orjson-3.8.0-cp311-cp311-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:9529990f3eab54b976d327360aa1ff244a4b12cb5e4c5b3712fcdd96e8fe56d4"},
{file = "orjson-3.8.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:e2defd9527651ad39ec20ae03c812adf47ef7662bdd6bc07dabb10888d70dc62"},
{file = "orjson-3.8.0-cp311-none-win_amd64.whl", hash = "sha256:b21c7af0ff6228ca7105f54f0800636eb49201133e15ddb80ac20c1ce973ef07"},
{file = "orjson-3.8.0-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:9e6ac22cec72d5b39035b566e4b86c74b84866f12b5b0b6541506a080fb67d6d"},
{file = "orjson-3.8.0-cp37-cp37m-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:e2f4a5542f50e3d336a18cb224fc757245ca66b1fd0b70b5dd4471b8ff5f2b0e"},
{file = "orjson-3.8.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1418feeb8b698b9224b1f024555895169d481604d5d884498c1838d7412794c"},
@ -3008,10 +2941,6 @@ pyjwt = [
{file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"},
{file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"},
]
pylint = [
{file = "pylint-2.15.10-py3-none-any.whl", hash = "sha256:9df0d07e8948a1c3ffa3b6e2d7e6e63d9fb457c5da5b961ed63106594780cc7e"},
{file = "pylint-2.15.10.tar.gz", hash = "sha256:b3dc5ef7d33858f297ac0d06cc73862f01e4f2e74025ec3eff347ce0bc60baf5"},
]
pyparsing = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
@ -3443,10 +3372,6 @@ tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
]
tomlkit = [
{file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"},
{file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"},
]
tornado = [
{file = "tornado-6.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:20f638fd8cc85f3cbae3c732326e96addff0a15e22d80f049e00121651e82e72"},
{file = "tornado-6.2-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:87dcafae3e884462f90c90ecc200defe5e580a7fbbb4365eda7c7c1eb809ebc9"},

View File

@ -28,7 +28,8 @@ flask-migrate = "*"
flask-restful = "*"
werkzeug = "*"
# temporarily switch off main to fix CI because poetry export doesn't capture the revision if it's not here (it ignores the lock)
SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "main"}
# SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "main"}
SpiffWorkflow = {git = "https://github.com/sartography/SpiffWorkflow", rev = "450ef3bcd639b6bc1c115fbe35bf3f93946cb0c7"}
# SpiffWorkflow = {develop = true, path = "../SpiffWorkflow" }
sentry-sdk = "^1.10"
sphinx-autoapi = "^2.0"

View File

@ -19,7 +19,6 @@ from spiffworkflow_backend.exceptions.api_error import api_error_blueprint
from spiffworkflow_backend.helpers.api_version import V1_API_PATH_PREFIX
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.db import migrate
from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint
from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import (
openid_blueprint,
)
@ -106,7 +105,6 @@ def create_app() -> flask.app.Flask:
app.register_blueprint(user_blueprint)
app.register_blueprint(api_error_blueprint)
app.register_blueprint(admin_blueprint, url_prefix="/admin")
app.register_blueprint(openid_blueprint, url_prefix="/openid")
# preflight options requests will be allowed if they meet the requirements of the url regex.

View File

@ -1,6 +1,7 @@
"""__init__.py."""
import os
import threading
import uuid
from flask.app import Flask
from werkzeug.utils import ImportStringError
@ -96,6 +97,8 @@ def setup_config(app: Flask) -> None:
if app.config["BPMN_SPEC_ABSOLUTE_DIR"] is None:
raise ConfigurationError("BPMN_SPEC_ABSOLUTE_DIR config must be set")
app.config["PROCESS_UUID"] = uuid.uuid4()
setup_database_uri(app)
setup_logger(app)

View File

@ -82,3 +82,7 @@ SYSTEM_NOTIFICATION_PROCESS_MODEL_MESSAGE_ID = environ.get(
"SYSTEM_NOTIFICATION_PROCESS_MODEL_MESSAGE_ID",
default="Message_SystemMessageNotification",
)
ALLOW_CONFISCATING_LOCK_AFTER_SECONDS = int(
environ.get("ALLOW_CONFISCATING_LOCK_AFTER_SECONDS", default="600")
)

View File

@ -26,9 +26,6 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
"""HumanTaskModel."""
__tablename__ = "human_task"
__table_args__ = (
db.UniqueConstraint("task_id", "process_instance_id", name="human_task_unique"),
)
id: int = db.Column(db.Integer, primary_key=True)
process_instance_id: int = db.Column(

View File

@ -75,6 +75,10 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
) # type: ignore
message_instances = relationship("MessageInstanceModel", cascade="delete") # type: ignore
message_correlations = relationship("MessageCorrelationModel", cascade="delete") # type: ignore
process_metadata = relationship(
"ProcessInstanceMetadataModel",
cascade="delete",
) # type: ignore
bpmn_json: str | None = deferred(db.Column(db.JSON)) # type: ignore
start_in_seconds: int | None = db.Column(db.Integer)
@ -83,11 +87,16 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
created_at_in_seconds: int = db.Column(db.Integer)
status: str = db.Column(db.String(50))
bpmn_xml_file_contents: str | None = None
bpmn_version_control_type: str = db.Column(db.String(50))
bpmn_version_control_identifier: str = db.Column(db.String(255))
spiff_step: int = db.Column(db.Integer)
locked_by: str | None = db.Column(db.String(80))
locked_at_in_seconds: int | None = db.Column(db.Integer)
bpmn_xml_file_contents: str | None = None
process_model_with_diagram_identifier: str | None = None
@property
def serialized(self) -> dict[str, Any]:
"""Return object data in serializeable format."""
@ -108,6 +117,14 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
"process_initiator_username": self.process_initiator.username,
}
def serialized_with_metadata(self) -> dict[str, Any]:
process_instance_attributes = self.serialized
process_instance_attributes["process_metadata"] = self.process_metadata
process_instance_attributes["process_model_with_diagram_identifier"] = (
self.process_model_with_diagram_identifier
)
return process_instance_attributes
@property
def serialized_flat(self) -> dict:
"""Return object in serializeable format with data merged together with top-level attributes.

View File

@ -1,187 +0,0 @@
"""APIs for dealing with process groups, process models, and process instances."""
from typing import Union
from flask import Blueprint
from flask import flash
from flask import redirect
from flask import render_template
from flask import request
from flask import url_for
from werkzeug.wrappers import Response
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceProcessor,
)
from spiffworkflow_backend.services.process_instance_service import (
ProcessInstanceService,
)
from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.spec_file_service import SpecFileService
from spiffworkflow_backend.services.user_service import UserService
admin_blueprint = Blueprint(
"admin", __name__, template_folder="templates", static_folder="static"
)
ALLOWED_BPMN_EXTENSIONS = {"bpmn", "dmn"}
@admin_blueprint.route("/process-groups", methods=["GET"])
def process_group_list() -> str:
"""Process_group_list."""
process_groups = ProcessModelService.get_process_groups()
return render_template("process_group_list.html", process_groups=process_groups)
@admin_blueprint.route("/process-groups/<process_group_id>", methods=["GET"])
def process_group_show(process_group_id: str) -> str:
"""Show_process_group."""
process_group = ProcessModelService.get_process_group(process_group_id)
return render_template("process_group_show.html", process_group=process_group)
@admin_blueprint.route("/process-models/<process_model_id>", methods=["GET"])
def process_model_show(process_model_id: str) -> Union[str, Response]:
"""Show_process_model."""
process_model = ProcessModelService.get_process_model(process_model_id)
files = SpecFileService.get_files(process_model, extension_filter="bpmn")
current_file_name = process_model.primary_file_name
if current_file_name is None:
flash("No primary_file_name", "error")
return redirect(url_for("admin.process_group_list"))
bpmn_xml = SpecFileService.get_data(process_model, current_file_name)
return render_template(
"process_model_show.html",
process_model=process_model,
bpmn_xml=bpmn_xml,
files=files,
current_file_name=current_file_name,
)
@admin_blueprint.route(
"/process-models/<process_model_id>/<file_name>", methods=["GET"]
)
def process_model_show_file(process_model_id: str, file_name: str) -> str:
"""Process_model_show_file."""
process_model = ProcessModelService.get_process_model(process_model_id)
bpmn_xml = SpecFileService.get_data(process_model, file_name)
files = SpecFileService.get_files(process_model, extension_filter="bpmn")
return render_template(
"process_model_show.html",
process_model=process_model,
bpmn_xml=bpmn_xml,
files=files,
current_file_name=file_name,
)
@admin_blueprint.route(
"/process-models/<process_model_id>/upload-file", methods=["POST"]
)
def process_model_upload_file(process_model_id: str) -> Response:
"""Process_model_upload_file."""
process_model = ProcessModelService.get_process_model(process_model_id)
if "file" not in request.files:
flash("No file part", "error")
request_file = request.files["file"]
# If the user does not select a file, the browser submits an
# empty file without a filename.
if request_file.filename == "" or request_file.filename is None:
flash("No selected file", "error")
else:
if request_file and _allowed_file(request_file.filename):
if request_file.filename is not None:
SpecFileService.add_file(
process_model, request_file.filename, request_file.stream.read()
)
ProcessModelService.save_process_model(process_model)
return redirect(
url_for("admin.process_model_show", process_model_id=process_model.id)
)
@admin_blueprint.route(
"/process_models/<process_model_id>/edit/<file_name>", methods=["GET"]
)
def process_model_edit(process_model_id: str, file_name: str) -> str:
"""Edit_bpmn."""
process_model = ProcessModelService.get_process_model(process_model_id)
bpmn_xml = SpecFileService.get_data(process_model, file_name)
return render_template(
"process_model_edit.html",
bpmn_xml=bpmn_xml.decode("utf-8"),
process_model=process_model,
file_name=file_name,
)
@admin_blueprint.route(
"/process-models/<process_model_id>/save/<file_name>", methods=["POST"]
)
def process_model_save(process_model_id: str, file_name: str) -> Union[str, Response]:
"""Process_model_save."""
process_model = ProcessModelService.get_process_model(process_model_id)
SpecFileService.update_file(process_model, file_name, request.get_data())
if process_model.primary_file_name is None:
flash("No primary_file_name", "error")
return redirect(url_for("admin.process_group_list"))
bpmn_xml = SpecFileService.get_data(process_model, process_model.primary_file_name)
return render_template(
"process_model_edit.html",
bpmn_xml=bpmn_xml.decode("utf-8"),
process_model=process_model,
file_name=file_name,
)
@admin_blueprint.route("/process-models/<process_model_id>/run", methods=["GET"])
def process_model_run(process_model_id: str) -> Union[str, Response]:
"""Process_model_run."""
user = UserService.create_user("Mr. Test", "internal", "Mr. Test")
process_instance = (
ProcessInstanceService.create_process_instance_from_process_model_identifier(
process_model_id, user
)
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps()
result = processor.get_data()
process_model = ProcessModelService.get_process_model(process_model_id)
files = SpecFileService.get_files(process_model, extension_filter="bpmn")
current_file_name = process_model.primary_file_name
if current_file_name is None:
flash("No primary_file_name", "error")
return redirect(url_for("admin.process_group_list"))
bpmn_xml = SpecFileService.get_data(process_model, current_file_name)
return render_template(
"process_model_show.html",
process_model=process_model,
bpmn_xml=bpmn_xml,
result=result,
files=files,
current_file_name=current_file_name,
)
# def _find_or_create_user(username: str = "test_user1") -> Any:
# """Find_or_create_user."""
# user = UserModel.query.filter_by(username=username).first()
# if user is None:
# user = UserModel(username=username)
# db.session.add(user)
# db.session.commit()
# return user
def _allowed_file(filename: str) -> bool:
"""_allowed_file."""
return (
"." in filename
and filename.rsplit(".", 1)[1].lower() in ALLOWED_BPMN_EXTENSIONS
)

View File

@ -1,26 +0,0 @@
import BpmnViewer from "bpmn-js";
var viewer = new BpmnViewer({
container: "#canvas",
});
viewer
.importXML(pizzaDiagram)
.then(function (result) {
const { warnings } = result;
console.log("success !", warnings);
viewer.get("canvas").zoom("fit-viewport");
})
.catch(function (err) {
const { warnings, message } = err;
console.log("something went wrong:", warnings, message);
});
export function sayHello() {
console.log("hello");
}
window.foo = "bar";

View File

@ -1,18 +0,0 @@
{
"name": "spiffworkflow-backend",
"version": "0.0.0",
"description": "Serve up Spiff Workflows to the World!",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"bpmn-js": "^9.1.0",
"bpmn-js-properties-panel": "^1.1.1"
},
"devDependencies": {
"webpack-cli": "^4.9.2"
}
}

View File

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html>
<head>
{% block head %}
<link
rel="stylesheet"
href="{{ url_for('admin.static', filename='style.css') }}"
/>
<title>{% block title %}{% endblock %}</title>
{% endblock %}
</head>
<body>
<h1>{{ self.title() }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %} {% if
messages %}
<ul class="flashes">
{% for category, message in messages %}
<li class="{{ category }}">{{ message }}</li>
{% endfor %}
</ul>
{% endif %} {% endwith %} {% block content %}{% endblock %}
</body>
</html>

View File

@ -1,18 +0,0 @@
{% extends "layout.html" %} {% block title %}Process Groups{% endblock %} {%
block content %}
<table>
<tbody>
{# here we iterate over every item in our list#} {% for process_group in
process_groups %}
<tr>
<td>
<a
href="{{ url_for('admin.process_group_show', process_group_id=process_group.id) }}"
>{{ process_group.display_name }}</a
>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -1,25 +0,0 @@
{% extends "layout.html" %}
{% block title %}Process Group: {{ process_group.id }}{% endblock %}
{% block content %}
<button
type="button"
onclick="window.location.href='{{ url_for( 'admin.process_group_list') }}';"
>
Back
</button>
<table>
<tbody>
{# here we iterate over every item in our list#}
{% for process_model in process_group.process_models %}
<tr>
<td>
<a
href="{{ url_for('admin.process_model_show', process_model_id=process_model.id) }}"
>{{ process_model.display_name }}</a
>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@ -1,167 +0,0 @@
{% extends "layout.html" %} {% block title %}
Process Model Edit: {{ process_model.id }}
{% endblock %}
{% block head %}
<meta charset="UTF-8" />
<!-- example styles -->
<!-- required modeler styles -->
<link rel="stylesheet" href="https://unpkg.com/bpmn-js@9.1.0/dist/assets/bpmn-js.css" />
<link rel="stylesheet" href="https://unpkg.com/bpmn-js@9.1.0/dist/assets/diagram-js.css" />
<link rel="stylesheet" href="https://unpkg.com/bpmn-js@9.1.0/dist/assets/bpmn-font/css/bpmn.css" />
<link rel="stylesheet" href="https://unpkg.com/bpmn-js-properties-panel/dist/assets/properties-panel.css">
<link rel="stylesheet" href="https://unpkg.com/bpmn-js-properties-panel/dist/assets/element-templates.css">
<!-- modeler distro -->
<script src="https://unpkg.com/bpmn-js@9.1.0/dist/bpmn-modeler.development.js"></script>
<!-- needed for this example only -->
<script src="https://unpkg.com/jquery@3.3.1/dist/jquery.js"></script>
<!-- example styles -->
<style>
html, body, #canvas {
height: 100%;
padding: 0;
margin: 0;
}
.diagram-note {
background-color: rgba(66, 180, 21, 0.7);
color: White;
border-radius: 5px;
font-family: Arial;
font-size: 12px;
padding: 5px;
min-height: 16px;
width: 50px;
text-align: center;
}
.needs-discussion:not(.djs-connection) .djs-visual > :nth-child(1) {
stroke: rgba(66, 180, 21, 0.7) !important; /* color elements as red */
}
#save-button {
position: fixed;
bottom: 20px;
left: 20px;
}
</style>
{% endblock %}
{% block content %}
<div id="result">{{ result }}</div>
<button
type="button"
onclick="window.location.href='{{ url_for( 'admin.process_model_show_file', process_model_id=process_model.id, file_name=file_name ) }}';"
>
Back
</button>
<button type="button" onclick="exportDiagram()">Save</button>
<!-- <div class="modeler"> -->
<div id="canvas"></div>
<div id="properties"></div>
<!-- </div> -->
<meta id="bpmn_xml" data-name="{{bpmn_xml}}" />
<script>
// import BpmnModeler from '/admin/static/node_modules/bpmn-js/lib/Modeler.js';
// import {
// BpmnPropertiesPanelModule,
// BpmnPropertiesProviderModule,
// } from '/admin/static/node_modules/bpmn-js-properties-panel/dist/index.js';
//
// const bpmnModeler = new BpmnModeler({
// container: '#canvas',
// propertiesPanel: {
// parent: '#properties'
// },
// additionalModules: [
// BpmnPropertiesPanelModule,
// BpmnPropertiesProviderModule
// ]
// });
// modeler instance
var bpmnModeler = new BpmnJS({
container: "#canvas",
keyboard: {
bindTo: window,
},
});
/**
* Save diagram contents and print them to the console.
*/
async function exportDiagram() {
try {
var data = await bpmnModeler.saveXML({ format: true });
//POST request with body equal on data in JSON format
fetch("/admin/process-models/{{ process_model.id }}/save/{{ file_name }}", {
method: "POST",
headers: {
"Content-Type": "text/xml",
},
body: data.xml,
})
.then((response) => response.json())
//Then with the data from the response in JSON...
.then((data) => {
console.log("Success:", data);
})
//Then with the error genereted...
.catch((error) => {
console.error("Error:", error);
});
alert("Diagram exported. Check the developer tools!");
} catch (err) {
console.error("could not save BPMN 2.0 diagram", err);
}
}
/**
* Open diagram in our modeler instance.
*
* @param {String} bpmnXML diagram to display
*/
async function openDiagram(bpmnXML) {
// import diagram
try {
await bpmnModeler.importXML(bpmnXML);
// access modeler components
var canvas = bpmnModeler.get("canvas");
var overlays = bpmnModeler.get("overlays");
// zoom to fit full viewport
canvas.zoom("fit-viewport");
// attach an overlay to a node
overlays.add("SCAN_OK", "note", {
position: {
bottom: 0,
right: 0,
},
html: '<div class="diagram-note">Mixed up the labels?</div>',
});
// add marker
canvas.addMarker("SCAN_OK", "needs-discussion");
} catch (err) {
console.error("could not import BPMN 2.0 diagram", err);
}
}
// trying to use the python variable bpmn_xml directly causes the xml to have escape sequences
// and using the meta tag seems to help with that
var bpmn_xml = $("#bpmn_xml").data();
openDiagram(bpmn_xml.name);
// wire save button
$("#save-button").click(exportDiagram);
</script>
{% endblock %}

View File

@ -1,159 +0,0 @@
{% extends "layout.html" %} {% block title %}Process Model: {{ process_model.id
}}{% endblock %} {% block head %} {{ super() }}
<meta charset="UTF-8" />
<script src="{{ url_for('admin.static', filename='node_modules/bpmn-js/dist/bpmn-viewer.development.js') }}"></script>
<!-- viewer distro (without pan and zoom) -->
<!--
<script src="https://unpkg.com/bpmn-js@9.1.0/dist/bpmn-viewer.development.js"></script>
-->
<!-- required viewer styles -->
<link
rel="stylesheet"
href="https://unpkg.com/bpmn-js@9.1.0/dist/assets/bpmn-js.css"
/>
<!-- viewer distro (with pan and zoom) -->
<script src="https://unpkg.com/bpmn-js@9.1.0/dist/bpmn-navigated-viewer.development.js"></script>
<!-- needed for this example only -->
<script src="https://unpkg.com/jquery@3.3.1/dist/jquery.js"></script>
<!-- example styles -->
<style>
html,
body,
#canvas {
height: 90%;
padding: 0;
margin: 0;
}
.diagram-note {
background-color: rgba(66, 180, 21, 0.7);
color: White;
border-radius: 5px;
font-family: Arial;
font-size: 12px;
padding: 5px;
min-height: 16px;
width: 50px;
text-align: center;
}
.needs-discussion:not(.djs-connection) .djs-visual > :nth-child(1) {
stroke: rgba(66, 180, 21, 0.7) !important; /* color elements as red */
}
</style>
{% endblock %} {% block content %}
<div id="result">{{ result }}</div>
<button
type="button"
onclick="window.location.href='{{ url_for( 'admin.process_group_show', process_group_id=process_model.process_group_id ) }}';"
>
Back
</button>
<button
type="button"
onclick="window.location.href='{{ url_for( 'admin.process_model_run' , process_model_id=process_model.id ) }}';"
>
Run
</button>
<button
type="button"
onclick="window.location.href='{{ url_for( 'admin.process_model_edit' , process_model_id=process_model.id, file_name=current_file_name ) }}';"
>
Edit
</button>
{% if files %}
<h3>BPMN Files</h3>
<ul>
{% for file in files %}
<li>
<a
href="{{ url_for('admin.process_model_show_file', process_model_id=process_model.id, file_name=file.name) }}"
>{{ file.name }}</a
>
{% if file.name == current_file_name %} (current) {% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<form
method="post"
action="/admin/process-models/{{process_model.id}}/upload-file"
enctype="multipart/form-data"
>
<input type="file" name="file" />
<input type="submit" value="Upload" />
</form>
<div id="canvas"></div>
<meta id="bpmn_xml" data-name="{{bpmn_xml}}" />
<script>
var diagramUrl =
"https://cdn.staticaly.com/gh/bpmn-io/bpmn-js-examples/dfceecba/starter/diagram.bpmn";
// viewer instance
var bpmnViewer = new BpmnJS({
container: "#canvas",
});
/**
* Open diagram in our viewer instance.
*
* @param {String} bpmnXML diagram to display
*/
async function openDiagram(bpmnXML) {
// import diagram
try {
await bpmnViewer.importXML(bpmnXML);
// access viewer components
var canvas = bpmnViewer.get("canvas");
var overlays = bpmnViewer.get("overlays");
// zoom to fit full viewport
canvas.zoom("fit-viewport");
// attach an overlay to a node
overlays.add("SCAN_OK", "note", {
position: {
bottom: 0,
right: 0,
},
html: '<div class="diagram-note">Mixed up the labels?</div>',
});
// add marker
canvas.addMarker("SCAN_OK", "needs-discussion");
} catch (err) {
console.error("could not import BPMN 2.0 diagram", err);
}
}
var bpmn_xml = $("#bpmn_xml").data();
openDiagram(bpmn_xml.name);
// load external diagram file via AJAX and open it
//$.get(diagramUrl, openDiagram, 'text');
</script>
<!--
Thanks for trying out our BPMN toolkit!
If you'd like to learn more about what our library,
continue with some more basic examples:
* https://github.com/bpmn-io/bpmn-js-examples/overlays
* https://github.com/bpmn-io/bpmn-js-examples/interaction
* https://github.com/bpmn-io/bpmn-js-examples/colors
* https://github.com/bpmn-io/bpmn-js-examples/commenting
To get a bit broader overview over how bpmn-js works,
follow our walkthrough:
* https://bpmn.io/toolkit/bpmn-js/walkthrough/
Related starters:
* https://raw.githubusercontent.com/bpmn-io/bpmn-js-examples/starter/modeler.html
-->
{% endblock %}

View File

@ -72,6 +72,18 @@ def process_instance_create(
process_model_identifier = _un_modify_modified_process_model_id(
modified_process_model_identifier
)
process_model = _get_process_model(process_model_identifier)
if process_model.primary_file_name is None:
raise ApiError(
error_code="process_model_missing_primary_bpmn_file",
message=(
f"Process Model '{process_model_identifier}' does not have a primary"
" bpmn file. One must be set in order to instantiate this model."
),
status_code=400,
)
process_instance = (
ProcessInstanceService.create_process_instance_from_process_model_identifier(
process_model_identifier, g.user
@ -102,6 +114,7 @@ def process_instance_run(
)
processor = ProcessInstanceProcessor(process_instance)
processor.lock_process_instance("Web")
if do_engine_steps:
try:
@ -118,6 +131,8 @@ def process_instance_run(
status_code=400,
task=task,
) from e
finally:
processor.unlock_process_instance("Web")
if not current_app.config["RUN_BACKGROUND_SCHEDULER"]:
MessageService.process_message_instances()
@ -658,6 +673,9 @@ def _get_process_instance(
spec_reference.process_model_id
)
name_of_file_with_diagram = spec_reference.file_name
process_instance.process_model_with_diagram_identifier = (
process_model_with_diagram.id
)
else:
process_model_with_diagram = _get_process_model(process_model_identifier)
if process_model_with_diagram.primary_file_name:
@ -679,7 +697,8 @@ def _get_process_instance(
)
process_instance.bpmn_xml_file_contents = bpmn_xml_file_contents
return make_response(jsonify(process_instance), 200)
process_instance_as_dict = process_instance.serialized_with_metadata()
return make_response(jsonify(process_instance_as_dict), 200)
def _find_process_instance_for_me_or_raise(

View File

@ -149,7 +149,30 @@ def process_model_update(
}
process_model = _get_process_model(process_model_identifier)
# FIXME: the logic to update the the process id would be better if it could go into the
# process model save method but this causes circular imports with SpecFileService.
# All we really need this for is to get the process id from a bpmn file so maybe that could
# all be moved to FileSystemService.
update_primary_bpmn_file = False
if (
"primary_file_name" in body_filtered
and "primary_process_id" not in body_filtered
):
if process_model.primary_file_name != body_filtered["primary_file_name"]:
update_primary_bpmn_file = True
ProcessModelService.update_process_model(process_model, body_filtered)
# update the file to ensure we get the correct process id if the primary file changed.
if update_primary_bpmn_file and process_model.primary_file_name:
primary_file_contents = SpecFileService.get_data(
process_model, process_model.primary_file_name
)
SpecFileService.update_file(
process_model, process_model.primary_file_name, primary_file_contents
)
_commit_and_push_to_git(
f"User: {g.user.username} updated process model {process_model_identifier}"
)
@ -277,6 +300,17 @@ def process_model_file_delete(
"""Process_model_file_delete."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
process_model = _get_process_model(process_model_identifier)
if process_model.primary_file_name == file_name:
raise ApiError(
error_code="process_model_file_cannot_be_deleted",
message=(
f"'{file_name}' is the primary bpmn file for"
f" '{process_model_identifier}' and cannot be deleted. Please set"
" another file as the primary before attempting to delete this one."
),
status_code=400,
)
try:
SpecFileService.delete_file(process_model, file_name)
except FileNotFoundError as exception:

View File

@ -377,6 +377,7 @@ def task_submit(
)
)
processor.lock_process_instance("Web")
ProcessInstanceService.complete_form_task(
processor=processor,
spiff_task=spiff_task,
@ -384,6 +385,7 @@ def task_submit(
user=g.user,
human_task=human_task,
)
processor.unlock_process_instance("Web")
# If we need to update all tasks, then get the next ready task and if it a multi-instance with the same
# task spec, complete that form as well.

View File

@ -77,11 +77,17 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [
]
class UserToGroupDict(TypedDict):
username: str
group_identifier: str
class DesiredPermissionDict(TypedDict):
"""DesiredPermissionDict."""
group_identifiers: Set[str]
permission_assignments: list[PermissionAssignmentModel]
user_to_group_identifiers: list[UserToGroupDict]
class AuthorizationService:
@ -212,6 +218,7 @@ class AuthorizationService:
default_group = None
unique_user_group_identifiers: Set[str] = set()
user_to_group_identifiers: list[UserToGroupDict] = []
if "default_group" in permission_configs:
default_group_identifier = permission_configs["default_group"]
default_group = GroupService.find_or_create_group(default_group_identifier)
@ -231,6 +238,11 @@ class AuthorizationService:
)
)
continue
user_to_group_dict: UserToGroupDict = {
"username": user.username,
"group_identifier": group_identifier,
}
user_to_group_identifiers.append(user_to_group_dict)
cls.associate_user_with_group(user, group)
permission_assignments = []
@ -275,6 +287,7 @@ class AuthorizationService:
return {
"group_identifiers": unique_user_group_identifiers,
"permission_assignments": permission_assignments,
"user_to_group_identifiers": user_to_group_identifiers,
}
@classmethod
@ -735,13 +748,20 @@ class AuthorizationService:
def refresh_permissions(cls, group_info: list[dict[str, Any]]) -> None:
"""Adds new permission assignments and deletes old ones."""
initial_permission_assignments = PermissionAssignmentModel.query.all()
initial_user_to_group_assignments = UserGroupAssignmentModel.query.all()
result = cls.import_permissions_from_yaml_file()
desired_permission_assignments = result["permission_assignments"]
desired_group_identifiers = result["group_identifiers"]
desired_user_to_group_identifiers = result["user_to_group_identifiers"]
for group in group_info:
group_identifier = group["name"]
for username in group["users"]:
user_to_group_dict: UserToGroupDict = {
"username": username,
"group_identifier": group_identifier,
}
desired_user_to_group_identifiers.append(user_to_group_dict)
GroupService.add_user_to_group_or_add_to_waiting(
username, group_identifier
)
@ -761,6 +781,14 @@ class AuthorizationService:
if ipa not in desired_permission_assignments:
db.session.delete(ipa)
for iutga in initial_user_to_group_assignments:
current_user_dict: UserToGroupDict = {
"username": iutga.user.username,
"group_identifier": iutga.group.identifier,
}
if current_user_dict not in desired_user_to_group_identifiers:
db.session.delete(iutga)
groups_to_delete = GroupModel.query.filter(
GroupModel.identifier.not_in(desired_group_identifiers)
).all()

View File

@ -69,6 +69,7 @@ from SpiffWorkflow.spiff.serializer.task_spec_converters import UserTaskConverte
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.task import TaskState
from SpiffWorkflow.util.deep_merge import DeepMerge # type: ignore
from sqlalchemy import text
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.models.db import db
@ -141,6 +142,14 @@ class MissingProcessInfoError(Exception):
"""MissingProcessInfoError."""
class ProcessInstanceIsAlreadyLockedError(Exception):
pass
class ProcessInstanceLockedBySomethingElseError(Exception):
pass
class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore
"""This is a custom script processor that can be easily injected into Spiff Workflow.
@ -761,6 +770,10 @@ class ProcessInstanceProcessor:
complete_states = [TaskState.CANCELLED, TaskState.COMPLETED]
user_tasks = list(self.get_all_user_tasks())
self.process_instance_model.status = self.get_status().value
current_app.logger.debug(
f"the_status: {self.process_instance_model.status} for instance"
f" {self.process_instance_model.id}"
)
self.process_instance_model.total_tasks = len(user_tasks)
self.process_instance_model.completed_tasks = sum(
1 for t in user_tasks if t.state in complete_states
@ -777,7 +790,7 @@ class ProcessInstanceProcessor:
db.session.commit()
human_tasks = HumanTaskModel.query.filter_by(
process_instance_id=self.process_instance_model.id
process_instance_id=self.process_instance_model.id, completed=False
).all()
ready_or_waiting_tasks = self.get_all_ready_or_waiting_tasks()
process_model_display_name = ""
@ -1142,7 +1155,55 @@ class ProcessInstanceProcessor:
def get_status(self) -> ProcessInstanceStatus:
"""Get_status."""
return self.status_of(self.bpmn_process_instance)
the_status = self.status_of(self.bpmn_process_instance)
# current_app.logger.debug(f"the_status: {the_status} for instance {self.process_instance_model.id}")
return the_status
# inspiration from https://github.com/collectiveidea/delayed_job_active_record/blob/master/lib/delayed/backend/active_record.rb
# could consider borrowing their "cleanup all my locks when the app quits" idea as well and
# implement via https://docs.python.org/3/library/atexit.html
def lock_process_instance(self, lock_prefix: str) -> None:
locked_by = f"{lock_prefix}_{current_app.config['PROCESS_UUID']}"
current_time_in_seconds = round(time.time())
lock_expiry_in_seconds = (
current_time_in_seconds
- current_app.config["ALLOW_CONFISCATING_LOCK_AFTER_SECONDS"]
)
query_text = text(
"UPDATE process_instance SET locked_at_in_seconds ="
" :current_time_in_seconds, locked_by = :locked_by where id = :id AND"
" (locked_by IS NULL OR locked_at_in_seconds < :lock_expiry_in_seconds);"
).execution_options(autocommit=True)
result = db.engine.execute(
query_text,
id=self.process_instance_model.id,
current_time_in_seconds=current_time_in_seconds,
locked_by=locked_by,
lock_expiry_in_seconds=lock_expiry_in_seconds,
)
# it seems like autocommit is working above (we see the statement in debug logs) but sqlalchemy doesn't
# seem to update properly so tell it to commit as well.
# if we omit this line then querying the record from a unit test doesn't ever show the record as locked.
db.session.commit()
if result.rowcount < 1:
raise ProcessInstanceIsAlreadyLockedError(
f"Cannot lock process instance {self.process_instance_model.id}."
"It has already been locked."
)
def unlock_process_instance(self, lock_prefix: str) -> None:
locked_by = f"{lock_prefix}_{current_app.config['PROCESS_UUID']}"
if self.process_instance_model.locked_by != locked_by:
raise ProcessInstanceLockedBySomethingElseError(
f"Cannot unlock process instance {self.process_instance_model.id}."
f"It locked by {self.process_instance_model.locked_by}"
)
self.process_instance_model.locked_by = None
self.process_instance_model.locked_at_in_seconds = None
db.session.add(self.process_instance_model)
db.session.commit()
# messages have one correlation key (possibly wrong)
# correlation keys may have many correlation properties

View File

@ -20,6 +20,9 @@ from spiffworkflow_backend.models.user import UserModel
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.process_instance_processor import (
ProcessInstanceIsAlreadyLockedError,
)
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceProcessor,
)
@ -74,12 +77,18 @@ class ProcessInstanceService:
.all()
)
for process_instance in records:
locked = False
processor = None
try:
current_app.logger.info(
f"Processing process_instance {process_instance.id}"
)
processor = ProcessInstanceProcessor(process_instance)
processor.lock_process_instance("Web")
locked = True
processor.do_engine_steps(save=True)
except ProcessInstanceIsAlreadyLockedError:
continue
except Exception as e:
db.session.rollback() # in case the above left the database with a bad transaction
process_instance.status = ProcessInstanceStatus.error.value
@ -91,6 +100,9 @@ class ProcessInstanceService:
+ f"({process_instance.process_model_identifier}). {str(e)}"
)
current_app.logger.error(error_message)
finally:
if locked and processor:
processor.unlock_process_instance("Web")
@staticmethod
def processor_to_process_instance_api(
@ -220,6 +232,8 @@ class ProcessInstanceService:
spiff_task.update_data(dot_dct)
# ProcessInstanceService.post_process_form(spiff_task) # some properties may update the data store.
processor.complete_task(spiff_task, human_task, user=user)
# maybe move this out once we have the interstitial page since this is here just so we can get the next human task
processor.do_engine_steps(save=True)
@staticmethod

View File

@ -0,0 +1,65 @@
<?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:process id="Process_jm3qjay" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_1w7l0lj</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_1w7l0lj" sourceRef="StartEvent_1" targetRef="manual_task" />
<bpmn:exclusiveGateway id="loopback_gateway" default="flow_default">
<bpmn:incoming>Flow_1ouak9p</bpmn:incoming>
<bpmn:outgoing>flow_default</bpmn:outgoing>
<bpmn:outgoing>flow_x_equals_one</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:sequenceFlow id="Flow_1ouak9p" sourceRef="manual_task" targetRef="loopback_gateway" />
<bpmn:endEvent id="Event_1we3snj">
<bpmn:incoming>flow_default</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="flow_default" sourceRef="loopback_gateway" targetRef="Event_1we3snj" />
<bpmn:manualTask id="manual_task" name="Manual task">
<bpmn:extensionElements>
<spiffworkflow:instructionsForEndUser>HEY</spiffworkflow:instructionsForEndUser>
<spiffworkflow:preScript>x = 1</spiffworkflow:preScript>
</bpmn:extensionElements>
<bpmn:incoming>Flow_1w7l0lj</bpmn:incoming>
<bpmn:incoming>flow_x_equals_one</bpmn:incoming>
<bpmn:outgoing>Flow_1ouak9p</bpmn:outgoing>
</bpmn:manualTask>
<bpmn:sequenceFlow id="flow_x_equals_one" sourceRef="loopback_gateway" targetRef="manual_task">
<bpmn:conditionExpression>x == 1</bpmn:conditionExpression>
</bpmn:sequenceFlow>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_jm3qjay">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_15tztve_di" bpmnElement="loopback_gateway" isMarkerVisible="true">
<dc:Bounds x="425" y="152" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1we3snj_di" bpmnElement="Event_1we3snj">
<dc:Bounds x="532" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1apgvvn_di" bpmnElement="manual_task">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_1w7l0lj_di" bpmnElement="Flow_1w7l0lj">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1ouak9p_di" bpmnElement="Flow_1ouak9p">
<di:waypoint x="370" y="177" />
<di:waypoint x="425" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0icwqfm_di" bpmnElement="flow_default">
<di:waypoint x="475" y="177" />
<di:waypoint x="532" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0jnhclm_di" bpmnElement="flow_x_equals_one">
<di:waypoint x="450" y="152" />
<di:waypoint x="450" y="100" />
<di:waypoint x="348" y="137" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -922,6 +922,28 @@ class TestProcessApi(BaseTest):
assert response.json is not None
assert response.json["error_code"] == "process_model_file_cannot_be_found"
def test_process_model_file_delete_when_primary_file(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
process_model_identifier = self.create_group_and_model_with_bpmn(
client, with_super_admin_user
)
modified_process_model_identifier = process_model_identifier.replace("/", ":")
response = client.delete(
f"/v1.0/process-models/{modified_process_model_identifier}/files/random_fact.bpmn",
follow_redirects=True,
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 400
assert response.json is not None
assert response.json["error_code"] == "process_model_file_cannot_be_deleted"
def test_process_model_file_delete(
self,
app: Flask,
@ -935,8 +957,16 @@ class TestProcessApi(BaseTest):
)
modified_process_model_identifier = process_model_identifier.replace("/", ":")
self.create_spec_file(
client,
process_model_id=process_model_identifier,
file_name="second_file.json",
file_data=b"<h1>HEY</h1>",
user=with_super_admin_user,
)
response = client.delete(
f"/v1.0/process-models/{modified_process_model_identifier}/files/random_fact.bpmn",
f"/v1.0/process-models/{modified_process_model_identifier}/files/second_file.json",
follow_redirects=True,
headers=self.logged_in_headers(with_super_admin_user),
)
@ -946,7 +976,7 @@ class TestProcessApi(BaseTest):
assert response.json["ok"]
response = client.get(
f"/v1.0/process-models/{modified_process_model_identifier}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_identifier}/files/second_file.json",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 404

View File

@ -1,4 +1,6 @@
"""Test_get_localtime."""
from operator import itemgetter
from flask.app import Flask
from flask.testing import FlaskClient
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
@ -57,4 +59,8 @@ class TestGetAllPermissions(BaseTest):
]
permissions = GetAllPermissions().run(script_attributes_context)
assert permissions == expected_permissions
sorted_permissions = sorted(permissions, key=itemgetter("uri"))
sorted_expected_permissions = sorted(
expected_permissions, key=itemgetter("uri")
)
assert sorted_permissions == sorted_expected_permissions

View File

@ -381,18 +381,27 @@ class TestAuthorizationService(BaseTest):
) -> None:
"""Test_can_refresh_permissions."""
user = self.find_or_create_user(username="user_one")
user_two = self.find_or_create_user(username="user_two")
admin_user = self.find_or_create_user(username="testadmin1")
# this group is not mentioned so it will get deleted
GroupService.find_or_create_group("group_two")
assert GroupModel.query.filter_by(identifier="group_two").first() is not None
GroupService.find_or_create_group("group_three")
assert GroupModel.query.filter_by(identifier="group_three").first() is not None
group_info = [
{
"users": ["user_one"],
"users": ["user_one", "user_two"],
"name": "group_one",
"permissions": [{"actions": ["create", "read"], "uri": "PG:hey"}],
}
},
{
"users": ["user_two"],
"name": "group_three",
"permissions": [{"actions": ["create", "read"], "uri": "PG:hey2"}],
},
]
AuthorizationService.refresh_permissions(group_info)
assert GroupModel.query.filter_by(identifier="group_two").first() is None
@ -402,12 +411,32 @@ class TestAuthorizationService(BaseTest):
self.assert_user_has_permission(user, "read", "/v1.0/process-groups/hey:yo")
self.assert_user_has_permission(user, "create", "/v1.0/process-groups/hey:yo")
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey")
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey:yo")
self.assert_user_has_permission(
user_two, "create", "/v1.0/process-groups/hey:yo"
)
assert GroupModel.query.filter_by(identifier="group_three").first() is not None
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey2")
self.assert_user_has_permission(
user_two, "read", "/v1.0/process-groups/hey2:yo"
)
self.assert_user_has_permission(
user_two, "create", "/v1.0/process-groups/hey2:yo"
)
# remove access to 'hey' from user_two
group_info = [
{
"users": ["user_one"],
"name": "group_one",
"permissions": [{"actions": ["read"], "uri": "PG:hey"}],
}
},
{
"users": ["user_two"],
"name": "group_three",
"permissions": [{"actions": ["create", "read"], "uri": "PG:hey2"}],
},
]
AuthorizationService.refresh_permissions(group_info)
assert GroupModel.query.filter_by(identifier="group_one").first() is not None
@ -417,3 +446,15 @@ class TestAuthorizationService(BaseTest):
user, "create", "/v1.0/process-groups/hey:yo", expected_result=False
)
self.assert_user_has_permission(admin_user, "create", "/anything-they-want")
self.assert_user_has_permission(
user_two, "read", "/v1.0/process-groups/hey", expected_result=False
)
assert GroupModel.query.filter_by(identifier="group_three").first() is not None
self.assert_user_has_permission(user_two, "read", "/v1.0/process-groups/hey2")
self.assert_user_has_permission(
user_two, "read", "/v1.0/process-groups/hey2:yo"
)
self.assert_user_has_permission(
user_two, "create", "/v1.0/process-groups/hey2:yo"
)

View File

@ -7,12 +7,19 @@ from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from tests.spiffworkflow_backend.helpers.test_data import load_test_spec
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus
from spiffworkflow_backend.models.user import UserModel
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import (
UserDoesNotHaveAccessToTaskError,
)
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceIsAlreadyLockedError,
)
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceLockedBySomethingElseError,
)
from spiffworkflow_backend.services.process_instance_processor import (
ProcessInstanceProcessor,
)
@ -170,7 +177,6 @@ class TestProcessInstanceProcessor(BaseTest):
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True)
processor.save()
assert len(process_instance.active_human_tasks) == 1
human_task = process_instance.active_human_tasks[0]
@ -293,3 +299,81 @@ class TestProcessInstanceProcessor(BaseTest):
assert len(process_instance.active_human_tasks) == 1
assert initial_human_task_id == process_instance.active_human_tasks[0].id
def test_it_can_lock_and_unlock_a_process_instance(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
initiator_user = self.find_or_create_user("initiator_user")
process_model = load_test_spec(
process_model_id="test_group/model_with_lanes",
bpmn_file_name="lanes_with_owner_dict.bpmn",
process_model_source_directory="model_with_lanes",
)
process_instance = self.create_process_instance_from_process_model(
process_model=process_model, user=initiator_user
)
processor = ProcessInstanceProcessor(process_instance)
assert process_instance.locked_by is None
assert process_instance.locked_at_in_seconds is None
processor.lock_process_instance("TEST")
process_instance = ProcessInstanceModel.query.filter_by(
id=process_instance.id
).first()
assert process_instance.locked_by is not None
assert process_instance.locked_at_in_seconds is not None
with pytest.raises(ProcessInstanceIsAlreadyLockedError):
processor.lock_process_instance("TEST")
with pytest.raises(ProcessInstanceLockedBySomethingElseError):
processor.unlock_process_instance("TEST2")
processor.unlock_process_instance("TEST")
process_instance = ProcessInstanceModel.query.filter_by(
id=process_instance.id
).first()
assert process_instance.locked_by is None
assert process_instance.locked_at_in_seconds is None
def test_it_can_loopback_to_previous_bpmn_task_with_gateway(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
) -> None:
initiator_user = self.find_or_create_user("initiator_user")
process_model = load_test_spec(
process_model_id="test_group/loopback_to_manual_task",
bpmn_file_name="loopback.bpmn",
process_model_source_directory="loopback_to_manual_task",
)
process_instance = self.create_process_instance_from_process_model(
process_model=process_model, user=initiator_user
)
processor = ProcessInstanceProcessor(process_instance)
processor.do_engine_steps(save=True)
assert len(process_instance.active_human_tasks) == 1
assert len(process_instance.human_tasks) == 1
human_task_one = process_instance.active_human_tasks[0]
spiff_task = processor.__class__.get_task_by_bpmn_identifier(
human_task_one.task_name, processor.bpmn_process_instance
)
ProcessInstanceService.complete_form_task(
processor, spiff_task, {}, initiator_user, human_task_one
)
assert len(process_instance.active_human_tasks) == 1
assert len(process_instance.human_tasks) == 2
human_task_two = process_instance.active_human_tasks[0]
# this is just asserting the way the functionality currently works in spiff.
# we would actually expect this to change one day if we stop reusing the same guid
# when we re-do a task.
assert human_task_two.task_id == human_task_one.task_id

View File

@ -115,9 +115,11 @@ export default function ProcessInstanceRun({
};
const processInstanceCreateAndRun = () => {
setErrorObject(null);
HttpService.makeCallToBackend({
path: processInstanceCreatePath,
successCallback: processModelRun,
failureCallback: setErrorObject,
httpMethod: 'POST',
});
};

View File

@ -173,6 +173,10 @@ h1.with-icons {
margin-top: 1.3em;
}
.with-top-margin-for-label-next-to-text-input {
margin-top: 2.3em;
}
.with-tiny-top-margin {
margin-top: 4px;
}

View File

@ -66,6 +66,12 @@ export interface ProcessFile {
file_contents?: string;
}
export interface ProcessInstanceMetadata {
id: number;
key: string;
value: string;
}
export interface ProcessInstance {
id: number;
process_model_identifier: string;
@ -80,6 +86,8 @@ export interface ProcessInstance {
updated_at_in_seconds: number;
bpmn_version_control_identifier: string;
bpmn_version_control_type: string;
process_metadata?: ProcessInstanceMetadata[];
process_model_with_diagram_identifier?: string;
}
export interface MessageCorrelationProperties {

View File

@ -12,7 +12,7 @@ import {
} from '@carbon/react';
import validator from '@rjsf/validator-ajv8';
import { FormField, JsonSchemaForm } from '../interfaces';
import Form from '../themes/carbon';
import { Form } from '../themes/carbon';
import {
modifyProcessIdentifierForPathParam,
slugifyString,

View File

@ -35,6 +35,7 @@ import HttpService from '../services/HttpService';
import ReactDiagramEditor from '../components/ReactDiagramEditor';
import {
convertSecondsToFormattedDateTime,
modifyProcessIdentifierForPathParam,
unModifyProcessIdentifierForPathParam,
} from '../helpers';
import ButtonWithConfirmation from '../components/ButtonWithConfirmation';
@ -43,6 +44,7 @@ import {
PermissionsToCheck,
ProcessData,
ProcessInstance,
ProcessInstanceMetadata,
ProcessInstanceTask,
} from '../interfaces';
import { usePermissionFetcher } from '../hooks/PermissionService';
@ -74,6 +76,8 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
const [eventTextEditorEnabled, setEventTextEditorEnabled] =
useState<boolean>(false);
const [displayDetails, setDisplayDetails] = useState<boolean>(false);
const [showProcessInstanceMetadata, setShowProcessInstanceMetadata] =
useState<boolean>(false);
const { addError, removeError } = useAPIError();
const unModifiedProcessModelId = unModifyProcessIdentifierForPathParam(
@ -391,6 +395,23 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
{processInstance.process_initiator_username}
</Column>
</Grid>
{processInstance.process_model_with_diagram_identifier ? (
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
Current Diagram:{' '}
</Column>
<Column sm={4} md={6} lg={8} className="grid-date">
<Link
data-qa="go-to-current-diagram-process-model"
to={`/admin/process-models/${modifyProcessIdentifierForPathParam(
processInstance.process_model_with_diagram_identifier || ''
)}`}
>
{processInstance.process_model_with_diagram_identifier}
</Link>
</Column>
</Grid>
) : null}
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
Started:{' '}
@ -445,6 +466,19 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
Messages
</Button>
</Can>
{processInstance.process_metadata &&
processInstance.process_metadata.length > 0 ? (
<Button
size="sm"
className="button-white-background"
data-qa="process-instance-show-metadata"
onClick={() => {
setShowProcessInstanceMetadata(true);
}}
>
Metadata
</Button>
) : null}
</ButtonSet>
</Column>
</Grid>
@ -899,6 +933,41 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
);
};
const processInstanceMetadataArea = () => {
if (
!processInstance ||
(processInstance.process_metadata &&
processInstance.process_metadata.length < 1)
) {
return null;
}
const metadataComponents: any[] = [];
(processInstance.process_metadata || []).forEach(
(processInstanceMetadata: ProcessInstanceMetadata) => {
metadataComponents.push(
<Grid condensed fullWidth>
<Column sm={1} md={1} lg={2} className="grid-list-title">
{processInstanceMetadata.key}
</Column>
<Column sm={3} md={3} lg={3} className="grid-date">
{processInstanceMetadata.value}
</Column>
</Grid>
);
}
);
return (
<Modal
open={showProcessInstanceMetadata}
modalHeading="Metadata"
passiveModal
onRequestClose={() => setShowProcessInstanceMetadata(false)}
>
{metadataComponents}
</Modal>
);
};
const taskUpdateDisplayArea = () => {
const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay };
const candidateEvents: any = getEvents(taskToUse);
@ -1030,6 +1099,7 @@ export default function ProcessInstanceShow({ variant }: OwnProps) {
<br />
{taskUpdateDisplayArea()}
{processDataDisplayArea()}
{processInstanceMetadataArea()}
{stepsElement()}
<br />
<ReactDiagramEditor

View File

@ -14,6 +14,9 @@ import {
Tab,
TabPanels,
TabPanel,
TextInput,
Grid,
Column,
// @ts-ignore
} from '@carbon/react';
import Row from 'react-bootstrap/Row';
@ -60,6 +63,8 @@ export default function ProcessModelEditDiagram() {
const [processes, setProcesses] = useState<ProcessReference[]>([]);
const [displaySaveFileMessage, setDisplaySaveFileMessage] =
useState<boolean>(false);
const [processModelFileInvalidText, setProcessModelFileInvalidText] =
useState<string>('');
const handleShowMarkdownEditor = () => setShowMarkdownEditor(true);
@ -160,6 +165,7 @@ export default function ProcessModelEditDiagram() {
const handleFileNameCancel = () => {
setShowFileNameEditor(false);
setNewFileName('');
setProcessModelFileInvalidText('');
};
const navigateToProcessModelFile = (_result: any) => {
@ -251,6 +257,11 @@ export default function ProcessModelEditDiagram() {
const handleFileNameSave = (event: any) => {
event.preventDefault();
if (!newFileName) {
setProcessModelFileInvalidText('Process Model file name is required.');
return;
}
setProcessModelFileInvalidText('');
setShowFileNameEditor(false);
saveDiagram(bpmnXmlForDiagramRendering);
};
@ -267,17 +278,32 @@ export default function ProcessModelEditDiagram() {
onRequestSubmit={handleFileNameSave}
onRequestClose={handleFileNameCancel}
>
<label>File Name:</label>
<span>
<input
name="file_name"
type="text"
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
autoFocus
/>
{fileExtension}
</span>
<Grid
condensed
fullWidth
className="megacondensed process-model-files-section"
>
<Column md={4} lg={8} sm={4}>
<TextInput
id="process_model_file_name"
labelText="File Name:"
value={newFileName}
onChange={(e: any) => setNewFileName(e.target.value)}
invalidText={processModelFileInvalidText}
invalid={!!processModelFileInvalidText}
size="sm"
autoFocus
/>
</Column>
<Column
md={4}
lg={8}
sm={4}
className="with-top-margin-for-label-next-to-text-input"
>
{fileExtension}
</Column>
</Grid>
</Modal>
);
};