Merge branch 'main' into feature/process-nav-improvements

This commit is contained in:
Elizabeth Esswein 2023-01-05 10:27:31 -05:00
commit a21247aa32
80 changed files with 1372 additions and 444 deletions

View File

@ -11,6 +11,12 @@ repos:
require_serial: true
# exclude: ^migrations/
exclude: "/migrations/"
# otherwise it will not fix long lines if the long lines contain long strings
# https://github.com/psf/black/pull/1132
# https://github.com/psf/black/pull/1609
args: [--preview]
- id: check-added-large-files
files: ^spiffworkflow-backend/
name: Check for added large files

View File

@ -200,7 +200,7 @@ class BpmnSpecMixin(TaskSpec):
for obj in self.data_input_associations:
# Remove the any copied input variables that might not have already been removed
my_task.data.pop(obj.name)
my_task.data.pop(obj.name, None)
super(BpmnSpecMixin, self)._on_complete_hook(my_task)
if isinstance(my_task.parent.task_spec, BpmnSpecMixin):

50
poetry.lock generated
View File

@ -163,7 +163,7 @@ python-versions = "*"
[[package]]
name = "black"
version = "22.10.0"
version = "23.1a1"
description = "The uncompromising code formatter."
category = "dev"
optional = false
@ -614,7 +614,7 @@ werkzeug = "*"
type = "git"
url = "https://github.com/sartography/flask-bpmn"
reference = "main"
resolved_reference = "860f2387bebdaa9220e9fbf6f8fa7f74e805d0d4"
resolved_reference = "c79c1e0b6d34ec05d82cce888b5e57b33d24403b"
[[package]]
name = "flask-cors"
@ -1760,7 +1760,7 @@ lxml = "*"
type = "git"
url = "https://github.com/sartography/SpiffWorkflow"
reference = "main"
resolved_reference = "bba7ddf5478af579b891ca63c50babbfccf6b7a4"
resolved_reference = "80640024a8030481645f0c34f34c57e88f7b4f0c"
[[package]]
name = "sqlalchemy"
@ -2182,27 +2182,18 @@ billiard = [
{file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
]
black = [
{file = "black-22.10.0-1fixedarch-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:5cc42ca67989e9c3cf859e84c2bf014f6633db63d1cbdf8fdb666dcd9e77e3fa"},
{file = "black-22.10.0-1fixedarch-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:5d8f74030e67087b219b032aa33a919fae8806d49c867846bfacde57f43972ef"},
{file = "black-22.10.0-1fixedarch-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:197df8509263b0b8614e1df1756b1dd41be6738eed2ba9e9769f3880c2b9d7b6"},
{file = "black-22.10.0-1fixedarch-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:2644b5d63633702bc2c5f3754b1b475378fbbfb481f62319388235d0cd104c2d"},
{file = "black-22.10.0-1fixedarch-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:e41a86c6c650bcecc6633ee3180d80a025db041a8e2398dcc059b3afa8382cd4"},
{file = "black-22.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2039230db3c6c639bd84efe3292ec7b06e9214a2992cd9beb293d639c6402edb"},
{file = "black-22.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14ff67aec0a47c424bc99b71005202045dc09270da44a27848d534600ac64fc7"},
{file = "black-22.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:819dc789f4498ecc91438a7de64427c73b45035e2e3680c92e18795a839ebb66"},
{file = "black-22.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5b9b29da4f564ba8787c119f37d174f2b69cdfdf9015b7d8c5c16121ddc054ae"},
{file = "black-22.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8b49776299fece66bffaafe357d929ca9451450f5466e997a7285ab0fe28e3b"},
{file = "black-22.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:21199526696b8f09c3997e2b4db8d0b108d801a348414264d2eb8eb2532e540d"},
{file = "black-22.10.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e464456d24e23d11fced2bc8c47ef66d471f845c7b7a42f3bd77bf3d1789650"},
{file = "black-22.10.0-cp37-cp37m-win_amd64.whl", hash = "sha256:9311e99228ae10023300ecac05be5a296f60d2fd10fff31cf5c1fa4ca4b1988d"},
{file = "black-22.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:fba8a281e570adafb79f7755ac8721b6cf1bbf691186a287e990c7929c7692ff"},
{file = "black-22.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:915ace4ff03fdfff953962fa672d44be269deb2eaf88499a0f8805221bc68c87"},
{file = "black-22.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:444ebfb4e441254e87bad00c661fe32df9969b2bf224373a448d8aca2132b395"},
{file = "black-22.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:974308c58d057a651d182208a484ce80a26dac0caef2895836a92dd6ebd725e0"},
{file = "black-22.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72ef3925f30e12a184889aac03d77d031056860ccae8a1e519f6cbb742736383"},
{file = "black-22.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:432247333090c8c5366e69627ccb363bc58514ae3e63f7fc75c54b1ea80fa7de"},
{file = "black-22.10.0-py3-none-any.whl", hash = "sha256:c957b2b4ea88587b46cf49d1dc17681c1e672864fd7af32fc1e9664d572b3458"},
{file = "black-22.10.0.tar.gz", hash = "sha256:f513588da599943e0cde4e32cc9879e825d58720d6557062d1098c5ad80080e1"},
{file = "black-23.1a1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fb7641d442ede92538bc70fa0201f884753a7d0f62f26c722b7b00301b95902"},
{file = "black-23.1a1-cp310-cp310-win_amd64.whl", hash = "sha256:88288a645402106b8eb9f50d7340ae741e16240bb01c2eed8466549153daa96e"},
{file = "black-23.1a1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4db1d8027ce7ae53f0ccf02b0be0b8808fefb291d6cb1543420f4165d96d364c"},
{file = "black-23.1a1-cp311-cp311-win_amd64.whl", hash = "sha256:88ec25a64063945b4591b6378bead544c5d3260de1c93ad96f3ad2d76ddd76fd"},
{file = "black-23.1a1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dff6f0157e47fbbeada046fca144b6557d3be2fb2602d668881cd179f04a352"},
{file = "black-23.1a1-cp37-cp37m-win_amd64.whl", hash = "sha256:ca658b69260a18bf7aa0b0a6562dbbd304a737487d1318998aaca5a75901fd2c"},
{file = "black-23.1a1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85dede655442f5e246e7abd667fe07e14916897ba52f3640b5489bf11f7dbf67"},
{file = "black-23.1a1-cp38-cp38-win_amd64.whl", hash = "sha256:ddbf9da228726d46f45c29024263e160d41030a415097254817d65127012d1a2"},
{file = "black-23.1a1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63330069d8ec909cf4e2c4d43a7f00aeb03335430ef9fec6cd2328e6ebde8a77"},
{file = "black-23.1a1-cp39-cp39-win_amd64.whl", hash = "sha256:793c9176beb2adf295f6b863d9a4dc953fe2ac359ca3da108d71d14cb2c09e52"},
{file = "black-23.1a1-py3-none-any.whl", hash = "sha256:e88e4b633d64b9e7adc4a6b922f52bb204af9f90d7b1e3317e6490f2b598b1ea"},
{file = "black-23.1a1.tar.gz", hash = "sha256:0b945a5a1e5a5321f884de0061d5a8585d947c9b608e37b6d26ceee4dfdf4b62"},
]
blinker = [
{file = "blinker-1.5-py2.py3-none-any.whl", hash = "sha256:1eb563df6fdbc39eeddc177d953203f99f097e9bf0e2b8f9f3cf18b6ca425e36"},
@ -2857,7 +2848,18 @@ psycopg2 = [
{file = "psycopg2-2.9.5.tar.gz", hash = "sha256:a5246d2e683a972e2187a8714b5c2cf8156c064629f9a9b1a873c1730d9e245a"},
]
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 = [

View File

@ -0,0 +1,10 @@
#!/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
grep -E '^ +\/' src/spiffworkflow_backend/api.yml | sort

View File

@ -27,7 +27,6 @@ def main():
"""Main."""
app = get_hacked_up_app_for_script()
with app.app_context():
process_model_identifier_ticket = "ticket"
db.session.query(ProcessInstanceModel).filter(
ProcessInstanceModel.process_model_identifier

View File

@ -40,7 +40,8 @@ def hello_world():
return (
'Hello, %s, <a href="/private">See private</a> '
'<a href="/logout">Log out</a>'
) % oidc.user_getfield("preferred_username")
% oidc.user_getfield("preferred_username")
)
else:
return 'Welcome anonymous, <a href="/private">Log in</a>'

View File

@ -93,7 +93,8 @@ def create_app() -> flask.app.Flask:
if os.environ.get("FLASK_SESSION_SECRET_KEY") is None:
raise KeyError(
"Cannot find the secret_key from the environment. Please set FLASK_SESSION_SECRET_KEY"
"Cannot find the secret_key from the environment. Please set"
" FLASK_SESSION_SECRET_KEY"
)
app.secret_key = os.environ.get("FLASK_SESSION_SECRET_KEY")

View File

@ -274,6 +274,12 @@ paths:
description: Get only the process models that the user can run
schema:
type: boolean
- name: include_parent_groups
in: query
required: false
description: Get the display names for the parent groups as well
schema:
type: boolean
- name: page
in: query
required: false
@ -327,6 +333,32 @@ paths:
schema:
$ref: "#/components/schemas/ProcessModel"
/process-models-natural-language/{modified_process_group_id}:
parameters:
- name: modified_process_group_id
in: path
required: true
description: modified id of an existing process group
schema:
type: string
post:
operationId: spiffworkflow_backend.routes.process_models_controller.process_model_create_with_natural_language
summary: Creates a new process model with the given parameters.
tags:
- Process Models
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ProcessModel"
responses:
"201":
description: Process model created successfully.
content:
application/json:
schema:
$ref: "#/components/schemas/ProcessModel"
/process-models/{modified_process_model_identifier}/files:
parameters:
- name: modified_process_model_identifier
@ -1382,11 +1414,34 @@ paths:
items:
$ref: "#/components/schemas/Task"
/users/search:
parameters:
- name: username_prefix
in: query
required: true
description: The prefix of the user
schema:
type: string
get:
tags:
- Users
operationId: spiffworkflow_backend.routes.users_controller.user_search
summary: Returns a list of users that the search param
responses:
"200":
description: list of users
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/User"
/user-groups/for-current-user:
get:
tags:
- Process Instances
operationId: spiffworkflow_backend.routes.process_api_blueprint.user_group_list_for_current_user
- User Groups
operationId: spiffworkflow_backend.routes.users_controller.user_group_list_for_current_user
summary: Group identifiers for current logged in user
responses:
"200":

View File

@ -17,21 +17,21 @@ def setup_database_uri(app: Flask) -> None:
if app.config.get("SPIFFWORKFLOW_BACKEND_DATABASE_URI") is None:
database_name = f"spiffworkflow_backend_{app.config['ENV_IDENTIFIER']}"
if app.config.get("SPIFF_DATABASE_TYPE") == "sqlite":
app.config[
"SQLALCHEMY_DATABASE_URI"
] = f"sqlite:///{app.instance_path}/db_{app.config['ENV_IDENTIFIER']}.sqlite3"
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"sqlite:///{app.instance_path}/db_{app.config['ENV_IDENTIFIER']}.sqlite3"
)
elif app.config.get("SPIFF_DATABASE_TYPE") == "postgres":
app.config[
"SQLALCHEMY_DATABASE_URI"
] = f"postgresql://spiffworkflow_backend:spiffworkflow_backend@localhost:5432/{database_name}"
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"postgresql://spiffworkflow_backend:spiffworkflow_backend@localhost:5432/{database_name}"
)
else:
# use pswd to trick flake8 with hardcoded passwords
db_pswd = os.environ.get("DB_PASSWORD")
if db_pswd is None:
db_pswd = ""
app.config[
"SQLALCHEMY_DATABASE_URI"
] = f"mysql+mysqlconnector://root:{db_pswd}@localhost/{database_name}"
app.config["SQLALCHEMY_DATABASE_URI"] = (
f"mysql+mysqlconnector://root:{db_pswd}@localhost/{database_name}"
)
else:
app.config["SQLALCHEMY_DATABASE_URI"] = app.config.get(
"SPIFFWORKFLOW_BACKEND_DATABASE_URI"
@ -91,10 +91,12 @@ def setup_config(app: Flask) -> None:
app.config["SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME"],
)
print(
f"set permissions file name config: {app.config['SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME']}"
"set permissions file name config:"
f" {app.config['SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME']}"
)
print(
f"set permissions file name full path: {app.config['PERMISSIONS_FILE_FULLPATH']}"
"set permissions file name full path:"
f" {app.config['PERMISSIONS_FILE_FULLPATH']}"
)
# unversioned (see .gitignore) config that can override everything and include secrets.

View File

@ -63,7 +63,7 @@ groups:
admin-ro:
users:
[
j,
j@sartography.com,
]
permissions:

View File

@ -0,0 +1,24 @@
"""Interfaces."""
from typing import NewType
from typing import TYPE_CHECKING
from typing import TypedDict
if TYPE_CHECKING:
from spiffworkflow_backend.models.process_group import ProcessGroup
IdToProcessGroupMapping = NewType("IdToProcessGroupMapping", dict[str, "ProcessGroup"])
class ProcessGroupLite(TypedDict):
"""ProcessGroupLite."""
id: str
display_name: str
class ProcessGroupLitesWithCache(TypedDict):
"""ProcessGroupLitesWithCache."""
cache: dict[str, "ProcessGroup"]
process_groups: list[ProcessGroupLite]

View File

@ -35,9 +35,9 @@ class HumanTaskModel(SpiffworkflowBaseDBModel):
ForeignKey(ProcessInstanceModel.id), nullable=False # type: ignore
)
lane_assignment_id: int | None = db.Column(ForeignKey(GroupModel.id))
completed_by_user_id: int = db.Column(ForeignKey(UserModel.id), nullable=True)
completed_by_user_id: int = db.Column(ForeignKey(UserModel.id), nullable=True) # type: ignore
actual_owner_id: int = db.Column(ForeignKey(UserModel.id))
actual_owner_id: int = db.Column(ForeignKey(UserModel.id)) # type: ignore
# actual_owner: RelationshipProperty[UserModel] = relationship(UserModel)
form_file_name: str | None = db.Column(db.String(50))

