From 09ac38291efd43c3d31484985fe917b40cf92eb2 Mon Sep 17 00:00:00 2001 From: jbirddog <100367399+jbirddog@users.noreply.github.com> Date: Wed, 2 Nov 2022 14:34:59 -0400 Subject: [PATCH] From the logs, allow viewing a diagram in a previous state (#15) Co-authored-by: Elizabeth Esswein --- .../{bdd1d64689db_.py => b1647eff45c9_.py} | 20 +++++- .../src/spiffworkflow_backend/api.yml | 6 ++ .../load_database_models.py | 3 + .../models/process_instance.py | 2 + .../models/spiff_logging.py | 1 + .../models/spiff_step_details.py | 23 +++++++ .../routes/process_api_blueprint.py | 17 ++++- .../services/logging_service.py | 8 +++ .../services/process_instance_processor.py | 62 ++++++++++++++++--- .../unit/test_spiff_logging.py | 1 + .../src/routes/AdminRoutes.tsx | 4 ++ .../src/routes/ProcessInstanceLogList.tsx | 11 +++- .../src/routes/ProcessInstanceShow.tsx | 14 +++-- 13 files changed, 155 insertions(+), 17 deletions(-) rename spiffworkflow-backend/migrations/versions/{bdd1d64689db_.py => b1647eff45c9_.py} (95%) create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py diff --git a/spiffworkflow-backend/migrations/versions/bdd1d64689db_.py b/spiffworkflow-backend/migrations/versions/b1647eff45c9_.py similarity index 95% rename from spiffworkflow-backend/migrations/versions/bdd1d64689db_.py rename to spiffworkflow-backend/migrations/versions/b1647eff45c9_.py index 55566149..d6ff25e3 100644 --- a/spiffworkflow-backend/migrations/versions/bdd1d64689db_.py +++ b/spiffworkflow-backend/migrations/versions/b1647eff45c9_.py @@ -1,8 +1,8 @@ """empty message -Revision ID: bdd1d64689db +Revision ID: b1647eff45c9 Revises: -Create Date: 2022-11-02 11:31:50.606843 +Create Date: 2022-11-02 14:25:09.992800 """ from alembic import op @@ -10,7 +10,7 @@ import sqlalchemy as sa # revision identifiers, used by Alembic. -revision = 'bdd1d64689db' +revision = 'b1647eff45c9' down_revision = None branch_labels = None depends_on = None @@ -106,6 +106,7 @@ def upgrade(): sa.Column('status', sa.String(length=50), nullable=True), sa.Column('bpmn_version_control_type', sa.String(length=50), nullable=True), sa.Column('bpmn_version_control_identifier', sa.String(length=255), nullable=True), + sa.Column('spiff_step', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['process_initiator_id'], ['user.id'], ), sa.PrimaryKeyConstraint('id') ) @@ -229,10 +230,22 @@ def upgrade(): sa.Column('timestamp', sa.DECIMAL(precision=17, scale=6), nullable=False), sa.Column('message', sa.String(length=255), nullable=True), sa.Column('current_user_id', sa.Integer(), nullable=True), + sa.Column('spiff_step', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['current_user_id'], ['user.id'], ), sa.ForeignKeyConstraint(['process_instance_id'], ['process_instance.id'], ), sa.PrimaryKeyConstraint('id') ) + op.create_table('spiff_step_details', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('process_instance_id', sa.Integer(), nullable=False), + sa.Column('spiff_step', sa.Integer(), nullable=False), + sa.Column('task_json', sa.JSON(), nullable=False), + sa.Column('timestamp', sa.DECIMAL(precision=17, scale=6), nullable=False), + sa.Column('completed_by_user_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['completed_by_user_id'], ['user.id'], ), + sa.ForeignKeyConstraint(['process_instance_id'], ['process_instance.id'], ), + sa.PrimaryKeyConstraint('id') + ) op.create_table('active_task_user', sa.Column('id', sa.Integer(), nullable=False), sa.Column('active_task_id', sa.Integer(), nullable=False), @@ -266,6 +279,7 @@ def downgrade(): op.drop_index(op.f('ix_active_task_user_user_id'), table_name='active_task_user') 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_table('spiff_logging') op.drop_table('permission_assignment') op.drop_table('message_instance') diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 614d4f26..0f0a49c2 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -1000,6 +1000,12 @@ paths: description: If true, this wil return all tasks associated with the process instance and not just user tasks. schema: type: boolean + - name: spiff_step + in: query + required: false + description: If set will return the tasks as they were during a specific step of execution. + schema: + type: integer get: tags: - Process Instances diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py index 7283b19b..14dcac0d 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py @@ -46,6 +46,9 @@ from spiffworkflow_backend.models.process_instance_report import ( from spiffworkflow_backend.models.refresh_token import RefreshTokenModel # noqa: F401 from spiffworkflow_backend.models.secret_model import SecretModel # noqa: F401 from spiffworkflow_backend.models.spiff_logging import SpiffLoggingModel # noqa: F401 +from spiffworkflow_backend.models.spiff_step_details import ( + SpiffStepDetailsModel, +) # noqa: F401 from spiffworkflow_backend.models.user import UserModel # noqa: F401 from spiffworkflow_backend.models.group import GroupModel # noqa: F401 diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index 1c2098e9..0e4112d6 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -81,6 +81,7 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): spiff_logs = relationship("SpiffLoggingModel", cascade="delete") # type: ignore message_instances = relationship("MessageInstanceModel", cascade="delete") # type: ignore message_correlations = relationship("MessageCorrelationModel", cascade="delete") # type: ignore + spiff_step_details = relationship("SpiffStepDetailsModel", cascade="delete") # type: ignore bpmn_json: str | None = deferred(db.Column(db.JSON)) # type: ignore start_in_seconds: int | None = db.Column(db.Integer) @@ -92,6 +93,7 @@ class ProcessInstanceModel(SpiffworkflowBaseDBModel): bpmn_xml_file_contents: bytes | None = None bpmn_version_control_type: str = db.Column(db.String(50)) bpmn_version_control_identifier: str = db.Column(db.String(255)) + spiff_step: int = db.Column(db.Integer) @property def serialized(self) -> dict[str, Any]: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_logging.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_logging.py index a655ec51..58f13cd4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_logging.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_logging.py @@ -25,3 +25,4 @@ class SpiffLoggingModel(SpiffworkflowBaseDBModel): timestamp: float = db.Column(db.DECIMAL(17, 6), nullable=False) message: Optional[str] = db.Column(db.String(255), nullable=True) current_user_id: int = db.Column(ForeignKey(UserModel.id), nullable=True) + spiff_step: int = db.Column(db.Integer, nullable=False) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py new file mode 100644 index 00000000..1706c2e9 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/spiff_step_details.py @@ -0,0 +1,23 @@ +"""Spiff_step_details.""" +from dataclasses import dataclass + +from flask_bpmn.models.db import db +from flask_bpmn.models.db import SpiffworkflowBaseDBModel +from sqlalchemy import ForeignKey +from sqlalchemy.orm import deferred + +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.user import UserModel + + +@dataclass +class SpiffStepDetailsModel(SpiffworkflowBaseDBModel): + """SpiffStepDetailsModel.""" + + __tablename__ = "spiff_step_details" + id: int = db.Column(db.Integer, primary_key=True) + process_instance_id: int = db.Column(ForeignKey(ProcessInstanceModel.id), nullable=False) # type: ignore + spiff_step: int = db.Column(db.Integer, nullable=False) + task_json: str | None = deferred(db.Column(db.JSON, nullable=False)) # type: ignore + timestamp: float = db.Column(db.DECIMAL(17, 6), nullable=False) + completed_by_user_id: int = db.Column(ForeignKey(UserModel.id), nullable=True) 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 0523ad25..41cd9d99 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -56,6 +56,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfoSchema from spiffworkflow_backend.models.secret_model import SecretModel from spiffworkflow_backend.models.secret_model import SecretModelSchema from spiffworkflow_backend.models.spiff_logging import SpiffLoggingModel +from spiffworkflow_backend.models.spiff_step_details import SpiffStepDetailsModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.routes.user import verify_token from spiffworkflow_backend.services.authorization_service import AuthorizationService @@ -954,10 +955,23 @@ def task_list_my_tasks(page: int = 1, per_page: int = 100) -> flask.wrappers.Res def process_instance_task_list( - process_instance_id: int, all_tasks: bool = False + process_instance_id: int, all_tasks: bool = False, spiff_step: int = 0 ) -> flask.wrappers.Response: """Process_instance_task_list.""" process_instance = find_process_instance_by_id_or_raise(process_instance_id) + + if spiff_step > 0: + step_detail = ( + db.session.query(SpiffStepDetailsModel) + .filter( + SpiffStepDetailsModel.process_instance_id == process_instance.id, + SpiffStepDetailsModel.spiff_step == spiff_step, + ) + .first() + ) + if step_detail is not None: + process_instance.bpmn_json = json.dumps(step_detail.task_json) + processor = ProcessInstanceProcessor(process_instance) spiff_tasks = None @@ -1233,6 +1247,7 @@ def script_unit_test_run( """Script_unit_test_run.""" # FIXME: We should probably clear this somewhere else but this works current_app.config["THREAD_LOCAL_DATA"].process_instance_id = None + current_app.config["THREAD_LOCAL_DATA"].spiff_step = None python_script = _get_required_parameter_or_raise("python_script", body) input_json = _get_required_parameter_or_raise("input_json", body) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py index ce30e8b9..13f66e00 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py @@ -108,6 +108,8 @@ class SpiffFilter(logging.Filter): if hasattr(tld, "process_instance_id"): process_instance_id = tld.process_instance_id setattr(record, "process_instance_id", process_instance_id) # noqa: B010 + if hasattr(tld, "spiff_step"): + setattr(record, "spiff_step", tld.spiff_step) # noqa: 8010 if hasattr(g, "user") and g.user: setattr(record, "current_user_id", g.user.id) # noqa: B010 return True @@ -204,6 +206,11 @@ class DBHandler(logging.Handler): timestamp = record.created message = record.msg if hasattr(record, "msg") else None current_user_id = record.current_user_id if hasattr(record, "current_user_id") else None # type: ignore + spiff_step = ( + record.spiff_step # type: ignore + if hasattr(record, "spiff_step") and record.spiff_step is not None # type: ignore + else 1 + ) spiff_log = SpiffLoggingModel( process_instance_id=record.process_instance_id, # type: ignore bpmn_process_identifier=bpmn_process_identifier, @@ -214,6 +221,7 @@ class DBHandler(logging.Handler): message=message, timestamp=timestamp, current_user_id=current_user_id, + spiff_step=spiff_step, ) db.session.add(spiff_log) db.session.commit() 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 fecde1b9..aa94704c 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -79,6 +79,7 @@ from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.script_attributes_context import ( ScriptAttributesContext, ) +from spiffworkflow_backend.models.spiff_step_details import SpiffStepDetailsModel from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.models.user import UserModelSchema from spiffworkflow_backend.scripts.script import Script @@ -276,9 +277,9 @@ class ProcessInstanceProcessor: self, process_instance_model: ProcessInstanceModel, validate_only: bool = False ) -> None: """Create a Workflow Processor based on the serialized information available in the process_instance model.""" - current_app.config[ - "THREAD_LOCAL_DATA" - ].process_instance_id = process_instance_model.id + tld = current_app.config["THREAD_LOCAL_DATA"] + tld.process_instance_id = process_instance_model.id + tld.spiff_step = process_instance_model.spiff_step # we want this to be the fully qualified path to the process model including all group subcomponents current_app.config["THREAD_LOCAL_DATA"].process_model_identifier = ( @@ -411,10 +412,8 @@ class ProcessInstanceProcessor: bpmn_process_spec, subprocesses ) - def add_user_info_to_process_instance( - self, bpmn_process_instance: BpmnWorkflow - ) -> None: - """Add_user_info_to_process_instance.""" + def current_user(self) -> Any: + """Current_user.""" current_user = None if UserService.has_user(): current_user = UserService.current_user() @@ -425,6 +424,14 @@ class ProcessInstanceProcessor: elif self.process_instance_model.process_initiator_id: current_user = self.process_instance_model.process_initiator + return current_user + + def add_user_info_to_process_instance( + self, bpmn_process_instance: BpmnWorkflow + ) -> None: + """Add_user_info_to_process_instance.""" + current_user = self.current_user() + if current_user: current_user_data = UserModelSchema().dump(current_user) tasks = bpmn_process_instance.get_tasks(TaskState.READY) @@ -542,9 +549,32 @@ class ProcessInstanceProcessor: "lane_assignment_id": lane_assignment_id, } + def save_spiff_step_details(self, bpmn_json: Optional[str]) -> None: + """SaveSpiffStepDetails.""" + if bpmn_json is None: + return + wf_json = json.loads(bpmn_json) + task_json = "{}" + if "tasks" in wf_json: + task_json = json.dumps(wf_json["tasks"]) + + # TODO want to just save the tasks, something wasn't immediately working + # so after the flow works with the full wf_json revisit this + task_json = wf_json + details_model = SpiffStepDetailsModel( + process_instance_id=self.process_instance_model.id, + spiff_step=self.process_instance_model.spiff_step or 1, + task_json=task_json, + timestamp=round(time.time()), + completed_by_user_id=self.current_user().id, + ) + db.session.add(details_model) + 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() + complete_states = [TaskState.CANCELLED, TaskState.COMPLETED] user_tasks = list(self.get_all_user_tasks()) self.process_instance_model.status = self.get_status().value @@ -930,8 +960,19 @@ class ProcessInstanceProcessor: db.session.commit() + def increment_spiff_step(self) -> None: + """Spiff_step++.""" + spiff_step = self.process_instance_model.spiff_step or 0 + spiff_step += 1 + self.process_instance_model.spiff_step = spiff_step + current_app.config["THREAD_LOCAL_DATA"].spiff_step = spiff_step + db.session.add(self.process_instance_model) + db.session.commit() + def do_engine_steps(self, exit_at: None = None, save: bool = False) -> None: """Do_engine_steps.""" + self.increment_spiff_step() + try: self.bpmn_process_instance.refresh_waiting_tasks() self.bpmn_process_instance.do_engine_steps(exit_at=exit_at) @@ -944,6 +985,10 @@ class ProcessInstanceProcessor: finally: if save: self.save() + bpmn_json = self.process_instance_model.bpmn_json + else: + bpmn_json = self.serialize() + self.save_spiff_step_details(bpmn_json) def cancel_notify(self) -> None: """Cancel_notify.""" @@ -1054,7 +1099,10 @@ class ProcessInstanceProcessor: def complete_task(self, task: SpiffTask) -> None: """Complete_task.""" + self.increment_spiff_step() self.bpmn_process_instance.complete_task_from_id(task.id) + bpmn_json = self.serialize() + self.save_spiff_step_details(bpmn_json) def get_data(self) -> dict[str, Any]: """Get_data.""" diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_spiff_logging.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_spiff_logging.py index c4a5984f..d8680b71 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_spiff_logging.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_spiff_logging.py @@ -36,6 +36,7 @@ class TestSpiffLogging(BaseTest): bpmn_task_identifier=bpmn_task_identifier, message=message, timestamp=timestamp, + spiff_step=1, ) assert spiff_log.timestamp == timestamp diff --git a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx index c3e39e16..776a5f34 100644 --- a/spiffworkflow-frontend/src/routes/AdminRoutes.tsx +++ b/spiffworkflow-frontend/src/routes/AdminRoutes.tsx @@ -76,6 +76,10 @@ export default function AdminRoutes() { path="process-models/:process_group_id/:process_model_id/process-instances/:process_instance_id" element={} /> + } + /> } diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx index f2d297f0..83e56ac5 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceLogList.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { Table } from 'react-bootstrap'; -import { useParams, useSearchParams } from 'react-router-dom'; +import { useParams, useSearchParams, Link } from 'react-router-dom'; import PaginationForTable from '../components/PaginationForTable'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; import { @@ -39,7 +39,14 @@ export default function ProcessInstanceLogList() { {rowToUse.bpmn_task_name} {rowToUse.bpmn_task_type} {rowToUse.username} - {convertSecondsToFormattedDate(rowToUse.timestamp)} + + + {convertSecondsToFormattedDate(rowToUse.timestamp)} + + ); }); diff --git a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx index f5336df8..010a7f48 100644 --- a/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessInstanceShow.tsx @@ -32,10 +32,16 @@ export default function ProcessInstanceShow() { path: `/process-models/${params.process_group_id}/${params.process_model_id}/process-instances/${params.process_instance_id}`, successCallback: setProcessInstance, }); - HttpService.makeCallToBackend({ - path: `/process-instance/${params.process_instance_id}/tasks?all_tasks=true`, - successCallback: setTasks, - }); + if (typeof params.spiff_step === 'undefined') + HttpService.makeCallToBackend({ + path: `/process-instance/${params.process_instance_id}/tasks?all_tasks=true`, + successCallback: setTasks, + }); + else + HttpService.makeCallToBackend({ + path: `/process-instance/${params.process_instance_id}/tasks?all_tasks=true&spiff_step=${params.spiff_step}`, + successCallback: setTasks, + }); }, [params]); const deleteProcessInstance = () => {