diff --git a/spiffworkflow-backend/.github/workflows/constraints.txt b/spiffworkflow-backend/.github/workflows/constraints.txt index 70c8f365..7ccc8711 100644 --- a/spiffworkflow-backend/.github/workflows/constraints.txt +++ b/spiffworkflow-backend/.github/workflows/constraints.txt @@ -1,5 +1,5 @@ pip==22.2.2 -nox==2022.8.7 -nox-poetry==1.0.1 +nox==2022.11.21 +nox-poetry==1.0.2 poetry==1.2.2 virtualenv==20.16.5 diff --git a/spiffworkflow-backend/migrations/versions/ff1c1628337c_.py b/spiffworkflow-backend/migrations/versions/40a2ed63cc5a_.py similarity index 98% rename from spiffworkflow-backend/migrations/versions/ff1c1628337c_.py rename to spiffworkflow-backend/migrations/versions/40a2ed63cc5a_.py index d8da6d3c..6abd6b4a 100644 --- a/spiffworkflow-backend/migrations/versions/ff1c1628337c_.py +++ b/spiffworkflow-backend/migrations/versions/40a2ed63cc5a_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: ff1c1628337c +Revision ID: 40a2ed63cc5a Revises: -Create Date: 2022-11-28 15:08:52.014254 +Create Date: 2022-11-29 16:59:02.980181 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'ff1c1628337c' +revision = '40a2ed63cc5a' down_revision = None branch_labels = None depends_on = None @@ -249,6 +249,7 @@ def upgrade(): sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('process_instance_id', 'key', name='process_instance_metadata_unique') ) + op.create_index(op.f('ix_process_instance_metadata_key'), 'process_instance_metadata', ['key'], unique=False) op.create_table('spiff_step_details', sa.Column('id', sa.Integer(), nullable=False), sa.Column('process_instance_id', sa.Integer(), nullable=False), @@ -295,6 +296,7 @@ def downgrade(): op.drop_index(op.f('ix_active_task_user_active_task_id'), table_name='active_task_user') op.drop_table('active_task_user') op.drop_table('spiff_step_details') + op.drop_index(op.f('ix_process_instance_metadata_key'), table_name='process_instance_metadata') op.drop_table('process_instance_metadata') op.drop_table('permission_assignment') op.drop_table('message_instance') diff --git a/spiffworkflow-backend/poetry.lock b/spiffworkflow-backend/poetry.lock index 85d0207f..a23004b4 100644 --- a/spiffworkflow-backend/poetry.lock +++ b/spiffworkflow-backend/poetry.lock @@ -1851,7 +1851,7 @@ lxml = "*" type = "git" url = "https://github.com/sartography/SpiffWorkflow" reference = "main" -resolved_reference = "062eaf15d28c66f8cf07f68409429560251b12c7" +resolved_reference = "ffb1686757f944065580dd2db8def73d6c1f0134" [[package]] name = "SQLAlchemy" @@ -2989,7 +2989,18 @@ psycopg2 = [ {file = "psycopg2-2.9.4.tar.gz", hash = "sha256:d529926254e093a1b669f692a3aa50069bc71faf5b0ecd91686a78f62767d52f"}, ] pyasn1 = [ + {file = "pyasn1-0.4.8-py2.4.egg", hash = "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3"}, + {file = "pyasn1-0.4.8-py2.5.egg", hash = "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf"}, + {file = "pyasn1-0.4.8-py2.6.egg", hash = "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00"}, + {file = "pyasn1-0.4.8-py2.7.egg", hash = "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8"}, {file = "pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d"}, + {file = "pyasn1-0.4.8-py3.1.egg", hash = "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86"}, + {file = "pyasn1-0.4.8-py3.2.egg", hash = "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7"}, + {file = "pyasn1-0.4.8-py3.3.egg", hash = "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576"}, + {file = "pyasn1-0.4.8-py3.4.egg", hash = "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12"}, + {file = "pyasn1-0.4.8-py3.5.egg", hash = "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2"}, + {file = "pyasn1-0.4.8-py3.6.egg", hash = "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359"}, + {file = "pyasn1-0.4.8-py3.7.egg", hash = "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776"}, {file = "pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba"}, ] pycodestyle = [ diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index d979c662..ea3e6998 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -338,9 +338,9 @@ paths: schema: $ref: "#/components/schemas/ProcessModel" - /process-models/{modified_process_model_id}/files: + /process-models/{modified_process_model_identifier}/files: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The process_model_id, modified to replace slashes (/) @@ -570,6 +570,12 @@ paths: description: Specifies the identifier of a report to use, if any schema: type: string + - name: report_id + in: query + required: false + description: Specifies the identifier of a report to use, if any + schema: + type: integer get: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_list summary: Returns a list of process instances for a given process model @@ -585,33 +591,6 @@ paths: items: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/task/{task_id}/update: - parameters: - - name: process_instance_id - in: path - required: true - description: The unique id of the process instance - schema: - type: string - - name: task_id - in: path - required: true - description: The unique id of the task - schema: - type: string - post: - operationId: spiffworkflow_backend.routes.process_api_blueprint.update_task_data - summary: Update the task data for requested instance and task - tags: - - Process Instances - responses: - "200": - description: Task Updated Successfully - content: - application/json: - schema: - $ref: "#/components/schemas/Workflow" - /process-models/{process_group_id}/{process_model_id}/script-unit-tests: parameters: - name: process_group_id @@ -666,15 +645,14 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-models/{modified_process_model_id}/process-instances: + /process-instances/{modified_process_model_identifier}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The unique id of an existing process model. schema: type: string - # process_instance_create post: operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_create summary: Creates an process instance from a process model and returns the instance @@ -688,28 +666,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}: - parameters: - - name: process_instance_id - in: path - required: true - description: The unique id of an existing process instance. - schema: - type: integer - delete: - operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_delete - summary: Deletes a single process instance - tags: - - Process Instances - responses: - "200": - description: The process instance was deleted. - content: - application/json: - schema: - $ref: "#/components/schemas/OkTrue" - - /process-models/{modified_process_model_identifier}/process-instances/{process_instance_id}: + /process-instances/{modified_process_model_identifier}/{process_instance_id}: parameters: - name: modified_process_model_identifier in: path @@ -735,6 +692,18 @@ paths: application/json: schema: $ref: "#/components/schemas/Workflow" + delete: + operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_delete + summary: Deletes a single process instance + tags: + - Process Instances + responses: + "200": + description: The process instance was deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" /process-instances/{modified_process_model_identifier}/{process_instance_id}/run: parameters: @@ -763,7 +732,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/terminate: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/terminate: parameters: - name: process_instance_id in: path @@ -784,7 +753,7 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/{process_instance_id}/suspend: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/suspend: parameters: - name: process_instance_id in: path @@ -805,7 +774,7 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/{process_instance_id}/resume: + /process-instances/{modified_process_model_identifier}/{process_instance_id}/resume: parameters: - name: process_instance_id in: path @@ -867,14 +836,30 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-instances/reports/{report_identifier}: + /process-instances/reports/columns: + get: + operationId: spiffworkflow_backend.routes.process_api_blueprint.process_instance_report_column_list + summary: Returns all available columns for a process instance report. + tags: + - Process Instances + responses: + "200": + description: Workflow. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Workflow" + + /process-instances/reports/{report_id}: parameters: - - name: report_identifier + - name: report_id in: path required: true description: The unique id of an existing report schema: - type: string + type: integer - name: page in: query required: false @@ -926,9 +911,9 @@ paths: schema: $ref: "#/components/schemas/OkTrue" - /process-models/{modified_process_model_id}/files/{file_name}: + /process-models/{modified_process_model_identifier}/files/{file_name}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The modified process model id @@ -1105,9 +1090,9 @@ paths: items: $ref: "#/components/schemas/Task" - /process-instances/{modified_process_model_id}/{process_instance_id}/tasks: + /task-data/{modified_process_model_identifier}/{process_instance_id}: parameters: - - name: modified_process_model_id + - name: modified_process_model_identifier in: path required: true description: The modified id of an existing process model @@ -1146,11 +1131,44 @@ paths: items: $ref: "#/components/schemas/Task" - /service_tasks: + /task-data/{modified_process_model_identifier}/{process_instance_id}/{task_id}: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: The modified id of an existing process model + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + - name: task_id + in: path + required: true + description: The unique id of the task. + schema: + type: string + put: + operationId: spiffworkflow_backend.routes.process_api_blueprint.update_task_data + summary: Update the task data for requested instance and task + tags: + - Process Instances + responses: + "200": + description: Task Updated Successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Workflow" + + /service-tasks: get: tags: - Service Tasks - operationId: spiffworkflow_backend.routes.process_api_blueprint.service_tasks_show + operationId: spiffworkflow_backend.routes.process_api_blueprint.service_task_list summary: Gets all available service task connectors responses: "200": @@ -1330,7 +1348,7 @@ paths: schema: $ref: "#/components/schemas/Workflow" - /process-instances/{process_instance_id}/logs: + /logs/{modified_process_model_identifier}/{process_instance_id}: parameters: - name: process_instance_id in: path @@ -1350,6 +1368,12 @@ paths: description: The number of items to show per page. Defaults to page 10. schema: type: integer + - name: detailed + in: query + required: false + description: Show the detailed view, which includes all log entries + schema: + type: boolean get: tags: - Process Instances diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml index e17e3f11..4c748fd9 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/development.yml @@ -12,7 +12,6 @@ groups: mike, jason, j, - amir, jarrad, elizabeth, jon, @@ -70,6 +69,12 @@ permissions: users: [] allowed_permissions: [create, read, update, delete] uri: /v1.0/tasks/* + service-tasks: + groups: [everybody] + users: [] + allowed_permissions: [read] + uri: /v1.0/service-tasks + # read all for everybody read-all-process-groups: @@ -98,6 +103,12 @@ permissions: allowed_permissions: [read] uri: /v1.0/processes + task-data-read: + groups: [demo] + users: [] + allowed_permissions: [read] + uri: /v1.0/task-data/* + manage-procurement-admin: groups: ["Project Lead"] @@ -170,17 +181,17 @@ permissions: uri: /v1.0/process-instances/manage-procurement:vendor-lifecycle-management:* core1-admin-models-instantiate: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create] uri: /v1.0/process-models/misc:category_number_one:process-model-with-form/process-instances core1-admin-instances: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create, read] uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form:* core1-admin-instances-slash: - groups: ["core-contributor"] + groups: ["core-contributor", "Finance Team"] users: [] allowed_permissions: [create, read] uri: /v1.0/process-instances/misc:category_number_one:process-model-with-form/* diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml index e60946b3..ce2e2dba 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/config/permissions/terraform_deployed_environment.yml @@ -12,7 +12,6 @@ groups: mike, jason, j, - amir, jarrad, elizabeth, jon, @@ -98,6 +97,12 @@ permissions: allowed_permissions: [read] uri: /v1.0/processes + task-data-read: + groups: [demo] + users: [] + allowed_permissions: [read] + uri: /v1.0/task-data/* + manage-procurement-admin: groups: ["Project Lead"] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py index 5a4d4ca5..c9003594 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_metadata.py @@ -23,7 +23,7 @@ class ProcessInstanceMetadataModel(SpiffworkflowBaseDBModel): process_instance_id: int = db.Column( ForeignKey(ProcessInstanceModel.id), nullable=False # type: ignore ) - key: str = db.Column(db.String(255), nullable=False) + key: str = db.Column(db.String(255), nullable=False, index=True) value: str = db.Column(db.String(255), nullable=False) updated_at_in_seconds: int = db.Column(db.Integer, nullable=False) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py index 5cccf4a5..1f22a383 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_report.py @@ -26,6 +26,10 @@ from spiffworkflow_backend.services.process_instance_processor import ( ReportMetadata = dict[str, Any] +class ProcessInstanceReportAlreadyExistsError(Exception): + """ProcessInstanceReportAlreadyExistsError.""" + + class ProcessInstanceReportResult(TypedDict): """ProcessInstanceReportResult.""" @@ -63,7 +67,7 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): ), ) - id = db.Column(db.Integer, primary_key=True) + id: int = db.Column(db.Integer, primary_key=True) identifier: str = db.Column(db.String(50), nullable=False, index=True) report_metadata: dict = deferred(db.Column(db.JSON)) # type: ignore created_by_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) @@ -71,6 +75,11 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): created_at_in_seconds = db.Column(db.Integer) updated_at_in_seconds = db.Column(db.Integer) + @classmethod + def default_order_by(cls) -> list[str]: + """Default_order_by.""" + return ["-start_in_seconds", "-id"] + @classmethod def add_fixtures(cls) -> None: """Add_fixtures.""" @@ -120,21 +129,27 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): identifier: str, user: UserModel, report_metadata: ReportMetadata, - ) -> None: + ) -> ProcessInstanceReportModel: """Make_fixture_report.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( identifier=identifier, created_by_id=user.id, ).first() - if process_instance_report is None: - process_instance_report = cls( - identifier=identifier, - created_by_id=user.id, - report_metadata=report_metadata, + if process_instance_report is not None: + raise ProcessInstanceReportAlreadyExistsError( + f"Process instance report with identifier already exists: {identifier}" ) - db.session.add(process_instance_report) - db.session.commit() + + process_instance_report = cls( + identifier=identifier, + created_by_id=user.id, + report_metadata=report_metadata, + ) + db.session.add(process_instance_report) + db.session.commit() + + return process_instance_report # type: ignore @classmethod def ticket_for_month_report(cls) -> dict: @@ -204,18 +219,8 @@ class ProcessInstanceReportModel(SpiffworkflowBaseDBModel): user: UserModel, ) -> ProcessInstanceReportModel: """Create_with_attributes.""" - # <<<<<<< HEAD - # process_model = ProcessModelService.get_process_model( - # process_model_id=f"{process_model_identifier}" - # ) - # process_instance_report = cls( - # identifier=identifier, - # process_group_identifier="process_model.process_group_id", - # process_model_identifier=process_model.id, - # ======= process_instance_report = cls( identifier=identifier, - # >>>>>>> main created_by_id=user.id, report_metadata=report_metadata, ) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py index 4f5ee2ad..e8d5eed1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model.py @@ -38,6 +38,7 @@ class ProcessModelInfo: fault_or_suspend_on_exception: str = NotificationType.fault.value exception_notification_addresses: list[str] = field(default_factory=list) parent_groups: list[dict] | None = None + metadata_extraction_paths: list[dict[str, str]] | None = None def __post_init__(self) -> None: """__post_init__.""" @@ -76,6 +77,13 @@ class ProcessModelInfoSchema(Schema): exception_notification_addresses = marshmallow.fields.List( marshmallow.fields.String ) + metadata_extraction_paths = marshmallow.fields.List( + marshmallow.fields.Dict( + keys=marshmallow.fields.Str(required=False), + values=marshmallow.fields.Str(required=False), + required=False, + ) + ) @post_load def make_spec( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py index e82d1247..cdd2ec30 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -1,6 +1,7 @@ """APIs for dealing with process groups, process models, and process instances.""" import json import random +import re import string import uuid from typing import Any @@ -30,6 +31,8 @@ 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 sqlalchemy.orm import joinedload from spiffworkflow_backend.exceptions.process_entity_not_found_error import ( @@ -52,6 +55,9 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceApiSche from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModelSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -152,9 +158,9 @@ def modify_process_model_id(process_model_id: str) -> str: return process_model_id.replace("/", ":") -def un_modify_modified_process_model_id(modified_process_model_id: str) -> str: +def un_modify_modified_process_model_id(modified_process_model_identifier: str) -> str: """Un_modify_modified_process_model_id.""" - return modified_process_model_id.replace(":", "/") + return modified_process_model_identifier.replace(":", "/") def process_group_add(body: dict) -> flask.wrappers.Response: @@ -256,19 +262,26 @@ def process_model_create( modified_process_group_id: str, body: Dict[str, Union[str, bool, int]] ) -> flask.wrappers.Response: """Process_model_create.""" - process_model_info = ProcessModelInfoSchema().load(body) + body_include_list = [ + "id", + "display_name", + "primary_file_name", + "primary_process_id", + "description", + "metadata_extraction_paths", + ] + body_filtered = { + include_item: body[include_item] + for include_item in body_include_list + if include_item in body + } + if modified_process_group_id is None: raise ApiError( error_code="process_group_id_not_specified", message="Process Model could not be created when process_group_id path param is unspecified", status_code=400, ) - if process_model_info is None: - raise ApiError( - error_code="process_model_could_not_be_created", - message=f"Process Model could not be created from given body: {body}", - status_code=400, - ) unmodified_process_group_id = un_modify_modified_process_model_id( modified_process_group_id @@ -281,6 +294,14 @@ def process_model_create( status_code=400, ) + process_model_info = ProcessModelInfo(**body_filtered) # type: ignore + if process_model_info is None: + raise ApiError( + error_code="process_model_could_not_be_created", + message=f"Process Model could not be created from given body: {body}", + status_code=400, + ) + ProcessModelService.add_process_model(process_model_info) return Response( json.dumps(ProcessModelInfoSchema().dump(process_model_info)), @@ -294,7 +315,6 @@ def process_model_delete( ) -> flask.wrappers.Response: """Process_model_delete.""" process_model_identifier = modified_process_model_identifier.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" ProcessModelService().process_model_delete(process_model_identifier) return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") @@ -309,6 +329,7 @@ def process_model_update( "primary_file_name", "primary_process_id", "description", + "metadata_extraction_paths", ] body_filtered = { include_item: body[include_item] @@ -316,7 +337,6 @@ def process_model_update( if include_item in body } - # process_model_identifier = f"{process_group_id}/{process_model_id}" process_model = get_process_model(process_model_identifier) ProcessModelService.update_process_model(process_model, body_filtered) return ProcessModelInfoSchema().dump(process_model) @@ -325,10 +345,7 @@ def process_model_update( def process_model_show(modified_process_model_identifier: str) -> Any: """Process_model_show.""" process_model_identifier = modified_process_model_identifier.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" process_model = get_process_model(process_model_identifier) - # TODO: Temporary. Should not need the next line once models have correct ids - # process_model.id = process_model_identifier files = sorted(SpecFileService.get_files(process_model)) process_model.files = files for file in process_model.files: @@ -405,9 +422,9 @@ def process_list() -> Any: return SpecReferenceSchema(many=True).dump(references) -def get_file(modified_process_model_id: str, file_name: str) -> Any: +def get_file(modified_process_model_identifier: str, file_name: str) -> Any: """Get_file.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) files = SpecFileService.get_files(process_model, file_name) if len(files) == 0: @@ -427,11 +444,10 @@ def get_file(modified_process_model_id: str, file_name: str) -> Any: def process_model_file_update( - modified_process_model_id: str, file_name: str + modified_process_model_identifier: str, file_name: str ) -> flask.wrappers.Response: """Process_model_file_update.""" - process_model_identifier = modified_process_model_id.replace(":", "/") - # process_model_identifier = f"{process_group_id}/{process_model_id}" + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) request_file = get_file_from_request() @@ -457,10 +473,10 @@ def process_model_file_update( def process_model_file_delete( - modified_process_model_id: str, file_name: str + modified_process_model_identifier: str, file_name: str ) -> flask.wrappers.Response: """Process_model_file_delete.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) try: SpecFileService.delete_file(process_model, file_name) @@ -476,9 +492,9 @@ def process_model_file_delete( return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") -def add_file(modified_process_model_id: str) -> flask.wrappers.Response: +def add_file(modified_process_model_identifier: str) -> flask.wrappers.Response: """Add_file.""" - process_model_identifier = modified_process_model_id.replace(":", "/") + process_model_identifier = modified_process_model_identifier.replace(":", "/") process_model = get_process_model(process_model_identifier) request_file = get_file_from_request() if not request_file.filename: @@ -499,10 +515,12 @@ def add_file(modified_process_model_id: str) -> flask.wrappers.Response: ) -def process_instance_create(modified_process_model_id: str) -> flask.wrappers.Response: +def process_instance_create( + modified_process_model_identifier: str, +) -> flask.wrappers.Response: """Create_process_instance.""" process_model_identifier = un_modify_modified_process_model_id( - modified_process_model_id + modified_process_model_identifier ) process_instance = ( ProcessInstanceService.create_process_instance_from_process_model_identifier( @@ -560,6 +578,7 @@ def process_instance_run( def process_instance_terminate( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_run.""" process_instance = ProcessInstanceService().get_process_instance( @@ -572,6 +591,7 @@ def process_instance_terminate( def process_instance_suspend( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_suspend.""" process_instance = ProcessInstanceService().get_process_instance( @@ -584,6 +604,7 @@ def process_instance_suspend( def process_instance_resume( process_instance_id: int, + modified_process_model_identifier: str, ) -> flask.wrappers.Response: """Process_instance_resume.""" process_instance = ProcessInstanceService().get_process_instance( @@ -595,19 +616,24 @@ def process_instance_resume( def process_instance_log_list( + modified_process_model_identifier: str, process_instance_id: int, page: int = 1, per_page: int = 100, + detailed: bool = False, ) -> flask.wrappers.Response: """Process_instance_log_list.""" # to make sure the process instance exists process_instance = find_process_instance_by_id_or_raise(process_instance_id) + log_query = SpiffLoggingModel.query.filter( + SpiffLoggingModel.process_instance_id == process_instance.id + ) + if not detailed: + log_query = log_query.filter(SpiffLoggingModel.message.in_(["State change to COMPLETED"])) # type: ignore + logs = ( - SpiffLoggingModel.query.filter( - SpiffLoggingModel.process_instance_id == process_instance.id - ) - .order_by(SpiffLoggingModel.timestamp.desc()) # type: ignore + log_query.order_by(SpiffLoggingModel.timestamp.desc()) # type: ignore .join( UserModel, UserModel.id == SpiffLoggingModel.current_user_id, isouter=True ) # isouter since if we don't have a user, we still want the log @@ -653,6 +679,7 @@ def message_instance_list( .add_columns( MessageModel.identifier.label("message_identifier"), ProcessInstanceModel.process_model_identifier, + ProcessInstanceModel.process_model_display_name, ) .paginate(page=page, per_page=per_page, error_out=False) ) @@ -787,10 +814,11 @@ def process_instance_list( with_tasks_completed_by_my_group: Optional[bool] = None, user_filter: Optional[bool] = False, report_identifier: Optional[str] = None, + report_id: Optional[int] = None, ) -> flask.wrappers.Response: """Process_instance_list.""" process_instance_report = ProcessInstanceReportService.report_with_identifier( - g.user, report_identifier + g.user, report_id, report_identifier ) if user_filter: @@ -821,7 +849,6 @@ def process_instance_list( ) ) - # process_model_identifier = un_modify_modified_process_model_id(modified_process_model_identifier) process_instance_query = ProcessInstanceModel.query # Always join that hot user table for good performance at serialization time. process_instance_query = process_instance_query.options( @@ -939,25 +966,78 @@ def process_instance_list( UserGroupAssignmentModel.user_id == g.user.id ) + instance_metadata_aliases = {} + stock_columns = ProcessInstanceReportService.get_column_names_for_model( + ProcessInstanceModel + ) + for column in process_instance_report.report_metadata["columns"]: + if column["accessor"] in stock_columns: + continue + instance_metadata_alias = aliased(ProcessInstanceMetadataModel) + instance_metadata_aliases[column["accessor"]] = instance_metadata_alias + + filter_for_column = None + if "filter_by" in process_instance_report.report_metadata: + filter_for_column = next( + ( + f + for f in process_instance_report.report_metadata["filter_by"] + if f["field_name"] == column["accessor"] + ), + None, + ) + isouter = True + conditions = [ + ProcessInstanceModel.id == instance_metadata_alias.process_instance_id, + instance_metadata_alias.key == column["accessor"], + ] + if filter_for_column: + isouter = False + conditions.append( + instance_metadata_alias.value == filter_for_column["field_value"] + ) + process_instance_query = process_instance_query.join( + instance_metadata_alias, and_(*conditions), isouter=isouter + ).add_columns(func.max(instance_metadata_alias.value).label(column["accessor"])) + + order_by_query_array = [] + order_by_array = process_instance_report.report_metadata["order_by"] + if len(order_by_array) < 1: + order_by_array = ProcessInstanceReportModel.default_order_by() + for order_by_option in order_by_array: + attribute = re.sub("^-", "", order_by_option) + if attribute in stock_columns: + if order_by_option.startswith("-"): + order_by_query_array.append( + getattr(ProcessInstanceModel, attribute).desc() + ) + else: + order_by_query_array.append( + getattr(ProcessInstanceModel, attribute).asc() + ) + elif attribute in instance_metadata_aliases: + if order_by_option.startswith("-"): + order_by_query_array.append( + instance_metadata_aliases[attribute].value.desc() + ) + else: + order_by_query_array.append( + instance_metadata_aliases[attribute].value.asc() + ) + process_instances = ( process_instance_query.group_by(ProcessInstanceModel.id) - .order_by( - ProcessInstanceModel.start_in_seconds.desc(), ProcessInstanceModel.id.desc() # type: ignore - ) + .add_columns(ProcessInstanceModel.id) + .order_by(*order_by_query_array) .paginate(page=page, per_page=per_page, error_out=False) ) - results = list( - map( - ProcessInstanceService.serialize_flat_with_task_data, - process_instances.items, - ) + results = ProcessInstanceReportService.add_metadata_columns_to_process_instance( + process_instances.items, process_instance_report.report_metadata["columns"] ) - report_metadata = process_instance_report.report_metadata response_json = { - "report_identifier": process_instance_report.identifier, - "report_metadata": report_metadata, + "report": process_instance_report, "results": results, "filters": report_filter.to_dict(), "pagination": { @@ -970,6 +1050,22 @@ def process_instance_list( return make_response(jsonify(response_json), 200) +def process_instance_report_column_list() -> flask.wrappers.Response: + """Process_instance_report_column_list.""" + table_columns = ProcessInstanceReportService.builtin_column_options() + columns_for_metadata = ( + db.session.query(ProcessInstanceMetadataModel.key) + .order_by(ProcessInstanceMetadataModel.key) + .distinct() # type: ignore + .all() + ) + columns_for_metadata_strings = [ + {"Header": i[0], "accessor": i[0], "filterable": True} + for i in columns_for_metadata + ] + return make_response(jsonify(table_columns + columns_for_metadata_strings), 200) + + def process_instance_show( modified_process_model_identifier: str, process_instance_id: int ) -> flask.wrappers.Response: @@ -996,7 +1092,9 @@ def process_instance_show( return make_response(jsonify(process_instance), 200) -def process_instance_delete(process_instance_id: int) -> flask.wrappers.Response: +def process_instance_delete( + process_instance_id: int, modified_process_model_identifier: str +) -> flask.wrappers.Response: """Create_process_instance.""" process_instance = find_process_instance_by_id_or_raise(process_instance_id) @@ -1026,22 +1124,22 @@ def process_instance_report_list( def process_instance_report_create(body: Dict[str, Any]) -> flask.wrappers.Response: """Process_instance_report_create.""" - ProcessInstanceReportModel.create_report( + process_instance_report = ProcessInstanceReportModel.create_report( identifier=body["identifier"], user=g.user, report_metadata=body["report_metadata"], ) - return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + return make_response(jsonify(process_instance_report), 201) def process_instance_report_update( - report_identifier: str, + report_id: int, body: Dict[str, Any], ) -> flask.wrappers.Response: """Process_instance_report_create.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1054,15 +1152,15 @@ def process_instance_report_update( process_instance_report.report_metadata = body["report_metadata"] db.session.commit() - return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + return make_response(jsonify(process_instance_report), 201) def process_instance_report_delete( - report_identifier: str, + report_id: int, ) -> flask.wrappers.Response: """Process_instance_report_create.""" process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1078,11 +1176,9 @@ def process_instance_report_delete( return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") -def service_tasks_show() -> flask.wrappers.Response: - """Service_tasks_show.""" +def service_task_list() -> flask.wrappers.Response: + """Service_task_list.""" available_connectors = ServiceTaskService.available_connectors() - print(available_connectors) - return Response( json.dumps(available_connectors), status=200, mimetype="application/json" ) @@ -1116,19 +1212,17 @@ def authentication_callback( def process_instance_report_show( - report_identifier: str, + report_id: int, page: int = 1, per_page: int = 100, ) -> flask.wrappers.Response: - """Process_instance_list.""" - process_instances = ProcessInstanceModel.query.order_by( # .filter_by(process_model_identifier=process_model.id) + """Process_instance_report_show.""" + process_instances = ProcessInstanceModel.query.order_by( ProcessInstanceModel.start_in_seconds.desc(), ProcessInstanceModel.id.desc() # type: ignore - ).paginate( - page=page, per_page=per_page, error_out=False - ) + ).paginate(page=page, per_page=per_page, error_out=False) process_instance_report = ProcessInstanceReportModel.query.filter_by( - identifier=report_identifier, + id=report_id, created_by_id=g.user.id, ).first() if process_instance_report is None: @@ -1290,7 +1384,7 @@ def get_tasks( def process_instance_task_list( - modified_process_model_id: str, + modified_process_model_identifier: str, process_instance_id: int, all_tasks: bool = False, spiff_step: int = 0, @@ -1405,9 +1499,6 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response task.form_ui_schema = ui_form_contents if task.properties and task.data and "instructionsForEndUser" in task.properties: - print( - f"task.properties['instructionsForEndUser']: {task.properties['instructionsForEndUser']}" - ) if task.properties["instructionsForEndUser"]: task.properties["instructionsForEndUser"] = render_jinja_template( task.properties["instructionsForEndUser"], task.data @@ -1854,7 +1945,12 @@ def _update_form_schema_with_task_data_as_needed( _update_form_schema_with_task_data_as_needed(o, task_data) -def update_task_data(process_instance_id: str, task_id: str, body: Dict) -> Response: +def update_task_data( + process_instance_id: str, + modified_process_model_identifier: str, + task_id: str, + body: Dict, +) -> Response: """Update task data.""" process_instance = ProcessInstanceModel.query.filter( ProcessInstanceModel.id == int(process_instance_id) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py index ae5fe00e..d9c1959a 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/save_process_instance_metadata.py @@ -1,4 +1,4 @@ -"""Get_env.""" +"""Save process instance metadata.""" from typing import Any from flask_bpmn.models.db import db diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py index 18f08d0f..3868adf6 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authentication_service.py @@ -235,8 +235,9 @@ class AuthenticationService: refresh_token_object: RefreshTokenModel = RefreshTokenModel.query.filter( RefreshTokenModel.user_id == user_id ).first() - assert refresh_token_object # noqa: S101 - return refresh_token_object.token + if refresh_token_object: + return refresh_token_object.token + return None @classmethod def get_auth_token_from_refresh_token(cls, refresh_token: str) -> dict: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index d1df6742..bdf71740 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -81,6 +81,9 @@ from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_instance import MessageModel from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.script_attributes_context import ( ScriptAttributesContext, @@ -178,7 +181,12 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore ) return Script.generate_augmented_list(script_attributes_context) - def evaluate(self, task: SpiffTask, expression: str, external_methods=None) -> Any: + def evaluate( + self, + task: SpiffTask, + expression: str, + external_methods: Optional[dict[str, Any]] = None, + ) -> Any: """Evaluate.""" return self._evaluate(expression, task.data, task, external_methods) @@ -571,6 +579,36 @@ class ProcessInstanceProcessor: db.session.add(details_model) db.session.commit() + def extract_metadata(self, process_model_info: ProcessModelInfo) -> None: + """Extract_metadata.""" + metadata_extraction_paths = process_model_info.metadata_extraction_paths + if metadata_extraction_paths is None: + return + if len(metadata_extraction_paths) <= 0: + return + + current_data = self.get_current_data() + for metadata_extraction_path in metadata_extraction_paths: + key = metadata_extraction_path["key"] + path = metadata_extraction_path["path"] + path_segments = path.split(".") + data_for_key = current_data + for path_segment in path_segments: + data_for_key = data_for_key[path_segment] + + pim = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=self.process_instance_model.id, + key=key, + ).first() + if pim is None: + pim = ProcessInstanceMetadataModel( + process_instance_id=self.process_instance_model.id, + key=key, + ) + pim.value = data_for_key + db.session.add(pim) + db.session.commit() + def save(self) -> None: """Saves the current state of this processor to the database.""" self.process_instance_model.bpmn_json = self.serialize() @@ -597,6 +635,15 @@ class ProcessInstanceProcessor: process_instance_id=self.process_instance_model.id ).all() ready_or_waiting_tasks = self.get_all_ready_or_waiting_tasks() + process_model_display_name = "" + process_model_info = self.process_model_service.get_process_model( + self.process_instance_model.process_model_identifier + ) + if process_model_info is not None: + process_model_display_name = process_model_info.display_name + + self.extract_metadata(process_model_info) + for ready_or_waiting_task in ready_or_waiting_tasks: # filter out non-usertasks task_spec = ready_or_waiting_task.task_spec @@ -615,13 +662,6 @@ class ProcessInstanceProcessor: if "formUiSchemaFilename" in properties: ui_form_file_name = properties["formUiSchemaFilename"] - process_model_display_name = "" - process_model_info = self.process_model_service.get_process_model( - self.process_instance_model.process_model_identifier - ) - if process_model_info is not None: - process_model_display_name = process_model_info.display_name - active_task = None for at in active_tasks: if at.task_id == str(ready_or_waiting_task.id): @@ -1146,8 +1186,8 @@ class ProcessInstanceProcessor: def get_current_data(self) -> dict[str, Any]: """Get the current data for the process. - Return either most recent task data or the process data - if the process instance is complete + Return either the most recent task data or--if the process instance is complete-- + the process data. """ if self.process_instance_model.status == "complete": return self.get_data() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py index fc5a93da..84d5d675 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_report_service.py @@ -2,6 +2,9 @@ from dataclasses import dataclass from typing import Optional +import sqlalchemy +from flask_bpmn.models.db import db + from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -57,12 +60,21 @@ class ProcessInstanceReportService: @classmethod def report_with_identifier( - cls, user: UserModel, report_identifier: Optional[str] = None + cls, + user: UserModel, + report_id: Optional[int] = None, + report_identifier: Optional[str] = None, ) -> ProcessInstanceReportModel: """Report_with_filter.""" + if report_id is not None: + process_instance_report = ProcessInstanceReportModel.query.filter_by( + id=report_id, created_by_id=user.id + ).first() + if process_instance_report is not None: + return process_instance_report # type: ignore + if report_identifier is None: report_identifier = "default" - process_instance_report = ProcessInstanceReportModel.query.filter_by( identifier=report_identifier, created_by_id=user.id ).first() @@ -73,17 +85,9 @@ class ProcessInstanceReportService: # TODO replace with system reports that are loaded on launch (or similar) temp_system_metadata_map = { "default": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), + "filter_by": [], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_initiated_by_me": { "columns": [ @@ -97,48 +101,31 @@ class ProcessInstanceReportService: {"Header": "status", "accessor": "status"}, ], "filter_by": [{"field_name": "initiated_by_me", "field_value": True}], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_with_tasks_completed_by_me": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), "filter_by": [ {"field_name": "with_tasks_completed_by_me", "field_value": True} ], + "order_by": ["-start_in_seconds", "-id"], }, "system_report_instances_with_tasks_completed_by_my_groups": { - "columns": [ - {"Header": "id", "accessor": "id"}, - { - "Header": "process_model_display_name", - "accessor": "process_model_display_name", - }, - {"Header": "start_in_seconds", "accessor": "start_in_seconds"}, - {"Header": "end_in_seconds", "accessor": "end_in_seconds"}, - {"Header": "username", "accessor": "username"}, - {"Header": "status", "accessor": "status"}, - ], + "columns": cls.builtin_column_options(), "filter_by": [ { "field_name": "with_tasks_completed_by_my_group", "field_value": True, } ], + "order_by": ["-start_in_seconds", "-id"], }, } process_instance_report = ProcessInstanceReportModel( identifier=report_identifier, created_by_id=user.id, - report_metadata=temp_system_metadata_map[report_identifier], # type: ignore + report_metadata=temp_system_metadata_map[report_identifier], ) return process_instance_report # type: ignore @@ -241,3 +228,43 @@ class ProcessInstanceReportService: ) return report_filter + + @classmethod + def add_metadata_columns_to_process_instance( + cls, + process_instance_sqlalchemy_rows: list[sqlalchemy.engine.row.Row], # type: ignore + metadata_columns: list[dict], + ) -> list[dict]: + """Add_metadata_columns_to_process_instance.""" + results = [] + for process_instance in process_instance_sqlalchemy_rows: + process_instance_dict = process_instance["ProcessInstanceModel"].serialized + for metadata_column in metadata_columns: + if metadata_column["accessor"] not in process_instance_dict: + process_instance_dict[ + metadata_column["accessor"] + ] = process_instance[metadata_column["accessor"]] + + results.append(process_instance_dict) + return results + + @classmethod + def get_column_names_for_model(cls, model: db.Model) -> list[str]: # type: ignore + """Get_column_names_for_model.""" + return [i.name for i in model.__table__.columns] + + @classmethod + def builtin_column_options(cls) -> list[dict]: + """Builtin_column_options.""" + return [ + {"Header": "Id", "accessor": "id", "filterable": False}, + { + "Header": "Process", + "accessor": "process_model_display_name", + "filterable": False, + }, + {"Header": "Start", "accessor": "start_in_seconds", "filterable": False}, + {"Header": "End", "accessor": "end_in_seconds", "filterable": False}, + {"Header": "Username", "accessor": "username", "filterable": False}, + {"Header": "Status", "accessor": "status", "filterable": False}, + ] diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index f98eaae1..46bd252b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -322,18 +322,3 @@ class ProcessInstanceService: ) return task - - @staticmethod - def serialize_flat_with_task_data( - process_instance: ProcessInstanceModel, - ) -> dict[str, Any]: - """NOTE: This is crazy slow. Put the latest task data in the database.""" - """Serialize_flat_with_task_data.""" - # results = {} - # try: - # processor = ProcessInstanceProcessor(process_instance) - # process_instance.data = processor.get_current_data() - # results = process_instance.serialized_flat - # except ApiError: - results = process_instance.serialized - return results diff --git a/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn b/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn index 1e5bc853..4be5adba 100644 --- a/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn +++ b/spiffworkflow-backend/tests/data/hello_world/hello_world.bpmn @@ -19,7 +19,11 @@ Flow_0bazl8x Flow_1mcaszp - a = 1 + a = 1 +b = 2 +outer = {} +outer["inner"] = 'sweet1' + Flow_1mcaszp diff --git a/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn b/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn new file mode 100644 index 00000000..7452216a --- /dev/null +++ b/spiffworkflow-backend/tests/data/nested-task-data-structure/nested-task-data-structure.bpmn @@ -0,0 +1,56 @@ + + + + + Flow_1ohrjz9 + + + + Flow_1flxgry + + + + Flow_1ohrjz9 + Flow_18gs4jt + outer = {} +invoice_number = 123 +outer["inner"] = 'sweet1' +outer['time'] = time.time_ns() + + + + Flow_18gs4jt + Flow_1flxgry + outer["inner"] = 'sweet2' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 8d56853b..48982fc6 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -265,7 +265,7 @@ class BaseTest: ) modified_process_model_id = test_process_model_id.replace("/", ":") response = client.post( - f"/v1.0/process-models/{modified_process_model_id}/process-instances", + f"/v1.0/process-instances/{modified_process_model_id}", headers=headers, ) assert response.status_code == 201 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py index 2f56d1d6..f9dd4452 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_logging_service.py @@ -57,7 +57,7 @@ class TestLoggingService(BaseTest): assert response.status_code == 200 log_response = client.get( - f"/v1.0/process-instances/{process_instance_id}/logs", + f"/v1.0/logs/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}", headers=headers, ) assert log_response.status_code == 200 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index 054be4ed..8f993302 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -20,6 +20,9 @@ from spiffworkflow_backend.models.group import GroupModel from spiffworkflow_backend.models.process_group import ProcessGroup from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_instance_report import ( ProcessInstanceReportModel, ) @@ -330,6 +333,9 @@ class TestProcessApi(BaseTest): process_model.display_name = "Updated Display Name" process_model.primary_file_name = "superduper.bpmn" process_model.primary_process_id = "superduper" + process_model.metadata_extraction_paths = [ + {"key": "extraction1", "path": "path1"} + ] modified_process_model_identifier = process_model_identifier.replace("/", ":") response = client.put( @@ -343,6 +349,9 @@ class TestProcessApi(BaseTest): assert response.json["display_name"] == "Updated Display Name" assert response.json["primary_file_name"] == "superduper.bpmn" assert response.json["primary_process_id"] == "superduper" + assert response.json["metadata_extraction_paths"] == [ + {"key": "extraction1", "path": "path1"} + ] def test_process_model_list_all( self, @@ -903,7 +912,7 @@ class TestProcessApi(BaseTest): modified_process_model_identifier = process_model_identifier.replace("/", ":") response = client.post( - f"/v1.0/process-models/{modified_process_model_identifier}/process-instances", + f"/v1.0/process-instances/{modified_process_model_identifier}", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 201 @@ -1145,10 +1154,11 @@ class TestProcessApi(BaseTest): headers=self.logged_in_headers(with_super_admin_user), ) show_response = client.get( - f"/v1.0/process-models/{modified_process_model_identifier}/process-instances/{process_instance_id}", + f"/v1.0/process-instances/{modified_process_model_identifier}/{process_instance_id}", headers=self.logged_in_headers(with_super_admin_user), ) assert show_response.json is not None + assert show_response.status_code == 200 file_system_root = FileSystemService.root_path() file_path = ( f"{file_system_root}/{process_model_identifier}/{process_model_id}.bpmn" @@ -1311,7 +1321,7 @@ class TestProcessApi(BaseTest): assert response.json is not None response = client.post( - f"/v1.0/process-instances/{process_instance_id}/terminate", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/terminate", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1358,7 +1368,7 @@ class TestProcessApi(BaseTest): assert response.json is not None delete_response = client.delete( - f"/v1.0/process-instances/{process_instance_id}", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}", headers=self.logged_in_headers(with_super_admin_user), ) assert delete_response.status_code == 200 @@ -1723,14 +1733,14 @@ class TestProcessApi(BaseTest): ], } - ProcessInstanceReportModel.create_with_attributes( + report = ProcessInstanceReportModel.create_with_attributes( identifier="sure", report_metadata=report_metadata, user=with_super_admin_user, ) response = client.get( - "/v1.0/process-instances/reports/sure", + f"/v1.0/process-instances/reports/{report.id}", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1769,14 +1779,14 @@ class TestProcessApi(BaseTest): ], } - ProcessInstanceReportModel.create_with_attributes( + report = ProcessInstanceReportModel.create_with_attributes( identifier="sure", report_metadata=report_metadata, user=with_super_admin_user, ) response = client.get( - "/v1.0/process-instances/reports/sure?grade_level=1", + f"/v1.0/process-instances/reports/{report.id}?grade_level=1", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 200 @@ -1791,9 +1801,9 @@ class TestProcessApi(BaseTest): with_super_admin_user: UserModel, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_process_instance_report_show_with_default_list.""" + """Test_process_instance_report_show_with_bad_identifier.""" response = client.get( - "/v1.0/process-instances/reports/sure?grade_level=1", + "/v1.0/process-instances/reports/13000000?grade_level=1", headers=self.logged_in_headers(with_super_admin_user), ) assert response.status_code == 404 @@ -2357,7 +2367,7 @@ class TestProcessApi(BaseTest): assert process_instance.status == "user_input_required" client.post( - f"/v1.0/process-instances/{process_instance_id}/suspend", + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model_identifier)}/{process_instance_id}/suspend", headers=self.logged_in_headers(with_super_admin_user), ) process_instance = ProcessInstanceService().get_process_instance( @@ -2661,3 +2671,191 @@ class TestProcessApi(BaseTest): # ) print("test_process_model_publish") + + def test_can_get_process_instance_list_with_report_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_get_process_instance_list_with_report_metadata.""" + process_model = load_test_spec( + process_model_id="save_process_instance_metadata/save_process_instance_metadata", + bpmn_file_name="save_process_instance_metadata.bpmn", + process_model_source_directory="save_process_instance_metadata", + ) + process_instance = self.create_process_instance_from_process_model( + process_model=process_model, user=with_super_admin_user + ) + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + process_instance_metadata = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id + ).all() + assert len(process_instance_metadata) == 3 + + report_metadata = { + "columns": [ + {"Header": "ID", "accessor": "id"}, + {"Header": "Status", "accessor": "status"}, + {"Header": "Key One", "accessor": "key1"}, + {"Header": "Key Two", "accessor": "key2"}, + ], + "order_by": ["status"], + "filter_by": [], + } + process_instance_report = ProcessInstanceReportModel.create_with_attributes( + identifier="sure", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_identifier={process_instance_report.identifier}", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.json is not None + assert response.status_code == 200 + + assert len(response.json["results"]) == 1 + assert response.json["results"][0]["status"] == "complete" + assert response.json["results"][0]["id"] == process_instance.id + assert response.json["results"][0]["key1"] == "value1" + assert response.json["results"][0]["key2"] == "value2" + assert response.json["pagination"]["count"] == 1 + assert response.json["pagination"]["pages"] == 1 + assert response.json["pagination"]["total"] == 1 + + def test_can_get_process_instance_report_column_list( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_get_process_instance_list_with_report_metadata.""" + process_model = load_test_spec( + process_model_id="save_process_instance_metadata/save_process_instance_metadata", + bpmn_file_name="save_process_instance_metadata.bpmn", + process_model_source_directory="save_process_instance_metadata", + ) + process_instance = self.create_process_instance_from_process_model( + process_model=process_model, user=with_super_admin_user + ) + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + process_instance_metadata = ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id + ).all() + assert len(process_instance_metadata) == 3 + + response = client.get( + "/v1.0/process-instances/reports/columns", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.json is not None + assert response.status_code == 200 + assert response.json == [ + {"Header": "Id", "accessor": "id", "filterable": False}, + { + "Header": "Process", + "accessor": "process_model_display_name", + "filterable": False, + }, + {"Header": "Start", "accessor": "start_in_seconds", "filterable": False}, + {"Header": "End", "accessor": "end_in_seconds", "filterable": False}, + {"Header": "Username", "accessor": "username", "filterable": False}, + {"Header": "Status", "accessor": "status", "filterable": False}, + {"Header": "key1", "accessor": "key1", "filterable": True}, + {"Header": "key2", "accessor": "key2", "filterable": True}, + {"Header": "key3", "accessor": "key3", "filterable": True}, + ] + + def test_process_instance_list_can_order_by_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_process_instance_list_can_order_by_metadata.""" + self.create_process_group( + client, with_super_admin_user, "test_group", "test_group" + ) + process_model = load_test_spec( + "test_group/hello_world", + process_model_source_directory="nested-task-data-structure", + ) + ProcessModelService.update_process_model( + process_model, + { + "metadata_extraction_paths": [ + {"key": "time_ns", "path": "outer.time"}, + ] + }, + ) + + process_instance_one = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance_one) + processor.do_engine_steps(save=True) + assert process_instance_one.status == "complete" + process_instance_two = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance_two) + processor.do_engine_steps(save=True) + assert process_instance_two.status == "complete" + + report_metadata = { + "columns": [ + {"Header": "id", "accessor": "id"}, + {"Header": "Time", "accessor": "time_ns"}, + ], + "order_by": ["time_ns"], + } + report_one = ProcessInstanceReportModel.create_with_attributes( + identifier="report_one", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_id={report_one.id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert response.status_code == 200 + assert response.json is not None + assert len(response.json["results"]) == 2 + assert response.json["results"][0]["id"] == process_instance_one.id + assert response.json["results"][1]["id"] == process_instance_two.id + + report_metadata = { + "columns": [ + {"Header": "id", "accessor": "id"}, + {"Header": "Time", "accessor": "time_ns"}, + ], + "order_by": ["-time_ns"], + } + report_two = ProcessInstanceReportModel.create_with_attributes( + identifier="report_two", + report_metadata=report_metadata, + user=with_super_admin_user, + ) + + response = client.get( + f"/v1.0/process-instances?report_id={report_two.id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + + assert response.status_code == 200 + assert response.json is not None + assert len(response.json["results"]) == 2 + assert response.json["results"][1]["id"] == process_instance_one.id + assert response.json["results"][0]["id"] == process_instance_two.id diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py index 48239507..0a5985f2 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_report.py @@ -37,7 +37,7 @@ def test_generate_report_with_filter_by_with_variable_substitution( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_filter_by_with_variable_substitution.""" process_instances = setup_process_instances_for_reports report_metadata = { "filter_by": [ @@ -61,7 +61,7 @@ def test_generate_report_with_order_by_and_one_field( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_and_one_field.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -75,7 +75,7 @@ def test_generate_report_with_order_by_and_two_fields( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_and_two_fields.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["grade_level", "test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -89,7 +89,7 @@ def test_generate_report_with_order_by_desc( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_order_by_desc.""" process_instances = setup_process_instances_for_reports report_metadata = {"order_by": ["grade_level", "-test_score"]} results = do_report_with_metadata_and_instances(report_metadata, process_instances) @@ -103,7 +103,7 @@ def test_generate_report_with_columns( with_db_and_bpmn_file_cleanup: None, setup_process_instances_for_reports: list[ProcessInstanceModel], ) -> None: - """Test_user_can_be_given_permission_to_administer_process_group.""" + """Test_generate_report_with_columns.""" process_instances = setup_process_instances_for_reports report_metadata = { "columns": [ diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py index 09421bc7..9eb6901b 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_model.py @@ -5,12 +5,16 @@ from flask_bpmn.models.db import db from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.test_data import load_test_spec +from spiffworkflow_backend.models.process_instance_metadata import ( + ProcessInstanceMetadataModel, +) from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.spec_reference import SpecReferenceCache from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.process_instance_processor import ( ProcessInstanceProcessor, ) +from spiffworkflow_backend.services.process_model_service import ProcessModelService class TestProcessModel(BaseTest): @@ -122,6 +126,53 @@ class TestProcessModel(BaseTest): processor.do_engine_steps(save=True) assert process_instance.status == "complete" + def test_extract_metadata( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_can_run_process_model_with_call_activities.""" + self.create_process_group( + client, with_super_admin_user, "test_group", "test_group" + ) + process_model = load_test_spec( + "test_group/hello_world", + process_model_source_directory="nested-task-data-structure", + ) + ProcessModelService.update_process_model( + process_model, + { + "metadata_extraction_paths": [ + {"key": "awesome_var", "path": "outer.inner"}, + {"key": "invoice_number", "path": "invoice_number"}, + ] + }, + ) + + process_instance = self.create_process_instance_from_process_model( + process_model + ) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True) + assert process_instance.status == "complete" + + process_instance_metadata_awesome_var = ( + ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id, key="awesome_var" + ).first() + ) + assert process_instance_metadata_awesome_var is not None + assert process_instance_metadata_awesome_var.value == "sweet2" + process_instance_metadata_awesome_var = ( + ProcessInstanceMetadataModel.query.filter_by( + process_instance_id=process_instance.id, key="invoice_number" + ).first() + ) + assert process_instance_metadata_awesome_var is not None + assert process_instance_metadata_awesome_var.value == "123" + def create_test_process_model(self, id: str, display_name: str) -> ProcessModelInfo: """Create_test_process_model.""" return ProcessModelInfo( diff --git a/spiffworkflow-frontend/src/components/MiniComponents.tsx b/spiffworkflow-frontend/src/components/MiniComponents.tsx new file mode 100644 index 00000000..6f0a1293 --- /dev/null +++ b/spiffworkflow-frontend/src/components/MiniComponents.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; +import { modifyProcessIdentifierForPathParam } from '../helpers'; +import { MessageInstance, ProcessInstance } from '../interfaces'; + +export function FormatProcessModelDisplayName( + instanceObject: ProcessInstance | MessageInstance +) { + const { + process_model_identifier: processModelIdentifier, + process_model_display_name: processModelDisplayName, + } = instanceObject; + return ( + + {processModelDisplayName} + + ); +} diff --git a/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx b/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx index a518e47b..79ab8253 100644 --- a/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx +++ b/spiffworkflow-frontend/src/components/ProcessGroupForm.tsx @@ -115,7 +115,6 @@ export default function ProcessGroupForm({ labelText="Display Name*" value={processGroup.display_name} onChange={(event: any) => onDisplayNameChanged(event.target.value)} - onBlur={(event: any) => console.log('event', event)} />, ]; diff --git a/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx b/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx new file mode 100644 index 00000000..a3d50d94 --- /dev/null +++ b/spiffworkflow-frontend/src/components/ProcessInstanceListSaveAsReport.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { + Button, + TextInput, + Stack, + Modal, + // @ts-ignore +} from '@carbon/react'; +import { + ReportFilter, + ProcessInstanceReport, + ProcessModel, + ReportColumn, + ReportMetadata, +} from '../interfaces'; +import HttpService from '../services/HttpService'; + +type OwnProps = { + onSuccess: (..._args: any[]) => any; + columnArray: ReportColumn[]; + orderBy: string; + processModelSelection: ProcessModel | null; + processStatusSelection: string[]; + startFromSeconds: string | null; + startToSeconds: string | null; + endFromSeconds: string | null; + endToSeconds: string | null; + buttonText?: string; + buttonClassName?: string; + processInstanceReportSelection?: ProcessInstanceReport | null; + reportMetadata: ReportMetadata; +}; + +export default function ProcessInstanceListSaveAsReport({ + onSuccess, + columnArray, + orderBy, + processModelSelection, + processInstanceReportSelection, + processStatusSelection, + startFromSeconds, + startToSeconds, + endFromSeconds, + endToSeconds, + buttonClassName, + buttonText = 'Save as Perspective', + reportMetadata, +}: OwnProps) { + const [identifier, setIdentifier] = useState( + processInstanceReportSelection?.identifier || '' + ); + const [showSaveForm, setShowSaveForm] = useState(false); + + const isEditMode = () => { + return ( + processInstanceReportSelection && + processInstanceReportSelection.identifier === identifier + ); + }; + + const responseHandler = (result: any) => { + if (result) { + onSuccess(result, isEditMode() ? 'edit' : 'new'); + } + }; + + const handleSaveFormClose = () => { + setIdentifier(processInstanceReportSelection?.identifier || ''); + setShowSaveForm(false); + }; + + const addProcessInstanceReport = (event: any) => { + event.preventDefault(); + + // TODO: make a field to set this + let orderByArray = ['-start_in_seconds', '-id']; + if (orderBy) { + orderByArray = orderBy.split(',').filter((n) => n); + } + const filterByArray: any = []; + + if (processModelSelection) { + filterByArray.push({ + field_name: 'process_model_identifier', + field_value: processModelSelection.id, + }); + } + + if (processStatusSelection.length > 0) { + filterByArray.push({ + field_name: 'process_status', + field_value: processStatusSelection.join(','), + operator: 'in', + }); + } + + if (startFromSeconds) { + filterByArray.push({ + field_name: 'start_from', + field_value: startFromSeconds, + }); + } + + if (startToSeconds) { + filterByArray.push({ + field_name: 'start_to', + field_value: startToSeconds, + }); + } + + if (endFromSeconds) { + filterByArray.push({ + field_name: 'end_from', + field_value: endFromSeconds, + }); + } + + if (endToSeconds) { + filterByArray.push({ + field_name: 'end_to', + field_value: endToSeconds, + }); + } + + reportMetadata.filter_by.forEach((reportFilter: ReportFilter) => { + columnArray.forEach((reportColumn: ReportColumn) => { + if ( + reportColumn.accessor === reportFilter.field_name && + reportColumn.filterable + ) { + filterByArray.push(reportFilter); + } + }); + }); + + let path = `/process-instances/reports`; + let httpMethod = 'POST'; + if (isEditMode() && processInstanceReportSelection) { + httpMethod = 'PUT'; + path = `${path}/${processInstanceReportSelection.id}`; + } + + HttpService.makeCallToBackend({ + path, + successCallback: responseHandler, + httpMethod, + postBody: { + identifier, + report_metadata: { + columns: columnArray, + order_by: orderByArray, + filter_by: filterByArray, + }, + }, + }); + handleSaveFormClose(); + }; + + let textInputComponent = null; + textInputComponent = ( + setIdentifier(e.target.value)} + /> + ); + + let descriptionText = + 'Save the current columns and filters as a perspective so you can come back to this view in the future.'; + if (processInstanceReportSelection) { + descriptionText = + 'Keep the identifier the same and click Save to update the current perspective. Change the identifier if you want to save the current view with a new name.'; + } + + return ( + + +

{descriptionText}

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

Process Instance {processInstance.id} kicked off ( view diff --git a/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx b/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx index a81779c7..deb2030e 100644 --- a/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx +++ b/spiffworkflow-frontend/src/components/TasksForMyOpenProcesses.tsx @@ -55,7 +55,7 @@ export default function MyOpenProcesses() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx b/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx index 92420224..7d06b7a3 100644 --- a/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx +++ b/spiffworkflow-frontend/src/components/TasksWaitingForMe.tsx @@ -47,7 +47,7 @@ export default function TasksWaitingForMe() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx b/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx index 51c38e94..565cd4a5 100644 --- a/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx +++ b/spiffworkflow-frontend/src/components/TasksWaitingForMyGroups.tsx @@ -55,7 +55,7 @@ export default function TasksWaitingForMyGroups() { {rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/config.tsx b/spiffworkflow-frontend/src/config.tsx index 5e7e96fe..b0816a39 100644 --- a/spiffworkflow-frontend/src/config.tsx +++ b/spiffworkflow-frontend/src/config.tsx @@ -14,6 +14,7 @@ export const PROCESS_STATUSES = [ 'complete', 'error', 'suspended', + 'terminated', ]; // with time: yyyy-MM-dd HH:mm:ss diff --git a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx index 80c78987..eff30a82 100644 --- a/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx +++ b/spiffworkflow-frontend/src/hooks/UriListForPermissions.tsx @@ -9,10 +9,12 @@ export const useUriListForPermissions = () => { messageInstanceListPath: '/v1.0/messages', processGroupListPath: '/v1.0/process-groups', processGroupShowPath: `/v1.0/process-groups/${params.process_group_id}`, - processInstanceActionPath: `/v1.0/process-models/${params.process_model_id}/process-instances`, + processInstanceCreatePath: `/v1.0/process-instances/${params.process_model_id}`, + processInstanceActionPath: `/v1.0/process-instances/${params.process_model_id}/${params.process_instance_id}`, processInstanceListPath: '/v1.0/process-instances', - processInstanceTaskListPath: `/v1.0/process-instances/${params.process_model_id}/${params.process_instance_id}/tasks`, + processInstanceLogListPath: `/v1.0/logs/${params.process_model_id}/${params.process_instance_id}`, processInstanceReportListPath: '/v1.0/process-instances/reports', + processInstanceTaskListPath: `/v1.0/task-data/${params.process_model_id}/${params.process_instance_id}`, processModelCreatePath: `/v1.0/process-models/${params.process_group_id}`, processModelFileCreatePath: `/v1.0/process-models/${params.process_model_id}/files`, processModelFileShowPath: `/v1.0/process-models/${params.process_model_id}/files/${params.file_name}`, diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index 4723e557..f4094785 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -69,6 +69,24 @@ h2 { color: black; } +/* match normal link colors */ +.cds--btn--ghost.button-link { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:visited { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:hover { + color: #0062fe; + padding-left: 0; +} +.cds--btn--ghost.button-link:visited:hover { + color: #0062fe; + padding-left: 0; +} + .cds--header__global .cds--btn--primary { background-color: #161616 } @@ -151,10 +169,22 @@ h1.with-icons { margin-top: 1em; } +.with-extra-top-margin { + margin-top: 1.3em; +} + +.with-tiny-top-margin { + margin-top: 4px; +} + .with-large-bottom-margin { margin-bottom: 3em; } +.with-tiny-bottom-margin { + margin-bottom: 4px; +} + .diagram-viewer-canvas { border:1px solid #000000; height:70vh; @@ -297,3 +327,38 @@ td.actions-cell { text-align: right; padding-bottom: 10px; } + +.cds--btn--ghost:not([disabled]) svg.red-icon { + fill: red; +} + +.failure-string { + color: red; +} + +.cds--btn--ghost.cds--btn--sm.button-tag-icon { + padding-left: 0; + padding-right: 0; + padding-top: 0; +} + +/* .no-wrap cds--label cds--label--inline cds--label--inline--md{ */ +.no-wrap .cds--label--inline{ + word-break: normal; +} + +.combo-box-in-modal { + height: 300px; +} + +.cds--btn.narrow-button { + max-width: 10rem; + min-width: 5rem; + word-break: normal; + +} + +.tag-type-green:hover { + background-color: #00FF00; +} + diff --git a/spiffworkflow-frontend/src/interfaces.ts b/spiffworkflow-frontend/src/interfaces.ts index 3c3d7c12..079e4cdc 100644 --- a/spiffworkflow-frontend/src/interfaces.ts +++ b/spiffworkflow-frontend/src/interfaces.ts @@ -38,11 +38,58 @@ export interface ProcessFile { export interface ProcessInstance { id: number; process_model_identifier: string; + process_model_display_name: string; +} + +export interface MessageCorrelationProperties { + [key: string]: string; +} + +export interface MessageCorrelations { + [key: string]: MessageCorrelationProperties; +} + +export interface MessageInstance { + id: number; + process_model_identifier: string; + process_model_display_name: string; + process_instance_id: number; + message_identifier: string; + message_type: string; + failure_cause: string; + status: string; + created_at_in_seconds: number; + message_correlations?: MessageCorrelations; +} + +export interface ReportFilter { + field_name: string; + field_value: string; + operator?: string; +} + +export interface ReportColumn { + Header: string; + accessor: string; + filterable: boolean; +} + +export interface ReportColumnForEditing extends ReportColumn { + filter_field_value: string; + filter_operator: string; +} + +export interface ReportMetadata { + columns: ReportColumn[]; + filter_by: ReportFilter[]; + order_by: string[]; } export interface ProcessInstanceReport { - id: string; - display_name: string; + id: number; + identifier: string; + name: string; + report_metadata: ReportMetadata; } export interface ProcessGroupLite { @@ -50,6 +97,11 @@ export interface ProcessGroupLite { display_name: string; } +export interface MetadataExtractionPath { + key: string; + path: string; +} + export interface ProcessModel { id: string; description: string; @@ -57,6 +109,7 @@ export interface ProcessModel { primary_file_name: string; files: ProcessFile[]; parent_groups?: ProcessGroupLite[]; + metadata_extraction_paths?: MetadataExtractionPath[]; } export interface ProcessGroup { diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index 91ae7ab0..da6cae35 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -71,11 +71,11 @@ export default function AdminRoutes() { element={} /> } /> } /> } /> } /> } /> diff --git a/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx b/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx index f1478058..a9ec6b69 100644 --- a/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx +++ b/spiffworkflow-frontend/src/routes/MessageInstanceList.tsx @@ -1,15 +1,19 @@ import { useEffect, useState } from 'react'; // @ts-ignore -import { Table } from '@carbon/react'; +import { ErrorOutline } from '@carbon/icons-react'; +// @ts-ignore +import { Table, Modal, Button } from '@carbon/react'; import { Link, useParams, useSearchParams } from 'react-router-dom'; import PaginationForTable from '../components/PaginationForTable'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import { - convertSecondsToFormattedDateString, + convertSecondsToFormattedDateTime, getPageInfoFromSearchParams, modifyProcessIdentifierForPathParam, } from '../helpers'; import HttpService from '../services/HttpService'; +import { FormatProcessModelDisplayName } from '../components/MiniComponents'; +import { MessageInstance } from '../interfaces'; export default function MessageInstanceList() { const params = useParams(); @@ -17,6 +21,9 @@ export default function MessageInstanceList() { const [messageIntances, setMessageInstances] = useState([]); const [pagination, setPagination] = useState(null); + const [messageInstanceForModal, setMessageInstanceForModal] = + useState(null); + useEffect(() => { const setMessageInstanceListFromResult = (result: any) => { setMessageInstances(result.results); @@ -35,41 +42,89 @@ export default function MessageInstanceList() { }); }, [searchParams, params]); - const buildTable = () => { - // return null; - const rows = messageIntances.map((row) => { - const rowToUse = row as any; + const handleCorrelationDisplayClose = () => { + setMessageInstanceForModal(null); + }; + + const correlationsDisplayModal = () => { + if (messageInstanceForModal) { + let failureCausePre = null; + if (messageInstanceForModal.failure_cause) { + failureCausePre = ( + <> +

+ {messageInstanceForModal.failure_cause} +

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

Correlations:

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

Messages

+ {correlationsDisplayModal()} Process Instance {processInstance.id} kicked off ( view @@ -95,7 +95,7 @@ export default function MyTasks() { - + - - + {isDetailedView && ( + <> + + + + + )}
Instance IdProcess ModelIdProcess Process InstanceMessage ModelName TypeFailure CauseDetails Status Created At
{rowToUse.process_instance_id} diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx index f41caf94..37ef5519 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; // @ts-ignore -import { Table } from '@carbon/react'; +import { Table, Tabs, TabList, Tab } from '@carbon/react'; import { useParams, useSearchParams, Link } from 'react-router-dom'; import PaginationForTable from '../components/PaginationForTable'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; @@ -10,15 +10,18 @@ import { convertSecondsToFormattedDateTime, } from '../helpers'; import HttpService from '../services/HttpService'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; export default function ProcessInstanceLogList() { const params = useParams(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [processInstanceLogs, setProcessInstanceLogs] = useState([]); const [pagination, setPagination] = useState(null); const modifiedProcessModelId = modifyProcessIdentifierForPathParam( `${params.process_model_id}` ); + const { targetUris } = useUriListForPermissions(); + const isDetailedView = searchParams.get('detailed') === 'true'; useEffect(() => { const setProcessInstanceLogListFromResult = (result: any) => { @@ -27,26 +30,36 @@ export default function ProcessInstanceLogList() { }; const { page, perPage } = getPageInfoFromSearchParams(searchParams); HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/logs?per_page=${perPage}&page=${page}`, + path: `${targetUris.processInstanceLogListPath}?per_page=${perPage}&page=${page}&detailed=${isDetailedView}`, successCallback: setProcessInstanceLogListFromResult, }); - }, [searchParams, params]); + }, [ + searchParams, + params, + targetUris.processInstanceLogListPath, + isDetailedView, + ]); const buildTable = () => { const rows = processInstanceLogs.map((row) => { const rowToUse = row as any; return (
{rowToUse.bpmn_process_identifier}{rowToUse.id} {rowToUse.message}{rowToUse.bpmn_task_identifier} {rowToUse.bpmn_task_name}{rowToUse.bpmn_task_type}{rowToUse.bpmn_task_identifier}{rowToUse.bpmn_task_type}{rowToUse.bpmn_process_identifier}{rowToUse.username} {convertSecondsToFormattedDateTime(rowToUse.timestamp)} @@ -58,11 +71,16 @@ export default function ProcessInstanceLogList() { - + - - + {isDetailedView && ( + <> + + + + + )} @@ -71,11 +89,12 @@ export default function ProcessInstanceLogList() {
Bpmn Process IdentifierId MessageTask Identifier Task NameTask TypeTask IdentifierTask TypeBpmn Process IdentifierUser Timestamp
); }; + const selectedTabIndex = isDetailedView ? 1 : 0; if (pagination) { const { page, perPage } = getPageInfoFromSearchParams(searchParams); return ( -
+ <> + + + { + searchParams.set('detailed', 'false'); + setSearchParams(searchParams); + }} + > + Simple + + { + searchParams.set('detailed', 'true'); + setSearchParams(searchParams); + }} + > + Detailed + + + +
-
+ ); } return null; diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index c407c771..9a0495d1 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -59,6 +59,11 @@ export default function ProcessInstanceShow() { const permissionRequestData: PermissionsToCheck = { [targetUris.messageInstanceListPath]: ['GET'], [targetUris.processInstanceTaskListPath]: ['GET'], + [targetUris.processInstanceActionPath]: ['DELETE'], + [targetUris.processInstanceLogListPath]: ['GET'], + [`${targetUris.processInstanceActionPath}/suspend`]: ['PUT'], + [`${targetUris.processInstanceActionPath}/terminate`]: ['PUT'], + [`${targetUris.processInstanceActionPath}/resume`]: ['PUT'], }; const { ability, permissionsLoaded } = usePermissionFetcher( permissionRequestData @@ -76,7 +81,7 @@ export default function ProcessInstanceShow() { setTasksCallHadError(true); }; HttpService.makeCallToBackend({ - path: `/process-models/${modifiedProcessModelId}/process-instances/${params.process_instance_id}`, + path: `/process-instances/${modifiedProcessModelId}/${params.process_instance_id}`, successCallback: setProcessInstance, }); let taskParams = '?all_tasks=true'; @@ -85,7 +90,7 @@ export default function ProcessInstanceShow() { } if (ability.can('GET', targetUris.processInstanceTaskListPath)) { HttpService.makeCallToBackend({ - path: `/process-instances/${modifiedProcessModelId}/${params.process_instance_id}/tasks${taskParams}`, + path: `${targetUris.processInstanceTaskListPath}${taskParams}`, successCallback: setTasks, failureCallback: processTaskFailure, }); @@ -97,7 +102,7 @@ export default function ProcessInstanceShow() { const deleteProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}`, + path: targetUris.processInstanceActionPath, successCallback: navigateToProcessInstances, httpMethod: 'DELETE', }); @@ -110,7 +115,7 @@ export default function ProcessInstanceShow() { const terminateProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/terminate`, + path: `${targetUris.processInstanceActionPath}/terminate`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -118,7 +123,7 @@ export default function ProcessInstanceShow() { const suspendProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/suspend`, + path: `${targetUris.processInstanceActionPath}/suspend`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -126,7 +131,7 @@ export default function ProcessInstanceShow() { const resumeProcessInstance = () => { HttpService.makeCallToBackend({ - path: `/process-instances/${params.process_instance_id}/resume`, + path: `${targetUris.processInstanceActionPath}/resume`, successCallback: refreshPage, httpMethod: 'POST', }); @@ -174,7 +179,7 @@ export default function ProcessInstanceShow() { - + Completed:{' '} @@ -235,7 +240,7 @@ export default function ProcessInstanceShow() { return ( <> - + Started:{' '} @@ -246,7 +251,7 @@ export default function ProcessInstanceShow() { {currentEndDateTag} - + Status:{' '} @@ -259,14 +264,20 @@ export default function ProcessInstanceShow() { - + + { const elements = []; - elements.push(terminateButton(processInstanceToUse)); - elements.push(suspendButton(processInstanceToUse)); - elements.push(resumeButton(processInstanceToUse)); - elements.push( - - ); + if ( + ability.can('POST', `${targetUris.processInstanceActionPath}/terminate`) + ) { + elements.push(terminateButton(processInstanceToUse)); + } + if ( + ability.can('POST', `${targetUris.processInstanceActionPath}/suspend`) + ) { + elements.push(suspendButton(processInstanceToUse)); + } + if (ability.can('POST', `${targetUris.processInstanceActionPath}/resume`)) { + elements.push(resumeButton(processInstanceToUse)); + } + if (ability.can('DELETE', targetUris.processInstanceActionPath)) { + elements.push( + + ); + } return elements; }; diff --git a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx index d117f798..1a5c751f 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx @@ -283,7 +283,7 @@ export default function ProcessModelEditDiagram() { const onServiceTasksRequested = (event: any) => { HttpService.makeCallToBackend({ - path: `/service_tasks`, + path: `/service-tasks`, successCallback: makeApiHandler(event), }); }; diff --git a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx index fc1c354f..1b55b007 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelShow.tsx @@ -66,7 +66,7 @@ export default function ProcessModelShow() { const permissionRequestData: PermissionsToCheck = { [targetUris.processModelShowPath]: ['PUT', 'DELETE'], [targetUris.processInstanceListPath]: ['GET'], - [targetUris.processInstanceActionPath]: ['POST'], + [targetUris.processInstanceCreatePath]: ['POST'], [targetUris.processModelFileCreatePath]: ['POST', 'PUT', 'GET', 'DELETE'], }; const { ability, permissionsLoaded } = usePermissionFetcher( @@ -95,7 +95,7 @@ export default function ProcessModelShow() {

Process Instance {processInstance.id} kicked off ( view @@ -564,7 +564,7 @@ export default function ProcessModelShow() { <> diff --git a/spiffworkflow-frontend/src/routes/TaskShow.tsx b/spiffworkflow-frontend/src/routes/TaskShow.tsx index 768043cd..9e0f65c5 100644 --- a/spiffworkflow-frontend/src/routes/TaskShow.tsx +++ b/spiffworkflow-frontend/src/routes/TaskShow.tsx @@ -26,6 +26,9 @@ import Form from '../themes/carbon'; import HttpService from '../services/HttpService'; import ErrorContext from '../contexts/ErrorContext'; import { modifyProcessIdentifierForPathParam } from '../helpers'; +import { useUriListForPermissions } from '../hooks/UriListForPermissions'; +import { PermissionsToCheck } from '../interfaces'; +import { usePermissionFetcher } from '../hooks/PermissionService'; export default function TaskShow() { const [task, setTask] = useState(null); @@ -35,24 +38,36 @@ export default function TaskShow() { const setErrorMessage = (useContext as any)(ErrorContext)[1]; - useEffect(() => { - const processResult = (result: any) => { - setTask(result); - HttpService.makeCallToBackend({ - path: `/process-instances/${modifyProcessIdentifierForPathParam( - result.process_model_identifier - )}/${params.process_instance_id}/tasks`, - successCallback: setUserTasks, - }); - }; + const { targetUris } = useUriListForPermissions(); + const permissionRequestData: PermissionsToCheck = { + [targetUris.processInstanceTaskListPath]: ['GET'], + }; + const { ability, permissionsLoaded } = usePermissionFetcher( + permissionRequestData + ); - HttpService.makeCallToBackend({ - path: `/tasks/${params.process_instance_id}/${params.task_id}`, - successCallback: processResult, - // This causes the page to continuously reload - // failureCallback: setErrorMessage, - }); - }, [params]); + useEffect(() => { + if (permissionsLoaded) { + const processResult = (result: any) => { + setTask(result); + if (ability.can('GET', targetUris.processInstanceTaskListPath)) { + HttpService.makeCallToBackend({ + path: `/task-data/${modifyProcessIdentifierForPathParam( + result.process_model_identifier + )}/${params.process_instance_id}`, + successCallback: setUserTasks, + }); + } + }; + + HttpService.makeCallToBackend({ + path: `/tasks/${params.process_instance_id}/${params.task_id}`, + successCallback: processResult, + // This causes the page to continuously reload + // failureCallback: setErrorMessage, + }); + } + }, [params, permissionsLoaded, ability, targetUris]); const processSubmitResult = (result: any) => { setErrorMessage(null); @@ -116,17 +131,18 @@ export default function TaskShow() { } return null; }); + return ( + + + {userTasksElement} + + + ); } - return ( - - - {userTasksElement} - - - ); + return null; }; const formElement = (taskToUse: any) => { @@ -207,7 +223,7 @@ export default function TaskShow() { ); }; - if (task && userTasks) { + if (task) { const taskToUse = task as any; let statusString = ''; if (taskToUse.state !== 'READY') {