View File

@ -29,4 +29,4 @@ class HumanTaskUserModel(SpiffworkflowBaseDBModel):
human_task_id = db.Column(
ForeignKey(HumanTaskModel.id), nullable=False, index=True # type: ignore
)
user_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True)
user_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) # type: ignore

View File

@ -86,5 +86,6 @@ def ensure_failure_cause_is_set_if_message_instance_failed(
if isinstance(instance, MessageInstanceModel):
if instance.status == "failed" and instance.failure_cause is None:
raise ValueError(
f"{instance.__class__.__name__}: failure_cause must be set if status is failed"
f"{instance.__class__.__name__}: failure_cause must be set if"
" status is failed"
)

View File

@ -27,7 +27,7 @@ class PrincipalModel(SpiffworkflowBaseDBModel):
__table_args__ = (CheckConstraint("NOT(user_id IS NULL AND group_id IS NULL)"),)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(ForeignKey(UserModel.id), nullable=True, unique=True)
user_id = db.Column(ForeignKey(UserModel.id), nullable=True, unique=True) # type: ignore
group_id = db.Column(ForeignKey(GroupModel.id), nullable=True, unique=True)
user = relationship("UserModel", viewonly=True)

View File

@ -11,6 +11,7 @@ import marshmallow
from marshmallow import post_load
from marshmallow import Schema
from spiffworkflow_backend.interfaces import ProcessGroupLite
from spiffworkflow_backend.models.process_model import ProcessModelInfo
@ -29,7 +30,7 @@ class ProcessGroup:
default_factory=list[ProcessModelInfo]
)
process_groups: list[ProcessGroup] = field(default_factory=list["ProcessGroup"])
parent_groups: list[dict] | None = None
parent_groups: list[ProcessGroupLite] | None = None
def __post_init__(self) -> None:
"""__post_init__."""

View File

@ -57,12 +57,15 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel):
process_model_display_name: str = db.Column(
db.String(255), nullable=False, index=True
)
process_initiator_id: int = db.Column(ForeignKey(UserModel.id), nullable=False)
process_initiator_id: int = db.Column(ForeignKey(UserModel.id), nullable=False) # type: ignore
process_initiator = relationship("UserModel")
active_human_tasks = relationship(
"HumanTaskModel",
primaryjoin="and_(HumanTaskModel.process_instance_id==ProcessInstanceModel.id, HumanTaskModel.completed == False)",
primaryjoin=(
"and_(HumanTaskModel.process_instance_id==ProcessInstanceModel.id,"
" HumanTaskModel.completed == False)"
),
) # type: ignore
human_tasks = relationship(

View File

@ -70,7 +70,7 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel):
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)
created_by_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) # type: ignore
created_by = relationship("UserModel")
created_at_in_seconds = db.Column(db.Integer)
updated_at_in_seconds = db.Column(db.Integer)

View File

@ -11,6 +11,7 @@ import marshmallow
from marshmallow import Schema
from marshmallow.decorators import post_load
from spiffworkflow_backend.interfaces import ProcessGroupLite
from spiffworkflow_backend.models.file import File
@ -37,7 +38,7 @@ class ProcessModelInfo:
files: list[File] | None = field(default_factory=list[File])
fault_or_suspend_on_exception: str = NotificationType.fault.value
exception_notification_addresses: list[str] = field(default_factory=list)
parent_groups: list[dict] | None = None
parent_groups: list[ProcessGroupLite] | None = None
metadata_extraction_paths: list[dict[str, str]] | None = None
def __post_init__(self) -> None:

View File

@ -17,7 +17,7 @@ class SecretModel(SpiffworkflowBaseDBModel):
id: int = db.Column(db.Integer, primary_key=True)
key: str = db.Column(db.String(50), unique=True, nullable=False)
value: str = db.Column(db.Text(), nullable=False)
user_id: int = db.Column(ForeignKey(UserModel.id), nullable=False)
user_id: int = db.Column(ForeignKey(UserModel.id), nullable=False) # type: ignore
updated_at_in_seconds: int = db.Column(db.Integer)
created_at_in_seconds: int = db.Column(db.Integer)

View File

