diff --git a/.flake8 b/.flake8 index 07c407f7f..d404e7fda 100644 --- a/.flake8 +++ b/.flake8 @@ -27,3 +27,8 @@ per-file-ignores = # this file overwrites methods from the logging library so we can't change them # and ignore long comment line spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py:N802,B950 + + # 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 diff --git a/.github/workflows/release_builds.yml b/.github/workflows/release_builds.yml new file mode 100644 index 000000000..ef1c3b992 --- /dev/null +++ b/.github/workflows/release_builds.yml @@ -0,0 +1,117 @@ +name: Release Builds + +on: + push: + tags: [ v* ] + +jobs: + create_frontend_docker_container: + runs-on: ubuntu-latest + env: + REGISTRY: ghcr.io + IMAGE_NAME: sartography/spiffworkflow-frontend + permissions: + contents: read + packages: write + steps: + - name: Check out the repository + uses: actions/checkout@v3.0.2 + with: + # Disabling shallow clone is recommended for improving relevancy of reporting in sonarcloud + fetch-depth: 0 + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + context: spiffworkflow-frontend + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Frontend Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + # this action doesn't seem to respect working-directory so set context + context: spiffworkflow-frontend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + create_backend_docker_container: + runs-on: ubuntu-latest + env: + REGISTRY: ghcr.io + IMAGE_NAME: sartography/spiffworkflow-backend + permissions: + contents: read + packages: write + steps: + - name: Check out the repository + uses: actions/checkout@v3.0.2 + with: + # Disabling shallow clone is recommended for improving relevancy of reporting in sonarcloud + fetch-depth: 0 + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Backend Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + # this action doesn't seem to respect working-directory so set context + context: spiffworkflow-backend + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} +# Is this getting updated, I wonder? + create_demo-proxy: + runs-on: ubuntu-latest + env: + REGISTRY: ghcr.io + IMAGE_NAME: sartography/connector-proxy-demo + + permissions: + contents: read + packages: write + steps: + - name: Check out the repository + uses: actions/checkout@v3.0.2 + with: + # Disabling shallow clone is recommended for improving relevancy of reporting in sonarcloud + fetch-depth: 0 + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + context: connector-proxy-demo + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push the connector proxy + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + # this action doesn't seem to respect working-directory so set context + context: connector-proxy-demo + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/connector-proxy-demo/Dockerfile b/connector-proxy-demo/Dockerfile new file mode 100644 index 000000000..2e1a76b7f --- /dev/null +++ b/connector-proxy-demo/Dockerfile @@ -0,0 +1,27 @@ +FROM ghcr.io/sartography/python:3.11 + +RUN pip install poetry +RUN useradd _gunicorn --no-create-home --user-group + +RUN apt-get update && \ + apt-get install -y -q \ + gcc libssl-dev \ + curl gunicorn3 + +WORKDIR /app +COPY pyproject.toml poetry.lock /app/ +RUN poetry install --without dev + +RUN set -xe \ + && apt-get remove -y gcc python3-dev libssl-dev \ + && apt-get autoremove -y \ + && apt-get clean -y \ + && rm -rf /var/lib/apt/lists/* + +COPY . /app/ + +# run poetry install again AFTER copying the app into the image +# otherwise it does not know what the main app module is +RUN poetry install --without dev + +CMD ./bin/boot_server_in_docker diff --git a/connector-proxy-demo/bin/boot_server_in_docker b/connector-proxy-demo/bin/boot_server_in_docker new file mode 100755 index 000000000..1179bf5bb --- /dev/null +++ b/connector-proxy-demo/bin/boot_server_in_docker @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +function error_handler() { + >&2 echo "Exited with BAD EXIT CODE '${2}' in ${0} script at line: ${1}." + exit "$2" +} +trap 'error_handler ${LINENO} $?' ERR +set -o errtrace -o errexit -o nounset -o pipefail + +port="${CONNECTOR_PROXY_PORT:-}" +if [[ -z "$port" ]]; then + port=7004 +fi + +workers=3 + +# THIS MUST BE THE LAST COMMAND! +# default --limit-request-line is 4094. see https://stackoverflow.com/a/66688382/6090676 +exec poetry run gunicorn --bind "0.0.0.0:$port" --workers="$workers" --limit-request-line 8192 --timeout 90 --capture-output --access-logfile '-' --log-level debug app:app diff --git a/connector-proxy-demo/poetry.lock b/connector-proxy-demo/poetry.lock index 9147d0315..d7798e2dc 100644 --- a/connector-proxy-demo/poetry.lock +++ b/connector-proxy-demo/poetry.lock @@ -55,7 +55,7 @@ optional = false python-versions = ">=3.6.0" [package.extras] -unicode_backport = ["unicodedata2"] +unicode-backport = ["unicodedata2"] [[package]] name = "click" @@ -127,6 +127,23 @@ Flask = "*" oauthlib = ">=1.1.2,<2.0.3 || >2.0.3,<2.0.4 || >2.0.4,<2.0.5 || >2.0.5,<3.0.0" requests-oauthlib = ">=0.6.2,<1.2.0" +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +setuptools = ">=3.0" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "idna" version = "3.4" @@ -214,7 +231,7 @@ urllib3 = ">=1.21.1,<1.27" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "requests-oauthlib" @@ -245,6 +262,19 @@ botocore = ">=1.12.36,<2.0a.0" [package.extras] crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] +[[package]] +name = "setuptools" +version = "65.6.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + [[package]] name = "simplejson" version = "3.17.6" @@ -310,7 +340,7 @@ watchdog = ["watchdog"] [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "86cf682d49dc495c8cf6dc60a8aedc31ad32a293e6ceaf7b1428e0c232f8319e" +content-hash = "cc395c0c1ce2b0b7ca063a17617981b2d55db39802265b36f0bc3c4383c89919" [metadata.files] boto3 = [ @@ -350,6 +380,10 @@ Flask-OAuthlib = [ {file = "Flask-OAuthlib-0.9.6.tar.gz", hash = "sha256:5bb79c8a8e670c2eb4cb553dfc3283b6c8d1202f674934676dc173cee94fe39c"}, {file = "Flask_OAuthlib-0.9.6-py3-none-any.whl", hash = "sha256:a5c3b62959aa1922470a62b6ebf4273b75f1c29561a7eb4a69cde85d45a1d669"}, ] +gunicorn = [ + {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, +] idna = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, @@ -428,6 +462,10 @@ s3transfer = [ {file = "s3transfer-0.6.0-py3-none-any.whl", hash = "sha256:06176b74f3a15f61f1b4f25a1fc29a4429040b7647133a463da8fa5bd28d5ecd"}, {file = "s3transfer-0.6.0.tar.gz", hash = "sha256:2ed07d3866f523cc561bf4a00fc5535827981b117dd7876f036b0c1aca42c947"}, ] +setuptools = [ + {file = "setuptools-65.6.0-py3-none-any.whl", hash = "sha256:6211d2f5eddad8757bd0484923ca7c0a6302ebc4ab32ea5e94357176e0ca0840"}, + {file = "setuptools-65.6.0.tar.gz", hash = "sha256:d1eebf881c6114e51df1664bc2c9133d022f78d12d5f4f665b9191f084e2862d"}, +] simplejson = [ {file = "simplejson-3.17.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a89acae02b2975b1f8e4974cb8cdf9bf9f6c91162fb8dec50c259ce700f2770a"}, {file = "simplejson-3.17.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:82ff356ff91be0ab2293fc6d8d262451eb6ac4fd999244c4b5f863e049ba219c"}, diff --git a/connector-proxy-demo/pyproject.toml b/connector-proxy-demo/pyproject.toml index 9a6f51f88..8acd820e6 100644 --- a/connector-proxy-demo/pyproject.toml +++ b/connector-proxy-demo/pyproject.toml @@ -5,14 +5,14 @@ description = "An example showing how to use the Spiffworkflow-proxy's Flask Blu authors = ["Dan "] license = "LGPL" readme = "README.md" -packages = [{include = "connector_proxy_demo", from = "src"}] +#packages = [{include = "connector_proxy_demo", from = "."}] [tool.poetry.dependencies] python = "^3.10" Flask = "^2.2.2" spiffworkflow-proxy = {git = "https://github.com/sartography/spiffworkflow-proxy"} connector-aws = { git = "https://github.com/sartography/connector-aws.git"} - +gunicorn = "^20.1.0" [build-system] requires = ["poetry-core"] @@ -20,5 +20,5 @@ build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] pythonpath = [ - ".", "src", + "." ] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..1cf550248 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +version: "3.8" +services: + 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=my-secret-pw + - MYSQL_TCP_PORT=7003 + ports: + - "7003" + healthcheck: + test: mysql --user=root --password=my-secret-pw -e 'select 1' spiffworkflow_backend_development + interval: 10s + timeout: 5s + retries: 10 + + spiffworkflow-backend: + container_name: spiffworkflow-backend + image: ghcr.io/sartography/spiffworkflow-backend:latest + depends_on: + 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:7000/openid + - SPIFFWORKFLOW_FRONTEND_URL=http://localhost:7001 + - SPIFFWORKFLOW_BACKEND_URL=http://localhost:7000 + - SPIFFWORKFLOW_BACKEND_PORT=7000 + - SPIFFWORKFLOW_BACKEND_UPGRADE_DB=true + - SPIFFWORKFLOW_BACKEND_DATABASE_URI=mysql+mysqlconnector://root:my-secret-pw@spiffworkflow-db:7003/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: + - "7000:7000" + volumes: + - ./process_models:/app/process_models + - ./log:/app/log + healthcheck: + test: curl localhost:7000/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=7001 + ports: + - "7001:7001" + + spiffworkflow-connector: + container_name: spiffworkflow-connector + image: ghcr.io/sartography/connector-proxy-demo + environment: + - FLASK_ENV=${FLASK_ENV:-development} + - FLASK_DEBUG=0 + - FLASK_SESSION_SECRET_KEY=${FLASK_SESSION_SECRET_KEY:-super_secret_key} + ports: + - "7004:7004" + healthcheck: + test: curl localhost:7004/liveness --fail + interval: 10s + timeout: 5s + retries: 20 + + +volumes: + spiffworkflow_backend: + driver: local diff --git a/flask-bpmn/pyproject.toml b/flask-bpmn/pyproject.toml index 3cb3217a5..105fa15d6 100644 --- a/flask-bpmn/pyproject.toml +++ b/flask-bpmn/pyproject.toml @@ -64,7 +64,6 @@ sphinx-click = "^4.3.0" Pygments = "^2.13.0" pyupgrade = "^3.2.2" furo = ">=2021.11.12" -MonkeyType = "^22.2.0" [tool.poetry.scripts] flask-bpmn = "flask_bpmn.__main__:main" diff --git a/poetry.lock b/poetry.lock index 2a8d7b0d5..e5c9c4c04 100644 --- a/poetry.lock +++ b/poetry.lock @@ -614,7 +614,7 @@ werkzeug = "*" type = "git" url = "https://github.com/sartography/flask-bpmn" reference = "main" -resolved_reference = "5e40777f4013f71f2c1237f13f7dba1bdd5c0de3" +resolved_reference = "860f2387bebdaa9220e9fbf6f8fa7f74e805d0d4" [[package]] name = "flask-cors" @@ -884,22 +884,6 @@ category = "main" optional = false python-versions = ">=3.7" -[[package]] -name = "libcst" -version = "0.4.7" -description = "A concrete syntax tree with AST-like properties for Python 3.5, 3.6, 3.7, 3.8, 3.9, and 3.10 programs." -category = "dev" -optional = false -python-versions = ">=3.7" - -[package.dependencies] -pyyaml = ">=5.2" -typing-extensions = ">=3.7.4.2" -typing-inspect = ">=0.4.0" - -[package.extras] -dev = ["black (==22.3.0)", "coverage (>=4.5.4)", "fixit (==0.1.1)", "flake8 (>=3.7.8)", "hypothesis (>=4.36.0)", "hypothesmith (>=0.0.4)", "jinja2 (==3.0.3)", "jupyter (>=1.0.0)", "maturin (>=0.8.3,<0.9)", "nbsphinx (>=0.4.2)", "prompt-toolkit (>=2.0.9)", "pyre-check (==0.9.9)", "setuptools-rust (>=0.12.1)", "setuptools-scm (>=6.0.1)", "slotscheck (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "ufmt (==1.3)", "usort (==1.0.0rc1)"] - [[package]] name = "livereload" version = "2.6.3" @@ -1005,18 +989,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "monkeytype" -version = "22.2.0" -description = "Generating type annotations from sampled production types" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -libcst = ">=0.3.7" -mypy-extensions = "*" - [[package]] name = "mypy" version = "0.982" @@ -1788,7 +1760,7 @@ lxml = "*" type = "git" url = "https://github.com/sartography/SpiffWorkflow" reference = "main" -resolved_reference = "580939cc8cb0b7ade1571483bd1e28f554434ac4" +resolved_reference = "bba7ddf5478af579b891ca63c50babbfccf6b7a4" [[package]] name = "sqlalchemy" @@ -1998,18 +1970,6 @@ category = "main" optional = false python-versions = ">=3.7" -[[package]] -name = "typing-inspect" -version = "0.8.0" -description = "Runtime inspection utilities for typing module." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -mypy-extensions = ">=0.3.0" -typing-extensions = ">=3.7.4" - [[package]] name = "tzdata" version = "2022.5" @@ -2151,7 +2111,7 @@ tests-strict = ["cmake (==3.21.2)", "codecov (==2.0.15)", "ninja (==1.10.2)", "p [metadata] lock-version = "1.1" python-versions = ">=3.11,<3.12" -content-hash = "8c37333988fdd68bc6868faf474e628a690582acd17ee3b31b18e005a864fecf" +content-hash = "17e037a3784758eb23a5ed9889fd774913ebde97225692dcd9df159f03da8a22" [metadata.files] alabaster = [ @@ -2484,6 +2444,7 @@ 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"}, @@ -2492,6 +2453,7 @@ 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"}, @@ -2500,6 +2462,7 @@ 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"}, @@ -2566,32 +2529,6 @@ lazy-object-proxy = [ {file = "lazy_object_proxy-1.8.0-pp38-pypy38_pp73-any.whl", hash = "sha256:7e1561626c49cb394268edd00501b289053a652ed762c58e1081224c8d881cec"}, {file = "lazy_object_proxy-1.8.0-pp39-pypy39_pp73-any.whl", hash = "sha256:ce58b2b3734c73e68f0e30e4e725264d4d6be95818ec0a0be4bb6bf9a7e79aa8"}, ] -libcst = [ - {file = "libcst-0.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dc6f8965b6ca68d47e11321772887d81fa6fd8ea86e6ef87434ca2147de10747"}, - {file = "libcst-0.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8f47d809df59fcd83058b777b86a300154ee3a1f1b0523a398a67b5f8affd4c"}, - {file = "libcst-0.4.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0d19de56aa733b4ef024527e3ce4896d4b0e9806889797f409ec24caa651a44"}, - {file = "libcst-0.4.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31da97bc986dc3f7a97f7d431fa911932aaf716d2f8bcda947fc964afd3b57cd"}, - {file = "libcst-0.4.7-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b2e2c5e33e53669c20de0853cecfac1ffb8657ee727ab8527140f39049b820"}, - {file = "libcst-0.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:76fae68bd6b7ce069e267b3322c806b4305341cea78d161ae40e0ed641c8c660"}, - {file = "libcst-0.4.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bac76d69980bb3254f503f52128c256ef4d1bcbaabe4a17c3a9ebcd1fc0472c0"}, - {file = "libcst-0.4.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f86535271eaefe84a99736875566a038449f92e1a2a61ea0b588d8359fbefd"}, - {file = "libcst-0.4.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:617f7fa2610a8c86cf22d8d03416f25391383d05bd0ad1ca8ef68023ddd6b4f6"}, - {file = "libcst-0.4.7-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3637fffe476c5b4ee2225c6474b83382518f2c1b2fe4771039e06bdd7835a4a"}, - {file = "libcst-0.4.7-cp37-cp37m-win_amd64.whl", hash = "sha256:f56565124c2541adee0634e411b2126b3f335306d19e91ed2bfe52efa698b219"}, - {file = "libcst-0.4.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0ca2771ff3cfdf1f148349f89fcae64afa365213ed5c2703a69a89319325d0c8"}, - {file = "libcst-0.4.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:aa438131b7befc7e5a3cbadb5a7b1506305de5d62262ea0556add0152f40925e"}, - {file = "libcst-0.4.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c6bd66a8be2ffad7b968d90dae86c62fd4739c0e011d71f3e76544a891ae743"}, - {file = "libcst-0.4.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:214a9c4f4f90cd5b4bfa18e17877da4dd9a896821d9af9be86fa3effdc289b9b"}, - {file = "libcst-0.4.7-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a37f2b459a8b51a41e260bd89c24ae41ab1d658f610c91650c79b1bbf27138"}, - {file = "libcst-0.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:2f6766391d90472f036b88a95251c87d498ab068c377724f212ab0cc20509a68"}, - {file = "libcst-0.4.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:234293aa8681a3d47fef1716c5622797a81cbe85a9381fe023815468cfe20eed"}, - {file = "libcst-0.4.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa618dc359663a0a097c633452b104c1ca93365da7a811e655c6944f6b323239"}, - {file = "libcst-0.4.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3569d9901c18940632414fb7a0943bffd326db9f726a9c041664926820857815"}, - {file = "libcst-0.4.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beb5347e46b419f782589da060e9300957e71d561aa5574309883b71f93c1dfe"}, - {file = "libcst-0.4.7-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e541ccfeebda1ae5f005fc120a5bf3e8ac9ccfda405ec3efd3df54fc4688ac3"}, - {file = "libcst-0.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:3a2b7253cd2e3f0f8a3e23b5c2acb492811d865ef36e0816091c925f32b713d2"}, - {file = "libcst-0.4.7.tar.gz", hash = "sha256:95c52c2130531f6e726a3b077442cfd486975435fecf3db8224d43fba7b85099"}, -] livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, ] @@ -2729,10 +2666,6 @@ mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] -monkeytype = [ - {file = "MonkeyType-22.2.0-py3-none-any.whl", hash = "sha256:3d0815c7e98a18e9267990a452548247f6775fd636e65df5a7d77100ea7ad282"}, - {file = "MonkeyType-22.2.0.tar.gz", hash = "sha256:6b0c00b49dcc5095a2c08d28246cf005e05673fc51f64d203f9a6bca2036dfab"}, -] mypy = [ {file = "mypy-0.982-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5085e6f442003fa915aeb0a46d4da58128da69325d8213b4b35cc7054090aed5"}, {file = "mypy-0.982-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:41fd1cf9bc0e1c19b9af13a6580ccb66c381a5ee2cf63ee5ebab747a4badeba3"}, @@ -3336,10 +3269,6 @@ typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] -typing-inspect = [ - {file = "typing_inspect-0.8.0-py3-none-any.whl", hash = "sha256:5fbf9c1e65d4fa01e701fe12a5bca6c6e08a4ffd5bc60bfac028253a447c5188"}, - {file = "typing_inspect-0.8.0.tar.gz", hash = "sha256:8b1ff0c400943b6145df8119c41c244ca8207f1f10c9c057aeed1560e4806e3d"}, -] tzdata = [ {file = "tzdata-2022.5-py2.py3-none-any.whl", hash = "sha256:323161b22b7802fdc78f20ca5f6073639c64f1a7227c40cd3e19fd1d0ce6650a"}, {file = "tzdata-2022.5.tar.gz", hash = "sha256:e15b2b3005e2546108af42a0eb4ccab4d9e225e2dfbf4f77aad50c70a4b1f3ab"}, diff --git a/pyproject.toml b/pyproject.toml index 3f74a8a60..371f30f0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,6 @@ sphinx-click = "^4.3.0" Pygments = "^2.10.0" pyupgrade = "^3.1.0" furo = ">=2021.11.12" -MonkeyType = "^22.2.0" [tool.poetry.scripts] spiffworkflow-backend = "spiffworkflow_backend.__main__:main" diff --git a/spiffworkflow-backend/.flake8 b/spiffworkflow-backend/.flake8 index 16f7c5591..1cc09c979 100644 --- a/spiffworkflow-backend/.flake8 +++ b/spiffworkflow-backend/.flake8 @@ -27,3 +27,5 @@ per-file-ignores = # this file overwrites methods from the logging library so we can't change them # 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 diff --git a/spiffworkflow-backend/.github/workflows/constraints.txt b/spiffworkflow-backend/.github/workflows/constraints.txt index 70c8f365d..7ccc87119 100644 --- a/spiffworkflow-backend/.github/workflows/constraints.txt +++ b/spiffworkflow-backend/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ pip==22.2.2 -nox==2022.8.7 -nox-poetry==1.0.1 +nox==2022.11.21 +nox-poetry==1.0.2 poetry==1.2.2 virtualenv==20.16.5 diff --git a/spiffworkflow-backend/bin/build_and_run_with_docker_compose b/spiffworkflow-backend/bin/build_and_run_with_docker_compose index 4356d974f..2dfa896e6 100755 --- a/spiffworkflow-backend/bin/build_and_run_with_docker_compose +++ b/spiffworkflow-backend/bin/build_and_run_with_docker_compose @@ -9,7 +9,7 @@ set -o errtrace -o errexit -o nounset -o pipefail if [[ -z "${BPMN_SPEC_ABSOLUTE_DIR:-}" ]]; then script_dir="$( cd -- "$(dirname "$0")" >/dev/null 2>&1 ; pwd -P )" - export BPMN_SPEC_ABSOLUTE_DIR="$script_dir/../../sample-process-models" + export BPMN_SPEC_ABSOLUTE_DIR="$script_dir/../../../sample-process-models" fi if [[ -z "${SPIFFWORKFLOW_BACKEND_DOCKER_COMPOSE_PROFILE:-}" ]]; then diff --git a/spiffworkflow-backend/bin/save_all_bpmn.py b/spiffworkflow-backend/bin/save_all_bpmn.py index 54a40841f..fd44bb549 100644 --- a/spiffworkflow-backend/bin/save_all_bpmn.py +++ b/spiffworkflow-backend/bin/save_all_bpmn.py @@ -1,13 +1,13 @@ """Grabs tickets from csv and makes process instances.""" import os -from spiffworkflow_backend import get_hacked_up_app_for_script +from spiffworkflow_backend import create_app from spiffworkflow_backend.services.data_setup_service import DataSetupService def main() -> None: """Main.""" - app = get_hacked_up_app_for_script() + app = create_app() with app.app_context(): failing_process_models = DataSetupService.save_all_process_models() for bpmn_errors in failing_process_models: diff --git a/spiffworkflow-backend/bin/spiffworkflow-realm.json b/spiffworkflow-backend/bin/spiffworkflow-realm.json index e0b7ee3f7..a30f53c14 100644 --- a/spiffworkflow-backend/bin/spiffworkflow-realm.json +++ b/spiffworkflow-backend/bin/spiffworkflow-realm.json @@ -1251,12 +1251,17 @@ }, { "id" : "f44558af-3601-4e54-b854-08396a247544", "clientId" : "spiffworkflow-backend", + "name" : "", + "description" : "", + "rootUrl" : "", + "adminUrl" : "", + "baseUrl" : "", "surrogateAuthRequired" : false, "enabled" : true, "alwaysDisplayInConsole" : false, "clientAuthenticatorType" : "client-secret", "secret" : "JXeQExm0JhQPLumgHtIIqf52bDalHz0q", - "redirectUris" : [ "http://localhost:7000/*", "https://api.unused-for-local-dev.spiffworkflow.org/*", "http://67.205.133.116:7000/*", "http://167.172.242.138:7000/*", "https://api.demo.spiffworkflow.org/*" ], + "redirectUris" : [ "http://localhost:7000/*", "https://api.unused-for-local-dev.spiffworkflow.org/*", "https://api.replace-me-with-spiff-subdomain.spiffworkflow.org/*", "http://67.205.133.116:7000/*", "http://167.172.242.138:7000/*" ], "webOrigins" : [ ], "notBefore" : 0, "bearerOnly" : false, @@ -1273,7 +1278,7 @@ "saml.force.post.binding" : "false", "saml.multivalued.roles" : "false", "frontchannel.logout.session.required" : "false", - "post.logout.redirect.uris" : "+", + "post.logout.redirect.uris" : "https://replace-me-with-spiff-subdomain.spiffworkflow.org/*##http://localhost:7001/*", "oauth2.device.authorization.grant.enabled" : "false", "backchannel.logout.revoke.offline.tokens" : "false", "saml.server.signature.keyinfo.ext" : "false", @@ -2161,7 +2166,7 @@ "subType" : "authenticated", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "oidc-address-mapper", "saml-user-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-address-mapper" ] } }, { "id" : "d68e938d-dde6-47d9-bdc8-8e8523eb08cd", @@ -2179,7 +2184,7 @@ "subType" : "anonymous", "subComponents" : { }, "config" : { - "allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-address-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper" ] + "allowed-protocol-mapper-types" : [ "oidc-address-mapper", "oidc-full-name-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-property-mapper", "saml-role-list-mapper" ] } }, { "id" : "3854361d-3fe5-47fb-9417-a99592e3dc5c", @@ -2269,7 +2274,7 @@ "internationalizationEnabled" : false, "supportedLocales" : [ ], "authenticationFlows" : [ { - "id" : "b30ab201-b13a-405f-bc57-cb5cd934bdc3", + "id" : "b896c673-57ab-4f24-bbb1-334bdadbecd3", "alias" : "Account verification options", "description" : "Method with which to verity the existing account", "providerId" : "basic-flow", @@ -2291,7 +2296,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "7d22faa2-1da8-49ae-a2cc-74e9c9f6ed51", + "id" : "4da99e29-371e-4f4b-a863-e5079f30a714", "alias" : "Authentication Options", "description" : "Authentication options.", "providerId" : "basic-flow", @@ -2320,7 +2325,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "ae089cf3-3179-4e12-a683-7969a31be566", + "id" : "d398c928-e201-4e8b-ab09-289bb351cd2e", "alias" : "Browser - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -2342,7 +2347,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "27a21643-2167-4847-a6b4-b07007671d9a", + "id" : "663b7aa3-84f6-4347-8ed4-588c2464b75d", "alias" : "Direct Grant - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -2364,7 +2369,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "0ee33ef7-da6b-4248-81c6-9f4f11b58195", + "id" : "98013bc1-e4dd-41f7-9849-1f898143b944", "alias" : "First broker login - Conditional OTP", "description" : "Flow to determine if the OTP is required for the authentication", "providerId" : "basic-flow", @@ -2386,7 +2391,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "e1d02af3-2886-42bb-95f4-bfa6f1299edc", + "id" : "b77e7545-9e39-4d72-93f8-1b38c954c2e2", "alias" : "Handle Existing Account", "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", "providerId" : "basic-flow", @@ -2408,7 +2413,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "35cfc75f-70e3-487c-acd7-0627ab1dbdf1", + "id" : "2470e6f4-9a01-476a-9057-75d78e577182", "alias" : "Reset - Conditional OTP", "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", "providerId" : "basic-flow", @@ -2430,7 +2435,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "cc2f7206-8d15-46db-b974-71e67d4d1077", + "id" : "8e7dad0b-f4e1-4534-b618-b635b0a0e4f9", "alias" : "User creation or linking", "description" : "Flow for the existing/non-existing user alternatives", "providerId" : "basic-flow", @@ -2453,7 +2458,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "d8314533-eacb-40ef-8f44-7c06321e9793", + "id" : "97c83e43-cba8-4d92-b108-9181bca07a1e", "alias" : "Verify Existing Account by Re-authentication", "description" : "Reauthentication of existing account", "providerId" : "basic-flow", @@ -2475,7 +2480,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "d58a5ff1-9a9c-45a9-9f97-1324565e9679", + "id" : "fbabd64c-20de-4b8c-bfd2-be6822572278", "alias" : "browser", "description" : "browser based authentication", "providerId" : "basic-flow", @@ -2511,7 +2516,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "3ea2aed9-12d9-4999-a104-67f5c5f7841a", + "id" : "0628a99f-b194-495d-8e54-cc4ca8684956", "alias" : "clients", "description" : "Base authentication for clients", "providerId" : "client-flow", @@ -2547,7 +2552,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "c605af3c-bede-4f8f-a5c5-94176171c82c", + "id" : "ce6bf7af-3bff-48ce-b214-7fed08503a2a", "alias" : "direct grant", "description" : "OpenID Connect Resource Owner Grant", "providerId" : "basic-flow", @@ -2576,7 +2581,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "901b4d6c-9c27-4d3d-981a-1b5281c1ea2b", + "id" : "60ce729b-d055-4ae7-83cb-85dbcf8cfdaa", "alias" : "docker auth", "description" : "Used by Docker clients to authenticate against the IDP", "providerId" : "basic-flow", @@ -2591,7 +2596,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "9d1de1bf-b170-4235-92f1-5dfd3ec31c45", + "id" : "0bd3cf93-7f33-46b2-ad1f-85cdfb0a87f9", "alias" : "first broker login", "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", "providerId" : "basic-flow", @@ -2614,7 +2619,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "8ee6b54f-4d31-4847-9ddc-36cb4c01b92b", + "id" : "3e52f178-9b9d-4a62-97d5-f9f3f872bcd9", "alias" : "forms", "description" : "Username, password, otp and other auth forms.", "providerId" : "basic-flow", @@ -2636,7 +2641,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "76d3380b-218b-443d-a3ea-bea712f4a1f4", + "id" : "3f5fd6cc-2935-45d8-9bef-6857bba3657a", "alias" : "http challenge", "description" : "An authentication flow based on challenge-response HTTP Authentication Schemes", "providerId" : "basic-flow", @@ -2658,7 +2663,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "cd756473-4606-4150-9ba5-5b96e6f39c3a", + "id" : "2c2b32dd-57dc-45d7-9a24-b4a253cb6a03", "alias" : "registration", "description" : "registration flow", "providerId" : "basic-flow", @@ -2674,7 +2679,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "574fcee6-e152-4069-b328-a7fe33aded3a", + "id" : "dbc28b13-dba7-42a0-a8ab-faa8762979c3", "alias" : "registration form", "description" : "registration form", "providerId" : "form-flow", @@ -2710,7 +2715,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "e5a890ee-140a-4ab3-8d79-87e3499385b0", + "id" : "b4a901d5-e7b9-4eb6-9f8e-1d3305846828", "alias" : "reset credentials", "description" : "Reset credentials for a user if they forgot their password or something", "providerId" : "basic-flow", @@ -2746,7 +2751,7 @@ "userSetupAllowed" : false } ] }, { - "id" : "6243167c-7e2e-4cc7-b35d-bad7862dc9ef", + "id" : "824fe757-cc5c-4e13-ab98-9a2132e10f5c", "alias" : "saml ecp", "description" : "SAML ECP Profile Authentication Flow", "providerId" : "basic-flow", @@ -2762,13 +2767,13 @@ } ] } ], "authenticatorConfig" : [ { - "id" : "ae605746-d169-4a81-8348-b5f52e07ae14", + "id" : "817a93da-29df-447f-ab05-cd9557e66745", "alias" : "create unique user config", "config" : { "require.password.update.after.registration" : "false" } }, { - "id" : "c5feb20c-eea5-4556-b9f8-797be4d67e26", + "id" : "4a8a9659-fa0d-4da8-907b-3b6daec1c878", "alias" : "review profile config", "config" : { "update.profile.on.first.login" : "missing" diff --git a/spiffworkflow-backend/bin/start_keycloak b/spiffworkflow-backend/bin/start_keycloak index 32b502ca0..f76347da7 100755 --- a/spiffworkflow-backend/bin/start_keycloak +++ b/spiffworkflow-backend/bin/start_keycloak @@ -18,7 +18,19 @@ set -o errtrace -o errexit -o nounset -o pipefail if ! docker network inspect spiffworkflow > /dev/null 2>&1; then docker network create spiffworkflow fi -docker rm keycloak 2>/dev/null || echo 'no keycloak container found, safe to start new container' + +# https://stackoverflow.com/a/60579344/6090676 +container_name="keycloak" +if [[ -n "$(docker ps -qa -f name=$container_name)" ]]; then + echo ":: Found container - $container_name" + if [[ -n "$(docker ps -q -f name=$container_name)" ]]; then + echo ":: Stopping running container - $container_name" + docker stop $container_name + fi + echo ":: Removing stopped container - $container_name" + docker rm $container_name +fi + docker run \ -p 7002:8080 \ -d \ diff --git a/spiffworkflow-backend/docker-compose.yml b/spiffworkflow-backend/docker-compose.yml index 1cbe9dcb7..410cbb7ab 100644 --- a/spiffworkflow-backend/docker-compose.yml +++ b/spiffworkflow-backend/docker-compose.yml @@ -68,7 +68,7 @@ services: - "7000:7000" network_mode: host volumes: - - ${BPMN_SPEC_ABSOLUTE_DIR:-./../sample-process-models}:/app/process_models + - ${BPMN_SPEC_ABSOLUTE_DIR:-../../sample-process-models}:/app/process_models - ./log:/app/log healthcheck: test: curl localhost:7000/v1.0/status --fail @@ -82,7 +82,7 @@ services: profiles: - debug volumes: - - ${BPMN_SPEC_ABSOLUTE_DIR:-./../sample-process-models}:/app/process_models + - ${BPMN_SPEC_ABSOLUTE_DIR:-../../sample-process-models}:/app/process_models - ./:/app command: /app/bin/boot_in_docker_debug_mode diff --git a/spiffworkflow-backend/migrations/versions/ff1c1628337c_.py b/spiffworkflow-backend/migrations/versions/4d75421c0af0_.py similarity index 98% rename from spiffworkflow-backend/migrations/versions/ff1c1628337c_.py rename to spiffworkflow-backend/migrations/versions/4d75421c0af0_.py index d8da6d3c4..34fa1e974 100644 --- a/spiffworkflow-backend/migrations/versions/ff1c1628337c_.py +++ b/spiffworkflow-backend/migrations/versions/4d75421c0af0_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: ff1c1628337c +Revision ID: 4d75421c0af0 Revises: -Create Date: 2022-11-28 15:08:52.014254 +Create Date: 2022-12-06 17:42:56.417673 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'ff1c1628337c' +revision = '4d75421c0af0' down_revision = None branch_labels = None depends_on = None @@ -79,8 +79,7 @@ def upgrade(): sa.Column('email', sa.String(length=255), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('service', 'service_id', name='service_key'), - sa.UniqueConstraint('uid'), - sa.UniqueConstraint('username') + sa.UniqueConstraint('uid') ) op.create_table('message_correlation_property', sa.Column('id', sa.Integer(), nullable=False), @@ -249,6 +248,7 @@ def upgrade(): sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('process_instance_id', 'key', name='process_instance_metadata_unique') ) + op.create_index(op.f('ix_process_instance_metadata_key'), 'process_instance_metadata', ['key'], unique=False) op.create_table('spiff_step_details', sa.Column('id', sa.Integer(), nullable=False), sa.Column('process_instance_id', sa.Integer(), nullable=False), @@ -295,6 +295,7 @@ def downgrade(): op.drop_index(op.f('ix_active_task_user_active_task_id'), table_name='active_task_user') op.drop_table('active_task_user') op.drop_table('spiff_step_details') + op.drop_index(op.f('ix_process_instance_metadata_key'), table_name='process_instance_metadata') op.drop_table('process_instance_metadata') op.drop_table('permission_assignment') op.drop_table('message_instance') diff --git a/spiffworkflow-backend/poetry.lock b/spiffworkflow-backend/poetry.lock index 85d0207ff..a23004b40 100644 --- a/spiffworkflow-backend/poetry.lock +++ b/spiffworkflow-backend/poetry.lock @@ -1851,7 +1851,7 @@ lxml = "*" type = "git" url = "https://github.com/sartography/SpiffWorkflow" reference = "main" -resolved_reference = "062eaf15d28c66f8cf07f68409429560251b12c7" +resolved_reference = "ffb1686757f944065580dd2db8def73d6c1f0134" [[package]] name = "SQLAlchemy" @@ -2989,7 +2989,18 @@ psycopg2 = [ {file = "psycopg2-2.9.4.tar.gz", hash = "sha256:d529926254e093a1b669f692a3aa50069bc71faf5b0ecd91686a78f62767d52f"}, ] pyasn1 = [ + {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, + {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, + {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, + {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, + {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, + {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, + {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, + {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, + {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, + {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, ] pycodestyle = [ diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py index de73385f3..9599116a2 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/__init__.py @@ -19,6 +19,9 @@ from werkzeug.exceptions import NotFound import spiffworkflow_backend.load_database_models # noqa: F401 from spiffworkflow_backend.config import setup_config from spiffworkflow_backend.routes.admin_blueprint.admin_blueprint import admin_blueprint +from spiffworkflow_backend.routes.openid_blueprint.openid_blueprint import ( + openid_blueprint, +) from spiffworkflow_backend.routes.process_api_blueprint import process_api_blueprint from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.routes.user_blueprint import user_blueprint @@ -103,6 +106,7 @@ def create_app() -> flask.app.Flask: app.register_blueprint(process_api_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. # we will add an Access-Control-Max-Age header to the response to tell the browser it doesn't diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index bc8bab525..a4ae235c0 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -338,9 +338,9 @@ paths: schema: $ref: "#/components/schemas/ProcessModel" - /process-models/{modified_process_model_id}/files: + /process-models/{modified_process_model_identifier}/files: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The process_model_id, modified to replace slashes (/) @@ -445,6 +445,32 @@ paths: schema: $ref: "#/components/schemas/ProcessModel" + /process-models/{modified_process_model_identifier}/publish: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: the modified process model id + schema: + type: string + - name: branch_to_update + in: query + required: false + description: the name of the branch we want to merge into + schema: + type: string + post: + operationId: spiffworkflow_backend.routes.process_api_blueprint.process_model_publish + summary: Merge changes from this model to another branch. + tags: + - Process Models + responses: + "200": + description: The process model was published. + content: + application/json: + schema: + type: string /processes: get: @@ -464,6 +490,25 @@ paths: items: $ref: "#/components/schemas/Process" + /github-webhook-receive: + post: + operationId: spiffworkflow_backend.routes.process_api_blueprint.github_webhook_receive + summary: receives push webhooks from github so we can keep our process model repo up to date + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessModelCategory" + tags: + - git + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + /process-instances: parameters: - name: process_model_identifier @@ -544,6 +589,12 @@ paths: description: Specifies the identifier of a report to use, if any schema: type: string + - name: report_id + in: query + required: false + description: Specifies the identifier of a report to use, if any + schema: + type: integer get: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_list summary: Returns a list of process instances for a given process model @@ -559,33 +610,6 @@ paths: items: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/task/{task_id}/update: - parameters: - - name: process_instance_id - in: path - required: true - description: The unique id of the process instance - schema: - type: string - - name: task_id - in: path - required: true - description: The unique id of the task - schema: - type: string - post: - operationId: spiffworkflow_backend.routes.process_api_blueprint.update_task_data - summary: Update the task data for requested instance and task - tags: - - Process Instances - responses: - "200": - description: Task Updated Successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/event: parameters: - name: process_instance_id @@ -661,15 +685,14 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-models/{modified_process_model_id}/process-instances: + /process-instances/{modified_process_model_identifier}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The unique id of an existing process model. schema: type: string - # process_instance_create post: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_create summary: Creates an process instance from a process model and returns the instance @@ -683,28 +706,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}: - parameters: - - name: process_instance_id - in: path - required: true - description: The unique id of an existing process instance. - schema: - type: integer - delete: - operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_delete - summary: Deletes a single process instance - tags: - - Process Instances - responses: - "200": - description: The process instance was deleted. - content: - application/json: - schema: - $ref: "#/components/schemas/OkTrue" - - /process-models/{modified_process_model_identifier}/process-instances/{process_instance_id}: + /process-instances/{modified_process_model_identifier}/{process_instance_id}: parameters: - name: modified_process_model_identifier in: path @@ -718,6 +720,12 @@ paths: description: The unique id of an existing process instance. schema: type: integer + - name: process_identifier + in: query + required: false + description: The identifier of the process to use for the diagram. Useful for displaying the diagram for a call activity. + schema: + type: string get: tags: - Process Instances @@ -730,6 +738,18 @@ paths: application/json: schema: $ref: "#/components/schemas/Workflow" + delete: + operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_delete + summary: Deletes a single process instance + tags: + - Process Instances + responses: + "200": + description: The process instance was deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" /process-instances/{modified_process_model_identifier}/{process_instance_id}/run: parameters: @@ -758,7 +778,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/terminate: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/terminate: parameters: - name: process_instance_id in: path @@ -779,7 +799,7 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/{process_instance_id}/suspend: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/suspend: parameters: - name: process_instance_id in: path @@ -800,7 +820,7 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/{process_instance_id}/resume: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/resume: parameters: - name: process_instance_id in: path @@ -862,14 +882,30 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/reports/{report_identifier}: + /process-instances/reports/columns: + get: + operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_report_column_list + summary: Returns all available columns for a process instance report. + tags: + - Process Instances + responses: + "200": + description: Workflow. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Workflow" + + /process-instances/reports/{report_id}: parameters: - - name: report_identifier + - name: report_id in: path required: true description: The unique id of an existing report schema: - type: string + type: integer - name: page in: query required: false @@ -921,9 +957,9 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-models/{modified_process_model_id}/files/{file_name}: + /process-models/{modified_process_model_identifier}/files/{file_name}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The modified process model id @@ -1100,9 +1136,9 @@ paths: items: $ref: "#/components/schemas/Task" - /process-instances/{modified_process_model_id}/{process_instance_id}/tasks: + /task-data/{modified_process_model_identifier}/{process_instance_id}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The modified id of an existing process model @@ -1141,11 +1177,44 @@ paths: items: $ref: "#/components/schemas/Task" - /service_tasks: + /task-data/{modified_process_model_identifier}/{process_instance_id}/{task_id}: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: The modified id of an existing process model + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + - name: task_id + in: path + required: true + description: The unique id of the task. + schema: + type: string + put: + operationId: spiffworkflow_backend.routes.process_api_blueprint.update_task_data + summary: Update the task data for requested instance and task + tags: + - Process Instances + responses: + "200": + description: Task Updated Successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Workflow" + + /service-tasks: get: tags: - Service Tasks - operationId: spiffworkflow_backend.routes.process_api_blueprint.service_tasks_show + operationId: spiffworkflow_backend.routes.process_api_blueprint.service_task_list summary: Gets all available service task connectors responses: "200": @@ -1325,7 +1394,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/logs: + /logs/{modified_process_model_identifier}/{process_instance_id}: parameters: - name: process_instance_id in: path @@ -1345,6 +1414,12 @@ paths: description: The number of items to show per page. Defaults to page 10. schema: type: integer + - name: detailed + in: query + required: false + description: Show the detailed view, which includes all log entries + schema: + type: boolean get: tags: - Process Instances diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py index b56683ca3..106b07357 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/__init__.py @@ -14,13 +14,13 @@ class ConfigurationError(Exception): def setup_database_uri(app: Flask) -> None: """Setup_database_uri.""" - if os.environ.get("SPIFFWORKFLOW_BACKEND_DATABASE_URI") is None: + if app.config.get("SPIFFWORKFLOW_BACKEND_DATABASE_URI") is None: database_name = f"spiffworkflow_backend_{app.config['ENV_IDENTIFIER']}" - if os.environ.get("SPIFF_DATABASE_TYPE") == "sqlite": + if app.config.get("SPIFF_DATABASE_TYPE") == "sqlite": app.config[ "SQLALCHEMY_DATABASE_URI" ] = f"sqlite:///{app.instance_path}/db_{app.config['ENV_IDENTIFIER']}.sqlite3" - elif os.environ.get("SPIFF_DATABASE_TYPE") == "postgres": + elif app.config.get("SPIFF_DATABASE_TYPE") == "postgres": app.config[ "SQLALCHEMY_DATABASE_URI" ] = f"postgresql://spiffworkflow_backend:spiffworkflow_backend@localhost:5432/{database_name}" @@ -33,11 +33,22 @@ def setup_database_uri(app: Flask) -> None: "SQLALCHEMY_DATABASE_URI" ] = f"mysql+mysqlconnector://root:{db_pswd}@localhost/{database_name}" else: - app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get( + app.config["SQLALCHEMY_DATABASE_URI"] = app.config.get( "SPIFFWORKFLOW_BACKEND_DATABASE_URI" ) +def load_config_file(app: Flask, env_config_module: str) -> None: + """Load_config_file.""" + try: + app.config.from_object(env_config_module) + except ImportStringError as exception: + if os.environ.get("TERRAFORM_DEPLOYED_ENVIRONMENT") != "true": + raise ModuleNotFoundError( + f"Cannot find config module: {env_config_module}" + ) from exception + + def setup_config(app: Flask) -> None: """Setup_config.""" # ensure the instance folder exists @@ -52,30 +63,22 @@ def setup_config(app: Flask) -> None: app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config.from_object("spiffworkflow_backend.config.default") + env_config_prefix = "spiffworkflow_backend.config." + if ( + os.environ.get("TERRAFORM_DEPLOYED_ENVIRONMENT") == "true" + and os.environ.get("SPIFFWORKFLOW_BACKEND_ENV") is not None + ): + load_config_file(app, f"{env_config_prefix}terraform_deployed_environment") + + env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] + load_config_file(app, env_config_module) + # This allows config/testing.py or instance/config.py to override the default config if "ENV_IDENTIFIER" in app.config and app.config["ENV_IDENTIFIER"] == "testing": app.config.from_pyfile("config/testing.py", silent=True) else: app.config.from_pyfile(f"{app.instance_path}/config.py", silent=True) - env_config_prefix = "spiffworkflow_backend.config." - env_config_module = env_config_prefix + app.config["ENV_IDENTIFIER"] - try: - app.config.from_object(env_config_module) - except ImportStringError as exception: - if ( - os.environ.get("TERRAFORM_DEPLOYED_ENVIRONMENT") == "true" - and os.environ.get("SPIFFWORKFLOW_BACKEND_ENV") is not None - ): - app.config.from_object("{env_config_prefix}terraform_deployed_environment") - else: - raise ModuleNotFoundError( - f"Cannot find config module: {env_config_module}" - ) from exception - - setup_database_uri(app) - setup_logger(app) - app.config["PERMISSIONS_FILE_FULLPATH"] = None if app.config["SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME"]: app.config["PERMISSIONS_FILE_FULLPATH"] = os.path.join( @@ -92,5 +95,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") + setup_database_uri(app) + setup_logger(app) + thread_local_data = threading.local() app.config["THREAD_LOCAL_DATA"] = thread_local_data diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py index 53d670c77..d0d6a4010 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/default.py @@ -27,12 +27,13 @@ CONNECTOR_PROXY_URL = environ.get( "CONNECTOR_PROXY_URL", default="http://localhost:7004" ) -GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true" - # Open ID server -OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7002") +OPEN_ID_SERVER_URL = environ.get( + "OPEN_ID_SERVER_URL", default="http://localhost:7002/realms/spiffworkflow" +) +# Replace above line with this to use the built-in Open ID Server. +# OPEN_ID_SERVER_URL = environ.get("OPEN_ID_SERVER_URL", default="http://localhost:7000/openid") OPEN_ID_CLIENT_ID = environ.get("OPEN_ID_CLIENT_ID", default="spiffworkflow-backend") -OPEN_ID_REALM_NAME = environ.get("OPEN_ID_REALM_NAME", default="spiffworkflow") OPEN_ID_CLIENT_SECRET_KEY = environ.get( "OPEN_ID_CLIENT_SECRET_KEY", default="JXeQExm0JhQPLumgHtIIqf52bDalHz0q" ) # noqa: S105 @@ -57,3 +58,19 @@ SENTRY_TRACES_SAMPLE_RATE = environ.get( SPIFFWORKFLOW_BACKEND_LOG_LEVEL = environ.get( "SPIFFWORKFLOW_BACKEND_LOG_LEVEL", default="info" ) + +# When a user clicks on the `Publish` button, this is the default branch this server merges into. +# I.e., dev server could have `staging` here. Staging server might have `production` here. +GIT_BRANCH_TO_PUBLISH_TO = environ.get("GIT_BRANCH_TO_PUBLISH_TO") +GIT_BRANCH = environ.get("GIT_BRANCH") +GIT_CLONE_URL_FOR_PUBLISHING = environ.get("GIT_CLONE_URL") +GIT_COMMIT_ON_SAVE = environ.get("GIT_COMMIT_ON_SAVE", default="false") == "true" + +# Datbase Configuration +SPIFF_DATABASE_TYPE = environ.get( + "SPIFF_DATABASE_TYPE", default="mysql" +) # can also be sqlite, postgres +# Overide above with specific sqlalchymy connection string. +SPIFFWORKFLOW_BACKEND_DATABASE_URI = environ.get( + "SPIFFWORKFLOW_BACKEND_DATABASE_URI", default=None +) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/demo.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/demo.py index db5abf0ec..06e9184d7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/demo.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/demo.py @@ -2,8 +2,8 @@ from os import environ GIT_COMMIT_ON_SAVE = True -GIT_COMMIT_USERNAME = "demo" -GIT_COMMIT_EMAIL = "demo@example.com" +GIT_USERNAME = "demo" +GIT_USER_EMAIL = "demo@example.com" SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get( "SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="terraform_deployed_environment.yml", diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/dev.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/dev.py new file mode 100644 index 000000000..ce6b516c0 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/dev.py @@ -0,0 +1,8 @@ +"""Dev.""" +from os import environ + +GIT_BRANCH_TO_PUBLISH_TO = environ.get("GIT_BRANCH_TO_PUBLISH_TO", default="staging") +GIT_USERNAME = environ.get("GIT_USERNAME", default="sartography-automated-committer") +GIT_USER_EMAIL = environ.get( + "GIT_USER_EMAIL", default="sartography-automated-committer@users.noreply.github.com" +) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/development.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/development.py index c3c479460..15cbead83 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/development.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/development.py @@ -12,3 +12,10 @@ SPIFFWORKFLOW_BACKEND_LOG_LEVEL = environ.get( RUN_BACKGROUND_SCHEDULER = ( environ.get("RUN_BACKGROUND_SCHEDULER", default="true") == "true" ) +GIT_CLONE_URL_FOR_PUBLISHING = environ.get( + "GIT_CLONE_URL", default="https://github.com/sartography/sample-process-models.git" +) +GIT_USERNAME = "sartography-automated-committer" +GIT_USER_EMAIL = f"{GIT_USERNAME}@users.noreply.github.com" +GIT_BRANCH_TO_PUBLISH_TO = "main" +GIT_BRANCH = "main" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml index e17e3f110..99790fed7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml @@ -1,5 +1,11 @@ default_group: everybody +users: + admin: + email: admin@spiffworkflow.org + password: admin + preferred_username: Admin + groups: admin: users: @@ -11,8 +17,6 @@ groups: dan, mike, jason, - j, - amir, jarrad, elizabeth, jon, @@ -27,7 +31,6 @@ groups: dan, mike, jason, - j, amir, jarrad, elizabeth, @@ -58,6 +61,12 @@ groups: harmeet, ] + admin-ro: + users: + [ + j, + ] + permissions: admin: groups: [admin] @@ -65,11 +74,28 @@ permissions: allowed_permissions: [create, read, update, delete] uri: /* + admin-readonly: + groups: [admin-ro] + users: [] + allowed_permissions: [read] + uri: /* + admin-process-instances-for-readonly: + groups: [admin-ro] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/process-instances/* + tasks-crud: groups: [everybody] users: [] allowed_permissions: [create, read, update, delete] uri: /v1.0/tasks/* + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/service-tasks + # read all for everybody read-all-process-groups: @@ -98,6 +124,12 @@ permissions: allowed_permissions: [read] uri: /v1.0/processes + task-data-read: + groups: [demo] + users: [] + allowed_permissions: [read] + uri: /v1.0/task-data/* + manage-procurement-admin: groups: ["Project Lead"] @@ -170,17 +202,17 @@ permissions: uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* core1-admin-models-instantiate: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create] uri: /v1.0/process-models/misc:category_number_one:process-model-with-form/process-instances core1-admin-instances: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create, read] uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form:* core1-admin-instances-slash: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create, read] uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml new file mode 100644 index 000000000..79bfed81d --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/example.yml @@ -0,0 +1,88 @@ +default_group: everybody + +users: + admin: + email: admin@spiffworkflow.org + password: admin + preferred_username: Admin + nelson: + email: nelson@spiffworkflow.org + password: nelson + preferred_username: Nelson + malala: + email: malala@spiffworkflow.org + password: malala + preferred_username: Malala + +groups: + admin: + users: + [ + admin, + ] + Education: + users: + [ + malala + ] + President: + users: + [ + nelson + ] + +permissions: + # Admins have access to everything. + admin: + groups: [admin] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /* + + # Everybody can participate in tasks assigned to them. + tasks-crud: + groups: [everybody] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/tasks/* + + # Everyone can see everything (all groups, and processes are visible) + read-all-process-groups: + groups: [ everybody ] + users: [ ] + allowed_permissions: [ read ] + uri: /v1.0/process-groups/* + read-all-process-models: + groups: [ everybody ] + users: [ ] + allowed_permissions: [ read ] + uri: /v1.0/process-models/* + read-all-process-instance: + groups: [ everybody ] + users: [ ] + allowed_permissions: [ read ] + uri: /v1.0/process-instances/* + read-process-instance-reports: + groups: [ everybody ] + users: [ ] + allowed_permissions: [ read ] + uri: /v1.0/process-instances/reports/* + processes-read: + groups: [ everybody ] + users: [ ] + allowed_permissions: [ read ] + uri: /v1.0/processes + + # Members of the Education group can change they processes work. + education-admin: + groups: ["Education", "President"] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/process-groups/education:* + + # Anyone can start an education process. + education-everybody: + groups: [everybody] + users: [] + allowed_permissions: [create, read] + uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml new file mode 100644 index 000000000..982b945c6 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/staging.yml @@ -0,0 +1,165 @@ +default_group: everybody + +groups: + admin: + users: + [ + admin, + jakub, + kb, + alex, + dan, + mike, + jason, + j, + jarrad, + elizabeth, + jon, + natalia, + ] + + Finance Team: + users: + [ + jakub, + alex, + dan, + mike, + jason, + j, + amir, + jarrad, + elizabeth, + jon, + natalia, + sasha, + fin, + fin1, + ] + + demo: + users: + [ + core, + fin, + fin1, + harmeet, + sasha, + manuchehr, + lead, + lead1 + ] + + core-contributor: + users: + [ + core, + harmeet, + ] + +permissions: + admin: + groups: [admin] + users: [] + allowed_permissions: [read] + uri: /* + admin-process-instances: + groups: [admin] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/process-instances/* + + tasks-crud: + groups: [everybody] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/tasks/* + + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/service-tasks + + + # read all for everybody + read-all-process-groups: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-groups/* + read-all-process-models: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-models/* + read-all-process-instance: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-instances/* + read-process-instance-reports: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/process-instances/reports/* + processes-read: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/processes + + + manage-procurement-admin-instances: + groups: ["Project Lead"] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/process-instances/manage-procurement:* + manage-procurement-admin-instances-slash: + groups: ["Project Lead"] + users: [] + allowed_permissions: [create, read, update, delete] + uri: /v1.0/process-instances/manage-procurement/* + manage-procurement-admin-instance-logs: + groups: ["Project Lead"] + users: [] + allowed_permissions: [read] + uri: /v1.0/logs/manage-procurement:* + manage-procurement-admin-instance-logs-slash: + groups: ["Project Lead"] + users: [] + allowed_permissions: [read] + uri: /v1.0/logs/manage-procurement/* + + manage-revenue-streams-instances: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [create, read] + uri: /v1.0/process-instances/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* + manage-revenue-streams-instance-logs: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [read] + uri: /v1.0/logs/manage-revenue-streams:product-revenue-streams:customer-contracts-trade-terms/* + + manage-procurement-invoice-instances: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [create, read] + uri: /v1.0/process-instances/manage-procurement:procurement:core-contributor-invoice-management:* + manage-procurement-invoice-instance-logs: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [read] + uri: /v1.0/logs/manage-procurement:procurement:core-contributor-invoice-management:* + + manage-procurement-instances: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [create, read] + uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* + manage-procurement-instance-logs: + groups: ["core-contributor", "demo"] + users: [] + allowed_permissions: [read] + uri: /v1.0/logs/manage-procurement:vendor-lifecycle-management:* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml index e60946b3c..2e41e3b00 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml @@ -12,7 +12,6 @@ groups: mike, jason, j, - amir, jarrad, elizabeth, jon, @@ -71,6 +70,13 @@ permissions: allowed_permissions: [create, read, update, delete] uri: /v1.0/tasks/* + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/service-tasks + + # read all for everybody read-all-process-groups: groups: [everybody] @@ -98,6 +104,12 @@ permissions: allowed_permissions: [read] uri: /v1.0/processes + task-data-read: + groups: [demo] + users: [] + allowed_permissions: [read] + uri: /v1.0/task-data/* + manage-procurement-admin: groups: ["Project Lead"] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/staging.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/staging.py index 53c8af61c..9cc247056 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/staging.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/staging.py @@ -1,9 +1,7 @@ """Staging.""" from os import environ -GIT_COMMIT_ON_SAVE = True -GIT_COMMIT_USERNAME = "staging" -GIT_COMMIT_EMAIL = "staging@example.com" -SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get( - "SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="staging.yml" -) +GIT_BRANCH = environ.get("GIT_BRANCH_TO_PUBLISH_TO", default="staging") +GIT_BRANCH_TO_PUBLISH_TO = environ.get("GIT_BRANCH_TO_PUBLISH_TO", default="main") +GIT_COMMIT_ON_SAVE = False +SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = "staging.yml" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.py b/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.py new file mode 100644 index 000000000..efd451834 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.py @@ -0,0 +1,29 @@ +"""Terraform-deployed environment.""" +from os import environ + +# default.py already ensured that this key existed as was not None +environment_identifier_for_this_config_file_only = environ["SPIFFWORKFLOW_BACKEND_ENV"] + +GIT_COMMIT_ON_SAVE = True +GIT_USERNAME = "sartography-automated-committer" +GIT_USER_EMAIL = f"{GIT_USERNAME}@users.noreply.github.com" +SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get( + "SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", + default="terraform_deployed_environment.yml", +) + +RUN_BACKGROUND_SCHEDULER = ( + environ.get("RUN_BACKGROUND_SCHEDULER", default="false") == "true" +) + +OPEN_ID_SERVER_URL = f"https://keycloak.{environment_identifier_for_this_config_file_only}.spiffworkflow.org/realms/spiffworkflow" +SPIFFWORKFLOW_FRONTEND_URL = ( + f"https://{environment_identifier_for_this_config_file_only}.spiffworkflow.org" +) +SPIFFWORKFLOW_BACKEND_URL = ( + f"https://api.{environment_identifier_for_this_config_file_only}.spiffworkflow.org" +) +CONNECTOR_PROXY_URL = f"https://connector-proxy.{environment_identifier_for_this_config_file_only}.spiffworkflow.org" +GIT_CLONE_URL_FOR_PUBLISHING = environ.get( + "GIT_CLONE_URL", default="https://github.com/sartography/sample-process-models.git" +) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.rb b/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.rb deleted file mode 100644 index f1be3410c..000000000 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/terraform_deployed_environment.rb +++ /dev/null @@ -1,16 +0,0 @@ -"""Terraform-deployed environment.""" -from os import environ - -# default.py already ensured that this key existed as was not None -environment_identifier_for_this_config_file_only = environ["SPIFFWORKFLOW_BACKEND_ENV"] - -GIT_COMMIT_ON_SAVE = True -GIT_COMMIT_USERNAME = environment_identifier_for_this_config_file_only -GIT_COMMIT_EMAIL = f"{environment_identifier_for_this_config_file_only}@example.com" -SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME = environ.get( - "SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME", default="terraform_deployed_environment.yml" -) - -RUN_BACKGROUND_SCHEDULER = ( - environ.get("RUN_BACKGROUND_SCHEDULER", default="false") == "true" -) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index e6a5f6849..c89f457b0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -93,7 +93,7 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): created_at_in_seconds: int = db.Column(db.Integer) status: str = db.Column(db.String(50)) - bpmn_xml_file_contents: bytes | None = None + 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) @@ -101,9 +101,6 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): @property def serialized(self) -> dict[str, Any]: """Return object data in serializeable format.""" - local_bpmn_xml_file_contents = "" - if self.bpmn_xml_file_contents: - local_bpmn_xml_file_contents = self.bpmn_xml_file_contents.decode("utf-8") return { "id": self.id, "process_model_identifier": self.process_model_identifier, @@ -112,7 +109,7 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): "start_in_seconds": self.start_in_seconds, "end_in_seconds": self.end_in_seconds, "process_initiator_id": self.process_initiator_id, - "bpmn_xml_file_contents": local_bpmn_xml_file_contents, + "bpmn_xml_file_contents": self.bpmn_xml_file_contents, "bpmn_version_control_identifier": self.bpmn_version_control_identifier, "bpmn_version_control_type": self.bpmn_version_control_type, "spiff_step": self.spiff_step, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py index 5a4d4ca5b..f2e4c2221 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py @@ -1,4 +1,4 @@ -"""Spiff_step_details.""" +"""Process_instance_metadata.""" from dataclasses import dataclass from flask_bpmn.models.db import db @@ -23,7 +23,7 @@ class ProcessInstanceMetadataModel(SpiffworkflowBaseDBModel): process_instance_id: int = db.Column( ForeignKey(ProcessInstanceModel.id), nullable=False # type: ignore ) - key: str = db.Column(db.String(255), nullable=False) + key: str = db.Column(db.String(255), nullable=False, index=True) value: str = db.Column(db.String(255), nullable=False) updated_at_in_seconds: int = db.Column(db.Integer, nullable=False) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py index 5cccf4a59..1f22a3830 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py @@ -26,6 +26,10 @@ from spiffworkflow_backend.services.process_instance_processor import ( ReportMetadata = dict[str, Any] +class ProcessInstanceReportAlreadyExistsError(Exception): + """ProcessInstanceReportAlreadyExistsError.""" + + class ProcessInstanceReportResult(TypedDict): """ProcessInstanceReportResult.""" @@ -63,7 +67,7 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): ), ) - id = db.Column(db.Integer, primary_key=True) + id: int = db.Column(db.Integer, primary_key=True) identifier: str = db.Column(db.String(50), nullable=False, index=True) report_metadata: dict = deferred(db.Column(db.JSON)) # type: ignore created_by_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) @@ -71,6 +75,11 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): created_at_in_seconds = db.Column(db.Integer) updated_at_in_seconds = db.Column(db.Integer) + @classmethod + def default_order_by(cls) -> list[str]: + """Default_order_by.""" + return ["-start_in_seconds", "-id"] + @classmethod def add_fixtures(cls) -> None: """Add_fixtures.""" @@ -120,21 +129,27 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): identifier: str, user: UserModel, report_metadata: ReportMetadata, - ) -> None: + ) -> ProcessInstanceReportModel: """Make_fixture_report.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( identifier=identifier, created_by_id=user.id, ).first() - if process_instance_report is None: - process_instance_report = cls( - identifier=identifier, - created_by_id=user.id, - report_metadata=report_metadata, + if process_instance_report is not None: + raise ProcessInstanceReportAlreadyExistsError( + f"Process instance report with identifier already exists: {identifier}" ) - db.session.add(process_instance_report) - db.session.commit() + + process_instance_report = cls( + identifier=identifier, + created_by_id=user.id, + report_metadata=report_metadata, + ) + db.session.add(process_instance_report) + db.session.commit() + + return process_instance_report # type: ignore @classmethod def ticket_for_month_report(cls) -> dict: @@ -204,18 +219,8 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): user: UserModel, ) -> ProcessInstanceReportModel: """Create_with_attributes.""" - # <<<<<<< HEAD - # process_model = ProcessModelService.get_process_model( - # process_model_id=f"{process_model_identifier}" - # ) - # process_instance_report = cls( - # identifier=identifier, - # process_group_identifier="process_model.process_group_id", - # process_model_identifier=process_model.id, - # ======= process_instance_report = cls( identifier=identifier, - # >>>>>>> main created_by_id=user.id, report_metadata=report_metadata, ) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py index 4f5ee2ada..e8d5eed1c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py @@ -38,6 +38,7 @@ class ProcessModelInfo: fault_or_suspend_on_exception: str = NotificationType.fault.value exception_notification_addresses: list[str] = field(default_factory=list) parent_groups: list[dict] | None = None + metadata_extraction_paths: list[dict[str, str]] | None = None def __post_init__(self) -> None: """__post_init__.""" @@ -76,6 +77,13 @@ class ProcessModelInfoSchema(Schema): exception_notification_addresses = marshmallow.fields.List( marshmallow.fields.String ) + metadata_extraction_paths = marshmallow.fields.List( + marshmallow.fields.Dict( + keys=marshmallow.fields.Str(required=False), + values=marshmallow.fields.Str(required=False), + required=False, + ) + ) @post_load def make_spec( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/spec_reference.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/spec_reference.py index 1e85f7229..50b73fbae 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/spec_reference.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/spec_reference.py @@ -8,6 +8,10 @@ from marshmallow import INCLUDE from sqlalchemy import UniqueConstraint +class SpecReferenceNotFoundError(Exception): + """SpecReferenceNotFoundError.""" + + @dataclass() class SpecReference: """File Reference Information. diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py index 91d70116a..9afb5d078 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py @@ -21,7 +21,7 @@ class SpiffStepDetailsModel(SpiffworkflowBaseDBModel): ForeignKey(ProcessInstanceModel.id), nullable=False # type: ignore ) spiff_step: int = db.Column(db.Integer, nullable=False) - task_json: str = deferred(db.Column(db.JSON, nullable=False)) # type: ignore + task_json: dict = deferred(db.Column(db.JSON, nullable=False)) # type: ignore timestamp: float = db.Column(db.DECIMAL(17, 6), nullable=False) completed_by_user_id: int = db.Column(db.Integer, nullable=True) lane_assignment_id: Optional[int] = db.Column( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py index be3a3e685..896c79e13 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/task.py @@ -108,7 +108,7 @@ class Task: multi_instance_type: Union[MultiInstanceType, None] = None, multi_instance_count: str = "", multi_instance_index: str = "", - process_name: str = "", + process_identifier: str = "", properties: Union[dict, None] = None, process_instance_id: Union[int, None] = None, process_instance_status: Union[str, None] = None, @@ -118,7 +118,8 @@ class Task: form_schema: Union[str, None] = None, form_ui_schema: Union[str, None] = None, parent: Optional[str] = None, - event_definition: Union[dict[str, Any], None] = None + event_definition: Union[dict[str, Any], None] = None, + call_activity_process_identifier: Optional[str] = None, ): """__init__.""" self.id = id @@ -131,6 +132,7 @@ class Task: self.lane = lane self.parent = parent self.event_definition = event_definition + self.call_activity_process_identifier = call_activity_process_identifier self.data = data if self.data is None: @@ -153,7 +155,7 @@ class Task: self.multi_instance_index = ( multi_instance_index # And the index of the currently repeating task. ) - self.process_name = process_name + self.process_identifier = process_identifier self.properties = properties # Arbitrary extension properties from BPMN editor. if self.properties is None: @@ -179,7 +181,7 @@ class Task: "multi_instance_type": multi_instance_type, "multi_instance_count": self.multi_instance_count, "multi_instance_index": self.multi_instance_index, - "process_name": self.process_name, + "process_identifier": self.process_identifier, "properties": self.properties, "process_instance_id": self.process_instance_id, "process_instance_status": self.process_instance_status, @@ -190,6 +192,7 @@ class Task: "form_ui_schema": self.form_ui_schema, "parent": self.parent, "event_definition": self.event_definition, + "call_activity_process_identifier": self.call_activity_process_identifier, } @classmethod @@ -285,7 +288,7 @@ class TaskSchema(Schema): "multi_instance_type", "multi_instance_count", "multi_instance_index", - "process_name", + "process_identifier", "properties", "process_instance_id", "form_schema", @@ -297,7 +300,7 @@ class TaskSchema(Schema): documentation = marshmallow.fields.String(required=False, allow_none=True) # form = marshmallow.fields.Nested(FormSchema, required=False, allow_none=True) title = marshmallow.fields.String(required=False, allow_none=True) - process_name = marshmallow.fields.String(required=False, allow_none=True) + process_identifier = marshmallow.fields.String(required=False, allow_none=True) lane = marshmallow.fields.String(required=False, allow_none=True) @marshmallow.post_load diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py index c33a72e7a..b8c83d0f7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/user.py @@ -30,7 +30,8 @@ class UserModel(SpiffworkflowBaseDBModel): __table_args__ = (db.UniqueConstraint("service", "service_id", name="service_key"),) id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(255), nullable=False, unique=True) + # server and service id must be unique, not username. + username = db.Column(db.String(255), nullable=False, unique=False) uid = db.Column(db.String(50), unique=True) service = db.Column(db.String(50), nullable=False, unique=False) service_id = db.Column(db.String(255), nullable=False, unique=False) @@ -83,10 +84,6 @@ class UserModel(SpiffworkflowBaseDBModel): algorithm="HS256", ) - def is_admin(self) -> bool: - """Is_admin.""" - return True - # @classmethod # def from_open_id_user_info(cls, user_info: dict) -> Any: # """From_open_id_user_info.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/__init__.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/__init__.py new file mode 100644 index 000000000..f520b09de --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/__init__.py @@ -0,0 +1 @@ +"""__init__.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py new file mode 100644 index 000000000..f812ab034 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/openid_blueprint.py @@ -0,0 +1,153 @@ +"""OpenID Implementation for demos and local development. + +A very insecure and partial OpenID implementation for use in demos and testing. +Provides the bare minimum endpoints required by SpiffWorkflow to +handle openid authentication -- definitely not a production ready system. +This is just here to make local development, testing, and demonstration easier. +""" +import base64 +import time +from typing import Any +from urllib.parse import urlencode + +import jwt +import yaml +from flask import Blueprint +from flask import current_app +from flask import redirect +from flask import render_template +from flask import request +from flask import url_for +from werkzeug.wrappers import Response + +openid_blueprint = Blueprint( + "openid", __name__, template_folder="templates", static_folder="static" +) + +OPEN_ID_CODE = ":this_is_not_secure_do_not_use_in_production" + + +@openid_blueprint.route("/.well-known/openid-configuration", methods=["GET"]) +def well_known() -> dict: + """Open ID Discovery endpoint. + + These urls can be very different from one openid impl to the next, this is just a small subset. + """ + host_url = request.host_url.strip("/") + return { + "issuer": f"{host_url}/openid", + "authorization_endpoint": f"{host_url}{url_for('openid.auth')}", + "token_endpoint": f"{host_url}{url_for('openid.token')}", + "end_session_endpoint": f"{host_url}{url_for('openid.end_session')}", + } + + +@openid_blueprint.route("/auth", methods=["GET"]) +def auth() -> str: + """Accepts a series of parameters.""" + return render_template( + "login.html", + state=request.args.get("state"), + response_type=request.args.get("response_type"), + client_id=request.args.get("client_id"), + scope=request.args.get("scope"), + redirect_uri=request.args.get("redirect_uri"), + error_message=request.args.get("error_message", ""), + ) + + +@openid_blueprint.route("/form_submit", methods=["POST"]) +def form_submit() -> Any: + """Handles the login form submission.""" + users = get_users() + if ( + request.values["Uname"] in users + and request.values["Pass"] == users[request.values["Uname"]]["password"] + ): + # Redirect back to the end user with some detailed information + state = request.values.get("state") + data = { + "state": state, + "code": request.values["Uname"] + OPEN_ID_CODE, + "session_state": "", + } + url = request.values.get("redirect_uri") + "?" + urlencode(data) + return redirect(url) + else: + return render_template( + "login.html", + state=request.values.get("state"), + response_type=request.values.get("response_type"), + client_id=request.values.get("client_id"), + scope=request.values.get("scope"), + redirect_uri=request.values.get("redirect_uri"), + error_message="Login failed. Please try again.", + ) + + +@openid_blueprint.route("/token", methods=["POST"]) +def token() -> dict: + """Url that will return a valid token, given the super secret sauce.""" + request.values.get("grant_type") + code = request.values.get("code") + request.values.get("redirect_uri") + + """We just stuffed the user name on the front of the code, so grab it.""" + user_name, secret_hash = code.split(":") + user_details = get_users()[user_name] + + """Get authentication from headers.""" + authorization = request.headers.get("Authorization", "Basic ") + authorization = authorization[6:] # Remove "Basic" + authorization = base64.b64decode(authorization).decode("utf-8") + client_id, client_secret = authorization.split(":") + + base_url = request.host_url + "openid" + + id_token = jwt.encode( + { + "iss": base_url, + "aud": [client_id, "account"], + "iat": time.time(), + "exp": time.time() + 86400, # Expire after a day. + "sub": user_name, + "preferred_username": user_details.get("preferred_username", user_name), + }, + client_secret, + algorithm="HS256", + ) + response = { + "access_token": id_token, + "id_token": id_token, + "refresh_token": id_token, + } + return response + + +@openid_blueprint.route("/end_session", methods=["GET"]) +def end_session() -> Response: + """Logout.""" + redirect_url = request.args.get("post_logout_redirect_uri", "http://localhost") + request.args.get("id_token_hint") + return redirect(redirect_url) + + +@openid_blueprint.route("/refresh", methods=["POST"]) +def refresh() -> str: + """Refresh.""" + return "" + + +permission_cache = None + + +def get_users() -> Any: + """Load users from a local configuration file.""" + global permission_cache + if not permission_cache: + with open(current_app.config["PERMISSIONS_FILE_FULLPATH"]) as file: + permission_cache = yaml.safe_load(file) + if "users" in permission_cache: + return permission_cache["users"] + else: + return {} diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/login.css b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/login.css new file mode 100644 index 000000000..15b093f67 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/login.css @@ -0,0 +1,112 @@ + body{ + margin: 0; + padding: 0; + background-color:white; + font-family: 'Arial'; + } + header { + width: 100%; + background-color: black; + } + .logo_small { + padding: 5px 20px; + } + .error { + margin: 20px auto; + color: red; + font-weight: bold; + text-align: center; + } + .login{ + width: 400px; + overflow: hidden; + margin: 20px auto; + padding: 50px; + background: #fff; + border-radius: 15px ; + } + h2{ + text-align: center; + color: #277582; + padding: 20px; + } + label{ + color: #fff; + width: 200px; + display: inline-block; + } + #log { + width: 100px; + height: 50px; + border: none; + padding-left: 7px; + background-color:#202020; + color: #DDD; + text-align: left; + } + .cds--btn--primary { + background-color: #0f62fe; + border: 1px solid #0000; + color: #fff; + } + .cds--btn { + align-items: center; + border: 0; + border-radius: 0; + box-sizing: border-box; + cursor: pointer; + display: inline-flex; + flex-shrink: 0; + font-family: inherit; + font-size: 100%; + font-size: .875rem; + font-weight: 400; + justify-content: space-between; + letter-spacing: .16px; + line-height: 1.28572; + margin: 0; + max-width: 20rem; + min-height: 3rem; + outline: none; + padding: calc(0.875rem - 3px) 63px calc(0.875rem - 3px) 15px; + position: relative; + text-align: left; + text-decoration: none; + transition: background 70ms cubic-bezier(0, 0, .38, .9), box-shadow 70ms cubic-bezier(0, 0, .38, .9), border-color 70ms cubic-bezier(0, 0, .38, .9), outline 70ms cubic-bezier(0, 0, .38, .9); + vertical-align: initial; + vertical-align: top; + width: max-content; + } + .cds--btn:hover { + background-color: #0145c5; + } + .cds--btn:focus { + background-color: #01369a; + } + + .cds--text-input { + background-color: #eee; + border: none; + border-bottom: 1px solid #8d8d8d; + color: #161616; + font-family: inherit; + font-size: .875rem; + font-weight: 400; + height: 2.5rem; + letter-spacing: .16px; + line-height: 1.28572; + outline: 2px solid #0000; + outline-offset: -2px; + padding: 0 1rem; + transition: background-color 70ms cubic-bezier(.2,0,.38,.9),outline 70ms cubic-bezier(.2,0,.38,.9); + width: 100%; + } + + span{ + color: white; + font-size: 17px; + } + a{ + float: right; + background-color: grey; + } diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo.png b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo.png new file mode 100644 index 000000000..4cffb07fd Binary files /dev/null and b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo.png differ diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo_small.png b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo_small.png new file mode 100644 index 000000000..d0ad4499a Binary files /dev/null and b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/static/logo_small.png differ diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html new file mode 100644 index 000000000..d9b8b901a --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/openid_blueprint/templates/login.html @@ -0,0 +1,36 @@ + + + + Login Form + + + +
+ +
+ +

