From 21992143239a10617e0baf5630db8315c5844ae1 Mon Sep 17 00:00:00 2001 From: burnettk Date: Mon, 29 May 2023 15:04:38 -0400 Subject: [PATCH 1/4] only expose value on show, not list, move tests, fix UI --- .../models/secret_model.py | 10 ++ .../routes/secrets_controller.py | 7 +- .../integration/test_secret_service.py | 115 --------------- .../integration/test_secrets_controller.py | 137 ++++++++++++++++++ .../src/components/Notification.tsx | 4 +- .../src/routes/SecretShow.tsx | 71 ++++++--- 6 files changed, 206 insertions(+), 138 deletions(-) create mode 100644 spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/secret_model.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/secret_model.py index 9ecfe534..e5b55c4b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/secret_model.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/secret_model.py @@ -1,5 +1,6 @@ """Secret_model.""" from dataclasses import dataclass +from typing import Any from marshmallow import Schema from sqlalchemy import ForeignKey @@ -21,6 +22,15 @@ class SecretModel(SpiffworkflowBaseDBModel): updated_at_in_seconds: int = db.Column(db.Integer) created_at_in_seconds: int = db.Column(db.Integer) + # value is not included in the serialized output because it is sensitive + @property + def serialized(self) -> dict[str, Any]: + return { + "id": self.id, + "key": self.key, + "user_id": self.user_id, + } + class SecretModelSchema(Schema): """SecretModelSchema.""" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/secrets_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/secrets_controller.py index 15c8a028..ada37542 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/secrets_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/secrets_controller.py @@ -16,7 +16,12 @@ from spiffworkflow_backend.services.user_service import UserService def secret_show(key: str) -> Response: """Secret_show.""" secret = SecretService.get_secret(key) - return make_response(jsonify(secret), 200) + + # normal serialization does not include the secret value, but this is the one endpoint where we want to return the goods + secret_as_dict = secret.serialized + secret_as_dict["value"] = SecretService._decrypt(secret.value) + + return make_response(secret_as_dict, 200) def secret_list( diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secret_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secret_service.py index 14b2f046..b4216c10 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secret_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secret_service.py @@ -1,5 +1,4 @@ """Test_secret_service.""" -import json import pytest from flask.app import Flask @@ -7,11 +6,9 @@ from flask.testing import FlaskClient from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.secret_model import SecretModel -from spiffworkflow_backend.models.secret_model import SecretModelSchema from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.secret_service import SecretService -from werkzeug.test import TestResponse # type: ignore from tests.spiffworkflow_backend.helpers.base_test import BaseTest @@ -164,115 +161,3 @@ class TestSecretService(SecretServiceTestHelpers): with pytest.raises(ApiError) as ae: SecretService.delete_secret(self.test_key + "x", with_super_admin_user.id) assert "Resource does not exist" in ae.value.message - - -class TestSecretServiceApi(SecretServiceTestHelpers): - """TestSecretServiceApi.""" - - def test_add_secret( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - """Test_add_secret.""" - secret_model = SecretModel( - key=self.test_key, - value=self.test_value, - user_id=with_super_admin_user.id, - ) - data = json.dumps(SecretModelSchema().dump(secret_model)) - response: TestResponse = client.post( - "/v1.0/secrets", - headers=self.logged_in_headers(with_super_admin_user), - content_type="application/json", - data=data, - ) - assert response.json - secret: dict = response.json - for key in ["key", "value", "user_id"]: - assert key in secret.keys() - assert secret["key"] == self.test_key - assert SecretService._decrypt(secret["value"]) == self.test_value - assert secret["user_id"] == with_super_admin_user.id - - def test_get_secret( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - """Test get secret.""" - self.add_test_secret(with_super_admin_user) - secret_response = client.get( - f"/v1.0/secrets/{self.test_key}", - headers=self.logged_in_headers(with_super_admin_user), - ) - assert secret_response - assert secret_response.status_code == 200 - assert secret_response.json - assert SecretService._decrypt(secret_response.json["value"]) == self.test_value - - def test_update_secret( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - """Test_update_secret.""" - self.add_test_secret(with_super_admin_user) - secret: SecretModel | None = SecretService.get_secret(self.test_key) - assert secret - assert SecretService._decrypt(secret.value) == self.test_value - secret_model = SecretModel( - key=self.test_key, - value="new_secret_value", - user_id=with_super_admin_user.id, - ) - response = client.put( - f"/v1.0/secrets/{self.test_key}", - headers=self.logged_in_headers(with_super_admin_user), - content_type="application/json", - data=json.dumps(SecretModelSchema().dump(secret_model)), - ) - assert response.status_code == 200 - - secret_model = SecretModel.query.filter(SecretModel.key == self.test_key).first() - assert SecretService._decrypt(secret_model.value) == "new_secret_value" - - def test_delete_secret( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - """Test delete secret.""" - self.add_test_secret(with_super_admin_user) - secret = SecretService.get_secret(self.test_key) - assert secret - assert SecretService._decrypt(secret.value) == self.test_value - secret_response = client.delete( - f"/v1.0/secrets/{self.test_key}", - headers=self.logged_in_headers(with_super_admin_user), - ) - assert secret_response.status_code == 200 - with pytest.raises(ApiError): - secret = SecretService.get_secret(self.test_key) - - def test_delete_secret_bad_key( - self, - app: Flask, - client: FlaskClient, - with_db_and_bpmn_file_cleanup: None, - with_super_admin_user: UserModel, - ) -> None: - """Test delete secret.""" - secret_response = client.delete( - "/v1.0/secrets/bad_secret_key", - headers=self.logged_in_headers(with_super_admin_user), - ) - assert secret_response.status_code == 404 diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py new file mode 100644 index 00000000..2cafaabb --- /dev/null +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py @@ -0,0 +1,137 @@ +from flask.app import Flask +import json +from spiffworkflow_backend.models.secret_model import SecretModel, SecretModelSchema +from spiffworkflow_backend.exceptions.api_error import ApiError +import pytest +from spiffworkflow_backend.models.user import UserModel +from flask.testing import FlaskClient + +from tests.spiffworkflow_backend.integration.test_secret_service import SecretServiceTestHelpers +from spiffworkflow_backend.services.secret_service import SecretService + + +class TestSecretsController(SecretServiceTestHelpers): + def test_add_secret( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_add_secret.""" + secret_model = SecretModel( + key=self.test_key, + value=self.test_value, + user_id=with_super_admin_user.id, + ) + data = json.dumps(SecretModelSchema().dump(secret_model)) + response = client.post( + "/v1.0/secrets", + headers=self.logged_in_headers(with_super_admin_user), + content_type="application/json", + data=data, + ) + assert response.json + secret: dict = response.json + for key in ["key", "value", "user_id"]: + assert key in secret.keys() + assert secret["key"] == self.test_key + assert SecretService._decrypt(secret["value"]) == self.test_value + assert secret["user_id"] == with_super_admin_user.id + + def test_get_secret( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test get secret.""" + self.add_test_secret(with_super_admin_user) + secret_response = client.get( + f"/v1.0/secrets/{self.test_key}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert secret_response + assert secret_response.status_code == 200 + assert secret_response.json + assert SecretService._decrypt(secret_response.json["value"]) == self.test_value + + def test_update_secret( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test_update_secret.""" + self.add_test_secret(with_super_admin_user) + secret: SecretModel | None = SecretService.get_secret(self.test_key) + assert secret + assert SecretService._decrypt(secret.value) == self.test_value + secret_model = SecretModel( + key=self.test_key, + value="new_secret_value", + user_id=with_super_admin_user.id, + ) + response = client.put( + f"/v1.0/secrets/{self.test_key}", + headers=self.logged_in_headers(with_super_admin_user), + content_type="application/json", + data=json.dumps(SecretModelSchema().dump(secret_model)), + ) + assert response.status_code == 200 + + secret_model = SecretModel.query.filter(SecretModel.key == self.test_key).first() + assert SecretService._decrypt(secret_model.value) == "new_secret_value" + + def test_delete_secret( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test delete secret.""" + self.add_test_secret(with_super_admin_user) + secret = SecretService.get_secret(self.test_key) + assert secret + assert SecretService._decrypt(secret.value) == self.test_value + secret_response = client.delete( + f"/v1.0/secrets/{self.test_key}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert secret_response.status_code == 200 + with pytest.raises(ApiError): + secret = SecretService.get_secret(self.test_key) + + def test_delete_secret_bad_key( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + """Test delete secret.""" + secret_response = client.delete( + "/v1.0/secrets/bad_secret_key", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert secret_response.status_code == 404 + + def test_secret_list( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + self.add_test_secret(with_super_admin_user) + secret_response = client.get( + "/v1.0/secrets", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert secret_response.status_code == 200 + first_secret_in_results = secret_response.json["results"][0] + assert first_secret_in_results['key'] == self.test_key + assert 'value' not in first_secret_in_results diff --git a/spiffworkflow-frontend/src/components/Notification.tsx b/spiffworkflow-frontend/src/components/Notification.tsx index 38191de7..2a853ce2 100644 --- a/spiffworkflow-frontend/src/components/Notification.tsx +++ b/spiffworkflow-frontend/src/components/Notification.tsx @@ -10,8 +10,8 @@ import { Button } from '@carbon/react'; type OwnProps = { title: string; - children: React.ReactNode; - onClose: (..._args: any[]) => any; + children?: React.ReactNode; + onClose: Function; type?: string; }; diff --git a/spiffworkflow-frontend/src/routes/SecretShow.tsx b/spiffworkflow-frontend/src/routes/SecretShow.tsx index 4a434d8e..b01ac2f7 100644 --- a/spiffworkflow-frontend/src/routes/SecretShow.tsx +++ b/spiffworkflow-frontend/src/routes/SecretShow.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; // @ts-ignore -import { Stack, Table, Button } from '@carbon/react'; +import { Stack, Table, Button, TextInput } from '@carbon/react'; import HttpService from '../services/HttpService'; import { Secret } from '../interfaces'; +import { Notification } from '../components/Notification'; import ButtonWithConfirmation from '../components/ButtonWithConfirmation'; export default function SecretShow() { @@ -11,7 +12,9 @@ export default function SecretShow() { const params = useParams(); const [secret, setSecret] = useState(null); - const [secretValue, setSecretValue] = useState(secret?.value); + const [displaySecretValue, setDisplaySecretValue] = useState(false); + const [showSuccessNotification, setShowSuccessNotification] = + useState(false); useEffect(() => { HttpService.makeCallToBackend({ @@ -22,22 +25,21 @@ export default function SecretShow() { const handleSecretValueChange = (event: any) => { if (secret) { - setSecretValue(event.target.value); + const newSecret = { ...secret, value: event.target.value }; + setSecret(newSecret); } }; const updateSecretValue = () => { - if (secret && secretValue) { - secret.value = secretValue; + if (secret) { HttpService.makeCallToBackend({ path: `/secrets/${secret.key}`, successCallback: () => { - setSecret(secret); + setShowSuccessNotification(true); }, httpMethod: 'PUT', postBody: { - value: secretValue, - creator_user_id: secret.creator_user_id, + value: secret.value, }, }); } @@ -58,9 +60,17 @@ export default function SecretShow() { }); }; + const successNotificationComponent = ( + setShowSuccessNotification(false)} + /> + ); + if (secret) { return ( <> + {showSuccessNotification && successNotificationComponent}