@ -43,8 +43,8 @@ class Task:
FIELD_TYPE_EMAIL = "email" # email: Email address
FIELD_TYPE_URL = "url" # url: Website address
FIELD_PROP_AUTO_COMPLETE_MAX = (
"autocomplete_num" # Not used directly, passed in from the front end.
FIELD_PROP_AUTO_COMPLETE_MAX = ( # Not used directly, passed in from the front end.
"autocomplete_num"
)
# Required field
@ -77,8 +77,8 @@ class Task:
# File specific field properties
FIELD_PROP_DOC_CODE = "doc_code" # to associate a file upload field with a doc code
FIELD_PROP_FILE_DATA = (
"file_data" # to associate a bit of data with a specific file upload file.
FIELD_PROP_FILE_DATA = ( # to associate a bit of data with a specific file upload file.
"file_data"
)
# Additional properties

View File

@ -1,6 +1,8 @@
"""User."""
from __future__ import annotations
from dataclasses import dataclass
import jwt
import marshmallow
from flask import current_app
@ -16,15 +18,16 @@ class UserNotFoundError(Exception):
"""UserNotFoundError."""
@dataclass
class UserModel(SpiffworkflowBaseDBModel):
"""UserModel."""
__tablename__ = "user"
__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
) # should always be a unique value
id: int = db.Column(db.Integer, primary_key=True)
username: str = db.Column(db.String(255), nullable=False, unique=True)
service = db.Column(
db.String(255), nullable=False, unique=False
) # not 'openid' -- google, aws

View File

@ -17,7 +17,7 @@ class UserGroupAssignmentModel(SpiffworkflowBaseDBModel):
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(ForeignKey(UserModel.id), nullable=False)
user_id = db.Column(ForeignKey(UserModel.id), nullable=False) # type: ignore
group_id = db.Column(ForeignKey(GroupModel.id), nullable=False)
group = relationship("GroupModel", overlaps="groups,user_group_assignments,users") # type: ignore

View File

@ -131,8 +131,11 @@ def message_start(
raise (
ApiError(
error_code="cannot_find_waiting_message",
message=f"Could not find waiting message for identifier {message_identifier} "
f"and process instance {process_instance.id}",
message=(
"Could not find waiting message for identifier"
f" {message_identifier} and process instance"
f" {process_instance.id}"
),
status_code=400,
)
)
@ -151,7 +154,10 @@ def message_start(
raise (
ApiError(
error_code="cannot_start_message",
message=f"Message with identifier cannot be start with message: {message_identifier}",
message=(
"Message with identifier cannot be start with message:"
f" {message_identifier}"
),
status_code=400,
)
)

View File

@ -43,7 +43,9 @@ def permissions_check(body: Dict[str, Dict[str, list[str]]]) -> flask.wrappers.R
raise (
ApiError(
error_code="could_not_requests_to_check",
message="The key 'requests_to_check' not found at root of request body.",
message=(
"The key 'requests_to_check' not found at root of request body."
),
status_code=400,
)
)
@ -69,14 +71,6 @@ def permissions_check(body: Dict[str, Dict[str, list[str]]]) -> flask.wrappers.R
return make_response(jsonify({"results": response_dict}), 200)
def user_group_list_for_current_user() -> flask.wrappers.Response:
"""User_group_list_for_current_user."""
groups = g.user.groups
# TODO: filter out the default group and have a way to know what is the default group
group_identifiers = [i.identifier for i in groups if i.identifier != "everybody"]
return make_response(jsonify(sorted(group_identifiers)), 200)
def process_list() -> Any:
"""Returns a list of all known processes.
@ -139,7 +133,8 @@ def task_data_update(
if process_instance:
if process_instance.status != "suspended":
raise ProcessInstanceTaskDataCannotBeUpdatedError(
f"The process instance needs to be suspended to udpate the task-data. It is currently: {process_instance.status}"
"The process instance needs to be suspended to udpate the task-data."
f" It is currently: {process_instance.status}"
)
process_instance_bpmn_json_dict = json.loads(process_instance.bpmn_json)
@ -163,12 +158,18 @@ def task_data_update(
else:
raise ApiError(
error_code="update_task_data_error",
message=f"Could not find Task: {task_id} in Instance: {process_instance_id}.",
message=(
f"Could not find Task: {task_id} in Instance:"
f" {process_instance_id}."
),
)
else:
raise ApiError(
error_code="update_task_data_error",
message=f"Could not update task data for Instance: {process_instance_id}, and Task: {task_id}.",
message=(
f"Could not update task data for Instance: {process_instance_id}, and"
f" Task: {task_id}."
),
)
return Response(
json.dumps(ProcessInstanceModelSchema().dump(process_instance)),
@ -236,7 +237,9 @@ def manual_complete_task(
else:
raise ApiError(
error_code="complete_task",
message=f"Could not complete Task {task_id} in Instance {process_instance_id}",
message=(
f"Could not complete Task {task_id} in Instance {process_instance_id}"
),
)
return Response(
json.dumps(ProcessInstanceModelSchema().dump(process_instance)),

View File

@ -124,6 +124,7 @@ def process_group_move(
original_process_group_id, new_location
)
_commit_and_push_to_git(
f"User: {g.user.username} moved process group {original_process_group_id} to {new_process_group.id}"
f"User: {g.user.username} moved process group {original_process_group_id} to"
f" {new_process_group.id}"
)
return make_response(jsonify(new_process_group), 200)

View File

@ -94,7 +94,10 @@ def process_instance_run(
if process_instance.status != "not_started":
raise ApiError(
error_code="process_instance_not_runnable",
message=f"Process Instance ({process_instance.id}) is currently running or has already run.",
message=(
f"Process Instance ({process_instance.id}) is currently running or has"
" already run."
),
status_code=400,
)
@ -350,8 +353,8 @@ def process_instance_delete(
if not process_instance.has_terminal_status():
raise ProcessInstanceCannotBeDeletedError(
f"Process instance ({process_instance.id}) cannot be deleted since it does not have a terminal status. "
f"Current status is {process_instance.status}."
f"Process instance ({process_instance.id}) cannot be deleted since it does"
f" not have a terminal status. Current status is {process_instance.status}."
)
# (Pdb) db.session.delete
@ -393,7 +396,7 @@ def process_instance_report_update(
report_id: int,
body: Dict[str, Any],
) -> flask.wrappers.Response:
"""Process_instance_report_create."""
"""Process_instance_report_update."""
process_instance_report = ProcessInstanceReportModel.query.filter_by(
id=report_id,
created_by_id=g.user.id,
@ -414,7 +417,7 @@ def process_instance_report_update(
def process_instance_report_delete(
report_id: int,
) -> flask.wrappers.Response:
"""Process_instance_report_create."""
"""Process_instance_report_delete."""
process_instance_report = ProcessInstanceReportModel.query.filter_by(
id=report_id,
created_by_id=g.user.id,
@ -594,7 +597,8 @@ def _get_process_instance(
).first()
if spec_reference is None:
raise SpecReferenceNotFoundError(
f"Could not find given process identifier in the cache: {process_identifier}"
"Could not find given process identifier in the cache:"
f" {process_identifier}"
)
process_model_with_diagram = ProcessModelService.get_process_model(
@ -652,7 +656,10 @@ def _find_process_instance_for_me_or_raise(
raise (
ApiError(
error_code="process_instance_cannot_be_found",
message=f"Process instance with id {process_instance_id} cannot be found that is associated with you.",
message=(
f"Process instance with id {process_instance_id} cannot be found"
" that is associated with you."
),
status_code=400,
)
)

View File

@ -1,5 +1,7 @@
"""APIs for dealing with process groups, process models, and process instances."""
import json
import os
import re
from typing import Any
from typing import Dict
from typing import Optional
@ -14,7 +16,12 @@ from flask import make_response
from flask.wrappers import Response
from flask_bpmn.api.api_error import ApiError
from spiffworkflow_backend.interfaces import IdToProcessGroupMapping
from spiffworkflow_backend.models.file import FileSchema
from spiffworkflow_backend.models.process_group import ProcessGroup
from spiffworkflow_backend.models.process_instance_report import (
ProcessInstanceReportModel,
)
from spiffworkflow_backend.models.process_model import ProcessModelInfo
from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema
from spiffworkflow_backend.routes.process_api_blueprint import _commit_and_push_to_git
@ -24,6 +31,9 @@ from spiffworkflow_backend.routes.process_api_blueprint import (
)
from spiffworkflow_backend.services.git_service import GitService
from spiffworkflow_backend.services.git_service import MissingGitConfigsError
from spiffworkflow_backend.services.process_instance_report_service import (
ProcessInstanceReportService,
)
from spiffworkflow_backend.services.process_model_service import ProcessModelService
from spiffworkflow_backend.services.spec_file_service import SpecFileService
@ -46,23 +56,7 @@ def process_model_create(
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,
)
unmodified_process_group_id = _un_modify_modified_process_model_id(
modified_process_group_id
)
process_group = ProcessModelService.get_process_group(unmodified_process_group_id)
if process_group is None:
raise ApiError(
error_code="process_model_could_not_be_created",
message=f"Process Model could not be created from given body because Process Group could not be found: {body}",
status_code=400,
)
_get_process_group_from_modified_identifier(modified_process_group_id)
process_model_info = ProcessModelInfo(**body_filtered) # type: ignore
if process_model_info is None:
@ -150,7 +144,8 @@ def process_model_move(
original_process_model_id, new_location
)
_commit_and_push_to_git(
f"User: {g.user.username} moved process model {original_process_model_id} to {new_process_model.id}"
f"User: {g.user.username} moved process model {original_process_model_id} to"
f" {new_process_model.id}"
)
return make_response(jsonify(new_process_model), 200)
@ -178,6 +173,7 @@ def process_model_list(
process_group_identifier: Optional[str] = None,
recursive: Optional[bool] = False,
filter_runnable_by_user: Optional[bool] = False,
include_parent_groups: Optional[bool] = False,
page: int = 1,
per_page: int = 100,
) -> flask.wrappers.Response:
@ -187,22 +183,35 @@ def process_model_list(
recursive=recursive,
filter_runnable_by_user=filter_runnable_by_user,
)
batch = ProcessModelService().get_batch(
process_models_to_return = ProcessModelService().get_batch(
process_models, page=page, per_page=per_page
)
if include_parent_groups:
process_group_cache = IdToProcessGroupMapping({})
for process_model in process_models_to_return:
parent_group_lites_with_cache = (
ProcessModelService.get_parent_group_array_and_cache_it(
process_model.id, process_group_cache
)
)
process_model.parent_groups = parent_group_lites_with_cache[
"process_groups"
]
pages = len(process_models) // per_page
remainder = len(process_models) % per_page
if remainder > 0:
pages += 1
response_json = {
"results": ProcessModelInfoSchema(many=True).dump(batch),
"results": process_models_to_return,
"pagination": {
"count": len(batch),
"count": len(process_models_to_return),
"total": len(process_models),
"pages": pages,
},
}
return Response(json.dumps(response_json), status=200, mimetype="application/json")
return make_response(jsonify(response_json), 200)
def process_model_file_update(
@ -223,7 +232,8 @@ def process_model_file_update(
SpecFileService.update_file(process_model, file_name, request_file_contents)
_commit_and_push_to_git(
f"User: {g.user.username} clicked save for {process_model_identifier}/{file_name}"
f"User: {g.user.username} clicked save for"
f" {process_model_identifier}/{file_name}"
)
return Response(json.dumps({"ok": True}), status=200, mimetype="application/json")
@ -247,7 +257,8 @@ def process_model_file_delete(
) from exception
_commit_and_push_to_git(
f"User: {g.user.username} deleted process model file {process_model_identifier}/{file_name}"
f"User: {g.user.username} deleted process model file"
f" {process_model_identifier}/{file_name}"
)
return Response(json.dumps({"ok": True}), status=200, mimetype="application/json")
@ -273,7 +284,8 @@ def process_model_file_create(
file.file_contents = file_contents
file.process_model_id = process_model.id
_commit_and_push_to_git(
f"User: {g.user.username} added process model file {process_model_identifier}/{file.name}"
f"User: {g.user.username} added process model file"
f" {process_model_identifier}/{file.name}"
)
return Response(
json.dumps(FileSchema().dump(file)), status=201, mimetype="application/json"
@ -290,8 +302,10 @@ def process_model_file_show(
if len(files) == 0:
raise ApiError(
error_code="unknown file",
message=f"No information exists for file {file_name}"
f" it does not exist in workflow {process_model_identifier}.",
message=(
f"No information exists for file {file_name}"
f" it does not exist in workflow {process_model_identifier}."
),
status_code=404,
)
@ -299,10 +313,147 @@ def process_model_file_show(
file_contents = SpecFileService.get_data(process_model, file.name)
file.file_contents = file_contents
file.process_model_id = process_model.id
# file.process_group_id = process_model.process_group_id
return FileSchema().dump(file)
# {
# "natural_language_text": "Create a bug tracker process model \
# with a bug-details form that collects summary, description, and priority"
# }
def process_model_create_with_natural_language(
modified_process_group_id: str, body: Dict[str, str]
) -> flask.wrappers.Response:
"""Process_model_create_with_natural_language."""
pattern = re.compile(
r"Create a (?P<pm_name>.*?) process model with a (?P<form_name>.*?) form that"
r" collects (?P<columns>.*)"
)
match = pattern.match(body["natural_language_text"])
if match is None:
raise ApiError(
error_code="natural_language_text_not_yet_supported",
message=(
"Natural language text is not yet supported. Please use the form:"
f" {pattern.pattern}"
),
status_code=400,
)
process_model_display_name = match.group("pm_name")
process_model_identifier = re.sub(r"[ _]", "-", process_model_display_name)
process_model_identifier = re.sub(r"-{2,}", "-", process_model_identifier).lower()
form_name = match.group("form_name")
form_identifier = re.sub(r"[ _]", "-", form_name)
form_identifier = re.sub(r"-{2,}", "-", form_identifier).lower()
column_names = match.group("columns")
columns = re.sub(r"(, (and )?)", ",", column_names).split(",")
process_group = _get_process_group_from_modified_identifier(
modified_process_group_id
)
qualified_process_model_identifier = (
f"{process_group.id}/{process_model_identifier}"
)
metadata_extraction_paths = []
for column in columns:
metadata_extraction_paths.append({"key": column, "path": column})
process_model_attributes = {
"id": qualified_process_model_identifier,
"display_name": process_model_display_name,
"description": None,
"metadata_extraction_paths": metadata_extraction_paths,
}
process_model_info = ProcessModelInfo(**process_model_attributes) # 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,
)
bpmn_template_file = os.path.join(
current_app.root_path, "templates", "basic_with_user_task_template.bpmn"
)
if not os.path.exists(bpmn_template_file):
raise ApiError(
error_code="bpmn_template_file_does_not_exist",
message="Could not find the bpmn template file to create process model.",
status_code=500,
)
ProcessModelService.add_process_model(process_model_info)
bpmn_process_identifier = f"{process_model_identifier}_process"
bpmn_template_contents = ""
with open(bpmn_template_file, encoding="utf-8") as f:
bpmn_template_contents = f.read()
bpmn_template_contents = bpmn_template_contents.replace(
"natural_language_process_id_template", bpmn_process_identifier
)
bpmn_template_contents = bpmn_template_contents.replace(
"form-identifier-id-template", form_identifier
)
form_uischema_json: dict = {"ui:order": columns}
form_properties: dict = {}
for column in columns:
form_properties[column] = {
"type": "string",
"title": column,
}
form_schema_json = {
"title": form_identifier,
"description": "",
"properties": form_properties,
"required": [],
}
SpecFileService.add_file(
process_model_info,
f"{process_model_identifier}.bpmn",
str.encode(bpmn_template_contents),
)
SpecFileService.add_file(
process_model_info,
f"{form_identifier}-schema.json",
str.encode(json.dumps(form_schema_json)),
)
SpecFileService.add_file(
process_model_info,
f"{form_identifier}-uischema.json",
str.encode(json.dumps(form_uischema_json)),
)
_commit_and_push_to_git(
f"User: {g.user.username} created process model via natural language:"
f" {process_model_info.id}"
)
default_report_metadata = ProcessInstanceReportService.system_metadata_map(
"default"
)
for column in columns:
default_report_metadata["columns"].append(
{"Header": column, "accessor": column, "filterable": True}
)
ProcessInstanceReportModel.create_report(
identifier=process_model_identifier,
user=g.user,
report_metadata=default_report_metadata,
)
return Response(
json.dumps(ProcessModelInfoSchema().dump(process_model_info)),
status=201,
mimetype="application/json",
)
def _get_file_from_request() -> Any:
"""Get_file_from_request."""
request_file = connexion.request.files.get("file")
@ -313,3 +464,33 @@ def _get_file_from_request() -> Any:
status_code=400,
)
return request_file
def _get_process_group_from_modified_identifier(
modified_process_group_id: str,
) -> ProcessGroup:
"""_get_process_group_from_modified_identifier."""
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,
)
unmodified_process_group_id = _un_modify_modified_process_model_id(
modified_process_group_id
)
process_group = ProcessModelService.get_process_group(unmodified_process_group_id)
if process_group is None:
raise ApiError(
error_code="process_model_could_not_be_created",
message=(
"Process Model could not be created from given body because Process"
f" Group could not be found: {unmodified_process_group_id}"
),
status_code=400,
)
return process_group

View File

@ -40,7 +40,10 @@ def script_unit_test_create(
if file is None:
raise ApiError(
error_code="cannot_find_file",
message=f"Could not find the primary bpmn file for process_model: {process_model.id}",
message=(
"Could not find the primary bpmn file for process_model:"
f" {process_model.id}"
),
status_code=404,
)

View File

@ -15,11 +15,14 @@ from flask import jsonify
from flask import make_response
from flask.wrappers import Response
from flask_bpmn.api.api_error import ApiError
from flask_bpmn.models.db import db
from SpiffWorkflow.task import Task as SpiffTask # type: ignore
from SpiffWorkflow.task import TaskState
from sqlalchemy import and_
from sqlalchemy import asc
from sqlalchemy import desc
from sqlalchemy import func
from sqlalchemy.orm import aliased
from spiffworkflow_backend.models.group import GroupModel
from spiffworkflow_backend.models.human_task import HumanTaskModel
@ -147,6 +150,21 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response
process_instance.process_model_identifier,
)
human_task = HumanTaskModel.query.filter_by(
process_instance_id=process_instance_id, task_id=task_id
).first()
if human_task is None:
raise (
ApiError(
error_code="no_human_task",
message=(
f"Cannot find a task to complete for task id '{task_id}' and"
f" process instance {process_instance_id}."
),
status_code=500,
)
)
form_schema_file_name = ""
form_ui_schema_file_name = ""
spiff_task = _get_spiff_task_from_process_instance(task_id, process_instance)
@ -188,7 +206,10 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response
raise (
ApiError(
error_code="missing_form_file",
message=f"Cannot find a form file for process_instance_id: {process_instance_id}, task_id: {task_id}",
message=(
"Cannot find a form file for process_instance_id:"
f" {process_instance_id}, task_id: {task_id}"
),
status_code=400,
)
)
@ -206,7 +227,10 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response
raise (
ApiError(
error_code="error_loading_form",
message=f"Could not load form schema from: {form_schema_file_name}. Error was: {str(exception)}",
message=(
f"Could not load form schema from: {form_schema_file_name}."
f" Error was: {str(exception)}"
),
status_code=400,
)
) from exception
@ -270,8 +294,10 @@ def task_submit(
if not process_instance.can_submit_task():
raise ApiError(
error_code="process_instance_not_runnable",
message=f"Process Instance ({process_instance.id}) has status "
f"{process_instance.status} which does not allow tasks to be submitted.",
message=(
f"Process Instance ({process_instance.id}) has status "
f"{process_instance.status} which does not allow tasks to be submitted."
),
status_code=400,
)
@ -302,7 +328,10 @@ def task_submit(
raise (
ApiError(
error_code="no_human_task",
message="Cannot find an human task with task id '{task_id}' for process instance {process_instance_id}.",
message=(
f"Cannot find a task to complete for task id '{task_id}' and"
f" process instance {process_instance_id}."
),
status_code=500,
)
)
@ -357,22 +386,25 @@ def _get_tasks(
# pagination later on
# https://stackoverflow.com/q/34582014/6090676
human_tasks_query = (
HumanTaskModel.query.distinct()
db.session.query(HumanTaskModel)
.group_by(HumanTaskModel.id) # type: ignore
.outerjoin(GroupModel, GroupModel.id == HumanTaskModel.lane_assignment_id)
.join(ProcessInstanceModel)
.join(UserModel, UserModel.id == ProcessInstanceModel.process_initiator_id)
.filter(HumanTaskModel.completed == False) # noqa: E712
)
assigned_user = aliased(UserModel)
if processes_started_by_user:
human_tasks_query = human_tasks_query.filter(
ProcessInstanceModel.process_initiator_id == user_id
).outerjoin(
HumanTaskUserModel,
and_(
HumanTaskUserModel.user_id == user_id,
human_tasks_query = (
human_tasks_query.filter(
ProcessInstanceModel.process_initiator_id == user_id
)
.outerjoin(
HumanTaskUserModel,
HumanTaskModel.id == HumanTaskUserModel.human_task_id,
),
)
.outerjoin(assigned_user, assigned_user.id == HumanTaskUserModel.user_id)
)
else:
human_tasks_query = human_tasks_query.filter(
@ -402,13 +434,15 @@ def _get_tasks(
ProcessInstanceModel.status.label("process_instance_status"), # type: ignore
ProcessInstanceModel.updated_at_in_seconds,
ProcessInstanceModel.created_at_in_seconds,
UserModel.username,
GroupModel.identifier.label("user_group_identifier"),
UserModel.username.label("process_initiator_username"), # type: ignore
GroupModel.identifier.label("assigned_user_group_identifier"),
HumanTaskModel.task_name,
HumanTaskModel.task_title,
HumanTaskModel.process_model_display_name,
HumanTaskModel.process_instance_id,
HumanTaskUserModel.user_id.label("current_user_is_potential_owner"),
func.group_concat(assigned_user.username.distinct()).label(
"potential_owner_usernames"
),
)
.order_by(desc(HumanTaskModel.id)) # type: ignore
.paginate(page=page, per_page=per_page, error_out=False)
@ -422,6 +456,7 @@ def _get_tasks(
"pages": human_tasks.pages,
},
}
return make_response(jsonify(response_json), 200)
@ -490,7 +525,10 @@ def _update_form_schema_with_task_data_as_needed(
raise (
ApiError(
error_code="missing_task_data_var",
message=f"Task data is missing variable: {task_data_var}",
message=(
"Task data is missing variable:"
f" {task_data_var}"
),
status_code=500,
)
)

View File

@ -67,13 +67,16 @@ def verify_token(
user_model = get_user_from_decoded_internal_token(decoded_token)
except Exception as e:
current_app.logger.error(
f"Exception in verify_token getting user from decoded internal token. {e}"
"Exception in verify_token getting user from decoded"
f" internal token. {e}"
)
elif "iss" in decoded_token.keys():
try:
if AuthenticationService.validate_id_token(token):
user_info = decoded_token
except ApiError as ae: # API Error is only thrown in the token is outdated.
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(
decoded_token["iss"], decoded_token["sub"]

View File

@ -26,6 +26,7 @@ user_blueprint = Blueprint("main", __name__)
# user = UserService.create_user('internal', username)
# return Response(json.dumps({"id": user.id}), status=201, mimetype=APPLICATION_JSON)
# def _create_user(username):
# user = UserModel.query.filter_by(username=username).first()
# if user is not None:

View File

@ -0,0 +1,26 @@
"""Users_controller."""
import flask
from flask import g
from flask import jsonify
from flask import make_response
from spiffworkflow_backend.models.user import UserModel
def user_search(username_prefix: str) -> flask.wrappers.Response:
"""User_search."""
found_users = UserModel.query.filter(UserModel.username.like(f"{username_prefix}%")).all() # type: ignore
response_json = {
"users": found_users,
"username_prefix": username_prefix,
}
return make_response(jsonify(response_json), 200)
def user_group_list_for_current_user() -> flask.wrappers.Response:
"""User_group_list_for_current_user."""
groups = g.user.groups
# TODO: filter out the default group and have a way to know what is the default group
group_identifiers = [i.identifier for i in groups if i.identifier != "everybody"]
return make_response(jsonify(sorted(group_identifiers)), 200)

View File

@ -0,0 +1,63 @@
"""Delete_process_instances_with_criteria."""
from time import time
from typing import Any
from flask_bpmn.models.db import db
from sqlalchemy import or_
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
from spiffworkflow_backend.models.script_attributes_context import (
ScriptAttributesContext,
)
from spiffworkflow_backend.models.spiff_step_details import SpiffStepDetailsModel
from spiffworkflow_backend.scripts.script import Script
class DeleteProcessInstancesWithCriteria(Script):
"""DeleteProcessInstancesWithCriteria."""
def get_description(self) -> str:
"""Get_description."""
return "Delete process instances that match the provided criteria,"
def run(
self,
script_attributes_context: ScriptAttributesContext,
*args: Any,
**kwargs: Any,
) -> Any:
"""Run."""
criteria_list = args[0]
delete_criteria = []
delete_time = time()
for criteria in criteria_list:
delete_criteria.append(
(ProcessInstanceModel.process_model_identifier == criteria["name"])
& ProcessInstanceModel.status.in_(criteria["status"]) # type: ignore
& (
ProcessInstanceModel.updated_at_in_seconds
< (delete_time - criteria["last_updated_delta"])
)
)
results = (
ProcessInstanceModel.query.filter(or_(*delete_criteria)).limit(100).all()
)
rows_affected = len(results)
if rows_affected > 0:
ids_to_delete = list(map(lambda r: r.id, results)) # type: ignore
step_details = SpiffStepDetailsModel.query.filter(
SpiffStepDetailsModel.process_instance_id.in_(ids_to_delete) # type: ignore
).all()
for deletion in step_details:
db.session.delete(deletion)
for deletion in results:
db.session.delete(deletion)
db.session.commit()
return rows_affected

View File

@ -35,7 +35,10 @@ class FactService(Script):
if fact == "cat":
details = "The cat in the hat" # self.get_cat()
elif fact == "norris":
details = "Chuck Norris doesnt read books. He stares them down until he gets the information he wants."
details = (
"Chuck Norris doesnt read books. He stares them down until he gets the"
" information he wants."
)
elif fact == "buzzword":
details = "Move the Needle." # self.get_buzzword()
else:

View File

@ -32,7 +32,8 @@ class GetGroupMembers(Script):
group = GroupModel.query.filter_by(identifier=group_identifier).first()
if group is None:
raise GroupNotFoundError(
f"Script 'get_group_members' could not find group with identifier '{group_identifier}'."
"Script 'get_group_members' could not find group with identifier"
f" '{group_identifier}'."
)
usernames = [u.username for u in group.users]

View File

@ -28,5 +28,7 @@ class GetProcessInfo(Script):
"""Run."""
return {
"process_instance_id": script_attributes_context.process_instance_id,
"process_model_identifier": script_attributes_context.process_model_identifier,
"process_model_identifier": (
script_attributes_context.process_model_identifier
),
}

View File

@ -98,8 +98,9 @@ class Script:
).first()
if process_instance is None:
raise ProcessInstanceNotFoundError(
f"Could not find a process instance with id '{script_attributes_context.process_instance_id}' "
f"when running script '{script_function_name}'"
"Could not find a process instance with id"
f" '{script_attributes_context.process_instance_id}' when"
f" running script '{script_function_name}'"
)
user = process_instance.process_initiator
has_permission = AuthorizationService.user_has_permission(
@ -107,7 +108,8 @@ class Script:
)
if not has_permission:
raise ScriptUnauthorizedForUserError(
f"User {user.username} does not have access to run privileged script '{script_function_name}'"
f"User {user.username} does not have access to run"
f" privileged script '{script_function_name}'"
)
def run_script_if_allowed(*ar: Any, **kw: Any) -> Any:
@ -149,7 +151,7 @@ class Script:
"""_get_all_subclasses."""
# hackish mess to make sure we have all the modules loaded for the scripts
pkg_dir = os.path.dirname(__file__)
for (_module_loader, name, _ispkg) in pkgutil.iter_modules([pkg_dir]):
for _module_loader, name, _ispkg in pkgutil.iter_modules([pkg_dir]):
importlib.import_module("." + name, __package__)
"""Returns a list of all classes that extend this class."""

View File

@ -29,7 +29,6 @@ def load_acceptance_test_fixtures() -> list[ProcessInstanceModel]:
# suspended - 6 hours ago
process_instances = []
for i in range(len(statuses)):
process_instance = ProcessInstanceService.create_process_instance_from_process_model_identifier(
test_process_model_id, user
)

View File

@ -52,12 +52,12 @@ class AuthenticationService:
@classmethod
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."""
openid_config_url = f"{cls.server_url()}/.well-known/openid-configuration"
if name not in AuthenticationService.ENDPOINT_CACHE:
request_url = f"{cls.server_url()}/.well-known/openid-configuration"
response = requests.get(request_url)
response = requests.get(openid_config_url)
AuthenticationService.ENDPOINT_CACHE = response.json()
if name not in AuthenticationService.ENDPOINT_CACHE:
raise Exception(f"Unknown OpenID Endpoint: {name}")
raise Exception(f"Unknown OpenID Endpoint: {name}. Tried to get from {openid_config_url}")
return AuthenticationService.ENDPOINT_CACHE.get(name, "")
@staticmethod

View File

@ -128,7 +128,8 @@ class AuthorizationService:
# to check for exact matches as well
# see test_user_can_access_base_path_when_given_wildcard_permission unit test
text(
f"'{target_uri_normalized}' = replace(replace(permission_target.uri, '/%', ''), ':%', '')"
f"'{target_uri_normalized}' ="
" replace(replace(permission_target.uri, '/%', ''), ':%', '')"
),
)
)
@ -200,7 +201,8 @@ class AuthorizationService:
if current_app.config["SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME"] is None:
raise (
PermissionsFileNotSetError(
"SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME needs to be set in order to import permissions"
"SPIFFWORKFLOW_BACKEND_PERMISSIONS_FILE_NAME needs to be set in"
" order to import permissions"
)
)
@ -280,9 +282,9 @@ class AuthorizationService:
"""Find_or_create_permission_target."""
uri_with_percent = re.sub(r"\*", "%", uri)
target_uri_normalized = uri_with_percent.removeprefix(V1_API_PATH_PREFIX)
permission_target: Optional[
PermissionTargetModel
] = PermissionTargetModel.query.filter_by(uri=target_uri_normalized).first()
permission_target: Optional[PermissionTargetModel] = (
PermissionTargetModel.query.filter_by(uri=target_uri_normalized).first()
)
if permission_target is None:
permission_target = PermissionTargetModel(uri=target_uri_normalized)
db.session.add(permission_target)
@ -297,13 +299,13 @@ class AuthorizationService:
permission: str,
) -> PermissionAssignmentModel:
"""Create_permission_for_principal."""
permission_assignment: Optional[
PermissionAssignmentModel
] = PermissionAssignmentModel.query.filter_by(
principal_id=principal.id,
permission_target_id=permission_target.id,
permission=permission,
).first()
permission_assignment: Optional[PermissionAssignmentModel] = (
PermissionAssignmentModel.query.filter_by(
principal_id=principal.id,
permission_target_id=permission_target.id,
permission=permission,
).first()
)
if permission_assignment is None:
permission_assignment = PermissionAssignmentModel(
principal_id=principal.id,
@ -403,7 +405,10 @@ class AuthorizationService:
raise ApiError(
error_code="unauthorized",
message=f"User {g.user.username} is not authorized to perform requested action: {permission_string} - {request.path}",
message=(
f"User {g.user.username} is not authorized to perform requested action:"
f" {permission_string} - {request.path}"
),
status_code=403,
)
@ -482,7 +487,10 @@ class AuthorizationService:
except jwt.InvalidTokenError as exception:
raise ApiError(
"token_invalid",
"The Authentication token you provided is invalid. You need a new token. ",
(
"The Authentication token you provided is invalid. You need a new"
" token. "
),
) from exception
@staticmethod
@ -504,8 +512,9 @@ class AuthorizationService:
if user not in human_task.potential_owners:
raise UserDoesNotHaveAccessToTaskError(
f"User {user.username} does not have access to update task'{spiff_task.task_spec.name}'"
f" for process instance '{process_instance_id}'"
f"User {user.username} does not have access to update"
f" task'{spiff_task.task_spec.name}' for process instance"
f" '{process_instance_id}'"
)
return True
@ -723,8 +732,9 @@ class AuthorizationService:
)
else:
raise InvalidPermissionError(
f"Target uri '{target}' with permission set '{permission_set}' is invalid. "
f"The target uri must either be a macro of PG, PM, BASIC, or ALL or an api uri."
f"Target uri '{target}' with permission set '{permission_set}' is"
" invalid. The target uri must either be a macro of PG, PM, BASIC, or"
" ALL or an api uri."
)
return permissions_to_assign