Login

+
{{error_message}}
+
+
+ +

+ +

+ + + + + + +

+ +
+
+ + 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 5eba99988..c0a84c52c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -1,6 +1,7 @@ """APIs for dealing with process groups, process models, and process instances.""" import json import random +import re import string import uuid from typing import Any @@ -30,7 +31,9 @@ from SpiffWorkflow.task import TaskState from sqlalchemy import and_ from sqlalchemy import asc from sqlalchemy import desc -from sqlalchemy.orm import joinedload +from sqlalchemy import func +from sqlalchemy.orm import aliased +from sqlalchemy.orm import selectinload from spiffworkflow_backend.exceptions.process_entity_not_found_error import ( ProcessEntityNotFoundError, @@ -52,6 +55,9 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceApiSche from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -60,6 +66,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema from spiffworkflow_backend.models.secret_model import SecretModel from spiffworkflow_backend.models.secret_model import SecretModelSchema from spiffworkflow_backend.models.spec_reference import SpecReferenceCache +from spiffworkflow_backend.models.spec_reference import SpecReferenceNotFoundError from spiffworkflow_backend.models.spec_reference import SpecReferenceSchema from spiffworkflow_backend.models.spiff_logging import SpiffLoggingModel from spiffworkflow_backend.models.spiff_step_details import SpiffStepDetailsModel @@ -152,9 +159,9 @@ def modify_process_model_id(process_model_id: str) -> str: return process_model_id.replace("/", ":") -def un_modify_modified_process_model_id(modified_process_model_id: str) -> str: +def un_modify_modified_process_model_id(modified_process_model_identifier: str) -> str: """Un_modify_modified_process_model_id.""" - return modified_process_model_id.replace(":", "/") + return modified_process_model_identifier.replace(":", "/") def process_group_add(body: dict) -> flask.wrappers.Response: @@ -256,19 +263,26 @@ def process_model_create( modified_process_group_id: str, body: Dict[str, Union[str, bool, int]] ) -> flask.wrappers.Response: """Process_model_create.""" - process_model_info = ProcessModelInfoSchema().load(body) + body_include_list = [ + "id", + "display_name", + "primary_file_name", + "primary_process_id", + "description", + "metadata_extraction_paths", + ] + body_filtered = { + include_item: body[include_item] + for include_item in body_include_list + if include_item in body + } + if modified_process_group_id is None: raise ApiError( error_code="process_group_id_not_specified", message="Process Model could not be created when process_group_id path param is unspecified", status_code=400, ) - if process_model_info is None: - raise ApiError( - error_code="process_model_could_not_be_created", - message=f"Process Model could not be created from given body: {body}", - status_code=400, - ) unmodified_process_group_id = un_modify_modified_process_model_id( modified_process_group_id @@ -281,6 +295,14 @@ def process_model_create( status_code=400, ) + process_model_info = ProcessModelInfo(**body_filtered) # type: ignore + if process_model_info is None: + raise ApiError( + error_code="process_model_could_not_be_created", + message=f"Process Model could not be created from given body: {body}", + status_code=400, + ) + ProcessModelService.add_process_model(process_model_info) return Response( json.dumps(ProcessModelInfoSchema().dump(process_model_info)), @@ -294,7 +316,6 @@ def process_model_delete( ) -> flask.wrappers.Response: """Process_model_delete.""" process_model_identifier = modified_process_model_identifier.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" ProcessModelService().process_model_delete(process_model_identifier) return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") @@ -309,6 +330,7 @@ def process_model_update( "primary_file_name", "primary_process_id", "description", + "metadata_extraction_paths", ] body_filtered = { include_item: body[include_item] @@ -316,7 +338,6 @@ def process_model_update( if include_item in body } - # process_model_identifier = f"{process_group_id}/{process_model_id}" process_model = get_process_model(process_model_identifier) ProcessModelService.update_process_model(process_model, body_filtered) return ProcessModelInfoSchema().dump(process_model) @@ -325,11 +346,11 @@ def process_model_update( def process_model_show(modified_process_model_identifier: str) -> Any: """Process_model_show.""" process_model_identifier = modified_process_model_identifier.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" process_model = get_process_model(process_model_identifier) - # TODO: Temporary. Should not need the next line once models have correct ids - # process_model.id = process_model_identifier - files = sorted(SpecFileService.get_files(process_model)) + files = sorted( + SpecFileService.get_files(process_model), + key=lambda f: "" if f.name == process_model.primary_file_name else f.sort_index, + ) process_model.files = files for file in process_model.files: file.references = SpecFileService.get_references_for_file(file, process_model) @@ -353,6 +374,20 @@ def process_model_move( return make_response(jsonify(new_process_model), 201) +def process_model_publish( + modified_process_model_identifier: str, branch_to_update: Optional[str] = None +) -> flask.wrappers.Response: + """Process_model_publish.""" + if branch_to_update is None: + branch_to_update = current_app.config["GIT_BRANCH_TO_PUBLISH_TO"] + process_model_identifier = un_modify_modified_process_model_id( + modified_process_model_identifier + ) + pr_url = GitService().publish(process_model_identifier, branch_to_update) + data = {"ok": True, "pr_url": pr_url} + return Response(json.dumps(data), status=200, mimetype="application/json") + + def process_model_list( process_group_identifier: Optional[str] = None, recursive: Optional[bool] = False, @@ -394,9 +429,9 @@ def process_list() -> Any: return SpecReferenceSchema(many=True).dump(references) -def get_file(modified_process_model_id: str, file_name: str) -> Any: +def get_file(modified_process_model_identifier: str, file_name: str) -> Any: """Get_file.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) files = SpecFileService.get_files(process_model, file_name) if len(files) == 0: @@ -416,11 +451,10 @@ def get_file(modified_process_model_id: str, file_name: str) -> Any: def process_model_file_update( - modified_process_model_id: str, file_name: str + modified_process_model_identifier: str, file_name: str ) -> flask.wrappers.Response: """Process_model_file_update.""" - process_model_identifier = modified_process_model_id.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) request_file = get_file_from_request() @@ -446,10 +480,10 @@ def process_model_file_update( def process_model_file_delete( - modified_process_model_id: str, file_name: str + modified_process_model_identifier: str, file_name: str ) -> flask.wrappers.Response: """Process_model_file_delete.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) try: SpecFileService.delete_file(process_model, file_name) @@ -465,9 +499,9 @@ def process_model_file_delete( return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") -def add_file(modified_process_model_id: str) -> flask.wrappers.Response: +def add_file(modified_process_model_identifier: str) -> flask.wrappers.Response: """Add_file.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) request_file = get_file_from_request() if not request_file.filename: @@ -488,10 +522,12 @@ def add_file(modified_process_model_id: str) -> flask.wrappers.Response: ) -def process_instance_create(modified_process_model_id: str) -> flask.wrappers.Response: +def process_instance_create( + modified_process_model_identifier: str, +) -> flask.wrappers.Response: """Create_process_instance.""" process_model_identifier = un_modify_modified_process_model_id( - modified_process_model_id + modified_process_model_identifier ) process_instance = ( ProcessInstanceService.create_process_instance_from_process_model_identifier( @@ -549,6 +585,7 @@ def process_instance_run( def process_instance_terminate( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_run.""" process_instance = ProcessInstanceService().get_process_instance( @@ -561,6 +598,7 @@ def process_instance_terminate( def process_instance_suspend( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_suspend.""" process_instance = ProcessInstanceService().get_process_instance( @@ -573,6 +611,7 @@ def process_instance_suspend( def process_instance_resume( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_resume.""" process_instance = ProcessInstanceService().get_process_instance( @@ -584,19 +623,24 @@ def process_instance_resume( def process_instance_log_list( + modified_process_model_identifier: str, process_instance_id: int, page: int = 1, per_page: int = 100, + detailed: bool = False, ) -> flask.wrappers.Response: """Process_instance_log_list.""" # to make sure the process instance exists process_instance = find_process_instance_by_id_or_raise(process_instance_id) + log_query = SpiffLoggingModel.query.filter( + SpiffLoggingModel.process_instance_id == process_instance.id + ) + if not detailed: + log_query = log_query.filter(SpiffLoggingModel.message.in_(["State change to COMPLETED"])) # type: ignore + logs = ( - SpiffLoggingModel.query.filter( - SpiffLoggingModel.process_instance_id == process_instance.id - ) - .order_by(SpiffLoggingModel.timestamp.desc()) # type: ignore + log_query.order_by(SpiffLoggingModel.timestamp.desc()) # type: ignore .join( UserModel, UserModel.id == SpiffLoggingModel.current_user_id, isouter=True ) # isouter since if we don't have a user, we still want the log @@ -642,6 +686,7 @@ def message_instance_list( .add_columns( MessageModel.identifier.label("message_identifier"), ProcessInstanceModel.process_model_identifier, + ProcessInstanceModel.process_model_display_name, ) .paginate(page=page, per_page=per_page, error_out=False) ) @@ -776,10 +821,11 @@ def process_instance_list( with_tasks_completed_by_my_group: Optional[bool] = None, user_filter: Optional[bool] = False, report_identifier: Optional[str] = None, + report_id: Optional[int] = None, ) -> flask.wrappers.Response: """Process_instance_list.""" process_instance_report = ProcessInstanceReportService.report_with_identifier( - g.user, report_identifier + g.user, report_id, report_identifier ) if user_filter: @@ -810,11 +856,10 @@ def process_instance_list( ) ) - # process_model_identifier = un_modify_modified_process_model_id(modified_process_model_identifier) process_instance_query = ProcessInstanceModel.query # Always join that hot user table for good performance at serialization time. process_instance_query = process_instance_query.options( - joinedload(ProcessInstanceModel.process_initiator) + selectinload(ProcessInstanceModel.process_initiator) ) if report_filter.process_model_identifier is not None: @@ -928,25 +973,78 @@ def process_instance_list( UserGroupAssignmentModel.user_id == g.user.id ) + instance_metadata_aliases = {} + stock_columns = ProcessInstanceReportService.get_column_names_for_model( + ProcessInstanceModel + ) + for column in process_instance_report.report_metadata["columns"]: + if column["accessor"] in stock_columns: + continue + instance_metadata_alias = aliased(ProcessInstanceMetadataModel) + instance_metadata_aliases[column["accessor"]] = instance_metadata_alias + + filter_for_column = None + if "filter_by" in process_instance_report.report_metadata: + filter_for_column = next( + ( + f + for f in process_instance_report.report_metadata["filter_by"] + if f["field_name"] == column["accessor"] + ), + None, + ) + isouter = True + conditions = [ + ProcessInstanceModel.id == instance_metadata_alias.process_instance_id, + instance_metadata_alias.key == column["accessor"], + ] + if filter_for_column: + isouter = False + conditions.append( + instance_metadata_alias.value == filter_for_column["field_value"] + ) + process_instance_query = process_instance_query.join( + instance_metadata_alias, and_(*conditions), isouter=isouter + ).add_columns(func.max(instance_metadata_alias.value).label(column["accessor"])) + + order_by_query_array = [] + order_by_array = process_instance_report.report_metadata["order_by"] + if len(order_by_array) < 1: + order_by_array = ProcessInstanceReportModel.default_order_by() + for order_by_option in order_by_array: + attribute = re.sub("^-", "", order_by_option) + if attribute in stock_columns: + if order_by_option.startswith("-"): + order_by_query_array.append( + getattr(ProcessInstanceModel, attribute).desc() + ) + else: + order_by_query_array.append( + getattr(ProcessInstanceModel, attribute).asc() + ) + elif attribute in instance_metadata_aliases: + if order_by_option.startswith("-"): + order_by_query_array.append( + func.max(instance_metadata_aliases[attribute].value).desc() + ) + else: + order_by_query_array.append( + func.max(instance_metadata_aliases[attribute].value).asc() + ) + process_instances = ( process_instance_query.group_by(ProcessInstanceModel.id) - .order_by( - ProcessInstanceModel.start_in_seconds.desc(), ProcessInstanceModel.id.desc() # type: ignore - ) + .add_columns(ProcessInstanceModel.id) + .order_by(*order_by_query_array) .paginate(page=page, per_page=per_page, error_out=False) ) - results = list( - map( - ProcessInstanceService.serialize_flat_with_task_data, - process_instances.items, - ) + results = ProcessInstanceReportService.add_metadata_columns_to_process_instance( + process_instances.items, process_instance_report.report_metadata["columns"] ) - report_metadata = process_instance_report.report_metadata response_json = { - "report_identifier": process_instance_report.identifier, - "report_metadata": report_metadata, + "report": process_instance_report, "results": results, "filters": report_filter.to_dict(), "pagination": { @@ -959,33 +1057,74 @@ def process_instance_list( return make_response(jsonify(response_json), 200) +def process_instance_report_column_list() -> flask.wrappers.Response: + """Process_instance_report_column_list.""" + table_columns = ProcessInstanceReportService.builtin_column_options() + columns_for_metadata = ( + db.session.query(ProcessInstanceMetadataModel.key) + .order_by(ProcessInstanceMetadataModel.key) + .distinct() # type: ignore + .all() + ) + columns_for_metadata_strings = [ + {"Header": i[0], "accessor": i[0], "filterable": True} + for i in columns_for_metadata + ] + return make_response(jsonify(table_columns + columns_for_metadata_strings), 200) + + def process_instance_show( - modified_process_model_identifier: str, process_instance_id: int + modified_process_model_identifier: str, + process_instance_id: int, + process_identifier: Optional[str] = None, ) -> flask.wrappers.Response: """Create_process_instance.""" process_model_identifier = modified_process_model_identifier.replace(":", "/") process_instance = find_process_instance_by_id_or_raise(process_instance_id) current_version_control_revision = GitService.get_current_revision() - process_model = get_process_model(process_model_identifier) - if process_model.primary_file_name: + process_model_with_diagram = None + name_of_file_with_diagram = None + if process_identifier: + spec_reference = SpecReferenceCache.query.filter_by( + identifier=process_identifier + ).first() + if spec_reference is None: + raise SpecReferenceNotFoundError( + f"Could not find given process identifier in the cache: {process_identifier}" + ) + + process_model_with_diagram = ProcessModelService.get_process_model( + spec_reference.process_model_id + ) + name_of_file_with_diagram = spec_reference.file_name + else: + process_model_with_diagram = get_process_model(process_model_identifier) + if process_model_with_diagram.primary_file_name: + name_of_file_with_diagram = process_model_with_diagram.primary_file_name + + if process_model_with_diagram and name_of_file_with_diagram: if ( process_instance.bpmn_version_control_identifier == current_version_control_revision ): bpmn_xml_file_contents = SpecFileService.get_data( - process_model, process_model.primary_file_name - ) + process_model_with_diagram, name_of_file_with_diagram + ).decode("utf-8") else: bpmn_xml_file_contents = GitService.get_instance_file_contents_for_revision( - process_model, process_instance.bpmn_version_control_identifier + process_model_with_diagram, + process_instance.bpmn_version_control_identifier, + file_name=name_of_file_with_diagram, ) process_instance.bpmn_xml_file_contents = bpmn_xml_file_contents return make_response(jsonify(process_instance), 200) -def process_instance_delete(process_instance_id: int) -> flask.wrappers.Response: +def process_instance_delete( + process_instance_id: int, modified_process_model_identifier: str +) -> flask.wrappers.Response: """Create_process_instance.""" process_instance = find_process_instance_by_id_or_raise(process_instance_id) @@ -1015,22 +1154,22 @@ def process_instance_report_list( def process_instance_report_create(body: Dict[str, Any]) -> flask.wrappers.Response: """Process_instance_report_create.""" - ProcessInstanceReportModel.create_report( + process_instance_report = ProcessInstanceReportModel.create_report( identifier=body["identifier"], user=g.user, report_metadata=body["report_metadata"], ) - return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + return make_response(jsonify(process_instance_report), 201) def process_instance_report_update( - report_identifier: str, + report_id: int, body: Dict[str, Any], ) -> flask.wrappers.Response: """Process_instance_report_create.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1043,15 +1182,15 @@ def process_instance_report_update( process_instance_report.report_metadata = body["report_metadata"] db.session.commit() - return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + return make_response(jsonify(process_instance_report), 201) def process_instance_report_delete( - report_identifier: str, + report_id: int, ) -> flask.wrappers.Response: """Process_instance_report_create.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1067,11 +1206,9 @@ def process_instance_report_delete( return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") -def service_tasks_show() -> flask.wrappers.Response: - """Service_tasks_show.""" +def service_task_list() -> flask.wrappers.Response: + """Service_task_list.""" available_connectors = ServiceTaskService.available_connectors() - print(available_connectors) - return Response( json.dumps(available_connectors), status=200, mimetype="application/json" ) @@ -1105,19 +1242,17 @@ def authentication_callback( def process_instance_report_show( - report_identifier: str, + report_id: int, page: int = 1, per_page: int = 100, ) -> flask.wrappers.Response: - """Process_instance_list.""" - process_instances = ProcessInstanceModel.query.order_by( # .filter_by(process_model_identifier=process_model.id) + """Process_instance_report_show.""" + process_instances = ProcessInstanceModel.query.order_by( ProcessInstanceModel.start_in_seconds.desc(), ProcessInstanceModel.id.desc() # type: ignore - ).paginate( - page=page, per_page=per_page, error_out=False - ) + ).paginate(page=page, per_page=per_page, error_out=False) process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1279,7 +1414,7 @@ def get_tasks( def process_instance_task_list( - modified_process_model_id: str, + modified_process_model_identifier: str, process_instance_id: int, all_tasks: bool = False, spiff_step: int = 0, @@ -1298,7 +1433,8 @@ def process_instance_task_list( ) if step_detail is not None and process_instance.bpmn_json is not None: bpmn_json = json.loads(process_instance.bpmn_json) - bpmn_json["tasks"] = step_detail.task_json + bpmn_json["tasks"] = step_detail.task_json["tasks"] + bpmn_json["subprocesses"] = step_detail.task_json["subprocesses"] process_instance.bpmn_json = json.dumps(bpmn_json) processor = ProcessInstanceProcessor(process_instance) @@ -1396,9 +1532,6 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response task.form_ui_schema = ui_form_contents if task.properties and task.data and "instructionsForEndUser" in task.properties: - print( - f"task.properties['instructionsForEndUser']: {task.properties['instructionsForEndUser']}" - ) if task.properties["instructionsForEndUser"]: task.properties["instructionsForEndUser"] = render_jinja_template( task.properties["instructionsForEndUser"], task.data @@ -1602,6 +1735,8 @@ def get_file_from_request() -> Any: return request_file +# process_model_id uses forward slashes on all OSes +# this seems to return an object where process_model.id has backslashes on windows def get_process_model(process_model_id: str) -> ProcessModelInfo: """Get_process_model.""" process_model = None @@ -1713,9 +1848,26 @@ def get_spiff_task_from_process_instance( return spiff_task +# sample body: +# {"ref": "refs/heads/main", "repository": {"name": "sample-process-models", +# "full_name": "sartography/sample-process-models", "private": False .... }} +# test with: ngrok http 7000 +# where 7000 is the port the app is running on locally +def github_webhook_receive(body: Dict) -> Response: + """Github_webhook_receive.""" + auth_header = request.headers.get("X-Hub-Signature-256") + AuthorizationService.verify_sha256_token(auth_header) + result = GitService.handle_web_hook(body) + return Response( + json.dumps({"git_pull": result}), status=200, mimetype="application/json" + ) + + # # Methods for secrets CRUD - maybe move somewhere else: # + + def get_secret(key: str) -> Optional[str]: """Get_secret.""" return SecretService.get_secret(key) @@ -1748,7 +1900,6 @@ def secret_list( def add_secret(body: Dict) -> Response: """Add secret.""" secret_model = SecretService().add_secret(body["key"], body["value"], g.user.id) - assert secret_model # noqa: S101 return Response( json.dumps(SecretModelSchema().dump(secret_model)), status=201, @@ -1845,7 +1996,12 @@ def _update_form_schema_with_task_data_as_needed( _update_form_schema_with_task_data_as_needed(o, task_data) -def update_task_data(process_instance_id: str, task_id: str, body: Dict) -> Response: +def update_task_data( + process_instance_id: str, + modified_process_model_identifier: str, + task_id: str, + body: Dict, +) -> Response: """Update task data.""" process_instance = ProcessInstanceModel.query.filter( ProcessInstanceModel.id == int(process_instance_id) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py index 5fe10e0af..ad98fbbc6 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/user.py @@ -1,6 +1,7 @@ """User.""" import ast import base64 +import json from typing import Any from typing import Dict from typing import Optional @@ -15,8 +16,9 @@ from flask_bpmn.api.api_error import ApiError from werkzeug.wrappers import Response from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.authentication_service import AuthenticationService from spiffworkflow_backend.services.authentication_service import ( - AuthenticationService, + MissingAccessTokenError, ) from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.user_service import UserService @@ -58,7 +60,6 @@ def verify_token( decoded_token = get_decoded_token(token) if decoded_token is not None: - if "token_type" in decoded_token: token_type = decoded_token["token_type"] if token_type == "internal": # noqa: S105 @@ -68,11 +69,11 @@ def verify_token( current_app.logger.error( f"Exception in verify_token getting user from decoded internal token. {e}" ) - elif "iss" in decoded_token.keys(): try: - user_info = AuthenticationService.get_user_info_from_open_id(token) - except ApiError as ae: + if AuthenticationService.validate_id_token(token): + user_info = decoded_token + except ApiError as ae: # API Error is only thrown in the token is outdated. # Try to refresh the token user = UserService.get_user_by_service_and_service_id( "open_id", decoded_token["sub"] @@ -86,14 +87,9 @@ def verify_token( ) ) if auth_token and "error" not in auth_token: - # redirect to original url, with auth_token? - user_info = ( - AuthenticationService.get_user_info_from_open_id( - auth_token["access_token"] - ) - ) - if not user_info: - raise ae + # We have the user, but this code is a bit convoluted, and will later demand + # a user_info object so it can look up the user. Sorry to leave this crap here. + user_info = {"sub": user.service_id} else: raise ae else: @@ -203,6 +199,18 @@ def login(redirect_url: str = "/") -> Response: return redirect(login_redirect_url) +def parse_id_token(token: str) -> Any: + """Parse the id token.""" + parts = token.split(".") + if len(parts) != 3: + raise Exception("Incorrect id token format") + + payload = parts[1] + padded = payload + "=" * (4 - len(payload) % 4) + decoded = base64.b64decode(padded) + return json.loads(decoded) + + def login_return(code: str, state: str, session_state: str) -> Optional[Response]: """Login_return.""" state_dict = ast.literal_eval(base64.b64decode(state).decode("utf-8")) @@ -211,10 +219,9 @@ def login_return(code: str, state: str, session_state: str) -> Optional[Response if "id_token" in auth_token_object: id_token = auth_token_object["id_token"] + user_info = parse_id_token(id_token) + if AuthenticationService.validate_id_token(id_token): - user_info = AuthenticationService.get_user_info_from_open_id( - auth_token_object["access_token"] - ) if user_info and "error" not in user_info: user_model = AuthorizationService.create_user_from_sign_in(user_info) g.user = user_model.id @@ -262,10 +269,10 @@ def login_api_return(code: str, state: str, session_state: str) -> str: code, "/v1.0/login_api_return" ) access_token: str = auth_token_object["access_token"] - assert access_token # noqa: S101 + if access_token is None: + raise MissingAccessTokenError("Cannot find the access token for the request") + return access_token - # return redirect("localhost:7000/v1.0/ui") - # return {'uid': 'user_1'} def logout(id_token: str, redirect_url: Optional[str]) -> Response: @@ -332,15 +339,11 @@ def get_user_from_decoded_internal_token(decoded_token: dict) -> Optional[UserMo .filter(UserModel.service_id == service_id) .first() ) - # user: UserModel = UserModel.query.filter() if user: return user user = UserModel( username=service_id, - uid=service_id, service=service, service_id=service_id, - name="API User", ) - return user diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py index ae5fe00ef..d9c1959aa 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py @@ -1,4 +1,4 @@ -"""Get_env.""" +"""Save process instance metadata.""" from typing import Any from flask_bpmn.models.db import db diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 18f08d0f3..95c1eaa89 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -16,6 +16,10 @@ from werkzeug.wrappers import Response from spiffworkflow_backend.models.refresh_token import RefreshTokenModel +class MissingAccessTokenError(Exception): + """MissingAccessTokenError.""" + + class AuthenticationProviderTypes(enum.Enum): """AuthenticationServiceProviders.""" @@ -26,58 +30,35 @@ class AuthenticationProviderTypes(enum.Enum): class AuthenticationService: """AuthenticationService.""" + ENDPOINT_CACHE: dict = ( + {} + ) # We only need to find the openid endpoints once, then we can cache them. + @staticmethod - def get_open_id_args() -> tuple: - """Get_open_id_args.""" - open_id_server_url = current_app.config["OPEN_ID_SERVER_URL"] - open_id_client_id = current_app.config["OPEN_ID_CLIENT_ID"] - open_id_realm_name = current_app.config["OPEN_ID_REALM_NAME"] - open_id_client_secret_key = current_app.config[ - "OPEN_ID_CLIENT_SECRET_KEY" - ] # noqa: S105 - return ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) + def client_id() -> str: + """Returns the client id from the config.""" + return current_app.config.get("OPEN_ID_CLIENT_ID", "") + + @staticmethod + def server_url() -> str: + """Returns the server url from the config.""" + return current_app.config.get("OPEN_ID_SERVER_URL", "") + + @staticmethod + def secret_key() -> str: + """Returns the secret key from the config.""" + return current_app.config.get("OPEN_ID_CLIENT_SECRET_KEY", "") @classmethod - def get_user_info_from_open_id(cls, token: str) -> dict: - """The token is an auth_token.""" - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = cls.get_open_id_args() - - headers = {"Authorization": f"Bearer {token}"} - - request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/userinfo" - try: - request_response = requests.get(request_url, headers=headers) - except Exception as e: - current_app.logger.error(f"Exception in get_user_info_from_id_token: {e}") - raise ApiError( - error_code="token_error", - message=f"Exception in get_user_info_from_id_token: {e}", - status_code=401, - ) from e - - if request_response.status_code == 401: - raise ApiError( - error_code="invalid_token", message="Please login", status_code=401 - ) - elif request_response.status_code == 200: - user_info: dict = json.loads(request_response.text) - return user_info - - raise ApiError( - error_code="user_info_error", - message="Cannot get user info in get_user_info_from_id_token", - status_code=401, - ) + def open_id_endpoint_for_name(cls, name: str) -> str: + """All openid systems provide a mapping of static names to the full path of that endpoint.""" + if name not in AuthenticationService.ENDPOINT_CACHE: + request_url = f"{cls.server_url()}/.well-known/openid-configuration" + response = requests.get(request_url) + AuthenticationService.ENDPOINT_CACHE = response.json() + if name not in AuthenticationService.ENDPOINT_CACHE: + raise Exception(f"Unknown OpenID Endpoint: {name}") + return AuthenticationService.ENDPOINT_CACHE.get(name, "") @staticmethod def get_backend_url() -> str: @@ -87,17 +68,10 @@ class AuthenticationService: def logout(self, id_token: str, redirect_url: Optional[str] = None) -> Response: """Logout.""" if redirect_url is None: - redirect_url = "/" - return_redirect_url = f"{self.get_backend_url()}/v1.0/logout_return" - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = AuthenticationService.get_open_id_args() + redirect_url = f"{self.get_backend_url()}/v1.0/logout_return" request_url = ( - f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/logout?" - + f"post_logout_redirect_uri={return_redirect_url}&" + self.open_id_endpoint_for_name("end_session_endpoint") + + f"?post_logout_redirect_uri={redirect_url}&" + f"id_token_hint={id_token}" ) @@ -113,18 +87,12 @@ class AuthenticationService: self, state: str, redirect_url: str = "/v1.0/login_return" ) -> str: """Get_login_redirect_url.""" - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = AuthenticationService.get_open_id_args() return_redirect_url = f"{self.get_backend_url()}{redirect_url}" login_redirect_url = ( - f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/auth?" - + f"state={state}&" + self.open_id_endpoint_for_name("authorization_endpoint") + + f"?state={state}&" + "response_type=code&" - + f"client_id={open_id_client_id}&" + + f"client_id={self.client_id()}&" + "scope=openid&" + f"redirect_uri={return_redirect_url}" ) @@ -134,14 +102,7 @@ class AuthenticationService: self, code: str, redirect_url: str = "/v1.0/login_return" ) -> dict: """Get_auth_token_object.""" - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = AuthenticationService.get_open_id_args() - - backend_basic_auth_string = f"{open_id_client_id}:{open_id_client_secret_key}" + backend_basic_auth_string = f"{self.client_id()}:{self.secret_key()}" backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) headers = { @@ -154,7 +115,7 @@ class AuthenticationService: "redirect_uri": f"{self.get_backend_url()}{redirect_url}", } - request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" + request_url = self.open_id_endpoint_for_name("token_endpoint") response = requests.post(request_url, data=data, headers=headers) auth_token_object: dict = json.loads(response.text) @@ -165,12 +126,6 @@ class AuthenticationService: """Https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation.""" valid = True now = time.time() - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = cls.get_open_id_args() try: decoded_token = jwt.decode(id_token, options={"verify_signature": False}) except Exception as e: @@ -179,15 +134,15 @@ class AuthenticationService: message="Cannot decode id_token", status_code=401, ) from e - if decoded_token["iss"] != f"{open_id_server_url}/realms/{open_id_realm_name}": + if decoded_token["iss"] != cls.server_url(): valid = False elif ( - open_id_client_id not in decoded_token["aud"] + cls.client_id() not in decoded_token["aud"] and "account" not in decoded_token["aud"] ): valid = False elif "azp" in decoded_token and decoded_token["azp"] not in ( - open_id_client_id, + cls.client_id(), "account", ): valid = False @@ -235,20 +190,14 @@ class AuthenticationService: refresh_token_object: RefreshTokenModel = RefreshTokenModel.query.filter( RefreshTokenModel.user_id == user_id ).first() - assert refresh_token_object # noqa: S101 - return refresh_token_object.token + if refresh_token_object: + return refresh_token_object.token + return None @classmethod def get_auth_token_from_refresh_token(cls, refresh_token: str) -> dict: - """Get a new auth_token from a refresh_token.""" - ( - open_id_server_url, - open_id_client_id, - open_id_realm_name, - open_id_client_secret_key, - ) = cls.get_open_id_args() - - backend_basic_auth_string = f"{open_id_client_id}:{open_id_client_secret_key}" + """Converts a refresh token to an Auth Token by calling the openid's auth endpoint.""" + backend_basic_auth_string = f"{cls.client_id()}:{cls.secret_key()}" backend_basic_auth_bytes = bytes(backend_basic_auth_string, encoding="ascii") backend_basic_auth = base64.b64encode(backend_basic_auth_bytes) headers = { @@ -259,11 +208,11 @@ class AuthenticationService: data = { "grant_type": "refresh_token", "refresh_token": refresh_token, - "client_id": open_id_client_id, - "client_secret": open_id_client_secret_key, + "client_id": cls.client_id(), + "client_secret": cls.secret_key(), } - request_url = f"{open_id_server_url}/realms/{open_id_realm_name}/protocol/openid-connect/token" + request_url = cls.open_id_endpoint_for_name("token_endpoint") response = requests.post(request_url, data=data, headers=headers) auth_token_object: dict = json.loads(response.text) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index ea488f7a9..9456f8f14 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -1,5 +1,9 @@ """Authorization_service.""" +import inspect import re +from hashlib import sha256 +from hmac import compare_digest +from hmac import HMAC from typing import Optional from typing import Union @@ -8,6 +12,7 @@ import yaml from flask import current_app from flask import g from flask import request +from flask import scaffold from flask_bpmn.api.api_error import ApiError from flask_bpmn.models.db import db from SpiffWorkflow.task import Task as SpiffTask # type: ignore @@ -23,6 +28,7 @@ from spiffworkflow_backend.models.principal import PrincipalModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserNotFoundError from spiffworkflow_backend.models.user_group_assignment import UserGroupAssignmentModel +from spiffworkflow_backend.routes.openid_blueprint import openid_blueprint from spiffworkflow_backend.services.group_service import GroupService from spiffworkflow_backend.services.user_service import UserService @@ -42,6 +48,27 @@ class UserDoesNotHaveAccessToTaskError(Exception): class AuthorizationService: """Determine whether a user has permission to perform their request.""" + # https://stackoverflow.com/a/71320673/6090676 + @classmethod + def verify_sha256_token(cls, auth_header: Optional[str]) -> None: + """Verify_sha256_token.""" + if auth_header is None: + raise ApiError( + error_code="unauthorized", + message="", + status_code=403, + ) + + received_sign = auth_header.split("sha256=")[-1].strip() + secret = current_app.config["GITHUB_WEBHOOK_SECRET"].encode() + expected_sign = HMAC(key=secret, msg=request.data, digestmod=sha256).hexdigest() + if not compare_digest(received_sign, expected_sign): + raise ApiError( + error_code="unauthorized", + message="", + status_code=403, + ) + @classmethod def has_permission( cls, principals: list[PrincipalModel], permission: str, target_uri: str @@ -229,7 +256,11 @@ class AuthorizationService: def should_disable_auth_for_request(cls) -> bool: """Should_disable_auth_for_request.""" swagger_functions = ["get_json_spec"] - authentication_exclusion_list = ["status", "authentication_callback"] + authentication_exclusion_list = [ + "status", + "authentication_callback", + "github_webhook_receive", + ] if request.method == "OPTIONS": return True @@ -241,6 +272,7 @@ class AuthorizationService: return True api_view_function = current_app.view_functions[request.endpoint] + module = inspect.getmodule(api_view_function) if ( api_view_function and api_view_function.__name__.startswith("login") @@ -248,6 +280,8 @@ class AuthorizationService: or api_view_function.__name__.startswith("console_ui_") or api_view_function.__name__ in authentication_exclusion_list or api_view_function.__name__ in swagger_functions + or module == openid_blueprint + or module == scaffold # don't check permissions for static assets ): return True diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/file_system_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/file_system_service.py index 5812748c7..a2a9181d4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/file_system_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/file_system_service.py @@ -1,6 +1,8 @@ """File_system_service.""" import os +from contextlib import contextmanager from datetime import datetime +from typing import Generator from typing import List from typing import Optional @@ -23,13 +25,25 @@ class FileSystemService: PROCESS_GROUP_JSON_FILE = "process_group.json" PROCESS_MODEL_JSON_FILE = "process_model.json" + # https://stackoverflow.com/a/24176022/6090676 + @staticmethod + @contextmanager + def cd(newdir: str) -> Generator: + """Cd.""" + prevdir = os.getcwd() + os.chdir(os.path.expanduser(newdir)) + try: + yield + finally: + os.chdir(prevdir) + @staticmethod def root_path() -> str: """Root_path.""" # fixme: allow absolute files dir_name = current_app.config["BPMN_SPEC_ABSOLUTE_DIR"] app_root = current_app.root_path - return os.path.join(app_root, "..", dir_name) + return os.path.abspath(os.path.join(app_root, "..", dir_name)) @staticmethod def id_string_to_relative_path(id_string: str) -> str: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/git_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/git_service.py index 815e4cadc..152aab1c0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/git_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/git_service.py @@ -1,56 +1,252 @@ """Git_service.""" import os +import shutil +import subprocess # noqa we need the subprocess module to safely run the git commands +import uuid +from typing import Optional +from typing import Union from flask import current_app +from flask import g +from spiffworkflow_backend.config import ConfigurationError from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.services.file_system_service import FileSystemService +class MissingGitConfigsError(Exception): + """MissingGitConfigsError.""" + + +class InvalidGitWebhookBodyError(Exception): + """InvalidGitWebhookBodyError.""" + + +class GitCloneUrlMismatchError(Exception): + """GitCloneUrlMismatchError.""" + + +class GitCommandError(Exception): + """GitCommandError.""" + + +# TOOD: check for the existence of git and configs on bootup if publishing is enabled class GitService: """GitService.""" - @staticmethod - def get_current_revision() -> str: + @classmethod + def get_current_revision(cls) -> str: """Get_current_revision.""" bpmn_spec_absolute_dir = current_app.config["BPMN_SPEC_ABSOLUTE_DIR"] # The value includes a carriage return character at the end, so we don't grab the last character - current_git_revision = os.popen( # noqa: S605 - f"cd {bpmn_spec_absolute_dir} && git rev-parse --short HEAD" - ).read()[ - :-1 - ] # noqa: S605 - return current_git_revision + with FileSystemService.cd(bpmn_spec_absolute_dir): + return cls.run_shell_command_to_get_stdout( + ["git", "rev-parse", "--short", "HEAD"] + ) - @staticmethod + @classmethod def get_instance_file_contents_for_revision( - process_model: ProcessModelInfo, revision: str - ) -> bytes: + cls, + process_model: ProcessModelInfo, + revision: str, + file_name: Optional[str] = None, + ) -> str: """Get_instance_file_contents_for_revision.""" bpmn_spec_absolute_dir = current_app.config["BPMN_SPEC_ABSOLUTE_DIR"] process_model_relative_path = FileSystemService.process_model_relative_path( process_model ) - shell_cd_command = f"cd {bpmn_spec_absolute_dir}" - shell_git_command = f"git show {revision}:{process_model_relative_path}/{process_model.primary_file_name}" - shell_command = f"{shell_cd_command} && {shell_git_command}" - # git show 78ae5eb:category_number_one/script-task/script-task.bpmn - file_contents: str = os.popen(shell_command).read()[:-1] # noqa: S605 - assert file_contents # noqa: S101 - return file_contents.encode("utf-8") + file_name_to_use = file_name + if file_name_to_use is None: + file_name_to_use = process_model.primary_file_name + with FileSystemService.cd(bpmn_spec_absolute_dir): + shell_command = [ + "git", + "show", + f"{revision}:{process_model_relative_path}/{file_name_to_use}", + ] + return cls.run_shell_command_to_get_stdout(shell_command) - @staticmethod - def commit(message: str) -> str: + @classmethod + def commit(cls, message: str, repo_path: Optional[str] = None) -> str: """Commit.""" - bpmn_spec_absolute_dir = current_app.config["BPMN_SPEC_ABSOLUTE_DIR"] + repo_path_to_use = repo_path + if repo_path is None: + repo_path_to_use = current_app.config["BPMN_SPEC_ABSOLUTE_DIR"] + if repo_path_to_use is None: + raise ConfigurationError("BPMN_SPEC_ABSOLUTE_DIR config must be set") + git_username = "" git_email = "" - if ( - current_app.config["GIT_COMMIT_USERNAME"] - and current_app.config["GIT_COMMIT_EMAIL"] - ): - git_username = current_app.config["GIT_COMMIT_USERNAME"] - git_email = current_app.config["GIT_COMMIT_EMAIL"] - shell_command = f"./bin/git_commit_bpmn_models_repo '{bpmn_spec_absolute_dir}' '{message}' '{git_username}' '{git_email}'" - output = os.popen(shell_command).read() # noqa: S605 - return output + if current_app.config["GIT_USERNAME"] and current_app.config["GIT_USER_EMAIL"]: + git_username = current_app.config["GIT_USERNAME"] + git_email = current_app.config["GIT_USER_EMAIL"] + shell_command_path = os.path.join( + current_app.root_path, "..", "..", "bin", "git_commit_bpmn_models_repo" + ) + shell_command = [ + shell_command_path, + repo_path_to_use, + message, + git_username, + git_email, + ] + return cls.run_shell_command_to_get_stdout(shell_command) + + @classmethod + def check_for_configs(cls) -> None: + """Check_for_configs.""" + if current_app.config["GIT_BRANCH_TO_PUBLISH_TO"] is None: + raise MissingGitConfigsError( + "Missing config for GIT_BRANCH_TO_PUBLISH_TO. " + "This is required for publishing process models" + ) + if current_app.config["GIT_CLONE_URL_FOR_PUBLISHING"] is None: + raise MissingGitConfigsError( + "Missing config for GIT_CLONE_URL_FOR_PUBLISHING. " + "This is required for publishing process models" + ) + + @classmethod + def run_shell_command_as_boolean(cls, command: list[str]) -> bool: + """Run_shell_command_as_boolean.""" + # we know result will be a bool here + result: bool = cls.run_shell_command(command, return_success_state=True) # type: ignore + return result + + @classmethod + def run_shell_command_to_get_stdout(cls, command: list[str]) -> str: + """Run_shell_command_to_get_stdout.""" + # we know result will be a CompletedProcess here + result: subprocess.CompletedProcess[bytes] = cls.run_shell_command( + command, return_success_state=False + ) # type: ignore + return result.stdout.decode("utf-8").strip() + + @classmethod + def run_shell_command( + cls, command: list[str], return_success_state: bool = False + ) -> Union[subprocess.CompletedProcess[bytes], bool]: + """Run_shell_command.""" + # this is fine since we pass the commands directly + result = subprocess.run(command, check=False, capture_output=True) # noqa + if return_success_state: + return result.returncode == 0 + + if result.returncode != 0: + stdout = result.stdout.decode("utf-8") + stderr = result.stderr.decode("utf-8") + raise GitCommandError( + f"Failed to execute git command: {command} " + f"Stdout: {stdout} " + f"Stderr: {stderr} " + ) + + return result + + # only supports github right now + @classmethod + def handle_web_hook(cls, webhook: dict) -> bool: + """Handle_web_hook.""" + cls.check_for_configs() + + if "repository" not in webhook or "clone_url" not in webhook["repository"]: + raise InvalidGitWebhookBodyError( + f"Cannot find required keys of 'repository:clone_url' from webhook body: {webhook}" + ) + + clone_url = webhook["repository"]["clone_url"] + if clone_url != current_app.config["GIT_CLONE_URL_FOR_PUBLISHING"]: + raise GitCloneUrlMismatchError( + f"Configured clone url does not match clone url from webhook: {clone_url}" + ) + + if "ref" not in webhook: + raise InvalidGitWebhookBodyError( + f"Could not find the 'ref' arg in the webhook boy: {webhook}" + ) + + if current_app.config["GIT_BRANCH"] is None: + raise MissingGitConfigsError( + "Missing config for GIT_BRANCH. " + "This is required for updating the repository as a result of the webhook" + ) + + ref = webhook["ref"] + git_branch = current_app.config["GIT_BRANCH"] + if ref != f"refs/heads/{git_branch}": + return False + + with FileSystemService.cd(current_app.config["BPMN_SPEC_ABSOLUTE_DIR"]): + cls.run_shell_command(["git", "pull"]) + return True + + @classmethod + def publish(cls, process_model_id: str, branch_to_update: str) -> str: + """Publish.""" + cls.check_for_configs() + source_process_model_root = FileSystemService.root_path() + source_process_model_path = os.path.join( + source_process_model_root, process_model_id + ) + unique_hex = uuid.uuid4().hex + clone_dir = f"sample-process-models.{unique_hex}" + + # clone new instance of sample-process-models, checkout branch_to_update + # we are adding a guid to this so the flake8 issue has been mitigated + destination_process_root = f"/tmp/{clone_dir}" # noqa + + git_clone_url = current_app.config["GIT_CLONE_URL_FOR_PUBLISHING"].replace( + "https://", + f"https://{current_app.config['GIT_USERNAME']}:{current_app.config['GIT_USER_PASSWORD']}@", + ) + cmd = ["git", "clone", git_clone_url, destination_process_root] + + cls.run_shell_command(cmd) + with FileSystemService.cd(destination_process_root): + # create publish branch from branch_to_update + cls.run_shell_command(["git", "checkout", branch_to_update]) + branch_to_pull_request = f"publish-{process_model_id}" + + # check if branch exists and checkout appropriately + command = [ + "git", + "show-ref", + "--verify", + f"refs/remotes/origin/{branch_to_pull_request}", + ] + if cls.run_shell_command_as_boolean(command): + cls.run_shell_command(["git", "checkout", branch_to_pull_request]) + else: + cls.run_shell_command(["git", "checkout", "-b", branch_to_pull_request]) + + # copy files from process model into the new publish branch + destination_process_model_path = os.path.join( + destination_process_root, process_model_id + ) + if os.path.exists(destination_process_model_path): + shutil.rmtree(destination_process_model_path) + shutil.copytree(source_process_model_path, destination_process_model_path) + + # add and commit files to branch_to_pull_request, then push + commit_message = ( + f"Request to publish changes to {process_model_id}, " + f"from {g.user.username} on {current_app.config['ENV_IDENTIFIER']}" + ) + cls.commit(commit_message, destination_process_root) + cls.run_shell_command( + ["git", "push", "--set-upstream", "origin", branch_to_pull_request] + ) + + # build url for github page to open PR + git_remote = cls.run_shell_command_to_get_stdout( + ["git", "config", "--get", "remote.origin.url"] + ) + remote_url = git_remote.strip().replace(".git", "") + pr_url = f"{remote_url}/compare/{branch_to_update}...{branch_to_pull_request}?expand=1" + + # try to clean up + if os.path.exists(destination_process_root): + shutil.rmtree(destination_process_root) + + return pr_url diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index 5b2fc88d6..adff3bbfc 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -80,6 +80,9 @@ from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_instance import MessageModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.script_attributes_context import ( ScriptAttributesContext, @@ -96,6 +99,7 @@ from spiffworkflow_backend.services.service_task_service import ServiceTaskDeleg from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.user_service import UserService + # Sorry about all this crap. I wanted to move this thing to another file, but # importing a bunch of types causes circular imports. @@ -177,7 +181,12 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore ) return Script.generate_augmented_list(script_attributes_context) - def evaluate(self, task: SpiffTask, expression: str, external_methods=None) -> Any: + def evaluate( + self, + task: SpiffTask, + expression: str, + external_methods: Optional[dict[str, Any]] = None, + ) -> Any: """Evaluate.""" return self._evaluate(expression, task.data, task, external_methods) @@ -543,7 +552,7 @@ class ProcessInstanceProcessor: """SaveSpiffStepDetails.""" bpmn_json = self.serialize() wf_json = json.loads(bpmn_json) - task_json = wf_json["tasks"] + task_json = {"tasks": wf_json["tasks"], "subprocesses": wf_json["subprocesses"]} return { "process_instance_id": self.process_instance_model.id, @@ -572,6 +581,41 @@ class ProcessInstanceProcessor: db.session.add(details_model) db.session.commit() + def extract_metadata(self, process_model_info: ProcessModelInfo) -> None: + """Extract_metadata.""" + metadata_extraction_paths = process_model_info.metadata_extraction_paths + if metadata_extraction_paths is None: + return + if len(metadata_extraction_paths) <= 0: + return + + current_data = self.get_current_data() + for metadata_extraction_path in metadata_extraction_paths: + key = metadata_extraction_path["key"] + path = metadata_extraction_path["path"] + path_segments = path.split(".") + data_for_key = current_data + for path_segment in path_segments: + if path_segment in data_for_key: + data_for_key = data_for_key[path_segment] + else: + data_for_key = None # type: ignore + break + + if data_for_key is not None: + pim = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=self.process_instance_model.id, + key=key, + ).first() + if pim is None: + pim = ProcessInstanceMetadataModel( + process_instance_id=self.process_instance_model.id, + key=key, + ) + pim.value = data_for_key + db.session.add(pim) + db.session.commit() + def save(self) -> None: """Saves the current state of this processor to the database.""" self.process_instance_model.bpmn_json = self.serialize() @@ -598,6 +642,15 @@ class ProcessInstanceProcessor: process_instance_id=self.process_instance_model.id ).all() ready_or_waiting_tasks = self.get_all_ready_or_waiting_tasks() + process_model_display_name = "" + process_model_info = self.process_model_service.get_process_model( + self.process_instance_model.process_model_identifier + ) + if process_model_info is not None: + process_model_display_name = process_model_info.display_name + + self.extract_metadata(process_model_info) + for ready_or_waiting_task in ready_or_waiting_tasks: # filter out non-usertasks task_spec = ready_or_waiting_task.task_spec @@ -616,13 +669,6 @@ class ProcessInstanceProcessor: if "formUiSchemaFilename" in properties: ui_form_file_name = properties["formUiSchemaFilename"] - process_model_display_name = "" - process_model_info = self.process_model_service.get_process_model( - self.process_instance_model.process_model_identifier - ) - if process_model_info is not None: - process_model_display_name = process_model_info.display_name - active_task = None for at in active_tasks: if at.task_id == str(ready_or_waiting_task.id): @@ -1159,8 +1205,8 @@ class ProcessInstanceProcessor: def get_current_data(self) -> dict[str, Any]: """Get the current data for the process. - Return either most recent task data or the process data - if the process instance is complete + Return either the most recent task data or--if the process instance is complete-- + the process data. """ if self.process_instance_model.status == "complete": return self.get_data() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index fc5a93da4..84d5d6752 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -2,6 +2,9 @@ from dataclasses import dataclass from typing import Optional +import sqlalchemy +from flask_bpmn.models.db import db + from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -57,12 +60,21 @@ class ProcessInstanceReportService: @classmethod def report_with_identifier( - cls, user: UserModel, report_identifier: Optional[str] = None + cls, + user: UserModel, + report_id: Optional[int] = None, + report_identifier: Optional[str] = None, ) -> ProcessInstanceReportModel: """Report_with_filter.""" + if report_id is not None: + process_instance_report = ProcessInstanceReportModel.query.filter_by( + id=report_id, created_by_id=user.id + ).first() + if process_instance_report is not None: + return process_instance_report # type: ignore + if report_identifier is None: report_identifier = "default" - process_instance_report = ProcessInstanceReportModel.query.filter_by( identifier=report_identifier, created_by_id=user.id ).first() @@ -73,17 +85,9 @@ class ProcessInstanceReportService: # TODO replace with system reports that are loaded on launch (or similar) temp_system_metadata_map = { "default": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), + "filter_by": [], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_initiated_by_me": { "columns": [ @@ -97,48 +101,31 @@ class ProcessInstanceReportService: {"Header": "status", "accessor": "status"}, ], "filter_by": [{"field_name": "initiated_by_me", "field_value": True}], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_with_tasks_completed_by_me": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), "filter_by": [ {"field_name": "with_tasks_completed_by_me", "field_value": True} ], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_with_tasks_completed_by_my_groups": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), "filter_by": [ { "field_name": "with_tasks_completed_by_my_group", "field_value": True, } ], + "order_by": ["-start_in_seconds", "-id"], }, } process_instance_report = ProcessInstanceReportModel( identifier=report_identifier, created_by_id=user.id, - report_metadata=temp_system_metadata_map[report_identifier], # type: ignore + report_metadata=temp_system_metadata_map[report_identifier], ) return process_instance_report # type: ignore @@ -241,3 +228,43 @@ class ProcessInstanceReportService: ) return report_filter + + @classmethod + def add_metadata_columns_to_process_instance( + cls, + process_instance_sqlalchemy_rows: list[sqlalchemy.engine.row.Row], # type: ignore + metadata_columns: list[dict], + ) -> list[dict]: + """Add_metadata_columns_to_process_instance.""" + results = [] + for process_instance in process_instance_sqlalchemy_rows: + process_instance_dict = process_instance["ProcessInstanceModel"].serialized + for metadata_column in metadata_columns: + if metadata_column["accessor"] not in process_instance_dict: + process_instance_dict[ + metadata_column["accessor"] + ] = process_instance[metadata_column["accessor"]] + + results.append(process_instance_dict) + return results + + @classmethod + def get_column_names_for_model(cls, model: db.Model) -> list[str]: # type: ignore + """Get_column_names_for_model.""" + return [i.name for i in model.__table__.columns] + + @classmethod + def builtin_column_options(cls) -> list[dict]: + """Builtin_column_options.""" + return [ + {"Header": "Id", "accessor": "id", "filterable": False}, + { + "Header": "Process", + "accessor": "process_model_display_name", + "filterable": False, + }, + {"Header": "Start", "accessor": "start_in_seconds", "filterable": False}, + {"Header": "End", "accessor": "end_in_seconds", "filterable": False}, + {"Header": "Username", "accessor": "username", "filterable": False}, + {"Header": "Status", "accessor": "status", "filterable": False}, + ] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index 4f1f60eea..adee870fc 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -304,6 +304,11 @@ class ProcessInstanceService: else: lane = None + if hasattr(spiff_task.task_spec, "spec"): + call_activity_process_identifier = spiff_task.task_spec.spec + else: + call_activity_process_identifier = None + parent_id = None if spiff_task.parent: parent_id = spiff_task.parent.id @@ -320,25 +325,11 @@ class ProcessInstanceService: multi_instance_type=mi_type, multi_instance_count=info["mi_count"], multi_instance_index=info["mi_index"], - process_name=spiff_task.task_spec._wf_spec.description, + process_identifier=spiff_task.task_spec._wf_spec.name, properties=props, parent=parent_id, event_definition=serialized_task_spec.get("event_definition"), + call_activity_process_identifier=call_activity_process_identifier, ) return task - - @staticmethod - def serialize_flat_with_task_data( - process_instance: ProcessInstanceModel, - ) -> dict[str, Any]: - """NOTE: This is crazy slow. Put the latest task data in the database.""" - """Serialize_flat_with_task_data.""" - # results = {} - # try: - # processor = ProcessInstanceProcessor(process_instance) - # process_instance.data = processor.get_current_data() - # results = process_instance.serialized_flat - # except ApiError: - results = process_instance.serialized - return results diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py index f009af688..67be986e1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_model_service.py @@ -148,20 +148,18 @@ class ProcessModelService(FileSystemService): error_code="existing_instances", message=f"We cannot delete the model `{process_model_id}`, there are existing instances that depend on it.", ) - self.get_process_model(process_model_id) - # path = self.workflow_path(process_model) - path = f"{FileSystemService.root_path()}/{process_model_id}" + process_model = self.get_process_model(process_model_id) + path = self.workflow_path(process_model) shutil.rmtree(path) def process_model_move( self, original_process_model_id: str, new_location: str ) -> ProcessModelInfo: """Process_model_move.""" - original_model_path = os.path.abspath( - os.path.join(FileSystemService.root_path(), original_process_model_id) - ) + process_model = self.get_process_model(original_process_model_id) + original_model_path = self.workflow_path(process_model) _, model_id = os.path.split(original_model_path) - new_relative_path = f"{new_location}/{model_id}" + new_relative_path = os.path.join(new_location, model_id) new_model_path = os.path.abspath( os.path.join(FileSystemService.root_path(), new_relative_path) ) @@ -174,7 +172,6 @@ class ProcessModelService(FileSystemService): cls, relative_path: str ) -> ProcessModelInfo: """Get_process_model_from_relative_path.""" - process_group_identifier, _ = os.path.split(relative_path) path = os.path.join(FileSystemService.root_path(), relative_path) return cls.__scan_process_model(path) @@ -226,7 +223,7 @@ class ProcessModelService(FileSystemService): user = UserService.current_user() new_process_model_list = [] for process_model in process_models: - uri = f"/v1.0/process-models/{process_model.id.replace('/', ':')}/process-instances" + uri = f"/v1.0/process-instances/{process_model.id.replace('/', ':')}" result = AuthorizationService.user_has_permission( user=user, permission="create", target_uri=uri ) @@ -245,7 +242,7 @@ class ProcessModelService(FileSystemService): if full_group_id_path is None: full_group_id_path = process_group_id_segment else: - full_group_id_path = f"{full_group_id_path}/{process_group_id_segment}" # type: ignore + full_group_id_path = os.path.join(full_group_id_path, process_group_id_segment) # type: ignore parent_group = ProcessModelService.get_process_group(full_group_id_path) if parent_group: parent_group_array.append( @@ -307,8 +304,8 @@ class ProcessModelService(FileSystemService): ) -> ProcessGroup: """Process_group_move.""" original_group_path = self.process_group_path(original_process_group_id) - original_root, original_group_id = os.path.split(original_group_path) - new_root = f"{FileSystemService.root_path()}/{new_location}" + _, original_group_id = os.path.split(original_group_path) + new_root = os.path.join(FileSystemService.root_path(), new_location) new_group_path = os.path.abspath( os.path.join(FileSystemService.root_path(), new_root, original_group_id) ) @@ -432,6 +429,9 @@ class ProcessModelService(FileSystemService): # process_group.process_groups.sort() return process_group + # path might have backslashes on windows, not sure + # not sure if os.path.join converts forward slashes in the relative_path argument to backslashes: + # path = os.path.join(FileSystemService.root_path(), relative_path) @classmethod def __scan_process_model( cls, @@ -448,6 +448,10 @@ class ProcessModelService(FileSystemService): data.pop("process_group_id") # we don't save `id` in the json file, so we add it back in here. relative_path = os.path.relpath(path, FileSystemService.root_path()) + + # even on windows, use forward slashes for ids + relative_path = relative_path.replace("\\", "/") + data["id"] = relative_path process_model_info = ProcessModelInfo(**data) if process_model_info is None: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py index 15e25a759..6fec8b796 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/service_task_service.py @@ -31,7 +31,6 @@ class ServiceTaskDelegate: if value.startswith(secret_prefix): key = value.removeprefix(secret_prefix) secret = SecretService().get_secret(key) - assert secret # noqa: S101 return secret.value file_prefix = "file:" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/spec_file_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/spec_file_service.py index c69f41c30..72f59d1f7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/spec_file_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/spec_file_service.py @@ -171,13 +171,18 @@ class SpecFileService(FileSystemService): ref.is_primary = True if ref.is_primary: - ProcessModelService.update_process_model( - process_model_info, - { - "primary_process_id": ref.identifier, - "primary_file_name": file_name, - }, - ) + update_hash = {} + if not process_model_info.primary_file_name: + update_hash["primary_process_id"] = ref.identifier + update_hash["primary_file_name"] = file_name + elif file_name == process_model_info.primary_file_name: + update_hash["primary_process_id"] = ref.identifier + + if len(update_hash) > 0: + ProcessModelService.update_process_model( + process_model_info, + update_hash, + ) SpecFileService.update_caches(ref) return file diff --git a/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn b/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn index 1e5bc8535..4be5adba5 100644 --- a/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn +++ b/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn @@ -19,7 +19,11 @@ Flow_0bazl8x Flow_1mcaszp - a = 1 + a = 1 +b = 2 +outer = {} +outer["inner"] = 'sweet1' + Flow_1mcaszp diff --git a/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn b/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn new file mode 100644 index 000000000..7452216a6 --- /dev/null +++ b/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn @@ -0,0 +1,56 @@ + + + + + Flow_1ohrjz9 + + + + Flow_1flxgry + + + + Flow_1ohrjz9 + Flow_18gs4jt + outer = {} +invoice_number = 123 +outer["inner"] = 'sweet1' +outer['time'] = time.time_ns() + + + + Flow_18gs4jt + Flow_1flxgry + outer["inner"] = 'sweet2' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 8d56853b4..48982fc60 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -265,7 +265,7 @@ class BaseTest: ) modified_process_model_id = test_process_model_id.replace("/", ":") response = client.post( - f"/v1.0/process-models/{modified_process_model_id}/process-instances", + f"/v1.0/process-instances/{modified_process_model_id}", headers=headers, ) assert response.status_code == 201 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py index 2f56d1d6c..f9dd44522 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py @@ -57,7 +57,7 @@ class TestLoggingService(BaseTest): assert response.status_code == 200 log_response = client.get( - f"/v1.0/process-instances/{process_instance_id}/logs", + f"/v1.0/logs/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}", headers=headers, ) assert log_response.status_code == 200 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py new file mode 100644 index 000000000..20a0bb67b --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_openid_blueprint.py @@ -0,0 +1,61 @@ +"""Test_authentication.""" +from flask import Flask +from flask.testing import FlaskClient +from tests.spiffworkflow_backend.helpers.base_test import BaseTest + + +class TestFlaskOpenId(BaseTest): + """An integrated Open ID that responds to openID requests. + + By referencing a build in YAML file. Useful for + local development, testing, demos etc... + """ + + def test_discovery_of_endpoints( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test discovery endpoints.""" + response = client.get("/openid/.well-known/openid-configuration") + discovered_urls = response.json + assert "http://localhost/openid" == discovered_urls["issuer"] + assert ( + "http://localhost/openid/auth" == discovered_urls["authorization_endpoint"] + ) + assert "http://localhost/openid/token" == discovered_urls["token_endpoint"] + + def test_get_login_page( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """It should be possible to get to a login page.""" + data = {"state": {"bubblegum": 1, "daydream": 2}} + response = client.get("/openid/auth", query_string=data) + assert b"

Login

" in response.data + assert b"bubblegum" in response.data + + def test_get_token( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """It should be possible to get a token.""" + code = ( + "c3BpZmZ3b3JrZmxvdy1iYWNrZW5kOkpYZVFFeG0wSmhRUEx1bWdIdElJcWY1MmJEYWxIejBx" + ) + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {code}", + } + data = { + "grant_type": "authorization_code", + "code": code, + "redirect_url": "http://localhost:7000/v1.0/login_return", + } + response = client.post("/openid/token", data=data, headers=headers) + assert response diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index 5ee5ae9f2..4a0100d33 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -20,6 +20,9 @@ from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.process_group import ProcessGroup from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -330,6 +333,9 @@ class TestProcessApi(BaseTest): process_model.display_name = "Updated Display Name" process_model.primary_file_name = "superduper.bpmn" process_model.primary_process_id = "superduper" + process_model.metadata_extraction_paths = [ + {"key": "extraction1", "path": "path1"} + ] modified_process_model_identifier = process_model_identifier.replace("/", ":") response = client.put( @@ -343,6 +349,9 @@ class TestProcessApi(BaseTest): assert response.json["display_name"] == "Updated Display Name" assert response.json["primary_file_name"] == "superduper.bpmn" assert response.json["primary_process_id"] == "superduper" + assert response.json["metadata_extraction_paths"] == [ + {"key": "extraction1", "path": "path1"} + ] def test_process_model_list_all( self, @@ -903,7 +912,7 @@ class TestProcessApi(BaseTest): modified_process_model_identifier = process_model_identifier.replace("/", ":") response = client.post( - f"/v1.0/process-models/{modified_process_model_identifier}/process-instances", + f"/v1.0/process-instances/{modified_process_model_identifier}", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 201 @@ -1145,10 +1154,11 @@ class TestProcessApi(BaseTest): headers=self.logged_in_headers(with_super_admin_user), ) show_response = client.get( - f"/v1.0/process-models/{modified_process_model_identifier}/process-instances/{process_instance_id}", + f"/v1.0/process-instances/{modified_process_model_identifier}/{process_instance_id}", headers=self.logged_in_headers(with_super_admin_user), ) assert show_response.json is not None + assert show_response.status_code == 200 file_system_root = FileSystemService.root_path() file_path = ( f"{file_system_root}/{process_model_identifier}/{process_model_id}.bpmn" @@ -1157,6 +1167,60 @@ class TestProcessApi(BaseTest): xml_file_contents = f_open.read() assert show_response.json["bpmn_xml_file_contents"] == xml_file_contents + def test_process_instance_show_with_specified_process_identifier( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_process_instance_show_with_specified_process_identifier.""" + process_model_id = "call_activity_nested" + process_model_identifier = self.create_group_and_model_with_bpmn( + client=client, + user=with_super_admin_user, + process_group_id="test_group_two", + process_model_id=process_model_id, + bpmn_file_location="call_activity_nested", + ) + spec_reference = SpecReferenceCache.query.filter_by( + identifier="Level2b" + ).first() + assert spec_reference + modified_process_model_identifier = ( + self.modify_process_identifier_for_path_param(process_model_identifier) + ) + headers = self.logged_in_headers(with_super_admin_user) + create_response = self.create_process_instance_from_process_model_id( + client, process_model_identifier, headers + ) + assert create_response.json is not None + assert create_response.status_code == 201 + process_instance_id = create_response.json["id"] + client.post( + f"/v1.0/process-instances/{modified_process_model_identifier}/{process_instance_id}/run", + headers=self.logged_in_headers(with_super_admin_user), + ) + show_response = client.get( + f"/v1.0/process-instances/{modified_process_model_identifier}/{process_instance_id}?process_identifier={spec_reference.identifier}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert show_response.json is not None + assert show_response.status_code == 200 + file_system_root = FileSystemService.root_path() + process_instance_file_path = ( + f"{file_system_root}/{process_model_identifier}/{process_model_id}.bpmn" + ) + with open(process_instance_file_path) as f_open: + xml_file_contents = f_open.read() + assert show_response.json["bpmn_xml_file_contents"] != xml_file_contents + spec_reference_file_path = os.path.join( + file_system_root, spec_reference.relative_path + ) + with open(spec_reference_file_path) as f_open: + xml_file_contents = f_open.read() + assert show_response.json["bpmn_xml_file_contents"] == xml_file_contents + def test_message_start_when_starting_process_instance( self, app: Flask, @@ -1311,7 +1375,7 @@ class TestProcessApi(BaseTest): assert response.json is not None response = client.post( - f"/v1.0/process-instances/{process_instance_id}/terminate", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/terminate", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1358,7 +1422,7 @@ class TestProcessApi(BaseTest): assert response.json is not None delete_response = client.delete( - f"/v1.0/process-instances/{process_instance_id}", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}", headers=self.logged_in_headers(with_super_admin_user), ) assert delete_response.status_code == 200 @@ -1723,14 +1787,14 @@ class TestProcessApi(BaseTest): ], } - ProcessInstanceReportModel.create_with_attributes( + report = ProcessInstanceReportModel.create_with_attributes( identifier="sure", report_metadata=report_metadata, user=with_super_admin_user, ) response = client.get( - "/v1.0/process-instances/reports/sure", + f"/v1.0/process-instances/reports/{report.id}", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1769,14 +1833,14 @@ class TestProcessApi(BaseTest): ], } - ProcessInstanceReportModel.create_with_attributes( + report = ProcessInstanceReportModel.create_with_attributes( identifier="sure", report_metadata=report_metadata, user=with_super_admin_user, ) response = client.get( - "/v1.0/process-instances/reports/sure?grade_level=1", + f"/v1.0/process-instances/reports/{report.id}?grade_level=1", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1791,9 +1855,9 @@ class TestProcessApi(BaseTest): with_super_admin_user: UserModel, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_process_instance_report_show_with_default_list.""" + """Test_process_instance_report_show_with_bad_identifier.""" response = client.get( - "/v1.0/process-instances/reports/sure?grade_level=1", + "/v1.0/process-instances/reports/13000000?grade_level=1", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 404 @@ -2357,7 +2421,7 @@ class TestProcessApi(BaseTest): assert process_instance.status == "user_input_required" client.post( - f"/v1.0/process-instances/{process_instance_id}/suspend", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/suspend", headers=self.logged_in_headers(with_super_admin_user), ) process_instance = ProcessInstanceService().get_process_instance( @@ -2544,3 +2608,313 @@ class TestProcessApi(BaseTest): # make sure the new subgroup does exist new_process_group = ProcessModelService.get_process_group(new_sub_path) assert new_process_group.id == new_sub_path + + # this doesn't work in CI + # assert "Initial Commit" in output + # def test_process_model_publish( + # self, + # app: Flask, + # client: FlaskClient, + # with_db_and_bpmn_file_cleanup: None, + # with_super_admin_user: UserModel, + # ) -> None: + # """Test_process_model_publish.""" + # bpmn_root = FileSystemService.root_path() + # shell_command = ["git", "init", "--initial-branch=main", bpmn_root] + # output = GitService.run_shell_command_to_get_stdout(shell_command) + # assert output == f"Initialized empty Git repository in {bpmn_root}/.git/\n" + # with FileSystemService.cd(bpmn_root): + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # assert "On branch main" in output + # assert "No commits yet" in output + # assert ( + # 'nothing to commit (create/copy files and use "git add" to track)' + # in output + # ) + # + # process_group_id = "test_group" + # self.create_process_group( + # client, with_super_admin_user, process_group_id, process_group_id + # ) + # + # sub_process_group_id = "test_group/test_sub_group" + # process_model_id = "hello_world" + # bpmn_file_name = "hello_world.bpmn" + # bpmn_file_location = "hello_world" + # process_model_identifier = self.create_group_and_model_with_bpmn( + # client=client, + # user=with_super_admin_user, + # process_group_id=sub_process_group_id, + # process_model_id=process_model_id, + # bpmn_file_name=bpmn_file_name, + # bpmn_file_location=bpmn_file_location, + # ) + # process_model_absolute_dir = os.path.join( + # bpmn_root, process_model_identifier + # ) + # + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # test_string = 'Untracked files:\n (use "git add ..." to include in what will be committed)\n\ttest_group' + # assert test_string in output + # + # os.system("git add .") + # output = os.popen("git commit -m 'Initial Commit'").read() + # assert "Initial Commit" in output + # assert "4 files changed" in output + # assert "test_group/process_group.json" in output + # assert "test_group/test_sub_group/hello_world/hello_world.bpmn" in output + # assert "test_group/test_sub_group/hello_world/process_model.json" in output + # assert "test_group/test_sub_group/process_group.json" in output + # + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # assert "On branch main" in output + # assert "nothing to commit" in output + # assert "working tree clean" in output + # + # output = os.popen("git branch --list").read() # noqa: S605 + # assert output == "* main\n" + # os.system("git branch staging") + # output = os.popen("git branch --list").read() # noqa: S605 + # assert output == "* main\n staging\n" + # + # os.system("git checkout staging") + # + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # assert "On branch staging" in output + # assert "nothing to commit" in output + # assert "working tree clean" in output + # + # # process_model = ProcessModelService.get_process_model(process_model_identifier) + # + # listing = os.listdir(process_model_absolute_dir) + # assert len(listing) == 2 + # assert "hello_world.bpmn" in listing + # assert "process_model.json" in listing + # + # os.system("git checkout main") + # + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # assert "On branch main" in output + # assert "nothing to commit" in output + # assert "working tree clean" in output + # + # file_data = b"abc123" + # new_file_path = os.path.join(process_model_absolute_dir, "new_file.txt") + # with open(new_file_path, "wb") as f_open: + # f_open.write(file_data) + # + # output = GitService.run_shell_command_to_get_stdout(["git", "status"]) + # assert "On branch main" in output + # assert "Untracked files:" in output + # assert "test_group/test_sub_group/hello_world/new_file.txt" in output + # + # os.system( + # "git add test_group/test_sub_group/hello_world/new_file.txt" + # ) # noqa: S605 + # output = os.popen("git commit -m 'add new_file.txt'").read() # noqa: S605 + # + # assert "add new_file.txt" in output + # assert "1 file changed, 1 insertion(+)" in output + # assert "test_group/test_sub_group/hello_world/new_file.txt" in output + # + # listing = os.listdir(process_model_absolute_dir) + # assert len(listing) == 3 + # assert "hello_world.bpmn" in listing + # assert "process_model.json" in listing + # assert "new_file.txt" in listing + # + # # modified_process_model_id = process_model_identifier.replace("/", ":") + # # response = client.post( + # # f"/v1.0/process-models/{modified_process_model_id}/publish?branch_to_update=staging", + # # headers=self.logged_in_headers(with_super_admin_user), + # # ) + # + # print("test_process_model_publish") + + def test_can_get_process_instance_list_with_report_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_get_process_instance_list_with_report_metadata.""" + process_model = load_test_spec( + process_model_id="save_process_instance_metadata/save_process_instance_metadata", + bpmn_file_name="save_process_instance_metadata.bpmn", + process_model_source_directory="save_process_instance_metadata", + ) + process_instance = self.create_process_instance_from_process_model( + process_model=process_model, user=with_super_admin_user + ) + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + process_instance_metadata = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id + ).all() + assert len(process_instance_metadata) == 3 + + report_metadata = { + "columns": [ + {"Header": "ID", "accessor": "id"}, + {"Header": "Status", "accessor": "status"}, + {"Header": "Key One", "accessor": "key1"}, + {"Header": "Key Two", "accessor": "key2"}, + ], + "order_by": ["status"], + "filter_by": [], + } + process_instance_report = ProcessInstanceReportModel.create_with_attributes( + identifier="sure", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_identifier={process_instance_report.identifier}", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.json is not None + assert response.status_code == 200 + + assert len(response.json["results"]) == 1 + assert response.json["results"][0]["status"] == "complete" + assert response.json["results"][0]["id"] == process_instance.id + assert response.json["results"][0]["key1"] == "value1" + assert response.json["results"][0]["key2"] == "value2" + assert response.json["pagination"]["count"] == 1 + assert response.json["pagination"]["pages"] == 1 + assert response.json["pagination"]["total"] == 1 + + def test_can_get_process_instance_report_column_list( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_get_process_instance_list_with_report_metadata.""" + process_model = load_test_spec( + process_model_id="save_process_instance_metadata/save_process_instance_metadata", + bpmn_file_name="save_process_instance_metadata.bpmn", + process_model_source_directory="save_process_instance_metadata", + ) + process_instance = self.create_process_instance_from_process_model( + process_model=process_model, user=with_super_admin_user + ) + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + process_instance_metadata = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id + ).all() + assert len(process_instance_metadata) == 3 + + response = client.get( + "/v1.0/process-instances/reports/columns", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.json is not None + assert response.status_code == 200 + assert response.json == [ + {"Header": "Id", "accessor": "id", "filterable": False}, + { + "Header": "Process", + "accessor": "process_model_display_name", + "filterable": False, + }, + {"Header": "Start", "accessor": "start_in_seconds", "filterable": False}, + {"Header": "End", "accessor": "end_in_seconds", "filterable": False}, + {"Header": "Username", "accessor": "username", "filterable": False}, + {"Header": "Status", "accessor": "status", "filterable": False}, + {"Header": "key1", "accessor": "key1", "filterable": True}, + {"Header": "key2", "accessor": "key2", "filterable": True}, + {"Header": "key3", "accessor": "key3", "filterable": True}, + ] + + def test_process_instance_list_can_order_by_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_process_instance_list_can_order_by_metadata.""" + self.create_process_group( + client, with_super_admin_user, "test_group", "test_group" + ) + process_model = load_test_spec( + "test_group/hello_world", + process_model_source_directory="nested-task-data-structure", + ) + ProcessModelService.update_process_model( + process_model, + { + "metadata_extraction_paths": [ + {"key": "time_ns", "path": "outer.time"}, + ] + }, + ) + + process_instance_one = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance_one) + processor.do_engine_steps(save=True) + assert process_instance_one.status == "complete" + process_instance_two = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance_two) + processor.do_engine_steps(save=True) + assert process_instance_two.status == "complete" + + report_metadata = { + "columns": [ + {"Header": "id", "accessor": "id"}, + {"Header": "Time", "accessor": "time_ns"}, + ], + "order_by": ["time_ns"], + } + report_one = ProcessInstanceReportModel.create_with_attributes( + identifier="report_one", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_id={report_one.id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert response.status_code == 200 + assert response.json is not None + assert len(response.json["results"]) == 2 + assert response.json["results"][0]["id"] == process_instance_one.id + assert response.json["results"][1]["id"] == process_instance_two.id + + report_metadata = { + "columns": [ + {"Header": "id", "accessor": "id"}, + {"Header": "Time", "accessor": "time_ns"}, + ], + "order_by": ["-time_ns"], + } + report_two = ProcessInstanceReportModel.create_with_attributes( + identifier="report_two", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_id={report_two.id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.status_code == 200 + assert response.json is not None + assert len(response.json["results"]) == 2 + assert response.json["results"][1]["id"] == process_instance_one.id + assert response.json["results"][0]["id"] == process_instance_two.id diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_git_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_git_service.py new file mode 100644 index 000000000..ed1e24e1e --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_git_service.py @@ -0,0 +1,22 @@ +"""Process Model.""" +from flask.app import Flask +from flask.testing import FlaskClient +from tests.spiffworkflow_backend.helpers.base_test import BaseTest + +from spiffworkflow_backend.services.git_service import GitService + + +class TestGitService(BaseTest): + """TestGitService.""" + + def test_strips_output_of_stdout_from_command( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + """Test_strips_output_of_stdout_from_command.""" + output = GitService.run_shell_command_to_get_stdout( + ["echo", " This output should not end in space or newline \n"] + ) + assert output == "This output should not end in space or newline" diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py index 482395076..0a5985f23 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py @@ -37,7 +37,7 @@ def test_generate_report_with_filter_by_with_variable_substitution( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_filter_by_with_variable_substitution.""" process_instances = setup_process_instances_for_reports report_metadata = { "filter_by": [ @@ -61,7 +61,7 @@ def test_generate_report_with_order_by_and_one_field( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_and_one_field.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -75,7 +75,7 @@ def test_generate_report_with_order_by_and_two_fields( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_and_two_fields.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["grade_level", "test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -89,7 +89,7 @@ def test_generate_report_with_order_by_desc( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_desc.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["grade_level", "-test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -103,7 +103,7 @@ def test_generate_report_with_columns( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_columns.""" process_instances = setup_process_instances_for_reports report_metadata = { "columns": [ diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py index 09421bc71..9eb6901b9 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py @@ -5,12 +5,16 @@ from flask_bpmn.models.db import db from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.test_data import load_test_spec +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.spec_reference import SpecReferenceCache from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, ) +from spiffworkflow_backend.services.process_model_service import ProcessModelService class TestProcessModel(BaseTest): @@ -122,6 +126,53 @@ class TestProcessModel(BaseTest): processor.do_engine_steps(save=True) assert process_instance.status == "complete" + def test_extract_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_run_process_model_with_call_activities.""" + self.create_process_group( + client, with_super_admin_user, "test_group", "test_group" + ) + process_model = load_test_spec( + "test_group/hello_world", + process_model_source_directory="nested-task-data-structure", + ) + ProcessModelService.update_process_model( + process_model, + { + "metadata_extraction_paths": [ + {"key": "awesome_var", "path": "outer.inner"}, + {"key": "invoice_number", "path": "invoice_number"}, + ] + }, + ) + + process_instance = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + assert process_instance.status == "complete" + + process_instance_metadata_awesome_var = ( + ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id, key="awesome_var" + ).first() + ) + assert process_instance_metadata_awesome_var is not None + assert process_instance_metadata_awesome_var.value == "sweet2" + process_instance_metadata_awesome_var = ( + ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id, key="invoice_number" + ).first() + ) + assert process_instance_metadata_awesome_var is not None + assert process_instance_metadata_awesome_var.value == "123" + def create_test_process_model(self, id: str, display_name: str) -> ProcessModelInfo: """Create_test_process_model.""" return ProcessModelInfo( diff --git a/spiffworkflow-frontend/.gitignore b/spiffworkflow-frontend/.gitignore index a694da806..c0316f7ea 100644 --- a/spiffworkflow-frontend/.gitignore +++ b/spiffworkflow-frontend/.gitignore @@ -8,6 +8,9 @@ # testing /coverage +# in case we accidentally run backend tests in frontend. :D +/.coverage.* + # production /build @@ -29,4 +32,4 @@ cypress/screenshots /test*.json # Editors -.idea \ No newline at end of file +.idea diff --git a/spiffworkflow-frontend/cypress/e2e/process_models.cy.js b/spiffworkflow-frontend/cypress/e2e/process_models.cy.js index 4fd1b4810..43fba108e 100644 --- a/spiffworkflow-frontend/cypress/e2e/process_models.cy.js +++ b/spiffworkflow-frontend/cypress/e2e/process_models.cy.js @@ -1,4 +1,5 @@ import { modifyProcessIdentifierForPathParam } from '../../src/helpers'; +import { miscDisplayName } from '../support/helpers'; describe('process-models', () => { beforeEach(() => { @@ -16,7 +17,7 @@ describe('process-models', () => { const modelDisplayName = `Test Model 2 ${id}`; const modelId = `test-model-2-${id}`; const newModelDisplayName = `${modelDisplayName} edited`; - cy.contains('99-Shared Resources').click(); + cy.contains(miscDisplayName).click(); cy.wait(500); cy.contains(groupDisplayName).click(); cy.createModel(groupId, modelId, modelDisplayName); @@ -34,7 +35,7 @@ describe('process-models', () => { cy.contains(`Process Model: ${newModelDisplayName}`); // go back to process model show by clicking on the breadcrumb - cy.contains(modelId).click(); + cy.contains(modelDisplayName).click(); cy.getBySel('delete-process-model-button').click(); cy.contains('Are you sure'); @@ -46,6 +47,7 @@ describe('process-models', () => { `process-groups/${modifyProcessIdentifierForPathParam(groupId)}` ); cy.contains(modelId).should('not.exist'); + cy.contains(modelDisplayName).should('not.exist'); }); it('can create new bpmn, dmn, and json files', () => { @@ -61,11 +63,11 @@ describe('process-models', () => { const dmnFileName = `dmn_test_file_${id}`; const jsonFileName = `json_test_file_${id}`; - cy.contains('99-Shared Resources').click(); + cy.contains(miscDisplayName).click(); cy.wait(500); cy.contains(groupDisplayName).click(); cy.createModel(groupId, modelId, modelDisplayName); - cy.contains(directParentGroupId).click(); + cy.contains(groupDisplayName).click(); cy.contains(modelDisplayName).click(); cy.url().should( 'include', @@ -90,7 +92,7 @@ describe('process-models', () => { cy.get('input[name=file_name]').type(bpmnFileName); cy.contains('Save Changes').click(); cy.contains(`Process Model File: ${bpmnFileName}`); - cy.contains(modelId).click(); + cy.contains(modelDisplayName).click(); cy.contains(`Process Model: ${modelDisplayName}`); // cy.getBySel('files-accordion').click(); cy.contains(`${bpmnFileName}.bpmn`).should('exist'); @@ -108,7 +110,7 @@ describe('process-models', () => { cy.get('input[name=file_name]').type(dmnFileName); cy.contains('Save Changes').click(); cy.contains(`Process Model File: ${dmnFileName}`); - cy.contains(modelId).click(); + cy.contains(modelDisplayName).click(); cy.contains(`Process Model: ${modelDisplayName}`); // cy.getBySel('files-accordion').click(); cy.contains(`${dmnFileName}.dmn`).should('exist'); @@ -124,7 +126,7 @@ describe('process-models', () => { cy.contains(`Process Model File: ${jsonFileName}`); // wait for json to load before clicking away to avoid network errors cy.wait(500); - cy.contains(modelId).click(); + cy.contains(modelDisplayName).click(); cy.contains(`Process Model: ${modelDisplayName}`); // cy.getBySel('files-accordion').click(); cy.contains(`${jsonFileName}.json`).should('exist'); @@ -151,12 +153,12 @@ describe('process-models', () => { const modelDisplayName = `Test Model 2 ${id}`; const modelId = `test-model-2-${id}`; cy.contains('Add a process group'); - cy.contains('99-Shared Resources').click(); + cy.contains(miscDisplayName).click(); cy.wait(500); cy.contains(groupDisplayName).click(); cy.createModel(groupId, modelId, modelDisplayName); - cy.contains(`${directParentGroupId}`).click(); + cy.contains(`${groupDisplayName}`).click(); cy.contains('Add a process model'); cy.contains(modelDisplayName).click(); cy.url().should( @@ -186,7 +188,7 @@ describe('process-models', () => { .click(); // in breadcrumb - cy.contains(modelId).click(); + cy.contains(modelDisplayName).click(); cy.getBySel('delete-process-model-button').click(); cy.contains('Are you sure'); @@ -203,7 +205,7 @@ describe('process-models', () => { // process models no longer has pagination post-tiles // it.only('can paginate items', () => { - // cy.contains('99-Shared Resources').click(); + // cy.contains(miscDisplayName).click(); // cy.wait(500); // cy.contains('Acceptance Tests Group One').click(); // cy.basicPaginationTest(); diff --git a/spiffworkflow-frontend/cypress/support/commands.js b/spiffworkflow-frontend/cypress/support/commands.js index f0034168c..f7c4e8467 100644 --- a/spiffworkflow-frontend/cypress/support/commands.js +++ b/spiffworkflow-frontend/cypress/support/commands.js @@ -1,5 +1,6 @@ import { string } from 'prop-types'; import { modifyProcessIdentifierForPathParam } from '../../src/helpers'; +import { miscDisplayName } from './helpers'; // *********************************************** // This example commands.js shows you how to @@ -86,15 +87,15 @@ Cypress.Commands.add('createModel', (groupId, modelId, modelDisplayName) => { Cypress.Commands.add( 'runPrimaryBpmnFile', (expectAutoRedirectToHumanTask = false) => { - cy.contains('Run').click(); + cy.contains('Start').click(); if (expectAutoRedirectToHumanTask) { // the url changes immediately, so also make sure we get some content from the next page, "Task:", or else when we try to interact with the page, it'll re-render and we'll get an error with cypress. cy.url().should('include', `/tasks/`); cy.contains('Task: '); } else { - cy.contains(/Process Instance.*kicked off/); + cy.contains(/Process Instance.*[kK]icked [oO]ff/); cy.reload(true); - cy.contains(/Process Instance.*kicked off/).should('not.exist'); + cy.contains(/Process Instance.*[kK]icked [oO]ff/).should('not.exist'); } } ); @@ -103,8 +104,8 @@ Cypress.Commands.add( 'navigateToProcessModel', (groupDisplayName, modelDisplayName, modelIdentifier) => { cy.navigateToAdmin(); - cy.contains('99-Shared Resources').click(); - cy.contains(`Process Group: 99-Shared Resources`, { timeout: 10000 }); + cy.contains(miscDisplayName).click(); + cy.contains(`Process Group: ${miscDisplayName}`, { timeout: 10000 }); cy.contains(groupDisplayName).click(); cy.contains(`Process Group: ${groupDisplayName}`); // https://stackoverflow.com/q/51254946/6090676 diff --git a/spiffworkflow-frontend/cypress/support/helpers.js b/spiffworkflow-frontend/cypress/support/helpers.js new file mode 100644 index 000000000..b3ae449ed --- /dev/null +++ b/spiffworkflow-frontend/cypress/support/helpers.js @@ -0,0 +1 @@ +export const miscDisplayName = 'Shared Resources'; diff --git a/spiffworkflow-frontend/package-lock.json b/spiffworkflow-frontend/package-lock.json index ba2339983..4ccea1922 100644 --- a/spiffworkflow-frontend/package-lock.json +++ b/spiffworkflow-frontend/package-lock.json @@ -68,7 +68,7 @@ "@cypress/grep": "^3.1.0", "@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/parser": "^5.30.6", - "cypress": "^10.8.0", + "cypress": "^12", "eslint": "^8.19.0", "eslint_d": "^12.2.0", "eslint-config-airbnb": "^19.0.4", @@ -9850,9 +9850,9 @@ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==" }, "node_modules/cypress": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.11.0.tgz", - "integrity": "sha512-lsaE7dprw5DoXM00skni6W5ElVVLGAdRUUdZjX2dYsGjbY/QnpzWZ95Zom1mkGg0hAaO/QVTZoFVS7Jgr/GUPA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.1.0.tgz", + "integrity": "sha512-7fz8N84uhN1+ePNDsfQvoWEl4P3/VGKKmAg+bJQFY4onhA37Ys+6oBkGbNdwGeC7n2QqibNVPhk8x3YuQLwzfw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -9903,7 +9903,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": ">=12.0.0" + "node": "^14.0.0 || ^16.0.0 || >=18.0.0" } }, "node_modules/cypress/node_modules/@types/node": { @@ -38586,9 +38586,9 @@ "integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==" }, "cypress": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-10.11.0.tgz", - "integrity": "sha512-lsaE7dprw5DoXM00skni6W5ElVVLGAdRUUdZjX2dYsGjbY/QnpzWZ95Zom1mkGg0hAaO/QVTZoFVS7Jgr/GUPA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.1.0.tgz", + "integrity": "sha512-7fz8N84uhN1+ePNDsfQvoWEl4P3/VGKKmAg+bJQFY4onhA37Ys+6oBkGbNdwGeC7n2QqibNVPhk8x3YuQLwzfw==", "dev": true, "requires": { "@cypress/request": "^2.88.10", diff --git a/spiffworkflow-frontend/package.json b/spiffworkflow-frontend/package.json index b896bdcec..6a84cea9e 100644 --- a/spiffworkflow-frontend/package.json +++ b/spiffworkflow-frontend/package.json @@ -104,7 +104,7 @@ "@cypress/grep": "^3.1.0", "@typescript-eslint/eslint-plugin": "^5.30.5", "@typescript-eslint/parser": "^5.30.6", - "cypress": "^10.8.0", + "cypress": "^12", "eslint": "^8.19.0", "eslint_d": "^12.2.0", "eslint-config-airbnb": "^19.0.4", diff --git a/spiffworkflow-frontend/public/index.html b/spiffworkflow-frontend/public/index.html index 8e5b00b0f..ae3a23076 100644 --- a/spiffworkflow-frontend/public/index.html +++ b/spiffworkflow-frontend/public/index.html @@ -41,4 +41,3 @@ --> - diff --git a/spiffworkflow-frontend/src/App.tsx b/spiffworkflow-frontend/src/App.tsx index deb38410d..6357a713f 100644 --- a/spiffworkflow-frontend/src/App.tsx +++ b/spiffworkflow-frontend/src/App.tsx @@ -13,6 +13,7 @@ import AdminRoutes from './routes/AdminRoutes'; import { ErrorForDisplay } from './interfaces'; import { AbilityContext } from './contexts/Can'; +import UserService from './services/UserService'; export default function App() { const [errorMessage, setErrorMessage] = useState( @@ -24,6 +25,11 @@ export default function App() { [errorMessage] ); + if (!UserService.isLoggedIn()) { + UserService.doLogin(); + return null; + } + const ability = defineAbility(() => {}); let errorTag = null; diff --git a/spiffworkflow-frontend/src/components/MiniComponents.tsx b/spiffworkflow-frontend/src/components/MiniComponents.tsx new file mode 100644 index 000000000..6f0a12931 --- /dev/null +++ b/spiffworkflow-frontend/src/components/MiniComponents.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; +import { modifyProcessIdentifierForPathParam } from '../helpers'; +import { MessageInstance, ProcessInstance } from '../interfaces'; + +export function FormatProcessModelDisplayName( + instanceObject: ProcessInstance | MessageInstance +) { + const { + process_model_identifier: processModelIdentifier, + process_model_display_name: processModelDisplayName, + } = instanceObject; + return ( + + {processModelDisplayName} + + ); +} diff --git a/spiffworkflow-frontend/src/components/NavigationBar.tsx b/spiffworkflow-frontend/src/components/NavigationBar.tsx index 47e0de998..7a0ffd3ea 100644 --- a/spiffworkflow-frontend/src/components/NavigationBar.tsx +++ b/spiffworkflow-frontend/src/components/NavigationBar.tsx @@ -24,6 +24,7 @@ import UserService from '../services/UserService'; import { useUriListForPermissions } from '../hooks/UriListForPermissions'; import { PermissionsToCheck } from '../interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; +import { UnauthenticatedError } from '../services/HttpService'; // for ref: https://react-bootstrap.github.io/components/navbar/ export default function NavigationBar() { @@ -39,6 +40,11 @@ export default function NavigationBar() { const [activeKey, setActiveKey] = useState(''); const { targetUris } = useUriListForPermissions(); + + // App.jsx forces login (which redirects to keycloak) so we should never get here if we're not logged in. + if (!UserService.isLoggedIn()) { + throw new UnauthenticatedError('You must be authenticated to do this.'); + } const permissionRequestData: PermissionsToCheck = { [targetUris.authenticationListPath]: ['GET'], [targetUris.messageInstanceListPath]: ['GET'], @@ -135,6 +141,9 @@ export default function NavigationBar() { }; const headerMenuItems = () => { + if (!UserService.isLoggedIn()) { + return null; + } return ( <> diff --git a/spiffworkflow-frontend/src/components/Notification.tsx b/spiffworkflow-frontend/src/components/Notification.tsx new file mode 100644 index 000000000..d7f14e026 --- /dev/null +++ b/spiffworkflow-frontend/src/components/Notification.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +// @ts-ignore +import { Close, CheckmarkFilled } from '@carbon/icons-react'; +// @ts-ignore +import { Button } from '@carbon/react'; + +type OwnProps = { + title: string; + children: React.ReactNode; + onClose: (..._args: any[]) => any; + type?: string; +}; + +export function Notification({ + title, + children, + onClose, + type = 'success', +}: OwnProps) { + let iconClassName = 'green-icon'; + if (type === 'error') { + iconClassName = 'red-icon'; + } + return ( +
+
+
+ +
{title}
+
{children}
+
+
+
+ ); +} diff --git a/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx b/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx index a518e47bb..79ab8253e 100644 --- a/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx +++ b/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx @@ -115,7 +115,6 @@ export default function ProcessGroupForm({ labelText="Display Name*" value={processGroup.display_name} onChange={(event: any) => onDisplayNameChanged(event.target.value)} - onBlur={(event: any) => console.log('event', event)} />, ]; diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx new file mode 100644 index 000000000..a3d50d94b --- /dev/null +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { + Button, + TextInput, + Stack, + Modal, + // @ts-ignore +} from '@carbon/react'; +import { + ReportFilter, + ProcessInstanceReport, + ProcessModel, + ReportColumn, + ReportMetadata, +} from '../interfaces'; +import HttpService from '../services/HttpService'; + +type OwnProps = { + onSuccess: (..._args: any[]) => any; + columnArray: ReportColumn[]; + orderBy: string; + processModelSelection: ProcessModel | null; + processStatusSelection: string[]; + startFromSeconds: string | null; + startToSeconds: string | null; + endFromSeconds: string | null; + endToSeconds: string | null; + buttonText?: string; + buttonClassName?: string; + processInstanceReportSelection?: ProcessInstanceReport | null; + reportMetadata: ReportMetadata; +}; + +export default function ProcessInstanceListSaveAsReport({ + onSuccess, + columnArray, + orderBy, + processModelSelection, + processInstanceReportSelection, + processStatusSelection, + startFromSeconds, + startToSeconds, + endFromSeconds, + endToSeconds, + buttonClassName, + buttonText = 'Save as Perspective', + reportMetadata, +}: OwnProps) { + const [identifier, setIdentifier] = useState( + processInstanceReportSelection?.identifier || '' + ); + const [showSaveForm, setShowSaveForm] = useState(false); + + const isEditMode = () => { + return ( + processInstanceReportSelection && + processInstanceReportSelection.identifier === identifier + ); + }; + + const responseHandler = (result: any) => { + if (result) { + onSuccess(result, isEditMode() ? 'edit' : 'new'); + } + }; + + const handleSaveFormClose = () => { + setIdentifier(processInstanceReportSelection?.identifier || ''); + setShowSaveForm(false); + }; + + const addProcessInstanceReport = (event: any) => { + event.preventDefault(); + + // TODO: make a field to set this + let orderByArray = ['-start_in_seconds', '-id']; + if (orderBy) { + orderByArray = orderBy.split(',').filter((n) => n); + } + const filterByArray: any = []; + + if (processModelSelection) { + filterByArray.push({ + field_name: 'process_model_identifier', + field_value: processModelSelection.id, + }); + } + + if (processStatusSelection.length > 0) { + filterByArray.push({ + field_name: 'process_status', + field_value: processStatusSelection.join(','), + operator: 'in', + }); + } + + if (startFromSeconds) { + filterByArray.push({ + field_name: 'start_from', + field_value: startFromSeconds, + }); + } + + if (startToSeconds) { + filterByArray.push({ + field_name: 'start_to', + field_value: startToSeconds, + }); + } + + if (endFromSeconds) { + filterByArray.push({ + field_name: 'end_from', + field_value: endFromSeconds, + }); + } + + if (endToSeconds) { + filterByArray.push({ + field_name: 'end_to', + field_value: endToSeconds, + }); + } + + reportMetadata.filter_by.forEach((reportFilter: ReportFilter) => { + columnArray.forEach((reportColumn: ReportColumn) => { + if ( + reportColumn.accessor === reportFilter.field_name && + reportColumn.filterable + ) { + filterByArray.push(reportFilter); + } + }); + }); + + let path = `/process-instances/reports`; + let httpMethod = 'POST'; + if (isEditMode() && processInstanceReportSelection) { + httpMethod = 'PUT'; + path = `${path}/${processInstanceReportSelection.id}`; + } + + HttpService.makeCallToBackend({ + path, + successCallback: responseHandler, + httpMethod, + postBody: { + identifier, + report_metadata: { + columns: columnArray, + order_by: orderByArray, + filter_by: filterByArray, + }, + }, + }); + handleSaveFormClose(); + }; + + let textInputComponent = null; + textInputComponent = ( + setIdentifier(e.target.value)} + /> + ); + + let descriptionText = + 'Save the current columns and filters as a perspective so you can come back to this view in the future.'; + if (processInstanceReportSelection) { + descriptionText = + 'Keep the identifier the same and click Save to update the current perspective. Change the identifier if you want to save the current view with a new name.'; + } + + return ( + + +

{descriptionText}

+ {textInputComponent} +
+ +
+ ); +} diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx index bd060af60..a21baec0d 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListTable.tsx @@ -7,7 +7,7 @@ import { } from 'react-router-dom'; // @ts-ignore -import { Filter } from '@carbon/icons-react'; +import { Filter, Close, AddAlt } from '@carbon/icons-react'; import { Button, ButtonSet, @@ -21,6 +21,12 @@ import { TableHead, TableRow, TimePicker, + Tag, + Stack, + Modal, + ComboBox, + TextInput, + FormLabel, // @ts-ignore } from '@carbon/react'; import { PROCESS_STATUSES, DATE_FORMAT, DATE_FORMAT_CARBON } from '../config'; @@ -49,9 +55,16 @@ import { ProcessModel, ProcessInstanceReport, ProcessInstance, + ReportColumn, + ReportColumnForEditing, + ReportMetadata, + ReportFilter, } from '../interfaces'; import ProcessModelSearch from './ProcessModelSearch'; import ProcessInstanceReportSearch from './ProcessInstanceReportSearch'; +import ProcessInstanceListSaveAsReport from './ProcessInstanceListSaveAsReport'; +import { FormatProcessModelDisplayName } from './MiniComponents'; +import { Notification } from './Notification'; const REFRESH_INTERVAL = 5; const REFRESH_TIMEOUT = 600; @@ -88,7 +101,7 @@ export default function ProcessInstanceListTable({ const navigate = useNavigate(); const [processInstances, setProcessInstances] = useState([]); - const [reportMetadata, setReportMetadata] = useState({}); + const [reportMetadata, setReportMetadata] = useState(); const [pagination, setPagination] = useState(null); const [processInstanceFilters, setProcessInstanceFilters] = useState({}); @@ -125,6 +138,17 @@ export default function ProcessInstanceListTable({ const [processInstanceReportSelection, setProcessInstanceReportSelection] = useState(null); + const [availableReportColumns, setAvailableReportColumns] = useState< + ReportColumn[] + >([]); + const [processInstanceReportJustSaved, setProcessInstanceReportJustSaved] = + useState(null); + const [showReportColumnForm, setShowReportColumnForm] = + useState(false); + const [reportColumnToOperateOn, setReportColumnToOperateOn] = + useState(null); + const [reportColumnFormMode, setReportColumnFormMode] = useState(''); + const dateParametersToAlwaysFilterBy: dateParameters = useMemo(() => { return { start_from: [setStartFromDate, setStartFromTime], @@ -155,16 +179,12 @@ export default function ProcessInstanceListTable({ function setProcessInstancesFromResult(result: any) { const processInstancesFromApi = result.results; setProcessInstances(processInstancesFromApi); - setReportMetadata(result.report_metadata); setPagination(result.pagination); setProcessInstanceFilters(result.filters); - // TODO: need to iron out this interaction some more - if (result.report_identifier !== 'default') { - setProcessInstanceReportSelection({ - id: result.report_identifier, - display_name: result.report_identifier, - }); + setReportMetadata(result.report.report_metadata); + if (result.report.id) { + setProcessInstanceReportSelection(result.report); } } function getProcessInstances() { @@ -186,14 +206,10 @@ export default function ProcessInstanceListTable({ queryParamString += `&user_filter=${userAppliedFilter}`; } - let reportIdentifierToUse: any = reportIdentifier; - - if (!reportIdentifierToUse) { - reportIdentifierToUse = searchParams.get('report_identifier'); - } - - if (reportIdentifierToUse) { - queryParamString += `&report_identifier=${reportIdentifierToUse}`; + if (searchParams.get('report_id')) { + queryParamString += `&report_id=${searchParams.get('report_id')}`; + } else if (reportIdentifier) { + queryParamString += `&report_identifier=${reportIdentifier}`; } Object.keys(dateParametersToAlwaysFilterBy).forEach( @@ -284,8 +300,13 @@ export default function ProcessInstanceListTable({ checkFiltersAndRun(); if (autoReload) { - refreshAtInterval(REFRESH_INTERVAL, REFRESH_TIMEOUT, checkFiltersAndRun); + return refreshAtInterval( + REFRESH_INTERVAL, + REFRESH_TIMEOUT, + checkFiltersAndRun + ); } + return undefined; }, [ autoReload, searchParams, @@ -349,6 +370,28 @@ export default function ProcessInstanceListTable({ processModelAvailableItems, ]); + const processInstanceReportSaveTag = () => { + if (processInstanceReportJustSaved) { + let titleOperation = 'Updated'; + if (processInstanceReportJustSaved === 'new') { + titleOperation = 'Created'; + } + return ( + setProcessInstanceReportJustSaved(null)} + > + {`'${ + processInstanceReportSelection + ? processInstanceReportSelection.identifier + : '' + }'`} + + ); + } + return null; + }; + // does the comparison, but also returns false if either argument // is not truthy and therefore not comparable. const isTrueComparison = (param1: any, operation: any, param2: any) => { @@ -366,16 +409,8 @@ export default function ProcessInstanceListTable({ } }; - const applyFilter = (event: any) => { - event.preventDefault(); - const { page, perPage } = getPageInfoFromSearchParams( - searchParams, - undefined, - undefined, - paginationQueryParamPrefix - ); - let queryParamString = `per_page=${perPage}&page=${page}&user_filter=true`; - + // TODO: after factoring this out page hangs when invalid date ranges and applying the filter + const calculateStartAndEndSeconds = () => { const startFromSeconds = convertDateAndTimeStringsToSeconds( startFromDate, startFromTime || '00:00:00' @@ -392,28 +427,59 @@ export default function ProcessInstanceListTable({ endToDate, endToTime || '00:00:00' ); + let valid = true; if (isTrueComparison(startFromSeconds, '>', startToSeconds)) { setErrorMessage({ message: '"Start date from" cannot be after "start date to"', }); - return; + valid = false; } if (isTrueComparison(endFromSeconds, '>', endToSeconds)) { setErrorMessage({ message: '"End date from" cannot be after "end date to"', }); - return; + valid = false; } if (isTrueComparison(startFromSeconds, '>', endFromSeconds)) { setErrorMessage({ message: '"Start date from" cannot be after "end date from"', }); - return; + valid = false; } if (isTrueComparison(startToSeconds, '>', endToSeconds)) { setErrorMessage({ message: '"Start date to" cannot be after "end date to"', }); + valid = false; + } + + return { + valid, + startFromSeconds, + startToSeconds, + endFromSeconds, + endToSeconds, + }; + }; + + const applyFilter = (event: any) => { + event.preventDefault(); + const { page, perPage } = getPageInfoFromSearchParams( + searchParams, + undefined, + undefined, + paginationQueryParamPrefix + ); + let queryParamString = `per_page=${perPage}&page=${page}&user_filter=true`; + const { + valid, + startFromSeconds, + startToSeconds, + endFromSeconds, + endToSeconds, + } = calculateStartAndEndSeconds(); + + if (!valid) { return; } @@ -438,10 +504,11 @@ export default function ProcessInstanceListTable({ } if (processInstanceReportSelection) { - queryParamString += `&report_identifier=${processInstanceReportSelection.id}`; + queryParamString += `&report_id=${processInstanceReportSelection.id}`; } setErrorMessage(null); + setProcessInstanceReportJustSaved(null); navigate(`/admin/process-instances?${queryParamString}`); }; @@ -529,12 +596,369 @@ export default function ProcessInstanceListTable({ setEndToTime(''); }; + const processInstanceReportDidChange = (selection: any, mode?: string) => { + clearFilters(); + const selectedReport = selection.selectedItem; + setProcessInstanceReportSelection(selectedReport); + + let queryParamString = ''; + if (selectedReport) { + queryParamString = `?report_id=${selectedReport.id}`; + } + + setErrorMessage(null); + setProcessInstanceReportJustSaved(mode || null); + navigate(`/admin/process-instances${queryParamString}`); + }; + + const reportColumns = () => { + return (reportMetadata as any).columns; + }; + + const reportColumnAccessors = () => { + return reportColumns().map((reportColumn: ReportColumn) => { + return reportColumn.accessor; + }); + }; + + // TODO onSuccess reload/select the new report in the report search + const onSaveReportSuccess = (result: any, mode: string) => { + processInstanceReportDidChange( + { + selectedItem: result, + }, + mode + ); + }; + + const saveAsReportComponent = () => { + const { + valid, + startFromSeconds, + startToSeconds, + endFromSeconds, + endToSeconds, + } = calculateStartAndEndSeconds(); + + if (!valid || !reportMetadata) { + return null; + } + return ( + + ); + }; + + const removeColumn = (reportColumn: ReportColumn) => { + if (reportMetadata) { + const reportMetadataCopy = { ...reportMetadata }; + const newColumns = reportColumns().filter( + (rc: ReportColumn) => rc.accessor !== reportColumn.accessor + ); + Object.assign(reportMetadataCopy, { columns: newColumns }); + setReportMetadata(reportMetadataCopy); + } + }; + + const handleColumnFormClose = () => { + setShowReportColumnForm(false); + setReportColumnFormMode(''); + setReportColumnToOperateOn(null); + }; + + const getFilterByFromReportMetadata = (reportColumnAccessor: string) => { + if (reportMetadata) { + return reportMetadata.filter_by.find((reportFilter: ReportFilter) => { + return reportColumnAccessor === reportFilter.field_name; + }); + } + return null; + }; + + const getNewFiltersFromReportForEditing = ( + reportColumnForEditing: ReportColumnForEditing + ) => { + if (!reportMetadata) { + return null; + } + const reportMetadataCopy = { ...reportMetadata }; + let newReportFilters = reportMetadataCopy.filter_by; + if (reportColumnForEditing.filterable) { + const newReportFilter: ReportFilter = { + field_name: reportColumnForEditing.accessor, + field_value: reportColumnForEditing.filter_field_value, + operator: reportColumnForEditing.filter_operator || 'equals', + }; + const existingReportFilter = getFilterByFromReportMetadata( + reportColumnForEditing.accessor + ); + if (existingReportFilter) { + const existingReportFilterIndex = + reportMetadataCopy.filter_by.indexOf(existingReportFilter); + if (reportColumnForEditing.filter_field_value) { + newReportFilters[existingReportFilterIndex] = newReportFilter; + } else { + newReportFilters.splice(existingReportFilterIndex, 1); + } + } else if (reportColumnForEditing.filter_field_value) { + newReportFilters = newReportFilters.concat([newReportFilter]); + } + } + return newReportFilters; + }; + + const handleUpdateReportColumn = () => { + if (reportColumnToOperateOn && reportMetadata) { + const reportMetadataCopy = { ...reportMetadata }; + let newReportColumns = null; + if (reportColumnFormMode === 'new') { + newReportColumns = reportColumns().concat([reportColumnToOperateOn]); + } else { + newReportColumns = reportColumns().map((rc: ReportColumn) => { + if (rc.accessor === reportColumnToOperateOn.accessor) { + return reportColumnToOperateOn; + } + return rc; + }); + } + Object.assign(reportMetadataCopy, { + columns: newReportColumns, + filter_by: getNewFiltersFromReportForEditing(reportColumnToOperateOn), + }); + setReportMetadata(reportMetadataCopy); + setReportColumnToOperateOn(null); + setShowReportColumnForm(false); + setShowReportColumnForm(false); + } + }; + + const reportColumnToReportColumnForEditing = (reportColumn: ReportColumn) => { + const reportColumnForEditing: ReportColumnForEditing = Object.assign( + reportColumn, + { filter_field_value: '', filter_operator: '' } + ); + const reportFilter = getFilterByFromReportMetadata( + reportColumnForEditing.accessor + ); + if (reportFilter) { + reportColumnForEditing.filter_field_value = reportFilter.field_value; + reportColumnForEditing.filter_operator = + reportFilter.operator || 'equals'; + } + return reportColumnForEditing; + }; + + const updateReportColumn = (event: any) => { + const reportColumnForEditing = reportColumnToReportColumnForEditing( + event.selectedItem + ); + setReportColumnToOperateOn(reportColumnForEditing); + }; + + // options includes item and inputValue + const shouldFilterReportColumn = (options: any) => { + const reportColumn: ReportColumn = options.item; + const { inputValue } = options; + return ( + !reportColumnAccessors().includes(reportColumn.accessor) && + (reportColumn.accessor || '') + .toLowerCase() + .includes((inputValue || '').toLowerCase()) + ); + }; + + const setReportColumnConditionValue = (event: any) => { + if (reportColumnToOperateOn) { + const reportColumnToOperateOnCopy = { + ...reportColumnToOperateOn, + }; + reportColumnToOperateOnCopy.filter_field_value = event.target.value; + setReportColumnToOperateOn(reportColumnToOperateOnCopy); + } + }; + + const reportColumnForm = () => { + if (reportColumnFormMode === '') { + return null; + } + const formElements = [ + { + if (reportColumnToOperateOn) { + const reportColumnToOperateOnCopy = { + ...reportColumnToOperateOn, + }; + reportColumnToOperateOnCopy.Header = event.target.value; + setReportColumnToOperateOn(reportColumnToOperateOnCopy); + } + }} + />, + ]; + if (reportColumnToOperateOn && reportColumnToOperateOn.filterable) { + formElements.push( + + ); + } + if (reportColumnFormMode === 'new') { + formElements.push( + { + if (reportColumn) { + return reportColumn.accessor; + } + return null; + }} + shouldFilterItem={shouldFilterReportColumn} + placeholder="Choose a column to show" + titleText="Column" + /> + ); + } + const modalHeading = + reportColumnFormMode === 'new' + ? 'Add Column' + : `Edit ${ + reportColumnToOperateOn ? reportColumnToOperateOn.accessor : '' + } column`; + return ( + + {formElements} + + ); + }; + + const columnSelections = () => { + if (reportColumns()) { + const tags: any = []; + + (reportColumns() as any).forEach((reportColumn: ReportColumn) => { + const reportColumnForEditing = + reportColumnToReportColumnForEditing(reportColumn); + + let tagType = 'cool-gray'; + let tagTypeClass = ''; + if (reportColumnForEditing.filterable) { + tagType = 'green'; + tagTypeClass = 'tag-type-green'; + } + let reportColumnLabel = reportColumnForEditing.Header; + if (reportColumnForEditing.filter_field_value) { + reportColumnLabel = `${reportColumnLabel}=${reportColumnForEditing.filter_field_value}`; + } + tags.push( + + + + + {saveAsReportComponent()} + ); @@ -635,7 +1063,7 @@ export default function ProcessInstanceListTable({ const getHeaderLabel = (header: string) => { return headerLabels[header] ?? header; }; - const headers = (reportMetadata as any).columns.map((column: any) => { + const headers = reportColumns().map((column: any) => { // return {getHeaderLabel((column as any).Header)}; return getHeaderLabel((column as any).Header); }); @@ -646,7 +1074,7 @@ export default function ProcessInstanceListTable({ return ( {id} @@ -665,22 +1093,6 @@ export default function ProcessInstanceListTable({ ); }; - const formatProcessModelDisplayName = ( - row: ProcessInstance, - displayName: string - ) => { - return ( - - {displayName} - - ); - }; - const formatSecondsForDisplay = (_row: any, seconds: any) => { return convertSecondsToFormattedDateTime(seconds) || '-'; }; @@ -688,15 +1100,16 @@ export default function ProcessInstanceListTable({ return value; }; - const columnFormatters: Record = { + const reportColumnFormatters: Record = { id: formatProcessInstanceId, process_model_identifier: formatProcessModelIdentifier, - process_model_display_name: formatProcessModelDisplayName, + process_model_display_name: FormatProcessModelDisplayName, start_in_seconds: formatSecondsForDisplay, end_in_seconds: formatSecondsForDisplay, }; const formattedColumn = (row: any, column: any) => { - const formatter = columnFormatters[column.accessor] ?? defaultFormatter; + const formatter = + reportColumnFormatters[column.accessor] ?? defaultFormatter; const value = row[column.accessor]; if (column.accessor === 'status') { return ( @@ -709,7 +1122,7 @@ export default function ProcessInstanceListTable({ }; const rows = processInstances.map((row: any) => { - const currentRow = (reportMetadata as any).columns.map((column: any) => { + const currentRow = reportColumns().map((column: any) => { return formattedColumn(row, column); }); return {currentRow}; @@ -738,27 +1151,20 @@ export default function ProcessInstanceListTable({ setShowFilterOptions(!showFilterOptions); }; - const processInstanceReportDidChange = (selection: any) => { - clearFilters(); - - const selectedReport = selection.selectedItem; - setProcessInstanceReportSelection(selectedReport); - - const queryParamString = selectedReport - ? `&report_identifier=${selectedReport.id}` - : ''; - - setErrorMessage(null); - navigate(`/admin/process-instances?${queryParamString}`); - }; - const reportSearchComponent = () => { if (showReports) { + const columns = [ + + + , + ]; return ( - + + {columns} + ); } return null; @@ -771,6 +1177,9 @@ export default function ProcessInstanceListTable({ return ( <> + + {reportSearchComponent()} + + {reportColumnForm()} + {processInstanceReportSaveTag()} {filterComponent()} - {reportSearchComponent()} (null); - function setProcessInstanceReportsFromResult(result: any) { - const processInstanceReportsFromApi = result.map((item: any) => { - return { id: item.identifier, display_name: item.identifier }; - }); - setProcessInstanceReports(processInstanceReportsFromApi); - } + const [searchParams] = useSearchParams(); + const reportId = searchParams.get('report_id'); + + useEffect(() => { + function setProcessInstanceReportsFromResult( + result: ProcessInstanceReport[] + ) { + setProcessInstanceReports(result); + } - if (processInstanceReports === null) { - setProcessInstanceReports([]); HttpService.makeCallToBackend({ path: `/process-instances/reports`, successCallback: setProcessInstanceReportsFromResult, }); - } + }, [reportId]); + + const reportSelectionString = ( + processInstanceReport: ProcessInstanceReport + ) => { + return `${truncateString(processInstanceReport.identifier, 20)} (Id: ${ + processInstanceReport.id + })`; + }; const shouldFilterProcessInstanceReport = (options: any) => { const processInstanceReport: ProcessInstanceReport = options.item; const { inputValue } = options; - return `${processInstanceReport.id} (${processInstanceReport.display_name})`.includes( - inputValue - ); + return reportSelectionString(processInstanceReport).includes(inputValue); }; const reportsAvailable = () => { return processInstanceReports && processInstanceReports.length > 0; }; - return reportsAvailable() ? ( - { - if (processInstanceReport) { - return `${processInstanceReport.id} (${truncateString( - processInstanceReport.display_name, - 20 - )})`; - } - return null; - }} - shouldFilterItem={shouldFilterProcessInstanceReport} - placeholder="Choose a process instance perspective" - titleText={titleText} - selectedItem={selectedItem} - /> - ) : null; + if (reportsAvailable()) { + return ( + + {titleText} + { + if (processInstanceReport) { + return reportSelectionString(processInstanceReport); + } + return null; + }} + shouldFilterItem={shouldFilterProcessInstanceReport} + placeholder="Choose a process instance perspective" + selectedItem={selectedItem} + /> + + ); + } + return null; } diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx index 87406f80f..05b643da8 100644 --- a/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx +++ b/spiffworkflow-frontend/src/components/ProcessInstanceRun.tsx @@ -83,9 +83,9 @@ export default function ProcessInstanceRun({ processModel.id ); - const processInstanceActionPath = `/v1.0/process-models/${modifiedProcessModelId}/process-instances`; + const processInstanceCreatePath = `/v1.0/process-instances/${modifiedProcessModelId}`; let permissionRequestData: PermissionsToCheck = { - [processInstanceActionPath]: ['POST'], + [processInstanceCreatePath]: ['POST'], }; if (!checkPermissions) { @@ -117,14 +117,14 @@ export default function ProcessInstanceRun({ const processInstanceCreateAndRun = () => { HttpService.makeCallToBackend({ - path: processInstanceActionPath, + path: processInstanceCreatePath, successCallback: processModelRun, httpMethod: 'POST', }); }; if (checkPermissions) { return ( - + diff --git a/spiffworkflow-frontend/src/components/ProcessModelForm.tsx b/spiffworkflow-frontend/src/components/ProcessModelForm.tsx index 396f1ea0f..7cfd4d616 100644 --- a/spiffworkflow-frontend/src/components/ProcessModelForm.tsx +++ b/spiffworkflow-frontend/src/components/ProcessModelForm.tsx @@ -1,10 +1,20 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { + Button, + ButtonSet, + Form, + Stack, + TextInput, + Grid, + Column, + // @ts-ignore +} from '@carbon/react'; // @ts-ignore -import { Button, ButtonSet, Form, Stack, TextInput } from '@carbon/react'; +import { AddAlt, TrashCan } from '@carbon/icons-react'; import { modifyProcessIdentifierForPathParam, slugifyString } from '../helpers'; import HttpService from '../services/HttpService'; -import { ProcessModel } from '../interfaces'; +import { MetadataExtractionPath, ProcessModel } from '../interfaces'; type OwnProps = { mode: string; @@ -23,6 +33,7 @@ export default function ProcessModelForm({ const [idHasBeenUpdatedByUser, setIdHasBeenUpdatedByUser] = useState(false); const [displayNameInvalid, setDisplayNameInvalid] = useState(false); + useState(false); const navigate = useNavigate(); const navigateToProcessModel = (result: ProcessModel) => { @@ -64,6 +75,7 @@ export default function ProcessModelForm({ const postBody = { display_name: processModel.display_name, description: processModel.description, + metadata_extraction_paths: processModel.metadata_extraction_paths, }; if (mode === 'new') { Object.assign(postBody, { @@ -87,6 +99,80 @@ export default function ProcessModelForm({ setProcessModel(processModelToCopy); }; + const metadataExtractionPathForm = ( + index: number, + metadataExtractionPath: MetadataExtractionPath + ) => { + return ( + + + { + const cep: MetadataExtractionPath[] = + processModel.metadata_extraction_paths || []; + const newMeta = { ...metadataExtractionPath }; + newMeta.key = event.target.value; + cep[index] = newMeta; + updateProcessModel({ metadata_extraction_paths: cep }); + }} + /> + + + { + const cep: MetadataExtractionPath[] = + processModel.metadata_extraction_paths || []; + const newMeta = { ...metadataExtractionPath }; + newMeta.path = event.target.value; + cep[index] = newMeta; + updateProcessModel({ metadata_extraction_paths: cep }); + }} + /> + + + + + + ); + return textInputs; }; diff --git a/spiffworkflow-frontend/src/components/ProcessModelListTiles.tsx b/spiffworkflow-frontend/src/components/ProcessModelListTiles.tsx index 4787fe94e..1412635c5 100644 --- a/spiffworkflow-frontend/src/components/ProcessModelListTiles.tsx +++ b/spiffworkflow-frontend/src/components/ProcessModelListTiles.tsx @@ -54,9 +54,9 @@ export default function ProcessModelListTiles({

Process Instance {processInstance.id} kicked off ( view diff --git a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx index 11839e9cf..a1be5efac 100644 --- a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx +++ b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx @@ -58,14 +58,14 @@ import HttpService from '../services/HttpService'; import ButtonWithConfirmation from './ButtonWithConfirmation'; import { makeid } from '../helpers'; import { useUriListForPermissions } from '../hooks/UriListForPermissions'; -import { PermissionsToCheck } from '../interfaces'; +import { PermissionsToCheck, ProcessInstanceTask } from '../interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; type OwnProps = { processModelId: string; diagramType: string; - readyOrWaitingBpmnTaskIds?: string[] | null; - completedTasksBpmnIds?: string[] | null; + readyOrWaitingProcessInstanceTasks?: ProcessInstanceTask[] | null; + completedProcessInstanceTasks?: ProcessInstanceTask[] | null; saveDiagram?: (..._args: any[]) => any; onDeleteFile?: (..._args: any[]) => any; onSetPrimaryFile?: (..._args: any[]) => any; @@ -88,8 +88,8 @@ type OwnProps = { export default function ReactDiagramEditor({ processModelId, diagramType, - readyOrWaitingBpmnTaskIds, - completedTasksBpmnIds, + readyOrWaitingProcessInstanceTasks, + completedProcessInstanceTasks, saveDiagram, onDeleteFile, onSetPrimaryFile, @@ -227,7 +227,9 @@ export default function ReactDiagramEditor({ function handleElementClick(event: any) { if (onElementClick) { - onElementClick(event.element); + const canvas = diagramModeler.get('canvas'); + const rootElement = canvas.getRootElement(); + onElementClick(event.element, rootElement); } } @@ -350,12 +352,15 @@ export default function ReactDiagramEditor({ function highlightBpmnIoElement( canvas: any, - taskBpmnId: string, - bpmnIoClassName: string + processInstanceTask: ProcessInstanceTask, + bpmnIoClassName: string, + bpmnRootElementId: string ) { - if (checkTaskCanBeHighlighted(taskBpmnId)) { + if (checkTaskCanBeHighlighted(processInstanceTask.name)) { try { - canvas.addMarker(taskBpmnId, bpmnIoClassName); + if (bpmnRootElementId === processInstanceTask.process_identifier) { + canvas.addMarker(processInstanceTask.name, bpmnIoClassName); + } } catch (bpmnIoError: any) { // the task list also contains task for processes called from call activities which will // not exist in this diagram so just ignore them for now. @@ -394,21 +399,25 @@ export default function ReactDiagramEditor({ // highlighting a field // Option 3 at: // https://github.com/bpmn-io/bpmn-js-examples/tree/master/colors - if (readyOrWaitingBpmnTaskIds) { - readyOrWaitingBpmnTaskIds.forEach((readyOrWaitingBpmnTaskId) => { + if (readyOrWaitingProcessInstanceTasks) { + const rootElement = canvas.getRootElement(); + readyOrWaitingProcessInstanceTasks.forEach((readyOrWaitingBpmnTask) => { highlightBpmnIoElement( canvas, - readyOrWaitingBpmnTaskId, - 'active-task-highlight' + readyOrWaitingBpmnTask, + 'active-task-highlight', + rootElement.id ); }); } - if (completedTasksBpmnIds) { - completedTasksBpmnIds.forEach((completedTaskBpmnId) => { + if (completedProcessInstanceTasks) { + const rootElement = canvas.getRootElement(); + completedProcessInstanceTasks.forEach((completedTask) => { highlightBpmnIoElement( canvas, - completedTaskBpmnId, - 'completed-task-highlight' + completedTask, + 'completed-task-highlight', + rootElement.id ); }); } @@ -484,8 +493,8 @@ export default function ReactDiagramEditor({ diagramType, diagramXML, diagramXMLString, - readyOrWaitingBpmnTaskIds, - completedTasksBpmnIds, + readyOrWaitingProcessInstanceTasks, + completedProcessInstanceTasks, fileName, performingXmlUpdates, processModelId, diff --git a/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx b/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx index a81779c7a..297f20716 100644 --- a/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx +++ b/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx @@ -41,7 +41,7 @@ export default function MyOpenProcesses() { }); }; getTasks(); - refreshAtInterval(REFRESH_INTERVAL, REFRESH_TIMEOUT, getTasks); + return refreshAtInterval(REFRESH_INTERVAL, REFRESH_TIMEOUT, getTasks); }, [searchParams]); const buildTable = () => { @@ -55,7 +55,7 @@ export default function MyOpenProcesses() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx b/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx index 924202248..7d06b7a30 100644 --- a/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx +++ b/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx @@ -47,7 +47,7 @@ export default function TasksWaitingForMe() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx b/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx index 51c38e946..5b05dcd0a 100644 --- a/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx +++ b/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx @@ -41,7 +41,7 @@ export default function TasksWaitingForMyGroups() { }); }; getTasks(); - refreshAtInterval(REFRESH_INTERVAL, REFRESH_TIMEOUT, getTasks); + return refreshAtInterval(REFRESH_INTERVAL, REFRESH_TIMEOUT, getTasks); }, [searchParams]); const buildTable = () => { @@ -55,7 +55,7 @@ export default function TasksWaitingForMyGroups() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/config.tsx b/spiffworkflow-frontend/src/config.tsx index 5e7e96feb..b0816a39d 100644 --- a/spiffworkflow-frontend/src/config.tsx +++ b/spiffworkflow-frontend/src/config.tsx @@ -14,6 +14,7 @@ export const PROCESS_STATUSES = [ 'complete', 'error', 'suspended', + 'terminated', ]; // with time: yyyy-MM-dd HH:mm:ss diff --git a/spiffworkflow-frontend/src/helpers.tsx b/spiffworkflow-frontend/src/helpers.tsx index 6781ada97..0b73e517d 100644 --- a/spiffworkflow-frontend/src/helpers.tsx +++ b/spiffworkflow-frontend/src/helpers.tsx @@ -208,5 +208,8 @@ export const refreshAtInterval = ( () => clearInterval(intervalRef), timeout * 1000 ); - return [intervalRef, timeoutRef]; + return () => { + clearInterval(intervalRef); + clearTimeout(timeoutRef); + }; }; diff --git a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx index 80c78987a..f84465c82 100644 --- a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx +++ b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx @@ -9,13 +9,16 @@ export const useUriListForPermissions = () => { messageInstanceListPath: '/v1.0/messages', processGroupListPath: '/v1.0/process-groups', processGroupShowPath: `/v1.0/process-groups/${params.process_group_id}`, - processInstanceActionPath: `/v1.0/process-models/${params.process_model_id}/process-instances`, + processInstanceCreatePath: `/v1.0/process-instances/${params.process_model_id}`, + processInstanceActionPath: `/v1.0/process-instances/${params.process_model_id}/${params.process_instance_id}`, processInstanceListPath: '/v1.0/process-instances', - processInstanceTaskListPath: `/v1.0/process-instances/${params.process_model_id}/${params.process_instance_id}/tasks`, + processInstanceLogListPath: `/v1.0/logs/${params.process_model_id}/${params.process_instance_id}`, processInstanceReportListPath: '/v1.0/process-instances/reports', + processInstanceTaskListPath: `/v1.0/task-data/${params.process_model_id}/${params.process_instance_id}`, processModelCreatePath: `/v1.0/process-models/${params.process_group_id}`, processModelFileCreatePath: `/v1.0/process-models/${params.process_model_id}/files`, processModelFileShowPath: `/v1.0/process-models/${params.process_model_id}/files/${params.file_name}`, + processModelPublishPath: `/v1.0/process-models/${params.process_model_id}/publish`, processModelShowPath: `/v1.0/process-models/${params.process_model_id}`, secretListPath: `/v1.0/secrets`, }; diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index 4723e5571..248a23d7d 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -69,6 +69,24 @@ h2 { color: black; } +/* match normal link colors */ +.cds--btn--ghost.button-link { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:visited { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:hover { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:visited:hover { + color: #0062fe; + padding-left: 0; +} + .cds--header__global .cds--btn--primary { background-color: #161616 } @@ -151,10 +169,22 @@ h1.with-icons { margin-top: 1em; } +.with-extra-top-margin { + margin-top: 1.3em; +} + +.with-tiny-top-margin { + margin-top: 4px; +} + .with-large-bottom-margin { margin-bottom: 3em; } +.with-tiny-bottom-margin { + margin-bottom: 4px; +} + .diagram-viewer-canvas { border:1px solid #000000; height:70vh; @@ -297,3 +327,46 @@ td.actions-cell { text-align: right; padding-bottom: 10px; } + +.cds--btn--ghost:not([disabled]) svg.red-icon { + fill: red; +} + +svg.green-icon { + fill: #198038; +} + +svg.notification-icon { + margin-right: 1rem; +} + +.failure-string { + color: red; +} + +.cds--btn--ghost.cds--btn--sm.button-tag-icon { + padding-left: 0; + padding-right: 0; + padding-top: 0; +} + +/* .no-wrap cds--label cds--label--inline cds--label--inline--md{ */ +.no-wrap .cds--label--inline{ + word-break: normal; +} + +.combo-box-in-modal { + height: 300px; +} + +.cds--btn.narrow-button { + max-width: 10rem; + min-width: 5rem; + word-break: normal; + +} + +/* lime green */ +.tag-type-green:hover { + background-color: #80ee90; +} diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 3c3d7c122..6afb11441 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -11,6 +11,13 @@ export interface RecentProcessModel { processModelDisplayName: string; } +export interface ProcessInstanceTask { + id: string; + state: string; + process_identifier: string; + name: string; +} + export interface ProcessReference { name: string; // The process or decision Display name. identifier: string; // The unique id of the process @@ -38,11 +45,59 @@ export interface ProcessFile { export interface ProcessInstance { id: number; process_model_identifier: string; + process_model_display_name: string; + spiff_step?: number; +} + +export interface MessageCorrelationProperties { + [key: string]: string; +} + +export interface MessageCorrelations { + [key: string]: MessageCorrelationProperties; +} + +export interface MessageInstance { + id: number; + process_model_identifier: string; + process_model_display_name: string; + process_instance_id: number; + message_identifier: string; + message_type: string; + failure_cause: string; + status: string; + created_at_in_seconds: number; + message_correlations?: MessageCorrelations; +} + +export interface ReportFilter { + field_name: string; + field_value: string; + operator?: string; +} + +export interface ReportColumn { + Header: string; + accessor: string; + filterable: boolean; +} + +export interface ReportColumnForEditing extends ReportColumn { + filter_field_value: string; + filter_operator: string; +} + +export interface ReportMetadata { + columns: ReportColumn[]; + filter_by: ReportFilter[]; + order_by: string[]; } export interface ProcessInstanceReport { - id: string; - display_name: string; + id: number; + identifier: string; + name: string; + report_metadata: ReportMetadata; } export interface ProcessGroupLite { @@ -50,6 +105,11 @@ export interface ProcessGroupLite { display_name: string; } +export interface MetadataExtractionPath { + key: string; + path: string; +} + export interface ProcessModel { id: string; description: string; @@ -57,6 +117,7 @@ export interface ProcessModel { primary_file_name: string; files: ProcessFile[]; parent_groups?: ProcessGroupLite[]; + metadata_extraction_paths?: MetadataExtractionPath[]; } export interface ProcessGroup { diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index 91ae7ab08..da6cae356 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -71,11 +71,11 @@ export default function AdminRoutes() { element={} /> } /> } /> } /> } /> } /> diff --git a/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx b/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx index f1478058e..a9ec6b69f 100644 --- a/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx +++ b/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx @@ -1,15 +1,19 @@ import { useEffect, useState } from 'react'; // @ts-ignore -import { Table } from '@carbon/react'; +import { ErrorOutline } from '@carbon/icons-react'; +// @ts-ignore +import { Table, Modal, Button } from '@carbon/react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import PaginationForTable from '../components/PaginationForTable'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import { - convertSecondsToFormattedDateString, + convertSecondsToFormattedDateTime, getPageInfoFromSearchParams, modifyProcessIdentifierForPathParam, } from '../helpers'; import HttpService from '../services/HttpService'; +import { FormatProcessModelDisplayName } from '../components/MiniComponents'; +import { MessageInstance } from '../interfaces'; export default function MessageInstanceList() { const params = useParams(); @@ -17,6 +21,9 @@ export default function MessageInstanceList() { const [messageIntances, setMessageInstances] = useState([]); const [pagination, setPagination] = useState(null); + const [messageInstanceForModal, setMessageInstanceForModal] = + useState(null); + useEffect(() => { const setMessageInstanceListFromResult = (result: any) => { setMessageInstances(result.results); @@ -35,41 +42,89 @@ export default function MessageInstanceList() { }); }, [searchParams, params]); - const buildTable = () => { - // return null; - const rows = messageIntances.map((row) => { - const rowToUse = row as any; + const handleCorrelationDisplayClose = () => { + setMessageInstanceForModal(null); + }; + + const correlationsDisplayModal = () => { + if (messageInstanceForModal) { + let failureCausePre = null; + if (messageInstanceForModal.failure_cause) { + failureCausePre = ( + <> +

+ {messageInstanceForModal.failure_cause} +

+
+ + ); + } return ( - - {rowToUse.id} - - - {rowToUse.process_model_identifier} - - + + {failureCausePre} +

Correlations:

+
+            {JSON.stringify(
+              messageInstanceForModal.message_correlations,
+              null,
+              2
+            )}
+          
+
+ ); + } + return null; + }; + + const buildTable = () => { + const rows = messageIntances.map((row: MessageInstance) => { + let errorIcon = null; + let errorTitle = null; + if (row.failure_cause) { + errorTitle = 'Instance has an error'; + errorIcon = ( + <> +   + + + ); + } + return ( + + {row.id} + {FormatProcessModelDisplayName(row)} - {rowToUse.process_instance_id} + {row.process_instance_id} - {rowToUse.message_identifier} - {rowToUse.message_type} - {rowToUse.failure_cause || '-'} - {rowToUse.status} + {row.message_identifier} + {row.message_type} - {convertSecondsToFormattedDateString( - rowToUse.created_at_in_seconds - )} + + + {row.status} + + {convertSecondsToFormattedDateTime(row.created_at_in_seconds)} ); @@ -78,12 +133,12 @@ export default function MessageInstanceList() { - - + + - + - + @@ -108,9 +163,9 @@ export default function MessageInstanceList() { }, [ `Process Instance: ${searchParams.get('process_instance_id')}`, - `/admin/process-models/${searchParams.get( + `/admin/process-instances/${searchParams.get( 'process_model_id' - )}/process-instances/${searchParams.get('process_instance_id')}`, + )}/${searchParams.get('process_instance_id')}`, ], ['Messages'], ]} @@ -121,6 +176,7 @@ export default function MessageInstanceList() { <> {breadcrumbElement}

Messages

+ {correlationsDisplayModal()} Process Instance {processInstance.id} kicked off ( view @@ -95,7 +95,7 @@ export default function MyTasks() { - + - - + {isDetailedView && ( + <> + + + + + )}
Instance IdProcess ModelIdProcess Process InstanceMessage ModelName TypeFailure CauseDetails Status Created At
{rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx index b6c08b213..1d75db565 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceList.tsx @@ -21,10 +21,11 @@ export default function ProcessInstanceList() { diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx index f41caf944..37ef5519c 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; // @ts-ignore -import { Table } from '@carbon/react'; +import { Table, Tabs, TabList, Tab } from '@carbon/react'; import { useParams, useSearchParams, Link } from 'react-router-dom'; import PaginationForTable from '../components/PaginationForTable'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; @@ -10,15 +10,18 @@ import { convertSecondsToFormattedDateTime, } from '../helpers'; import HttpService from '../services/HttpService'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; export default function ProcessInstanceLogList() { const params = useParams(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [processInstanceLogs, setProcessInstanceLogs] = useState([]); const [pagination, setPagination] = useState(null); const modifiedProcessModelId = modifyProcessIdentifierForPathParam( `${params.process_model_id}` ); + const { targetUris } = useUriListForPermissions(); + const isDetailedView = searchParams.get('detailed') === 'true'; useEffect(() => { const setProcessInstanceLogListFromResult = (result: any) => { @@ -27,26 +30,36 @@ export default function ProcessInstanceLogList() { }; const { page, perPage } = getPageInfoFromSearchParams(searchParams); HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/logs?per_page=${perPage}&page=${page}`, + path: `${targetUris.processInstanceLogListPath}?per_page=${perPage}&page=${page}&detailed=${isDetailedView}`, successCallback: setProcessInstanceLogListFromResult, }); - }, [searchParams, params]); + }, [ + searchParams, + params, + targetUris.processInstanceLogListPath, + isDetailedView, + ]); const buildTable = () => { const rows = processInstanceLogs.map((row) => { const rowToUse = row as any; return (
{rowToUse.bpmn_process_identifier}{rowToUse.id} {rowToUse.message}{rowToUse.bpmn_task_identifier} {rowToUse.bpmn_task_name}{rowToUse.bpmn_task_type}{rowToUse.bpmn_task_identifier}{rowToUse.bpmn_task_type}{rowToUse.bpmn_process_identifier}{rowToUse.username} {convertSecondsToFormattedDateTime(rowToUse.timestamp)} @@ -58,11 +71,16 @@ export default function ProcessInstanceLogList() { - + - - + {isDetailedView && ( + <> + + + + + )} @@ -71,11 +89,12 @@ export default function ProcessInstanceLogList() {
Bpmn Process IdentifierId MessageTask Identifier Task NameTask TypeTask IdentifierTask TypeBpmn Process IdentifierUser Timestamp
); }; + const selectedTabIndex = isDetailedView ? 1 : 0; if (pagination) { const { page, perPage } = getPageInfoFromSearchParams(searchParams); return ( -
+ <> + + + { + searchParams.set('detailed', 'false'); + setSearchParams(searchParams); + }} + > + Simple + + { + searchParams.set('detailed', 'true'); + setSearchParams(searchParams); + }} + > + Detailed + + + +
-
+ ); } return null; diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index 3710cee02..792efab01 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -1,6 +1,11 @@ import { useContext, useEffect, useState } from 'react'; import Editor from '@monaco-editor/react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { + useParams, + useNavigate, + Link, + useSearchParams, +} from 'react-router-dom'; import { TrashCan, StopOutline, @@ -35,15 +40,21 @@ import { import ButtonWithConfirmation from '../components/ButtonWithConfirmation'; import ErrorContext from '../contexts/ErrorContext'; import { useUriListForPermissions } from '../hooks/UriListForPermissions'; -import { PermissionsToCheck } from '../interfaces'; +import { + PermissionsToCheck, + ProcessInstance, + ProcessInstanceTask, +} from '../interfaces'; import { usePermissionFetcher } from '../hooks/PermissionService'; export default function ProcessInstanceShow() { const navigate = useNavigate(); const params = useParams(); + const [searchParams] = useSearchParams(); - const [processInstance, setProcessInstance] = useState(null); - const [tasks, setTasks] = useState | null>(null); + const [processInstance, setProcessInstance] = + useState(null); + const [tasks, setTasks] = useState(null); const [tasksCallHadError, setTasksCallHadError] = useState(false); const [taskToDisplay, setTaskToDisplay] = useState(null); const [taskDataToDisplay, setTaskDataToDisplay] = useState(''); @@ -65,6 +76,11 @@ export default function ProcessInstanceShow() { const permissionRequestData: PermissionsToCheck = { [targetUris.messageInstanceListPath]: ['GET'], [targetUris.processInstanceTaskListPath]: ['GET'], + [targetUris.processInstanceActionPath]: ['DELETE'], + [targetUris.processInstanceLogListPath]: ['GET'], + [`${targetUris.processInstanceActionPath}/suspend`]: ['PUT'], + [`${targetUris.processInstanceActionPath}/terminate`]: ['PUT'], + [`${targetUris.processInstanceActionPath}/resume`]: ['PUT'], }; const { ability, permissionsLoaded } = usePermissionFetcher( permissionRequestData @@ -81,8 +97,13 @@ export default function ProcessInstanceShow() { const processTaskFailure = () => { setTasksCallHadError(true); }; + let queryParams = ''; + const processIdentifier = searchParams.get('process_identifier'); + if (processIdentifier) { + queryParams = `?process_identifier=${processIdentifier}`; + } HttpService.makeCallToBackend({ - path: `/process-models/${modifiedProcessModelId}/process-instances/${params.process_instance_id}`, + path: `/process-instances/${modifiedProcessModelId}/${params.process_instance_id}${queryParams}`, successCallback: setProcessInstance, }); let taskParams = '?all_tasks=true'; @@ -91,7 +112,7 @@ export default function ProcessInstanceShow() { } if (ability.can('GET', targetUris.processInstanceTaskListPath)) { HttpService.makeCallToBackend({ - path: `/process-instances/${modifiedProcessModelId}/${params.process_instance_id}/tasks${taskParams}`, + path: `${targetUris.processInstanceTaskListPath}${taskParams}`, successCallback: setTasks, failureCallback: processTaskFailure, }); @@ -99,11 +120,18 @@ export default function ProcessInstanceShow() { setTasksCallHadError(true); } } - }, [params, modifiedProcessModelId, permissionsLoaded, ability, targetUris]); + }, [ + params, + modifiedProcessModelId, + permissionsLoaded, + ability, + targetUris, + searchParams, + ]); const deleteProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}`, + path: targetUris.processInstanceActionPath, successCallback: navigateToProcessInstances, httpMethod: 'DELETE', }); @@ -116,7 +144,7 @@ export default function ProcessInstanceShow() { const terminateProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/terminate`, + path: `${targetUris.processInstanceActionPath}/terminate`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -124,7 +152,7 @@ export default function ProcessInstanceShow() { const suspendProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/suspend`, + path: `${targetUris.processInstanceActionPath}/suspend`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -132,7 +160,7 @@ export default function ProcessInstanceShow() { const resumeProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/resume`, + path: `${targetUris.processInstanceActionPath}/resume`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -141,12 +169,12 @@ export default function ProcessInstanceShow() { const getTaskIds = () => { const taskIds = { completed: [], readyOrWaiting: [] }; if (tasks) { - tasks.forEach(function getUserTasksElement(task: any) { + tasks.forEach(function getUserTasksElement(task: ProcessInstanceTask) { if (task.state === 'COMPLETED') { - (taskIds.completed as any).push(task.name); + (taskIds.completed as any).push(task); } if (task.state === 'READY' || task.state === 'WAITING') { - (taskIds.readyOrWaiting as any).push(task.name); + (taskIds.readyOrWaiting as any).push(task); } }); } @@ -176,15 +204,18 @@ export default function ProcessInstanceShow() { label: any, distance: number ) => { + const processIdentifier = searchParams.get('process_identifier'); + let queryParams = ''; + if (processIdentifier) { + queryParams = `?process_identifier=${processIdentifier}`; + } return ( {label} @@ -215,7 +246,7 @@ export default function ProcessInstanceShow() { if (currentEndDate) { currentEndDateTag = ( - + Completed:{' '} @@ -241,7 +272,7 @@ export default function ProcessInstanceShow() { return ( <> - + Started:{' '} @@ -252,7 +283,7 @@ export default function ProcessInstanceShow() { {currentEndDateTag} - + Status:{' '} @@ -265,14 +296,20 @@ export default function ProcessInstanceShow() { - + + { + const handleClickedDiagramTask = ( + shapeElement: any, + bpmnRootElement: any + ) => { if (tasks) { const matchingTask: any = tasks.find( - (task: any) => task.name === shapeElement.id + (task: any) => + task.name === shapeElement.id && + task.process_identifier === bpmnRootElement.id ); if (matchingTask) { setTaskToDisplay(matchingTask); @@ -472,8 +514,8 @@ export default function ProcessInstanceShow() { // taskToUse is copy of taskToDisplay, with taskDataToDisplay in data attribute const taskToUse: any = { ...taskToDisplay, data: taskDataToDisplay }; HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/task/${taskToUse.id}/update`, - httpMethod: 'POST', + path: `/task-data/${modifiedProcessModelId}/${params.process_instance_id}/${taskToUse.id}`, + httpMethod: 'PUT', successCallback: saveTaskDataResult, failureCallback: saveTaskDataFailure, postBody: { @@ -508,13 +550,22 @@ export default function ProcessInstanceShow() { ); } + if (task.type === 'Call Activity') { + buttons.push( + + View Call Activity Diagram + + ); + } + if (canEditTaskData(task) || canSendEvent(task)) { if (editingTaskData) { buttons.push( - ); @@ -534,7 +585,7 @@ export default function ProcessInstanceShow() { ); buttons.push( + - {processInstanceRunResultTag()} {processModelFilesSection()} {processInstanceListTableButton()} diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 768043cd8..9e0f65c53 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -26,6 +26,9 @@ import Form from '../themes/carbon'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; import { modifyProcessIdentifierForPathParam } from '../helpers'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; +import { PermissionsToCheck } from '../interfaces'; +import { usePermissionFetcher } from '../hooks/PermissionService'; export default function TaskShow() { const [task, setTask] = useState(null); @@ -35,24 +38,36 @@ export default function TaskShow() { const setErrorMessage = (useContext as any)(ErrorContext)[1]; - useEffect(() => { - const processResult = (result: any) => { - setTask(result); - HttpService.makeCallToBackend({ - path: `/process-instances/${modifyProcessIdentifierForPathParam( - result.process_model_identifier - )}/${params.process_instance_id}/tasks`, - successCallback: setUserTasks, - }); - }; + const { targetUris } = useUriListForPermissions(); + const permissionRequestData: PermissionsToCheck = { + [targetUris.processInstanceTaskListPath]: ['GET'], + }; + const { ability, permissionsLoaded } = usePermissionFetcher( + permissionRequestData + ); - HttpService.makeCallToBackend({ - path: `/tasks/${params.process_instance_id}/${params.task_id}`, - successCallback: processResult, - // This causes the page to continuously reload - // failureCallback: setErrorMessage, - }); - }, [params]); + useEffect(() => { + if (permissionsLoaded) { + const processResult = (result: any) => { + setTask(result); + if (ability.can('GET', targetUris.processInstanceTaskListPath)) { + HttpService.makeCallToBackend({ + path: `/task-data/${modifyProcessIdentifierForPathParam( + result.process_model_identifier + )}/${params.process_instance_id}`, + successCallback: setUserTasks, + }); + } + }; + + HttpService.makeCallToBackend({ + path: `/tasks/${params.process_instance_id}/${params.task_id}`, + successCallback: processResult, + // This causes the page to continuously reload + // failureCallback: setErrorMessage, + }); + } + }, [params, permissionsLoaded, ability, targetUris]); const processSubmitResult = (result: any) => { setErrorMessage(null); @@ -116,17 +131,18 @@ export default function TaskShow() { } return null; }); + return ( + + + {userTasksElement} + + + ); } - return ( - - - {userTasksElement} - - - ); + return null; }; const formElement = (taskToUse: any) => { @@ -207,7 +223,7 @@ export default function TaskShow() { ); }; - if (task && userTasks) { + if (task) { const taskToUse = task as any; let statusString = ''; if (taskToUse.state !== 'READY') { diff --git a/spiffworkflow-frontend/src/services/HttpService.ts b/spiffworkflow-frontend/src/services/HttpService.ts index 119765a7b..78a29d07e 100644 --- a/spiffworkflow-frontend/src/services/HttpService.ts +++ b/spiffworkflow-frontend/src/services/HttpService.ts @@ -26,7 +26,7 @@ type backendCallProps = { postBody?: any; }; -class UnauthenticatedError extends Error { +export class UnauthenticatedError extends Error { constructor(message: string) { super(message); this.name = 'UnauthenticatedError'; diff --git a/spiffworkflow-frontend/src/services/UserService.ts b/spiffworkflow-frontend/src/services/UserService.ts index 84e84d6f4..df0f213e5 100644 --- a/spiffworkflow-frontend/src/services/UserService.ts +++ b/spiffworkflow-frontend/src/services/UserService.ts @@ -27,8 +27,8 @@ const doLogout = () => { const idToken = getIdToken(); localStorage.removeItem('jwtAccessToken'); localStorage.removeItem('jwtIdToken'); - const redirctUrl = `${window.location.origin}/`; - const url = `${BACKEND_BASE_URL}/logout?redirect_url=${redirctUrl}&id_token=${idToken}`; + const redirectUrl = `${window.location.origin}`; + const url = `${BACKEND_BASE_URL}/logout?redirect_url=${redirectUrl}&id_token=${idToken}`; window.location.href = url; };