Secret Key: {secret.key}

-
@@ -77,21 +93,36 @@ export default function SecretShow() { Key - Value + {displaySecretValue && ( + <> + Value + Actions + + )} {params.key} - - - + {displaySecretValue && ( + <> + + + + + {displaySecretValue && ( + + )} + + + )} From ad3f2830982c7b4a48fd9eae88de7e24b4ff5487 Mon Sep 17 00:00:00 2001 From: burnettk Date: Mon, 29 May 2023 17:03:30 -0400 Subject: [PATCH 2/4] get compatible with ruff --- .../integration/test_secrets_controller.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py index 2cafaabb..f27b7549 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_secrets_controller.py @@ -1,13 +1,15 @@ -from flask.app import Flask import json -from spiffworkflow_backend.models.secret_model import SecretModel, SecretModelSchema -from spiffworkflow_backend.exceptions.api_error import ApiError + import pytest -from spiffworkflow_backend.models.user import UserModel +from flask.app import Flask from flask.testing import FlaskClient +from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.models.secret_model import SecretModel +from spiffworkflow_backend.models.secret_model import SecretModelSchema +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.secret_service import SecretService from tests.spiffworkflow_backend.integration.test_secret_service import SecretServiceTestHelpers -from spiffworkflow_backend.services.secret_service import SecretService class TestSecretsController(SecretServiceTestHelpers): @@ -133,5 +135,5 @@ class TestSecretsController(SecretServiceTestHelpers): ) assert secret_response.status_code == 200 first_secret_in_results = secret_response.json["results"][0] - assert first_secret_in_results['key'] == self.test_key - assert 'value' not in first_secret_in_results + assert first_secret_in_results["key"] == self.test_key + assert "value" not in first_secret_in_results From 9117435f91b045c53435dde4a9c0bdf285e0f2e3 Mon Sep 17 00:00:00 2001 From: burnettk Date: Mon, 29 May 2023 17:24:26 -0400 Subject: [PATCH 3/4] if we get garbage from frontend for nonessential feature, do not blow up --- spiffworkflow-frontend/src/components/ActiveUsers.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/spiffworkflow-frontend/src/components/ActiveUsers.tsx b/spiffworkflow-frontend/src/components/ActiveUsers.tsx index aa972275..87b2e885 100644 --- a/spiffworkflow-frontend/src/components/ActiveUsers.tsx +++ b/spiffworkflow-frontend/src/components/ActiveUsers.tsx @@ -40,6 +40,12 @@ export default function ActiveUsers() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // it is critical to only run this once. + // activeUsers is supposed to be an array, but it is based on the response body + // from a network call, so who knows what might happen. Be safe. + if (!activeUsers.map) { + return null; + } + const au = activeUsers.map((activeUser: User) => { return (
Date: Tue, 30 May 2023 13:51:37 -0400 Subject: [PATCH 4/4] Cycle Timer Start Event Support (#285) --- .../migrations/versions/e4b6bbf83a3e_.py | 42 +++++++++++ .../load_database_models.py | 3 + .../models/process_model_cycle.py | 19 +++++ .../services/email_service.py | 1 - .../services/message_service.py | 1 - .../services/process_instance_service.py | 73 +++++++++++++++++-- .../services/secret_service.py | 1 - .../services/workflow_service.py | 30 +++----- .../specs/start_event.py | 34 ++++++--- .../unit/test_workflow_service.py | 8 +- 10 files changed, 167 insertions(+), 45 deletions(-) create mode 100644 spiffworkflow-backend/migrations/versions/e4b6bbf83a3e_.py create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/models/process_model_cycle.py diff --git a/spiffworkflow-backend/migrations/versions/e4b6bbf83a3e_.py b/spiffworkflow-backend/migrations/versions/e4b6bbf83a3e_.py new file mode 100644 index 00000000..82cb312d --- /dev/null +++ b/spiffworkflow-backend/migrations/versions/e4b6bbf83a3e_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: e4b6bbf83a3e +Revises: 6aa02463da9c +Create Date: 2023-05-30 10:17:10.595965 + +""" +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = 'e4b6bbf83a3e' +down_revision = '6aa02463da9c' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('process_model_cycle', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('process_model_identifier', sa.String(length=255), nullable=False), + sa.Column('cycle_count', sa.Integer(), nullable=True), + sa.Column('duration_in_seconds', sa.Integer(), nullable=True), + sa.Column('current_cycle', sa.Integer(), nullable=True), + sa.Column('updated_at_in_seconds', sa.Integer(), nullable=True), + sa.Column('created_at_in_seconds', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('process_model_cycle', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_process_model_cycle_process_model_identifier'), ['process_model_identifier'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('process_model_cycle', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_process_model_cycle_process_model_identifier')) + + op.drop_table('process_model_cycle') + # ### end Alembic commands ### diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py index ed542761..22e570e1 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py @@ -76,5 +76,8 @@ from spiffworkflow_backend.models.process_instance_queue import ( from spiffworkflow_backend.models.active_user import ( ActiveUserModel, ) # noqa: F401 +from spiffworkflow_backend.models.process_model_cycle import ( + ProcessModelCycleModel, +) # noqa: F401 add_listeners() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model_cycle.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model_cycle.py new file mode 100644 index 00000000..bc745590 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_model_cycle.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel +from spiffworkflow_backend.models.db import db + + +@dataclass +class ProcessModelCycleModel(SpiffworkflowBaseDBModel): + """ProcessInstanceQueueModel.""" + + __tablename__ = "process_model_cycle" + + id: int = db.Column(db.Integer, primary_key=True) + process_model_identifier: str = db.Column(db.String(255), nullable=False, index=True) + cycle_count: int = db.Column(db.Integer) + duration_in_seconds: int = db.Column(db.Integer) + current_cycle: int = db.Column(db.Integer) + updated_at_in_seconds: int = db.Column(db.Integer) + created_at_in_seconds: int = db.Column(db.Integer) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/email_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/email_service.py index 7108dc58..0d6da09e 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/email_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/email_service.py @@ -1,4 +1,3 @@ - from flask import current_app from flask_mail import Message # type: ignore diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py index ce7240f5..d26aa8b0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py @@ -1,4 +1,3 @@ - from spiffworkflow_backend.models.db import db from spiffworkflow_backend.models.message_instance import MessageInstanceModel from spiffworkflow_backend.models.message_instance import MessageStatuses 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 4a8606ca..f342f5f0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -21,6 +21,7 @@ from spiffworkflow_backend.models.process_instance import ProcessInstanceModel from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.process_instance_file_data import ProcessInstanceFileDataModel from spiffworkflow_backend.models.process_model import ProcessModelInfo +from spiffworkflow_backend.models.process_model_cycle import ProcessModelCycleModel from spiffworkflow_backend.models.task import Task from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authorization_service import AuthorizationService @@ -33,6 +34,7 @@ from spiffworkflow_backend.services.process_instance_queue_service import Proces from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService from spiffworkflow_backend.services.process_model_service import ProcessModelService from spiffworkflow_backend.services.workflow_service import WorkflowService +from spiffworkflow_backend.specs.start_event import StartConfiguration class ProcessInstanceService: @@ -42,22 +44,27 @@ class ProcessInstanceService: TASK_STATE_LOCKED = "locked" @staticmethod - def calculate_start_delay_in_seconds(process_instance_model: ProcessInstanceModel) -> int: + def next_start_event_configuration(process_instance_model: ProcessInstanceModel) -> StartConfiguration: try: processor = ProcessInstanceProcessor(process_instance_model) - delay_in_seconds = WorkflowService.calculate_run_at_delay_in_seconds( + start_configuration = WorkflowService.next_start_event_configuration( processor.bpmn_process_instance, datetime.now(timezone.utc) ) except Exception: - delay_in_seconds = 0 - return delay_in_seconds + start_configuration = None + + if start_configuration is None: + start_configuration = (0, 0, 0) + + return start_configuration @classmethod def create_process_instance( cls, process_model: ProcessModelInfo, user: UserModel, - ) -> ProcessInstanceModel: + start_configuration: StartConfiguration | None = None, + ) -> tuple[ProcessInstanceModel, StartConfiguration]: """Get_process_instance_from_spec.""" db.session.commit() try: @@ -75,10 +82,13 @@ class ProcessInstanceService: ) db.session.add(process_instance_model) db.session.commit() - delay_in_seconds = cls.calculate_start_delay_in_seconds(process_instance_model) + + if start_configuration is None: + start_configuration = cls.next_start_event_configuration(process_instance_model) + _, delay_in_seconds, _ = start_configuration run_at_in_seconds = round(time.time()) + delay_in_seconds ProcessInstanceQueueService.enqueue_new_process_instance(process_instance_model, run_at_in_seconds) - return process_instance_model + return (process_instance_model, start_configuration) @classmethod def create_process_instance_from_process_model_identifier( @@ -88,7 +98,52 @@ class ProcessInstanceService: ) -> ProcessInstanceModel: """Create_process_instance_from_process_model_identifier.""" process_model = ProcessModelService.get_process_model(process_model_identifier) - return cls.create_process_instance(process_model, user) + process_instance_model, (cycle_count, _, duration_in_seconds) = cls.create_process_instance( + process_model, user + ) + cls.register_process_model_cycles(process_model_identifier, cycle_count, duration_in_seconds) + return process_instance_model + + @classmethod + def register_process_model_cycles( + cls, process_model_identifier: str, cycle_count: int, duration_in_seconds: int + ) -> None: + # clean up old cycle record if it exists. event if the given cycle_count is 0 the previous version + # of the model could have included a cycle timer start event + cycles = ProcessModelCycleModel.query.filter( + ProcessModelCycleModel.process_model_identifier == process_model_identifier, + ).all() + + for cycle in cycles: + db.session.delete(cycle) + + if cycle_count != 0: + cycle = ProcessModelCycleModel( + process_model_identifier=process_model_identifier, + cycle_count=cycle_count, + duration_in_seconds=duration_in_seconds, + current_cycle=0, + ) + db.session.add(cycle) + + db.session.commit() + + @classmethod + def schedule_next_process_model_cycle(cls, process_instance_model: ProcessInstanceModel) -> None: + cycle = ProcessModelCycleModel.query.filter( + ProcessModelCycleModel.process_model_identifier == process_instance_model.process_model_identifier + ).first() + + if cycle is None or cycle.cycle_count == 0: + return + + if cycle.cycle_count == -1 or cycle.current_cycle < cycle.cycle_count: + process_model = ProcessModelService.get_process_model(process_instance_model.process_model_identifier) + start_configuration = (cycle.cycle_count, cycle.duration_in_seconds, cycle.duration_in_seconds) + cls.create_process_instance(process_model, process_instance_model.process_initiator, start_configuration) + cycle.current_cycle += 1 + db.session.add(cycle) + db.session.commit() @classmethod def waiting_event_can_be_skipped(cls, waiting_event: dict[str, Any], now_in_utc: datetime) -> bool: @@ -155,6 +210,8 @@ class ProcessInstanceService: cls.run_process_instance_with_processor( process_instance, status_value=status_value, execution_strategy_name=execution_strategy_name ) + if process_instance.status == "complete": + cls.schedule_next_process_model_cycle(process_instance) except ProcessInstanceIsAlreadyLockedError: continue except Exception as e: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/secret_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/secret_service.py index da0f5c36..6edeb364 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/secret_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/secret_service.py @@ -1,4 +1,3 @@ - from flask import current_app from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.models.db import db diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_service.py index 5668226d..83acc4e2 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/workflow_service.py @@ -3,6 +3,7 @@ from datetime import datetime from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.task import TaskState +from spiffworkflow_backend.specs.start_event import StartConfiguration from spiffworkflow_backend.specs.start_event import StartEvent @@ -14,24 +15,13 @@ class WorkflowService: return [t for t in workflow.get_tasks(TaskState.FUTURE) if isinstance(t.task_spec, StartEvent)] @classmethod - def next_start_event_delay_in_seconds(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> int: + def next_start_event_configuration(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> StartConfiguration | None: start_events = cls.future_start_events(workflow) - start_delays: list[int] = [] - for start_event in start_events: - start_delay = start_event.task_spec.start_delay_in_seconds(start_event, now_in_utc) - start_delays.append(start_delay) - start_delays.sort() - return start_delays[0] if len(start_delays) > 0 else 0 - - @classmethod - def calculate_run_at_delay_in_seconds(cls, workflow: BpmnWorkflow, now_in_utc: datetime) -> int: - # TODO: for now we are using the first start time because I am not sure how multiple - # start events should work. I think the right answer is to take the earliest start - # time and have later start events stay FUTURE/WAITING?, then we need to be able - # to respect the other start events when enqueue'ing. - # - # TODO: this method should also expand to include other FUTURE/WAITING timers when - # enqueue'ing so that we don't have to check timers every 10 or whatever seconds - # right now we assume that this is being called to create a process - - return cls.next_start_event_delay_in_seconds(workflow, now_in_utc) + configurations = list( + map( + lambda start_event: start_event.task_spec.configuration(start_event, now_in_utc), # type: ignore + start_events, + ) + ) + configurations.sort(key=lambda configuration: configuration[1]) # type: ignore + return configurations[0] if len(configurations) > 0 else None diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/specs/start_event.py b/spiffworkflow-backend/src/spiffworkflow_backend/specs/start_event.py index 56e238f3..5cb929e4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/specs/start_event.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/specs/start_event.py @@ -13,8 +13,11 @@ from SpiffWorkflow.bpmn.specs.event_definitions import TimerEventDefinition from SpiffWorkflow.spiff.parser.event_parsers import SpiffStartEventParser # type: ignore from SpiffWorkflow.task import Task as SpiffTask # type: ignore +StartConfiguration = tuple[int, int, int] # TODO: cylce timers and repeat counts? + + class StartEvent(DefaultStartEvent): # type: ignore def __init__(self, wf_spec, bpmn_id, event_definition, **kwargs): # type: ignore if isinstance(event_definition, TimerEventDefinition): @@ -33,27 +36,36 @@ class StartEvent(DefaultStartEvent): # type: ignore def register_parser_class(parser_config: dict[str, Any]) -> None: parser_config[full_tag("startEvent")] = (SpiffStartEventParser, StartEvent) - def start_delay_in_seconds(self, my_task: SpiffTask, now_in_utc: datetime) -> int: - script_engine = my_task.workflow.script_engine - evaluated_expression = None - parsed_duration = None - - if isinstance(self.timer_definition, TimerEventDefinition) and script_engine is not None: - evaluated_expression = script_engine.evaluate(my_task, self.timer_definition.expression) + def configuration(self, my_task: SpiffTask, now_in_utc: datetime) -> StartConfiguration: + evaluated_expression = self.evaluated_timer_expression(my_task) + cycles = 0 + start_delay_in_seconds = 0 + duration = 0 if evaluated_expression is not None: if isinstance(self.timer_definition, TimeDateEventDefinition): parsed_duration = TimerEventDefinition.parse_time_or_duration(evaluated_expression) time_delta = parsed_duration - now_in_utc - return time_delta.seconds # type: ignore + start_delay_in_seconds = time_delta.seconds elif isinstance(self.timer_definition, DurationTimerEventDefinition): parsed_duration = TimerEventDefinition.parse_iso_duration(evaluated_expression) time_delta = TimerEventDefinition.get_timedelta_from_start(parsed_duration, now_in_utc) - return time_delta.seconds # type: ignore + start_delay_in_seconds = time_delta.seconds elif isinstance(self.timer_definition, CycleTimerEventDefinition): - return 0 + cycles, start, cycle_duration = TimerEventDefinition.parse_iso_recurring_interval(evaluated_expression) + time_delta = start - now_in_utc + cycle_duration + start_delay_in_seconds = time_delta.seconds + duration = cycle_duration.seconds - return 0 + return (cycles, start_delay_in_seconds, duration) + + def evaluated_timer_expression(self, my_task: SpiffTask) -> Any: + script_engine = my_task.workflow.script_engine + evaluated_expression = None + + if isinstance(self.timer_definition, TimerEventDefinition) and script_engine is not None: + evaluated_expression = script_engine.evaluate(my_task, self.timer_definition.expression) + return evaluated_expression class StartEventConverter(EventConverter): # type: ignore diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_service.py index 3428c60c..26537d92 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_workflow_service.py @@ -84,7 +84,7 @@ class TestWorkflowService(BaseTest): """, "no_tasks", ) - delay = WorkflowService.calculate_run_at_delay_in_seconds(workflow, now_in_utc) + _, delay, _ = WorkflowService.next_start_event_configuration(workflow, now_in_utc) # type: ignore assert delay == 0 def test_run_at_delay_is_30_for_30_second_duration_start_timer_event(self, now_in_utc: datetime) -> None: @@ -105,7 +105,7 @@ class TestWorkflowService(BaseTest): """, "Process_aldvgey", ) - delay = WorkflowService.calculate_run_at_delay_in_seconds(workflow, now_in_utc) + _, delay, _ = WorkflowService.next_start_event_configuration(workflow, now_in_utc) # type: ignore assert delay == 30 def test_run_at_delay_is_300_if_5_mins_before_date_start_timer_event( @@ -128,5 +128,7 @@ class TestWorkflowService(BaseTest): """, "Process_aldvgey", ) - delay = WorkflowService.calculate_run_at_delay_in_seconds(workflow, example_start_datetime_minus_5_mins_in_utc) + _, delay, _ = WorkflowService.next_start_event_configuration( + workflow, example_start_datetime_minus_5_mins_in_utc + ) # type: ignore assert delay == 300