View File

@ -40,10 +40,9 @@ class FileSystemService:
@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.abspath(os.path.join(app_root, "..", dir_name))
# ensure this is a string - thanks mypy...
return os.path.abspath(os.path.join(dir_name, ""))
@staticmethod
def id_string_to_relative_path(id_string: str) -> str:

View File

@ -173,13 +173,15 @@ class GitService:
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}"
"Cannot find required keys of 'repository:clone_url' from webhook"
f" 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}"
"Configured clone url does not match clone url from webhook:"
f" {clone_url}"
)
if "ref" not in webhook:
@ -189,8 +191,8 @@ class GitService:
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"
"Missing config for GIT_BRANCH. This is required for updating the"
" repository as a result of the webhook"
)
ref = webhook["ref"]

View File

@ -122,7 +122,8 @@ def setup_logger(app: Flask) -> None:
if upper_log_level_string not in log_levels:
raise InvalidLogLevelError(
f"Log level given is invalid: '{upper_log_level_string}'. Valid options are {log_levels}"
f"Log level given is invalid: '{upper_log_level_string}'. Valid options are"
f" {log_levels}"
)
log_level = getattr(logging, upper_log_level_string)
@ -176,7 +177,8 @@ def setup_logger(app: Flask) -> None:
spiff_logger = logging.getLogger("spiff")
spiff_logger.setLevel(spiff_log_level)
spiff_formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(message)s | %(action)s | %(task_type)s | %(process)s | %(processName)s | %(process_instance_id)s"
"%(asctime)s | %(levelname)s | %(message)s | %(action)s | %(task_type)s |"
" %(process)s | %(processName)s | %(process_instance_id)s"
)
# if you add a handler to spiff, it will be used/inherited by spiff.metrics

View File

@ -145,8 +145,11 @@ class MessageService:
if process_instance_receive is None:
raise MessageServiceError(
(
f"Process instance cannot be found for queued message: {message_instance_receive.id}."
f"Tried with id {message_instance_receive.process_instance_id}",
(
"Process instance cannot be found for queued message:"
f" {message_instance_receive.id}.Tried with id"
f" {message_instance_receive.process_instance_id}"
),
)
)
@ -182,7 +185,6 @@ class MessageService:
)
for message_instance_receive in message_instances_receive:
# sqlalchemy supports select / where statements like active record apparantly
# https://docs.sqlalchemy.org/en/14/core/tutorial.html#conjunctions
message_correlation_select = (

View File

@ -157,6 +157,7 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore
"_strptime": _strptime,
"enumerate": enumerate,
"list": list,
"map": map,
}
# This will overwrite the standard builtins
@ -215,14 +216,14 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore
except Exception as exception:
if task is None:
raise ProcessInstanceProcessorError(
"Error evaluating expression: "
"'%s', exception: %s" % (expression, str(exception)),
"Error evaluating expression: '%s', exception: %s"
% (expression, str(exception)),
) from exception
else:
raise WorkflowTaskExecException(
task,
"Error evaluating expression "
"'%s', %s" % (expression, str(exception)),
"Error evaluating expression '%s', %s"
% (expression, str(exception)),
) from exception
def execute(
@ -383,8 +384,10 @@ class ProcessInstanceProcessor:
except MissingSpecError as ke:
raise ApiError(
error_code="unexpected_process_instance_structure",
message="Failed to deserialize process_instance"
" '%s' due to a mis-placed or missing task '%s'"
message=(
"Failed to deserialize process_instance"
" '%s' due to a mis-placed or missing task '%s'"
)
% (self.process_model_identifier, str(ke)),
) from ke
@ -400,7 +403,10 @@ class ProcessInstanceProcessor:
raise (
ApiError(
"process_model_not_found",
f"The given process model was not found: {process_model_identifier}.",
(
"The given process model was not found:"
f" {process_model_identifier}."
),
)
)
spec_files = SpecFileService.get_files(process_model_info)
@ -530,8 +536,11 @@ class ProcessInstanceProcessor:
potential_owner_ids.append(lane_owner_user.id)
self.raise_if_no_potential_owners(
potential_owner_ids,
f"No users found in task data lane owner list for lane: {task_lane}. "
f"The user list used: {task.data['lane_owners'][task_lane]}",
(
"No users found in task data lane owner list for lane:"
f" {task_lane}. The user list used:"
f" {task.data['lane_owners'][task_lane]}"
),
)
else:
group_model = GroupModel.query.filter_by(identifier=task_lane).first()
@ -581,12 +590,6 @@ class ProcessInstanceProcessor:
)
return details_model
def save_spiff_step_details(self) -> None:
"""SaveSpiffStepDetails."""
details_model = self.spiff_step_details()
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
@ -725,7 +728,8 @@ class ProcessInstanceProcessor:
if payload is not None:
event_definition.payload = payload
current_app.logger.info(
f"Event of type {event_definition.event_type} sent to process instance {self.process_instance_model.id}"
f"Event of type {event_definition.event_type} sent to process instance"
f" {self.process_instance_model.id}"
)
self.bpmn_process_instance.catch(event_definition)
self.do_engine_steps(save=True)
@ -742,7 +746,8 @@ class ProcessInstanceProcessor:
spiff_task = self.bpmn_process_instance.get_task(UUID(task_id))
if execute:
current_app.logger.info(
f"Manually executing Task {spiff_task.task_spec.name} of process instance {self.process_instance_model.id}"
f"Manually executing Task {spiff_task.task_spec.name} of process"
f" instance {self.process_instance_model.id}"
)
spiff_task.complete()
else:
@ -838,7 +843,8 @@ class ProcessInstanceProcessor:
"""Bpmn_file_full_path_from_bpmn_process_identifier."""
if bpmn_process_identifier is None:
raise ValueError(
"bpmn_file_full_path_from_bpmn_process_identifier: bpmn_process_identifier is unexpectedly None"
"bpmn_file_full_path_from_bpmn_process_identifier:"
" bpmn_process_identifier is unexpectedly None"
)
spec_reference = SpecReferenceCache.query.filter_by(
@ -860,7 +866,10 @@ class ProcessInstanceProcessor:
raise (
ApiError(
error_code="could_not_find_bpmn_process_identifier",
message="Could not find the the given bpmn process identifier from any sources: %s"
message=(
"Could not find the the given bpmn process identifier from any"
" sources: %s"
)
% bpmn_process_identifier,
)
)
@ -884,7 +893,6 @@ class ProcessInstanceProcessor:
new_bpmn_files = set()
for bpmn_process_identifier in processor_dependencies_new:
# ignore identifiers that spiff already knows about
if bpmn_process_identifier in bpmn_process_identifiers_in_parser:
continue
@ -927,7 +935,10 @@ class ProcessInstanceProcessor:
raise (
ApiError(
error_code="no_primary_bpmn_error",
message="There is no primary BPMN process id defined for process_model %s"
message=(
"There is no primary BPMN process id defined for"
" process_model %s"
)
% process_model_info.id,
)
)
@ -988,7 +999,10 @@ class ProcessInstanceProcessor:
if not bpmn_message.correlations:
raise ApiError(
"message_correlations_missing",
f"Could not find any message correlations bpmn_message: {bpmn_message.name}",
(
"Could not find any message correlations bpmn_message:"
f" {bpmn_message.name}"
),
)
message_correlations = []
@ -1008,12 +1022,16 @@ class ProcessInstanceProcessor:
if message_correlation_property is None:
raise ApiError(
"message_correlations_missing_from_process",
"Could not find a known message correlation with identifier:"
f"{message_correlation_property_identifier}",
(
"Could not find a known message correlation with"
f" identifier:{message_correlation_property_identifier}"
),
)
message_correlations.append(
{
"message_correlation_property": message_correlation_property,
"message_correlation_property": (
message_correlation_property
),
"name": message_correlation_key,
"value": message_correlation_property_value,
}
@ -1070,7 +1088,10 @@ class ProcessInstanceProcessor:
if message_model is None:
raise ApiError(
"invalid_message_name",
f"Invalid message name: {waiting_task.task_spec.event_definition.name}.",
(
"Invalid message name:"
f" {waiting_task.task_spec.event_definition.name}."
),
)
# Ensure we are only creating one message instance for each waiting message
@ -1284,9 +1305,13 @@ class ProcessInstanceProcessor:
self.increment_spiff_step()
self.bpmn_process_instance.complete_task_from_id(task.id)
human_task.completed_by_user_id = user.id
human_task.completed = True
db.session.add(human_task)
db.session.commit()
self.save_spiff_step_details()
details_model = self.spiff_step_details()
db.session.add(details_model)
# this is the thing that actually commits the db transaction (on behalf of the other updates above as well)
self.save()
def get_data(self) -> dict[str, Any]:
"""Get_data."""

View File

@ -1,6 +1,7 @@
"""Process_instance_report_service."""
import re
from dataclasses import dataclass
from typing import Any
from typing import Optional
import sqlalchemy
@ -84,29 +85,8 @@ class ProcessInstanceReportService:
"""ProcessInstanceReportService."""
@classmethod
def report_with_identifier(
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()
if process_instance_report is not None:
return process_instance_report # type: ignore
def system_metadata_map(cls, metadata_key: str) -> dict[str, Any]:
"""System_metadata_map."""
# TODO replace with system reports that are loaded on launch (or similar)
temp_system_metadata_map = {
"default": {
@ -151,10 +131,36 @@ class ProcessInstanceReportService:
"order_by": ["-start_in_seconds", "-id"],
},
}
return temp_system_metadata_map[metadata_key]
@classmethod
def report_with_identifier(
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()
if process_instance_report is not None:
return process_instance_report # type: ignore
process_instance_report = ProcessInstanceReportModel(
identifier=report_identifier,
created_by_id=user.id,
report_metadata=temp_system_metadata_map[report_identifier],
report_metadata=cls.system_metadata_map(report_identifier),
)
return process_instance_report # type: ignore
@ -283,9 +289,9 @@ class ProcessInstanceReportService:
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"]]
process_instance_dict[metadata_column["accessor"]] = (
process_instance[metadata_column["accessor"]]
)
results.append(process_instance_dict)
return results

View File

@ -85,7 +85,8 @@ class ProcessInstanceService:
db.session.add(process_instance)
db.session.commit()
error_message = (
f"Error running waiting task for process_instance {process_instance.id}"
"Error running waiting task for process_instance"
f" {process_instance.id}"
+ f"({process_instance.process_model_identifier}). {str(e)}"
)
current_app.logger.error(error_message)
@ -178,7 +179,10 @@ class ProcessInstanceService:
else:
raise ApiError.from_task(
error_code="task_lane_user_error",
message="Spiff Task %s lane user dict must have a key called 'value' with the user's uid in it."
message=(
"Spiff Task %s lane user dict must have a key called"
" 'value' with the user's uid in it."
)
% spiff_task.task_spec.name,
task=spiff_task,
)

View File

@ -13,6 +13,8 @@ from flask_bpmn.api.api_error import ApiError
from spiffworkflow_backend.exceptions.process_entity_not_found_error import (
ProcessEntityNotFoundError,
)
from spiffworkflow_backend.interfaces import ProcessGroupLite
from spiffworkflow_backend.interfaces import ProcessGroupLitesWithCache
from spiffworkflow_backend.models.process_group import ProcessGroup
from spiffworkflow_backend.models.process_group import ProcessGroupSchema
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
@ -146,7 +148,10 @@ class ProcessModelService(FileSystemService):
if len(instances) > 0:
raise ApiError(
error_code="existing_instances",
message=f"We cannot delete the model `{process_model_id}`, there are existing instances that depend on it.",
message=(
f"We cannot delete the model `{process_model_id}`, there are"
" existing instances that depend on it."
),
)
process_model = self.get_process_model(process_model_id)
path = self.workflow_path(process_model)
@ -234,21 +239,36 @@ class ProcessModelService(FileSystemService):
return process_models
@classmethod
def get_parent_group_array(cls, process_identifier: str) -> list[dict]:
def get_parent_group_array_and_cache_it(
cls, process_identifier: str, process_group_cache: dict[str, ProcessGroup]
) -> ProcessGroupLitesWithCache:
"""Get_parent_group_array."""
full_group_id_path = None
parent_group_array = []
parent_group_array: list[ProcessGroupLite] = []
for process_group_id_segment in process_identifier.split("/")[0:-1]:
if full_group_id_path is None:
full_group_id_path = process_group_id_segment
else:
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)
parent_group = process_group_cache.get(full_group_id_path, None)
if parent_group is None:
parent_group = ProcessModelService.get_process_group(full_group_id_path)
if parent_group:
if full_group_id_path not in process_group_cache:
process_group_cache[full_group_id_path] = parent_group
parent_group_array.append(
{"id": parent_group.id, "display_name": parent_group.display_name}
)
return parent_group_array
return {"cache": process_group_cache, "process_groups": parent_group_array}
@classmethod
def get_parent_group_array(cls, process_identifier: str) -> list[ProcessGroupLite]:
"""Get_parent_group_array."""
parent_group_lites_with_cache = cls.get_parent_group_array_and_cache_it(
process_identifier, {}
)
return parent_group_lites_with_cache["process_groups"]
@classmethod
def get_process_groups(
@ -339,8 +359,11 @@ class ProcessModelService(FileSystemService):
if len(problem_models) > 0:
raise ApiError(
error_code="existing_instances",
message=f"We cannot delete the group `{process_group_id}`, "
f"there are models with existing instances inside the group. {problem_models}",
message=(
f"We cannot delete the group `{process_group_id}`, there are"
" models with existing instances inside the group."
f" {problem_models}"
),
)
shutil.rmtree(path)
self.cleanup_process_group_display_order()
@ -392,7 +415,10 @@ class ProcessModelService(FileSystemService):
if process_group is None:
raise ApiError(
error_code="process_group_could_not_be_loaded_from_disk",
message=f"We could not load the process_group from disk from: {dir_path}",
message=(
"We could not load the process_group from disk from:"
f" {dir_path}"
),
)
else:
process_group_id = dir_path.replace(FileSystemService.root_path(), "")
@ -457,7 +483,10 @@ class ProcessModelService(FileSystemService):
if process_model_info is None:
raise ApiError(
error_code="process_model_could_not_be_loaded_from_disk",
message=f"We could not load the process_model from disk with data: {data}",
message=(
"We could not load the process_model from disk with data:"
f" {data}"
),
)
else:
if name is None:

View File

@ -112,7 +112,10 @@ class ScriptUnitTestRunner:
except json.decoder.JSONDecodeError as ex:
return ScriptUnitTestResult(
result=False,
error=f"Failed to parse expectedOutputJson: {unit_test['expectedOutputJson']}: {str(ex)}",
error=(
"Failed to parse expectedOutputJson:"
f" {unit_test['expectedOutputJson']}: {str(ex)}"
),
)
script = task.task_spec.script

View File

@ -44,8 +44,10 @@ class SecretService:
except Exception as e:
raise ApiError(
error_code="create_secret_error",
message=f"There was an error creating a secret with key: {key} and value ending with: {value[:-4]}. "
f"Original error is {e}",
message=(
f"There was an error creating a secret with key: {key} and value"
f" ending with: {value[:-4]}. Original error is {e}"
),
) from e
return secret_model
@ -89,7 +91,9 @@ class SecretService:
else:
raise ApiError(
error_code="update_secret_error",
message=f"Cannot update secret with key: {key}. Resource does not exist.",
message=(
f"Cannot update secret with key: {key}. Resource does not exist."
),
status_code=404,
)
@ -104,11 +108,16 @@ class SecretService:
except Exception as e:
raise ApiError(
error_code="delete_secret_error",
message=f"Could not delete secret with key: {key}. Original error is: {e}",
message=(
f"Could not delete secret with key: {key}. Original error"
f" is: {e}"
),
) from e
else:
raise ApiError(
error_code="delete_secret_error",
message=f"Cannot delete secret with key: {key}. Resource does not exist.",
message=(
f"Cannot delete secret with key: {key}. Resource does not exist."
),
status_code=404,
)

View File

@ -192,7 +192,8 @@ class SpecFileService(FileSystemService):
full_file_path = SpecFileService.full_file_path(process_model_info, file_name)
if not os.path.exists(full_file_path):
raise ProcessModelFileNotFoundError(
f"No file found with name {file_name} in {process_model_info.display_name}"
f"No file found with name {file_name} in"
f" {process_model_info.display_name}"
)
with open(full_file_path, "rb") as f_handle:
spec_file_data = f_handle.read()
@ -314,8 +315,9 @@ class SpecFileService(FileSystemService):
).first()
if message_model is None:
raise ValidationException(
f"Could not find message model with identifier '{message_model_identifier}'"
f"Required by a Start Event in : {ref.file_name}"
"Could not find message model with identifier"
f" '{message_model_identifier}'Required by a Start Event in :"
f" {ref.file_name}"
)
message_triggerable_process_model = (
MessageTriggerableProcessModel.query.filter_by(
@ -335,7 +337,8 @@ class SpecFileService(FileSystemService):
!= ref.process_model_id
):
raise ValidationException(
f"Message model is already used to start process model {ref.process_model_id}"
"Message model is already used to start process model"
f" {ref.process_model_id}"
)
@staticmethod
@ -353,8 +356,9 @@ class SpecFileService(FileSystemService):
).first()
if message_model is None:
raise ValidationException(
f"Could not find message model with identifier '{message_model_identifier}'"
f"specified by correlation property: {cpre}"
"Could not find message model with identifier"
f" '{message_model_identifier}'specified by correlation"
f" property: {cpre}"
)
# fixme: I think we are currently ignoring the correction properties.
message_correlation_property = (

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" id="Definitions_96f6665" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.0.0-dev">
<bpmn:process id="natural_language_process_id_template" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0gixxkm</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0gixxkm" sourceRef="StartEvent_1" targetRef="present_form" />
<bpmn:userTask id="present_form" name="Present Form">
<bpmn:extensionElements>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="form-identifier-id-template-schema.json" />
<spiffworkflow:property name="formUiSchemaFilename" value="form-identifier-id-template-uischema.json" />
</spiffworkflow:properties>
</bpmn:extensionElements>
<bpmn:incoming>Flow_0gixxkm</bpmn:incoming>
<bpmn:outgoing>Flow_1oi9nsn</bpmn:outgoing>
</bpmn:userTask>
<bpmn:endEvent id="Event_003bxs1">
<bpmn:incoming>Flow_1oi9nsn</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1oi9nsn" sourceRef="present_form" targetRef="Event_003bxs1" />
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="natural_language_process_id_template">
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0ajk9gf_di" bpmnElement="present_form">
<dc:Bounds x="270" y="137" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_003bxs1_di" bpmnElement="Event_003bxs1">
<dc:Bounds x="432" y="159" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0gixxkm_di" bpmnElement="Flow_0gixxkm">
<di:waypoint x="215" y="177" />
<di:waypoint x="270" y="177" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1oi9nsn_di" bpmnElement="Flow_1oi9nsn">
<di:waypoint x="370" y="177" />
<di:waypoint x="432" y="177" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,6 @@
{
"title": "{FORM_IDENTIFIER}",
"description": "",
"properties": {},
"required": []
}

View File

@ -133,7 +133,6 @@ class BaseTest:
) -> TestResponse:
"""Create_process_model."""
if process_model_id is not None:
# make sure we have a group
process_group_id, _ = os.path.split(process_model_id)
modified_process_group_id = process_group_id.replace("/", ":")
@ -141,7 +140,6 @@ class BaseTest:
os.path.join(FileSystemService.root_path(), process_group_id)
)
if ProcessModelService.is_group(process_group_path):
if exception_notification_addresses is None:
exception_notification_addresses = []
@ -171,7 +169,8 @@ class BaseTest:
raise Exception("You must create the group first")
else:
raise Exception(
"You must include the process_model_id, which must be a path to the model"
"You must include the process_model_id, which must be a path to the"
" model"
)
def get_test_data_file_contents(

View File

@ -163,6 +163,83 @@ class TestProcessApi(BaseTest):
assert process_model.primary_file_name == bpmn_file_name
assert process_model.primary_process_id == "sample"
def test_process_model_create_with_natural_language(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_create_with_natural_language."""
process_group_id = "test_process_group"
process_group_description = "Test Process Group"
process_model_id = "sample"
process_model_identifier = f"{process_group_id}/{process_model_id}"
self.create_process_group(
client, with_super_admin_user, process_group_id, process_group_description
)
text = "Create a Bug Tracker process model "
text += (
"with a Bug Details form that collects summary, description, and priority"
)
body = {"natural_language_text": text}
self.create_process_model_with_api(
client,
process_model_id=process_model_identifier,
user=with_super_admin_user,
)
response = client.post(
f"/v1.0/process-models-natural-language/{process_group_id}",
content_type="application/json",
data=json.dumps(body),
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 201
assert response.json is not None
assert response.json["id"] == f"{process_group_id}/bug-tracker"
assert response.json["display_name"] == "Bug Tracker"
assert response.json["metadata_extraction_paths"] == [
{"key": "summary", "path": "summary"},
{"key": "description", "path": "description"},
{"key": "priority", "path": "priority"},
]
process_model = ProcessModelService.get_process_model(response.json["id"])
process_model_path = os.path.join(
FileSystemService.root_path(),
FileSystemService.id_string_to_relative_path(process_model.id),
)
process_model_diagram = os.path.join(process_model_path, "bug-tracker.bpmn")
assert os.path.exists(process_model_diagram)
form_schema_json = os.path.join(process_model_path, "bug-details-schema.json")
assert os.path.exists(form_schema_json)
form_uischema_json = os.path.join(
process_model_path, "bug-details-uischema.json"
)
assert os.path.exists(form_uischema_json)
process_instance_report = ProcessInstanceReportModel.query.filter_by(
identifier="bug-tracker"
).first()
assert process_instance_report is not None
report_column_accessors = [
i["accessor"] for i in process_instance_report.report_metadata["columns"]
]
expected_column_accessors = [
"id",
"process_model_display_name",
"start_in_seconds",
"end_in_seconds",
"username",
"status",
"summary",
"description",
"priority",
]
assert report_column_accessors == expected_column_accessors
def test_primary_process_id_updates_via_xml(
self,
app: Flask,
@ -250,10 +327,6 @@ class TestProcessApi(BaseTest):
assert response.json is not None
assert response.json["ok"] is True
# assert we no longer have a model
with pytest.raises(ProcessEntityNotFoundError):
ProcessModelService.get_process_model(process_model_identifier)
def test_process_model_delete_with_instances(
self,
app: Flask,
@ -305,7 +378,8 @@ class TestProcessApi(BaseTest):
assert data["error_code"] == "existing_instances"
assert (
data["message"]
== f"We cannot delete the model `{process_model_identifier}`, there are existing instances that depend on it."
== f"We cannot delete the model `{process_model_identifier}`, there are"
" existing instances that depend on it."
)
def test_process_model_update(
@ -2020,7 +2094,6 @@ class TestProcessApi(BaseTest):
mail = app.config["MAIL_APP"]
with mail.record_messages() as outbox:
response = client.post(
f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/run",
headers=self.logged_in_headers(with_super_admin_user),
@ -2923,7 +2996,9 @@ class TestProcessApi(BaseTest):
) -> 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",
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",
)
@ -2980,7 +3055,9 @@ class TestProcessApi(BaseTest):
) -> 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",
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",
)

View File

@ -0,0 +1,47 @@
"""Test_users_controller."""
from flask.app import Flask
from flask.testing import FlaskClient
from tests.spiffworkflow_backend.helpers.base_test import BaseTest
from spiffworkflow_backend.models.user import UserModel
class TestUsersController(BaseTest):
"""TestUsersController."""
def test_user_search_returns_a_user(
self,
app: Flask,
client: FlaskClient,
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_user_search_returns_a_user."""
self.find_or_create_user(username="aa")
self.find_or_create_user(username="ab")
self.find_or_create_user(username="abc")
self.find_or_create_user(username="ac")
self._assert_search_has_count(client, with_super_admin_user, "aa", 1)
self._assert_search_has_count(client, with_super_admin_user, "ab", 2)
self._assert_search_has_count(client, with_super_admin_user, "ac", 1)
self._assert_search_has_count(client, with_super_admin_user, "ad", 0)
self._assert_search_has_count(client, with_super_admin_user, "a", 4)
def _assert_search_has_count(
self,
client: FlaskClient,
with_super_admin_user: UserModel,
username_prefix: str,
expected_count: int,
) -> None:
"""_assert_search_has_count."""
response = client.get(
f"/v1.0/users/search?username_prefix={username_prefix}",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 200
assert response.json
assert response.json["users"] is not None
assert response.json["username_prefix"] == username_prefix
assert len(response.json["users"]) == expected_count

View File

@ -28,7 +28,9 @@ class TestSaveProcessInstanceMetadata(BaseTest):
client, with_super_admin_user, "test_group", "test_group"
)
process_model = load_test_spec(
process_model_id="save_process_instance_metadata/save_process_instance_metadata",
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",
)

View File

@ -16,6 +16,7 @@ from spiffworkflow_backend.services.user_service import UserService
# we think we can get the list of roles for a user.
# spiff needs a way to determine what each role allows.
# user role allows list and read of all process groups/models
# super-admin role allows create, update, and delete of all process groups/models
# * super-admins users maybe conventionally get the user role as well

View File

@ -52,7 +52,8 @@ class TestProcessInstanceProcessor(BaseTest):
result = script_engine._evaluate("fact_service(type='norris')", {})
assert (
result
== "Chuck Norris doesnt read books. He stares them down until he gets the information he wants."
== "Chuck Norris doesnt read books. He stares them down until he gets the"
" information he wants."
)
app.config["THREAD_LOCAL_DATA"].process_model_identifier = None
app.config["THREAD_LOCAL_DATA"].process_instance_id = None

View File

@ -880,7 +880,9 @@ class TestProcessInstanceReportService(BaseTest):
process_instance_report = ProcessInstanceReportService.report_with_identifier(
user=user_one,
report_identifier="system_report_completed_instances_with_tasks_completed_by_me",
report_identifier=(
"system_report_completed_instances_with_tasks_completed_by_me"
),
)
report_filter = (
ProcessInstanceReportService.filter_from_metadata_with_overrides(
@ -983,7 +985,9 @@ class TestProcessInstanceReportService(BaseTest):
process_instance_report = ProcessInstanceReportService.report_with_identifier(
user=user_one,
report_identifier="system_report_completed_instances_with_tasks_completed_by_my_groups",
report_identifier=(
"system_report_completed_instances_with_tasks_completed_by_my_groups"
),
)
report_filter = (
ProcessInstanceReportService.filter_from_metadata_with_overrides(

View File

@ -10,6 +10,8 @@ set -o errtrace -o errexit -o nounset -o pipefail
# see also: npx cypress run --env grep="can filter",grepFilterSpecs=true
# https://github.com/cypress-io/cypress/tree/develop/npm/grep#pre-filter-specs-grepfilterspecs
iterations="${1:-10}"
test_case_matches="$(rg '^ it\(')"
stats_file="/var/tmp/cypress_stats.txt"
@ -37,7 +39,8 @@ function run_all_test_cases() {
# clear the stats file
echo > "$stats_file"
for global_stat_index in {1..100}; do
for ((global_stat_index=1;global_stat_index<=$iterations;global_stat_index++)); do
# for global_stat_index in {1..$iterations}; do
run_all_test_cases "$global_stat_index"
done

View File

@ -9,16 +9,19 @@ describe('process-models', () => {
cy.logout();
});
const groupDisplayName = 'Acceptance Tests Group One';
const deleteProcessModelButtonId = 'delete-process-model-button';
const saveChangesButtonText = 'Save Changes';
const fileNameInputSelector = 'input[name=file_name]';
it('can perform crud operations', () => {
const uuid = () => Cypress._.random(0, 1e6);
const id = uuid();
const groupId = 'misc/acceptance-tests-group-one';
const groupDisplayName = 'Acceptance Tests Group One';
const modelDisplayName = `Test Model 2 ${id}`;
const modelId = `test-model-2-${id}`;
const newModelDisplayName = `${modelDisplayName} edited`;
cy.contains(miscDisplayName).click();
cy.wait(500);
cy.contains(groupDisplayName).click();
cy.createModel(groupId, modelId, modelDisplayName);
cy.url().should(
@ -34,18 +37,8 @@ describe('process-models', () => {
cy.contains('Submit').click();
cy.contains(`Process Model: ${newModelDisplayName}`);
// go back to process model show by clicking on the breadcrumb
cy.contains(modelDisplayName).click();
cy.deleteProcessModelAndConfirm(deleteProcessModelButtonId, groupId);
cy.getBySel('delete-process-model-button').click();
cy.contains('Are you sure');
cy.getBySel('delete-process-model-button-modal-confirmation-dialog')
.find('.cds--btn--danger')
.click();
cy.url().should(
'include',
`process-groups/${modifyProcessIdentifierForPathParam(groupId)}`
);
cy.contains(modelId).should('not.exist');
cy.contains(modelDisplayName).should('not.exist');
});
@ -54,17 +47,17 @@ describe('process-models', () => {
const uuid = () => Cypress._.random(0, 1e6);
const id = uuid();
const directParentGroupId = 'acceptance-tests-group-one';
const directParentGroupName = 'Acceptance Tests Group One';
const groupId = `misc/${directParentGroupId}`;
const groupDisplayName = 'Acceptance Tests Group One';
const modelDisplayName = `Test Model 2 ${id}`;
const modelId = `test-model-2-${id}`;
const bpmnFileName = `bpmn_test_file_${id}`;
const dmnFileName = `dmn_test_file_${id}`;
const jsonFileName = `json_test_file_${id}`;
const decision_acceptance_test_id = `decision_acceptance_test_${id}`;
cy.contains(miscDisplayName).click();
cy.wait(500);
cy.contains(groupDisplayName).click();
cy.createModel(groupId, modelId, modelDisplayName);
cy.contains(groupDisplayName).click();
@ -89,8 +82,8 @@ describe('process-models', () => {
cy.wait(500);
cy.contains('Save').click();
cy.contains('Start Event Name');
cy.get('input[name=file_name]').type(bpmnFileName);
cy.contains('Save Changes').click();
cy.get(fileNameInputSelector).type(bpmnFileName);
cy.contains(saveChangesButtonText).click();
cy.contains(`Process Model File: ${bpmnFileName}`);
cy.contains(modelDisplayName).click();
cy.contains(`Process Model: ${modelDisplayName}`);
@ -104,11 +97,11 @@ describe('process-models', () => {
cy.contains('General').click();
cy.get('#bio-properties-panel-id')
.clear()
.type('decision_acceptance_test_1');
.type(decision_acceptance_test_id);
cy.contains('General').click();
cy.contains('Save').click();
cy.get('input[name=file_name]').type(dmnFileName);
cy.contains('Save Changes').click();
cy.get(fileNameInputSelector).type(dmnFileName);
cy.contains(saveChangesButtonText).click();
cy.contains(`Process Model File: ${dmnFileName}`);
cy.contains(modelDisplayName).click();
cy.contains(`Process Model: ${modelDisplayName}`);
@ -121,8 +114,8 @@ describe('process-models', () => {
// Some reason, cypress evals json strings so we have to escape it it with '{{}'
cy.get('.view-line').type('{{} "test_key": "test_value" }');
cy.getBySel('file-save-button').click();
cy.get('input[name=file_name]').type(jsonFileName);
cy.contains('Save Changes').click();
cy.get(fileNameInputSelector).type(jsonFileName);
cy.contains(saveChangesButtonText).click();
cy.contains(`Process Model File: ${jsonFileName}`);
// wait for json to load before clicking away to avoid network errors
cy.wait(500);
@ -131,15 +124,7 @@ describe('process-models', () => {
// cy.getBySel('files-accordion').click();
cy.contains(`${jsonFileName}.json`).should('exist');
cy.getBySel('delete-process-model-button').click();
cy.contains('Are you sure');
cy.getBySel('delete-process-model-button-modal-confirmation-dialog')
.find('.cds--btn--danger')
.click();
cy.url().should(
'include',
`process-groups/${modifyProcessIdentifierForPathParam(groupId)}`
);
cy.deleteProcessModelAndConfirm(deleteProcessModelButtonId, groupId);
cy.contains(modelId).should('not.exist');
cy.contains(modelDisplayName).should('not.exist');
@ -152,12 +137,10 @@ describe('process-models', () => {
const id = uuid();
const directParentGroupId = 'acceptance-tests-group-one';
const groupId = `misc/${directParentGroupId}`;
const groupDisplayName = 'Acceptance Tests Group One';
const modelDisplayName = `Test Model 2 ${id}`;
const modelId = `test-model-2-${id}`;
cy.contains('Add a process group');
cy.contains(miscDisplayName).click();
cy.wait(500);
cy.contains(groupDisplayName).click();
cy.createModel(groupId, modelId, modelDisplayName);
@ -193,7 +176,7 @@ describe('process-models', () => {
// in breadcrumb
cy.contains(modelDisplayName).click();
cy.getBySel('delete-process-model-button').click();
cy.getBySel(deleteProcessModelButtonId).click();
cy.contains('Are you sure');
cy.getBySel('delete-process-model-button-modal-confirmation-dialog')
.find('.cds--btn--danger')
@ -206,14 +189,6 @@ describe('process-models', () => {
cy.contains(modelDisplayName).should('not.exist');
});
// process models no longer has pagination post-tiles
// it.only('can paginate items', () => {
// cy.contains(miscDisplayName).click();
// cy.wait(500);
// cy.contains('Acceptance Tests Group One').click();
// cy.basicPaginationTest();
// });
it('can allow searching for model', () => {
cy.getBySel('process-model-selection').click().type('model-3');
cy.contains('acceptance-tests-group-one/acceptance-tests-model-3').click();

View File

@ -151,3 +151,18 @@ Cypress.Commands.add('assertAtLeastOneItemInPaginatedResults', () => {
Cypress.Commands.add('assertNoItemInPaginatedResults', () => {
cy.contains(/\b00 of 0 items/);
});
Cypress.Commands.add(
'deleteProcessModelAndConfirm',
(buttonId, groupId) => {
cy.getBySel(buttonId).click();
cy.contains('Are you sure');
cy.getBySel('delete-process-model-button-modal-confirmation-dialog')
.find('.cds--btn--danger')
.click();
cy.url().should(
'include',
`process-groups/${modifyProcessIdentifierForPathParam(groupId)}`
);
}
);

View File

@ -9850,9 +9850,9 @@
"integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A=="
},
"node_modules/cypress": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.1.0.tgz",
"integrity": "sha512-7fz8N84uhN1+ePNDsfQvoWEl4P3/VGKKmAg+bJQFY4onhA37Ys+6oBkGbNdwGeC7n2QqibNVPhk8x3YuQLwzfw==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz",
"integrity": "sha512-kvl95ri95KK8mAy++tEU/wUgzAOMiIciZSL97LQvnOinb532m7dGvwN0mDSIGbOd71RREtmT9o4h088RjK5pKw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@ -38586,9 +38586,9 @@
"integrity": "sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A=="
},
"cypress": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.1.0.tgz",
"integrity": "sha512-7fz8N84uhN1+ePNDsfQvoWEl4P3/VGKKmAg+bJQFY4onhA37Ys+6oBkGbNdwGeC7n2QqibNVPhk8x3YuQLwzfw==",
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/cypress/-/cypress-12.2.0.tgz",
"integrity": "sha512-kvl95ri95KK8mAy++tEU/wUgzAOMiIciZSL97LQvnOinb532m7dGvwN0mDSIGbOd71RREtmT9o4h088RjK5pKw==",
"dev": true,
"requires": {
"@cypress/request": "^2.88.10",

View File

@ -14,7 +14,7 @@ import { ErrorForDisplay } from './interfaces';
import { AbilityContext } from './contexts/Can';
import UserService from './services/UserService';
import { Notification } from './components/Notification';
import ErrorDisplay from './components/ErrorDisplay';
export default function App() {
const [errorObject, setErrorObject] = useState<ErrorForDisplay | null>(null);
@ -31,52 +31,6 @@ export default function App() {
const ability = defineAbility(() => {});
let errorTag = null;
if (errorObject) {
let sentryLinkTag = null;
if (errorObject.sentry_link) {
sentryLinkTag = (
<span>
{
': Find details about this error here (it may take a moment to become available): '
}
<a href={errorObject.sentry_link} target="_blank" rel="noreferrer">
{errorObject.sentry_link}
</a>
</span>
);
}
let message = <div>{errorObject.message}</div>;
let title = 'Error:';
if ('task_name' in errorObject) {
title = `Error in python script:`;
message = (
<>
<br />
<div>
Task: {errorObject.task_name} ({errorObject.task_id})
</div>
<div>File name: {errorObject.file_name}</div>
<div>Line number in script task: {errorObject.line_number}</div>
<br />
<div>{errorObject.message}</div>
</>
);
}
errorTag = (
<Notification
title={title}
onClose={() => setErrorObject(null)}
type="error"
>
{message}
{sentryLinkTag}
</Notification>
);
}
return (
<div className="cds--white">
{/* @ts-ignore */}
@ -85,7 +39,7 @@ export default function App() {
<BrowserRouter>
<NavigationBar />
<Content>
{errorTag}
<ErrorDisplay />
<ErrorBoundary>
<Routes>
<Route path="/*" element={<HomePageRoutes />} />

View File

@ -0,0 +1,55 @@
import { useContext } from 'react';
import ErrorContext from '../contexts/ErrorContext';
import { Notification } from './Notification';
export default function ErrorDisplay() {
const [errorObject, setErrorObject] = (useContext as any)(ErrorContext);
let errorTag = null;
if (errorObject) {
let sentryLinkTag = null;
if (errorObject.sentry_link) {
sentryLinkTag = (
<span>
{
': Find details about this error here (it may take a moment to become available): '
}
<a href={errorObject.sentry_link} target="_blank" rel="noreferrer">
{errorObject.sentry_link}
</a>
</span>
);
}
let message = <div>{errorObject.message}</div>;
let title = 'Error:';
if ('task_name' in errorObject && errorObject.task_name) {
title = 'Error in python script:';
message = (
<>
<br />
<div>
Task: {errorObject.task_name} ({errorObject.task_id})
</div>
<div>File name: {errorObject.file_name}</div>
<div>Line number in script task: {errorObject.line_number}</div>
<br />
<div>{errorObject.message}</div>
</>
);
}
errorTag = (
<Notification
title={title}
onClose={() => setErrorObject(null)}
type="error"
>
{message}
{sentryLinkTag}
</Notification>
);
}
return errorTag;
}

View File

@ -81,7 +81,7 @@ export default function NavigationBar() {
return (
<>
<HeaderGlobalAction className="username-header-text">
{UserService.getUsername()}
{UserService.getPreferredUsername()}
</HeaderGlobalAction>
<HeaderGlobalAction
aria-label="Logout"

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useState } from 'react';
import { useContext, useEffect, useMemo, useRef, useState } from 'react';
import {
Link,
useNavigate,
@ -60,6 +60,7 @@ import {
ReportColumnForEditing,
ReportMetadata,
ReportFilter,
User,
} from '../interfaces';
import ProcessModelSearch from './ProcessModelSearch';
import ProcessInstanceReportSearch from './ProcessInstanceReportSearch';
@ -134,10 +135,14 @@ export default function ProcessInstanceListTable({
const [errorObject, setErrorObject] = (useContext as any)(ErrorContext);
const processInstancePathPrefix =
const processInstanceListPathPrefix =
variant === 'all'
? '/admin/process-instances/all'
: '/admin/process-instances/for-me';
const processInstanceShowPathPrefix =
variant === 'all'
? '/admin/process-instances'
: '/admin/process-instances/for-me';
const [processStatusAllOptions, setProcessStatusAllOptions] = useState<any[]>(
[]
@ -164,6 +169,12 @@ export default function ProcessInstanceListTable({
useState<ReportColumnForEditing | null>(null);
const [reportColumnFormMode, setReportColumnFormMode] = useState<string>('');
const [processInstanceInitiatorOptions, setProcessInstanceInitiatorOptions] =
useState<string[]>([]);
const [processInitiatorSelection, setProcessInitiatorSelection] =
useState<User | null>(null);
const lastRequestedInitatorSearchTerm = useRef<string>();
const dateParametersToAlwaysFilterBy: dateParameters = useMemo(() => {
return {
start_from: [setStartFromDate, setStartFromTime],
@ -309,7 +320,7 @@ export default function ProcessInstanceListTable({
if (filtersEnabled) {
// populate process model selection
HttpService.makeCallToBackend({
path: `/process-models?per_page=1000&recursive=true`,
path: `/process-models?per_page=1000&recursive=true&include_parent_groups=true`,
successCallback: processResultForProcessModels,
});
} else {
@ -529,7 +540,7 @@ export default function ProcessInstanceListTable({
setErrorObject(null);
setProcessInstanceReportJustSaved(null);
navigate(`${processInstancePathPrefix}?${queryParamString}`);
navigate(`${processInstanceListPathPrefix}?${queryParamString}`);
};
const dateComponent = (
@ -628,7 +639,7 @@ export default function ProcessInstanceListTable({
setErrorObject(null);
setProcessInstanceReportJustSaved(mode || null);
navigate(`${processInstancePathPrefix}${queryParamString}`);
navigate(`${processInstanceListPathPrefix}${queryParamString}`);
};
const reportColumns = () => {
@ -774,7 +785,6 @@ export default function ProcessInstanceListTable({
setReportMetadata(reportMetadataCopy);
setReportColumnToOperateOn(null);
setShowReportColumnForm(false);
setShowReportColumnForm(false);
}
};
@ -795,9 +805,12 @@ export default function ProcessInstanceListTable({
};
const updateReportColumn = (event: any) => {
const reportColumnForEditing = reportColumnToReportColumnForEditing(
event.selectedItem
);
let reportColumnForEditing = null;
if (event.selectedItem) {
reportColumnForEditing = reportColumnToReportColumnForEditing(
event.selectedItem
);
}
setReportColumnToOperateOn(reportColumnForEditing);
};
@ -827,7 +840,29 @@ export default function ProcessInstanceListTable({
if (reportColumnFormMode === '') {
return null;
}
const formElements = [
const formElements = [];
if (reportColumnFormMode === 'new') {
formElements.push(
<ComboBox
onChange={updateReportColumn}
id="report-column-selection"
data-qa="report-column-selection"
data-modal-primary-focus
items={availableReportColumns}
itemToString={(reportColumn: ReportColumn) => {
if (reportColumn) {
return reportColumn.accessor;
}
return null;
}}
shouldFilterItem={shouldFilterReportColumn}
placeholder="Choose a column to show"
titleText="Column"
selectedItem={reportColumnToOperateOn}
/>
);
}
formElements.push([
<TextInput
id="report-column-display-name"
name="report-column-display-name"
@ -844,7 +879,7 @@ export default function ProcessInstanceListTable({
}
}}
/>,
];
]);
if (reportColumnToOperateOn && reportColumnToOperateOn.filterable) {
formElements.push(
<TextInput
@ -860,27 +895,9 @@ export default function ProcessInstanceListTable({
/>
);
}
if (reportColumnFormMode === 'new') {
formElements.push(
<ComboBox
onChange={updateReportColumn}
className="combo-box-in-modal"
id="report-column-selection"
data-qa="report-column-selection"
data-modal-primary-focus
items={availableReportColumns}
itemToString={(reportColumn: ReportColumn) => {
if (reportColumn) {
return reportColumn.accessor;
}
return null;
}}
shouldFilterItem={shouldFilterReportColumn}
placeholder="Choose a column to show"
titleText="Column"
/>
);
}
formElements.push(
<div className="vertical-spacer-to-allow-combo-box-to-expand-in-modal" />
);
const modalHeading =
reportColumnFormMode === 'new'
? 'Add Column'
@ -970,6 +987,22 @@ export default function ProcessInstanceListTable({
return null;
};
const handleProcessInstanceInitiatorSearchResult = (result: any) => {
if (lastRequestedInitatorSearchTerm.current === result.username_prefix) {
setProcessInstanceInitiatorOptions(result.users);
}
};
const searchForProcessInitiator = (inputText: string) => {
if (inputText) {
lastRequestedInitatorSearchTerm.current = inputText;
HttpService.makeCallToBackend({
path: `/users/search?username_prefix=${inputText}`,
successCallback: handleProcessInstanceInitiatorSearchResult,
});
}
};
const filterOptions = () => {
if (!showFilterOptions) {
return null;
@ -1002,7 +1035,27 @@ export default function ProcessInstanceListTable({
selectedItem={processModelSelection}
/>
</Column>
<Column md={8}>{processStatusSearch()}</Column>
<Column md={4}>
<ComboBox
onInputChange={searchForProcessInitiator}
onChange={(event: any) => {
setProcessInitiatorSelection(event.selectedItem);
}}
id="process-instance-initiator-search"
data-qa="process-instance-initiator-search"
items={processInstanceInitiatorOptions}
itemToString={(processInstanceInitatorOption: User) => {
if (processInstanceInitatorOption) {
return processInstanceInitatorOption.username;
}
return null;
}}
placeholder="Process Initiator"
titleText="PROC"
selectedItem={processInitiatorSelection}
/>
</Column>
<Column md={4}>{processStatusSearch()}</Column>
</Grid>
<Grid fullWidth className="with-bottom-margin">
<Column md={4}>
@ -1108,7 +1161,7 @@ export default function ProcessInstanceListTable({
return (
<Link
data-qa="process-instance-show-link"
to={`${processInstancePathPrefix}/${modifiedProcessModelId}/${id}`}
to={`${processInstanceShowPathPrefix}/${modifiedProcessModelId}/${id}`}
title={`View process instance ${id}`}
>
<span data-qa="paginated-entity-id">{id}</span>

View File

@ -2,8 +2,7 @@ import {
ComboBox,
// @ts-ignore
} from '@carbon/react';
import { truncateString } from '../helpers';
import { ProcessModel } from '../interfaces';
import { ProcessGroupLite, ProcessModel } from '../interfaces';
type OwnProps = {
onChange: (..._args: any[]) => any;
@ -18,12 +17,27 @@ export default function ProcessModelSearch({
onChange,
titleText = 'Process model',
}: OwnProps) {
const getParentGroupsDisplayName = (processModel: ProcessModel) => {
if (processModel.parent_groups) {
return processModel.parent_groups
.map((parentGroup: ProcessGroupLite) => {
return parentGroup.display_name;
})
.join(' / ');
}
return '';
};
const getFullProcessModelLabel = (processModel: ProcessModel) => {
return `${processModel.id} (${getParentGroupsDisplayName(processModel)} ${
processModel.display_name
})`;
};
const shouldFilterProcessModel = (options: any) => {
const processModel: ProcessModel = options.item;
const { inputValue } = options;
return `${processModel.id} (${processModel.display_name})`.includes(
inputValue
);
return getFullProcessModelLabel(processModel).includes(inputValue);
};
return (
<ComboBox
@ -33,10 +47,7 @@ export default function ProcessModelSearch({
items={processModels}
itemToString={(processModel: ProcessModel) => {
if (processModel) {
return `${processModel.id} (${truncateString(
processModel.display_name,
75
)})`;
return getFullProcessModelLabel(processModel);
}
return null;
}}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
// @ts-ignore
import { Button, Table } from '@carbon/react';
import { Link, useSearchParams } from 'react-router-dom';
import UserService from '../services/UserService';
import PaginationForTable from './PaginationForTable';
import {
convertSecondsToFormattedDateTime,
@ -46,6 +47,9 @@ export default function TaskListTable({
const [tasks, setTasks] = useState<ProcessInstanceTask[] | null>(null);
const [pagination, setPagination] = useState<PaginationObject | null>(null);
const preferredUsername = UserService.getPreferredUsername();
const userEmail = UserService.getUserEmail();
useEffect(() => {
const getTasks = () => {
const { page, perPage } = getPageInfoFromSearchParams(
@ -80,56 +84,82 @@ export default function TaskListTable({
autoReload,
]);
const getWaitingForTableCellComponent = (
processInstanceTask: ProcessInstanceTask
) => {
let fullUsernameString = '';
let shortUsernameString = '';
if (processInstanceTask.assigned_user_group_identifier) {
fullUsernameString = processInstanceTask.assigned_user_group_identifier;
shortUsernameString = processInstanceTask.assigned_user_group_identifier;
}
if (processInstanceTask.potential_owner_usernames) {
fullUsernameString = processInstanceTask.potential_owner_usernames;
const usernames =
processInstanceTask.potential_owner_usernames.split(',');
const firstTwoUsernames = usernames.slice(0, 2);
if (usernames.length > 2) {
firstTwoUsernames.push('...');
}
shortUsernameString = firstTwoUsernames.join(',');
}
return <span title={fullUsernameString}>{shortUsernameString}</span>;
};
const buildTable = () => {
if (!tasks) {
return null;
}
const rows = tasks.map((row) => {
const rowToUse = row as any;
const taskUrl = `/tasks/${rowToUse.process_instance_id}/${rowToUse.task_id}`;
const rows = tasks.map((row: ProcessInstanceTask) => {
const taskUrl = `/tasks/${row.process_instance_id}/${row.task_id}`;
const modifiedProcessModelIdentifier =
modifyProcessIdentifierForPathParam(rowToUse.process_model_identifier);
modifyProcessIdentifierForPathParam(row.process_model_identifier);
const regex = new RegExp(`\\b(${preferredUsername}|${userEmail})\\b`);
let hasAccessToCompleteTask = false;
if (row.potential_owner_usernames.match(regex)) {
hasAccessToCompleteTask = true;
}
return (
<tr key={rowToUse.id}>
<tr key={row.id}>
<td>
<Link
data-qa="process-instance-show-link"
to={`/admin/process-instances/for-me/${modifiedProcessModelIdentifier}/${rowToUse.process_instance_id}`}
title={`View process instance ${rowToUse.process_instance_id}`}
to={`/admin/process-instances/for-me/${modifiedProcessModelIdentifier}/${row.process_instance_id}`}
title={`View process instance ${row.process_instance_id}`}
>
{rowToUse.process_instance_id}
{row.process_instance_id}
</Link>
</td>
<td>
<Link
data-qa="process-model-show-link"
to={`/admin/process-models/${modifiedProcessModelIdentifier}`}
title={rowToUse.process_model_identifier}
title={row.process_model_identifier}
>
{rowToUse.process_model_display_name}
{row.process_model_display_name}
</Link>
</td>
<td
title={`task id: ${rowToUse.name}, spiffworkflow task guid: ${rowToUse.id}`}
title={`task id: ${row.name}, spiffworkflow task guid: ${row.id}`}
>
{rowToUse.task_title}
{row.task_title}
</td>
{showStartedBy ? <td>{rowToUse.username}</td> : ''}
{showWaitingOn ? <td>{rowToUse.group_identifier || '-'}</td> : ''}
{showStartedBy ? <td>{row.process_initiator_username}</td> : ''}
{showWaitingOn ? <td>{getWaitingForTableCellComponent(row)}</td> : ''}
<td>
{convertSecondsToFormattedDateTime(
rowToUse.created_at_in_seconds
) || '-'}
{convertSecondsToFormattedDateTime(row.created_at_in_seconds) ||
'-'}
</td>
<TableCellWithTimeAgoInWords
timeInSeconds={rowToUse.updated_at_in_seconds}
timeInSeconds={row.updated_at_in_seconds}
/>
<td>
<Button
variant="primary"
href={taskUrl}
hidden={rowToUse.process_instance_status === 'suspended'}
disabled={!rowToUse.current_user_is_potential_owner}
hidden={row.process_instance_status === 'suspended'}
disabled={!hasAccessToCompleteTask}
>
Go
</Button>

View File

@ -355,8 +355,8 @@ svg.notification-icon {
word-break: normal;
}
.combo-box-in-modal {
height: 300px;
.vertical-spacer-to-allow-combo-box-to-expand-in-modal {
height: 250px;
}
.cds--btn.narrow-button {

View File

@ -1,3 +1,8 @@
export interface User {
id: number;
username: string;
}
export interface Secret {
id: number;
key: string;
@ -17,16 +22,23 @@ export interface RecentProcessModel {
}
export interface ProcessInstanceTask {
id: string;
id: number;
task_id: string;
process_instance_id: number;
process_model_display_name: string;
process_model_identifier: string;
task_title: string;
lane_assignment_id: string;
process_instance_status: number;
updated_at_in_seconds: number;
process_instance_status: string;
state: string;
process_identifier: string;
name: string;
process_initiator_username: string;
assigned_user_group_identifier: string;
created_at_in_seconds: number;
updated_at_in_seconds: number;
current_user_is_potential_owner: number;
potential_owner_usernames: string;
}
export interface ProcessReference {

View File

@ -22,6 +22,7 @@ import ProcessInstanceLogList from './ProcessInstanceLogList';
import MessageInstanceList from './MessageInstanceList';
import Configuration from './Configuration';
import JsonSchemaFormBuilder from './JsonSchemaFormBuilder';
import ProcessModelNewExperimental from './ProcessModelNewExperimental';
export default function AdminRoutes() {
const location = useLocation();
@ -50,6 +51,10 @@ export default function AdminRoutes() {
path="process-models/:process_group_id/new"
element={<ProcessModelNew />}
/>
<Route
path="process-models/:process_group_id/new-e"
element={<ProcessModelNewExperimental />}
/>
<Route
path="process-models/:process_model_id"
element={<ProcessModelShow />}

View File

@ -39,7 +39,7 @@ export default function ProcessGroupList() {
};
// for search box
HttpService.makeCallToBackend({
path: `/process-models?per_page=1000&recursive=true`,
path: `/process-models?per_page=1000&recursive=true&include_parent_groups=true`,
successCallback: processResultForProcessModels,
});
}, [searchParams]);

View File

@ -0,0 +1,73 @@
import { useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
// @ts-ignore
import { TextArea, Button, Form } from '@carbon/react';
import ProcessBreadcrumb from '../components/ProcessBreadcrumb';
import { ProcessModel } from '../interfaces';
import { modifyProcessIdentifierForPathParam } from '../helpers';
import HttpService from '../services/HttpService';
export default function ProcessModelNewExperimental() {
const params = useParams();
const navigate = useNavigate();
const [processModelDescriptiveText, setProcessModelDescriptiveText] =
useState<string>('');
const helperText =
'Create a bug tracker process model with a bug-details form that collects summary, description, and priority';
const navigateToProcessModel = (result: ProcessModel) => {
if ('id' in result) {
const modifiedProcessModelPathFromResult =
modifyProcessIdentifierForPathParam(result.id);
navigate(`/admin/process-models/${modifiedProcessModelPathFromResult}`);
}
};
const handleFormSubmission = (event: any) => {
event.preventDefault();
HttpService.makeCallToBackend({
path: `/process-models-natural-language/${params.process_group_id}`,
successCallback: navigateToProcessModel,
httpMethod: 'POST',
postBody: { natural_language_text: processModelDescriptiveText },
});
};
const ohYeeeeaah = () => {
setProcessModelDescriptiveText(helperText);
};
return (
<>
<ProcessBreadcrumb
hotCrumbs={[
['Process Groups', '/admin'],
{
entityToExplode: params.process_group_id || '',
entityType: 'process-group-id',
linkLastItem: true,
},
]}
/>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<h1 title={helperText} onClick={ohYeeeeaah} onKeyDown={ohYeeeeaah}>
Add Process Model
</h1>
<Form onSubmit={handleFormSubmission}>
<TextArea
id="process-model-descriptive-text"
value={processModelDescriptiveText}
labelText="Process Model Descriptive Text"
placeholder="your text"
onChange={(event: any) =>
setProcessModelDescriptiveText(event.target.value)
}
/>
<Button kind="secondary" type="submit">
Submit
</Button>
</Form>
</>
);
}

View File

@ -232,6 +232,15 @@ export default function ProcessModelShow() {
isPrimaryBpmnFile: boolean
) => {
const elements = [];
// So there is a bug in here. Since we use a react context for error messages, and since
// its provider wraps the entire app, child components will re-render when there is an
// error displayed. This is normally fine, but it interacts badly with the casl ability.can
// functionality. We have observed that permissionsLoaded is never set to false. So when
// you run a process and it fails, for example, process model show will re-render, the ability
// will be cleared out and it will start fetching permissions from the server, but this
// component still thinks permissionsLoaded is telling the truth (it says true, but it's actually false).
// The only bad effect that we know of is that the Edit icon becomes an eye icon even for admins.
let icon = View;
let actionWord = 'View';
if (ability.can('PUT', targetUris.processModelFileCreatePath)) {
@ -327,11 +336,7 @@ export default function ProcessModelShow() {
let fileLink = null;
const fileUrl = profileModelFileEditUrl(processModelFile);
if (fileUrl) {
if (ability.can('GET', targetUris.processModelFileCreatePath)) {
fileLink = <Link to={fileUrl}>{processModelFile.name}</Link>;
} else {
fileLink = <span>{processModelFile.name}</span>;
}
fileLink = <Link to={fileUrl}>{processModelFile.name}</Link>;
}
constructedTag = (
<TableRow key={processModelFile.name}>
@ -480,7 +485,7 @@ export default function ProcessModelShow() {
fullWidth
className="megacondensed process-model-files-section"
>
<Column md={5} lg={9} sm={3}>
<Column md={8} lg={14} sm={4}>
<Accordion align="end" open>
<AccordionItem
open

View File

@ -39,7 +39,16 @@ const isLoggedIn = () => {
return !!getAuthToken();
};
const getUsername = () => {
const getUserEmail = () => {
const idToken = getIdToken();
if (idToken) {
const idObject = jwt(idToken);
return (idObject as any).email;
}
return null;
};
const getPreferredUsername = () => {
const idToken = getIdToken();
if (idToken) {
const idObject = jwt(idToken);
@ -78,7 +87,8 @@ const UserService = {
isLoggedIn,
getAuthToken,
getAuthTokenFromParams,
getUsername,
getPreferredUsername,
getUserEmail,
hasRole,
};

View File

@ -1,6 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",