From 0f141e18dad4a82e1f4054f8d4e5dcf4cec56ecf Mon Sep 17 00:00:00 2001 From: Sumesh Punakkal Kariyil Date: Fri, 11 Oct 2024 13:17:29 -0700 Subject: [PATCH] Changes for new rest endpoint --- spiffworkflow-backend/migrations/alembic.ini | 1 + .../migrations/versions/da22d9039670_.py | 40 ++ .../src/spiffworkflow_backend/api.yml | 304 ++++++++++++++ .../models/human_task_user.py | 2 + .../models/process_instance.py | 4 +- .../routes/authentication_controller.py | 18 +- .../routes/ff_tasks_controller.py | 391 ++++++++++++++++++ .../routes/messages_controller.py | 2 + .../routes/process_api_blueprint.py | 11 + .../routes/process_instances_controller.py | 35 +- .../routes/process_models_controller.py | 1 + .../routes/tasks_controller.py | 8 +- .../scripts/get_token.py | 31 ++ .../services/authorization_service.py | 7 + .../services/message_service.py | 3 +- .../services/process_instance_processor.py | 48 ++- .../services/user_service.py | 37 ++ 17 files changed, 916 insertions(+), 27 deletions(-) create mode 100644 spiffworkflow-backend/migrations/versions/da22d9039670_.py create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/routes/ff_tasks_controller.py create mode 100644 spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_token.py diff --git a/spiffworkflow-backend/migrations/alembic.ini b/spiffworkflow-backend/migrations/alembic.ini index ec9d45c26..2919e80e2 100644 --- a/spiffworkflow-backend/migrations/alembic.ini +++ b/spiffworkflow-backend/migrations/alembic.ini @@ -1,6 +1,7 @@ # A generic, single database configuration. [alembic] +script_location = migrations # template used to generate migration files # file_template = %%(rev)s_%%(slug)s diff --git a/spiffworkflow-backend/migrations/versions/da22d9039670_.py b/spiffworkflow-backend/migrations/versions/da22d9039670_.py new file mode 100644 index 000000000..3ff2b0a45 --- /dev/null +++ b/spiffworkflow-backend/migrations/versions/da22d9039670_.py @@ -0,0 +1,40 @@ +"""empty message + +Revision ID: da22d9039670 +Revises: 384e2bbda36b +Create Date: 2024-09-17 15:13:48.384925 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'da22d9039670' +down_revision = '384e2bbda36b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('human_task_user', schema=None) as batch_op: + batch_op.add_column(sa.Column('ended_at_in_seconds', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('created_at_in_seconds', sa.Integer(), nullable=True)) + + # with op.batch_alter_table('task', schema=None) as batch_op: + # batch_op.drop_constraint('guid', type_='unique') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + # with op.batch_alter_table('task', schema=None) as batch_op: + # batch_op.create_unique_constraint('guid', ['guid']) + + with op.batch_alter_table('human_task_user', schema=None) as batch_op: + batch_op.drop_column('created_at_in_seconds') + batch_op.drop_column('ended_at_in_seconds') + + # ### end Alembic commands ### diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 2b50f7832..53617ce7d 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -3422,6 +3422,135 @@ paths: application/json: schema: $ref: "#/components/schemas/Workflow" + /task-filters: + post: + summary: Retrieve filtered tasks based on pagination and other criteria + operationId: spiffworkflow_backend.routes.ff_tasks_controller.filter_tasks + parameters: + - name: firstResult + in: query + required: true + description: The starting index of the results to return + schema: + type: integer + - name: maxResults + in: query + required: true + description: The maximum number of results to return + schema: + type: integer + responses: + "200": + description: Successfully retrieved filtered tasks + content: + application/json: + schema: + type: array + items: + type: object + properties: + _links: + type: object + properties: + self: + type: object + properties: + href: + type: string + _embedded: + type: object +# properties: +# assignee: +# type: array +# items: +# $ref: '#/components/schemas/FfUser' +# processDefinition: +# type: array +# items: +# $ref: '#/components/schemas/ProcessDefinition' +# task: +# type: array +# items: +# $ref: '#/components/schemas/FfTask' + count: + type: integer + /task/{task_id}: + parameters: + - name: task_id + in: path + required: true + description: "The unique id of an existing process group." + schema: + type: string + get: + tags: + - Tasks + operationId: spiffworkflow_backend.routes.ff_tasks_controller.get_task_by_id + summary: "Gets one task that a user wants to complete" + responses: + "200": + description: "One task" + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + + + /task/{task_id}/claim: + post: + summary: "Claim task" + operationId: spiffworkflow_backend.routes.ff_tasks_controller.claim_task + parameters: + - name: task_id + in: path + required: true + description: "The unique id of an existing process group." + schema: + type: string + responses: + "200": + description: "Successfully claimed task" + content: + application/json: + schema: + type: object + /task/{task_id}/unclaim: + post: + summary: "Unclaim task" + operationId: spiffworkflow_backend.routes.ff_tasks_controller.unclaim_task + parameters: + - name: task_id + in: path + required: true + description: "The unique id of an existing process group." + schema: + type: string + responses: + "200": + description: "Successfully unclaimed task" + content: + application/json: + schema: + type: object + + /task/{task_id}/submit-form: + post: + summary: "Submit form" + operationId: spiffworkflow_backend.routes.ff_tasks_controller.submit_task + parameters: + - name: task_id + in: path + required: true + description: "The unique id of an existing process group." + schema: + type: string + responses: + "200": + description: "Successfully submitted task" + content: + application/json: + schema: + type: object components: securitySchemes: @@ -4105,3 +4234,178 @@ components: type: number example: 1 nullable: false + +# FfUser: +# type: object +# properties: +# _links: +# type: object +# properties: +# self: +# type: object +# properties: +# href: +# type: string +# _embedded: +# type: object +# id: +# type: string +# firstName: +# type: string +# lastName: +# type: string +# email: +# type: string +# ProcessDefinition: +# type: object +# properties: +# _links: +# type: object +# additionalProperties: +# type: object +# properties: +# href: +# type: string +# _embedded: +# type: object +# id: +# type: string +# key: +# type: string +# category: +# type: string +# description: +# type: string +# name: +# type: string +# versionTag: +# type: string +# version: +# type: integer +# resource: +# type: string +# deploymentId: +# type: string +# diagram: +# type: string +# suspended: +# type: boolean +# contextPath: +# type: string +# FfTask: +# type: object +# properties: +# _links: +# type: object +# additionalProperties: +# type: object +# properties: +# href: +# type: string +# _embedded: +# type: object +# properties: +# candidateGroups: +# type: array +# items: +# $ref: '#/components/schemas/CandidateGroup' +# variable: +# type: array +# items: +# $ref: '#/components/schemas/Variable' +# id: +# type: string +# name: +# type: string +# assignee: +# type: string +# created: +# type: string +# format: date-time +# due: +# type: string +# format: date-time +# followUp: +# type: string +# format: date-time +# delegationState: +# type: string +# description: +# type: string +# executionId: +# type: string +# owner: +# type: string +# parentTaskId: +# type: string +# priority: +# type: integer +# processDefinitionId: +# type: string +# processInstanceId: +# type: string +# taskDefinitionKey: +# type: string +# caseExecutionId: +# type: string +# caseInstanceId: +# type: string +# caseDefinitionId: +# type: string +# suspended: +# type: boolean +# formKey: +# type: string +# camundaFormRef: +# type: string +# tenantId: +# type: string +# CandidateGroup: +# type: object +# properties: +# _links: +# type: object +# properties: +# group: +# type: object +# properties: +# href: +# type: string +# task: +# type: object +# properties: +# href: +# type: string +# _embedded: +# type: object +# type: +# type: string +# userId: +# type: string +# groupId: +# type: string +# taskId: +# type: string +# Variable: +# type: object +# properties: +# _links: +# type: object +# properties: +# self: +# type: object +# properties: +# href: +# type: string +# _embedded: +# type: object +# name: +# type: string +# value: +# type: string +# type: +# type: string +# valueInfo: +# type: object +# additionalProperties: +# type: string diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task_user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task_user.py index c570c683f..3d759e9fd 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task_user.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/human_task_user.py @@ -26,5 +26,7 @@ class HumanTaskUserModel(SpiffworkflowBaseDBModel): id = db.Column(db.Integer, primary_key=True) human_task_id = db.Column(ForeignKey(HumanTaskModel.id), nullable=False, index=True) # type: ignore user_id = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) # type: ignore + ended_at_in_seconds: int = db.Column(db.Integer) + created_at_in_seconds: int = db.Column(db.Integer) human_task = relationship(HumanTaskModel, back_populates="human_task_users") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py index 1b633c565..2fb4d3bd0 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance.py @@ -19,7 +19,7 @@ from spiffworkflow_backend.models.future_task import FutureTaskModel from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import UserModel - +from flask import current_app class ProcessInstanceNotFoundError(Exception): pass @@ -212,7 +212,9 @@ def get_last_completed_task(self) -> TaskModel | None: def get_data(self) -> dict: """Returns the data of the last completed task in this process instance.""" last_completed_task = self.get_last_completed_task() + current_app.logger.info(f"get_data::last_completed_task : {last_completed_task}") if last_completed_task: # pragma: no cover + current_app.logger.info(f"last_completed_task : {last_completed_task.json_data()}") return last_completed_task.json_data() else: return {} diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py index 3461c67b6..4941bc5f4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/authentication_controller.py @@ -356,6 +356,8 @@ def _get_user_model_from_token(decoded_token: dict) -> UserModel | None: } if user_info is None: + # here create a user from the token. + AuthenticationService.set_user_has_logged_out() raise ApiError( error_code="invalid_token", @@ -377,12 +379,14 @@ def _get_user_model_from_token(decoded_token: dict) -> UserModel | None: .first() ) if user_model is None: - AuthenticationService.set_user_has_logged_out() - raise ApiError( - error_code="invalid_user", - message="Invalid user. Please log in.", - status_code=401, - ) + user_model: UserModel = UserService.create_user_from_token(decoded_token) + # if user_model is None: + # AuthenticationService.set_user_has_logged_out() + # raise ApiError( + # error_code="invalid_user", + # message="Invalid user. Please log in.", + # status_code=401, + # ) # no user_info else: AuthenticationService.set_user_has_logged_out() @@ -400,7 +404,7 @@ def _get_user_model_from_token(decoded_token: dict) -> UserModel | None: message="Invalid token. Please log in.", status_code=401, ) - + UserService.sync_user_with_token(decoded_token, user_model) return user_model diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/ff_tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/ff_tasks_controller.py new file mode 100644 index 000000000..641579177 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/ff_tasks_controller.py @@ -0,0 +1,391 @@ +import json +from collections import OrderedDict +from collections.abc import Generator +from typing import Any +from flask import jsonify, make_response +from datetime import datetime + +import flask.wrappers +import sentry_sdk +from flask import current_app +from flask import g +from flask import jsonify +from flask import make_response +from flask import stream_with_context +from flask.wrappers import Response +from SpiffWorkflow.bpmn.exceptions import WorkflowTaskException # type: ignore +from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore +from SpiffWorkflow.task import Task as SpiffTask # type: ignore +from SpiffWorkflow.util.task import TaskState # type: ignore +from sqlalchemy import and_ +from sqlalchemy import desc +from sqlalchemy import func +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import aliased +from sqlalchemy.orm.util import AliasedClass + +from spiffworkflow_backend.constants import SPIFFWORKFLOW_BACKEND_SERIALIZER_VERSION +from spiffworkflow_backend.data_migrations.process_instance_migrator import ProcessInstanceMigrator +from spiffworkflow_backend.exceptions.api_error import ApiError +from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError +from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.models.group import GroupModel +from spiffworkflow_backend.models.human_task import HumanTaskModel +from spiffworkflow_backend.models.human_task_user import HumanTaskUserModel +from spiffworkflow_backend.models.process_model import ProcessModelInfo +from spiffworkflow_backend.models.json_data import JsonDataModel +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 import ProcessInstanceTaskDataCannotBeUpdatedError +from spiffworkflow_backend.models.process_instance_event import ProcessInstanceEventType +from spiffworkflow_backend.models.task import Task +from spiffworkflow_backend.models.task import TaskModel +from spiffworkflow_backend.models.task_definition import TaskDefinitionModel +from spiffworkflow_backend.models.task_draft_data import TaskDraftDataDict +from spiffworkflow_backend.models.task_draft_data import TaskDraftDataModel +from spiffworkflow_backend.models.task_instructions_for_end_user import TaskInstructionsForEndUserModel +from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_or_raise +from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise +from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_for_me_or_raise +from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model +from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_for_request, _get_task_model_by_guid +from spiffworkflow_backend.routes.process_api_blueprint import _get_task_model_from_guid_or_raise +from spiffworkflow_backend.routes.process_api_blueprint import _munge_form_ui_schema_based_on_hidden_fields_in_task_data +from spiffworkflow_backend.routes.process_api_blueprint import _task_submit_shared +from spiffworkflow_backend.routes.process_api_blueprint import _update_form_schema_with_task_data_as_needed +from spiffworkflow_backend.services.authorization_service import AuthorizationService +from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService +from spiffworkflow_backend.services.jinja_service import JinjaService +from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor +from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError +from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService +from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService +from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService +from spiffworkflow_backend.services.task_service import TaskService +from .tasks_controller import _get_tasks, task_assign +import time + + +def filter_tasks(body: dict, firstResult: int = 1, maxResults: int = 100) -> flask.wrappers.Response: + """Filter tasks and return the list.""" + if not body or body.get('criteria') is None: + return None + user_model: UserModel = g.user + + human_tasks_query = ( + db.session.query(HumanTaskModel, ProcessInstanceModel.id, ProcessModelInfo) + .group_by( + HumanTaskModel.id, # Group by the ID of the human task + ProcessInstanceModel.id, # Add the process instance ID to the GROUP BY clause + ProcessModelInfo.process_id + ) # type: ignore + .outerjoin(GroupModel, GroupModel.id == HumanTaskModel.lane_assignment_id) + .join(ProcessInstanceModel) + .join(ProcessModelInfo, ProcessModelInfo.id == ProcessInstanceModel.process_model_identifier) + .filter( + HumanTaskModel.completed == False, # noqa: E712 + ProcessInstanceModel.status != ProcessInstanceStatus.error.value, + ) + ) + + # Join through HumanTaskUserModel to associate users to tasks + human_tasks_query = human_tasks_query.outerjoin( + HumanTaskUserModel, + and_(HumanTaskModel.id == HumanTaskUserModel.human_task_id, HumanTaskUserModel.ended_at_in_seconds == None) + ).outerjoin(UserModel, UserModel.id == HumanTaskUserModel.user_id) # Join UserModel using HumanTaskUserModel + + # Check candidateGroupsExpression with value ${currentUserGroups()} + if body.get('criteria').get('candidateGroupsExpression') == '${currentUserGroups()}': + human_tasks_query = human_tasks_query.filter( + GroupModel.identifier.in_([group.identifier for group in user_model.groups])) + if candidate_group := body.get('criteria').get('candidateGroup'): + human_tasks_query = human_tasks_query.filter(GroupModel.identifier == candidate_group) + if body.get('criteria').get('includeAssignedTasks', False): + human_tasks_query = human_tasks_query + else: + human_tasks_query = human_tasks_query.filter(~HumanTaskModel.human_task_users.any()) + + if process_def_key := body.get('criteria').get('processDefinitionKey'): + human_tasks_query = human_tasks_query.filter(ProcessInstanceModel.process_model_identifier == process_def_key) + if ''.join(body.get('criteria').get('assigneeExpression', '').split()) == '${currentUser()}': + human_tasks_query = human_tasks_query.filter(UserModel.username == user_model.username) + + # TODO body.get('criteria').get('assignee', '') + # TODO body.get('criteria').get('processVariables', '') + # TODO body.get('criteria').get('sorting', '') + + + + user_username_column = func.max(UserModel.username).label("process_initiator_username") + user_displayname_column = func.max(UserModel.display_name).label("process_initiator_firstname") + user_email_column = func.max(UserModel.email).label("process_initiator_email") + group_identifier_column = func.max(GroupModel.identifier).label("assigned_user_group_identifier") + + human_tasks = ( + human_tasks_query.add_columns( + user_username_column, + user_displayname_column, + user_email_column, + group_identifier_column, + HumanTaskModel.task_name, + HumanTaskModel.task_title, + HumanTaskModel.process_model_display_name, + HumanTaskModel.process_instance_id, + HumanTaskModel.updated_at_in_seconds, + HumanTaskModel.created_at_in_seconds + ) + .order_by(desc(HumanTaskModel.id)) # type: ignore + .paginate(page=firstResult, per_page=maxResults, error_out=False) + ) + + return _format_response(human_tasks) + + +def get_task_by_id( + task_id: str +) -> flask.wrappers.Response: + # Query to join HumanTaskModel with HumanTaskUserModel + task_query = ( + db.session.query(HumanTaskModel, HumanTaskUserModel, UserModel) + .join(HumanTaskUserModel, and_(HumanTaskModel.id == HumanTaskUserModel.human_task_id, HumanTaskUserModel.ended_at_in_seconds == None)) + .join(UserModel, HumanTaskUserModel.user_id == UserModel.id) # Join with UserModel to get user details + .filter(HumanTaskModel.task_guid == task_id) + ) + + tasks = task_query.all() + + # If no tasks are found, return an empty list + if not tasks: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with id '{task_id}'", + status_code=400, + ) + if not len(tasks) > 1: + raise ApiError( + error_code="more_than_one_task_found", + message=f"More tasks found for '{task_id}'", + status_code=400, + ) + human_task, human_task_user, user_model = tasks[0] + return make_response(jsonify(format_human_task_response(human_task, user_model)), 200) + + +def claim_task( + task_id: str, +body: dict[str, Any], +) -> flask.wrappers.Response: + task_model: HumanTaskModel | None = HumanTaskModel.query.filter_by(id=task_id).one_or_none() + if task_model is None: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with id '{task_id}'", + status_code=400, + ) + + task_assign(modified_process_model_identifier=None, process_instance_id=task_model.process_instance_id, task_guid= task_model.task_guid,body={'user_ids': [body.get("userId")]}) + + return make_response(jsonify(format_human_task_response(task_model)), 200) + +def unclaim_task( + task_id: str, +body: dict[str, Any], +) -> flask.wrappers.Response: + task_model: HumanTaskModel | None = HumanTaskModel.query.filter_by(id=task_id).one_or_none() + if task_model is None: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with id '{task_id}'", + status_code=400, + ) + + # formsflow.ai allows only one user per task. + human_task_users = HumanTaskUserModel.query.filter_by(ended_at_in_seconds=None, human_task=task_model).all() + for human_task_user in human_task_users: + human_task_user.ended_at_in_seconds = round(time.time()) + + SpiffworkflowBaseDBModel.commit_with_rollback_on_exception() + + return make_response(jsonify({"ok": True}), 200) + + +def get_task_variables( #TODO + task_id: int +) -> flask.wrappers.Response: + pass + +def get_task_identity_links( #TODO + task_id: int +) -> flask.wrappers.Response: + pass + +def submit_task( + task_id: str, +body: dict[str, Any], +) -> flask.wrappers.Response: + task_model: HumanTaskModel | None = HumanTaskModel.query.filter_by(id=task_id).one_or_none() + if task_model is None: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with id '{task_id}'", + status_code=400, + ) + # TODO Manage task variables submitted. + with sentry_sdk.start_span(op="controller_action", description="tasks_controller.task_submit"): + response_item = _task_submit_shared(task_model.process_instance_id, task_model.task_guid, body) + return make_response(jsonify(response_item), 200) + + + + + +def _format_response(human_tasks): + response = [] + + tasks = [] + for task in human_tasks.items: + task_data = { + "_links": { + # pass empty _links as spiff doesn't support HATEOAS + }, + "_embedded": { + "candidateGroups": [ + { + "_links": { + "group": { + "href": f"/group/{task.group_identifier_column}" + }, + "task": { + "href": f"/task/{task.HumanTaskModel.id}" + } + }, + "_embedded": None, + "type": "candidate", + "userId": None, # TODO Find User ID + "groupId": task.group_identifier_column, + "taskId": task.HumanTaskModel.id + } + ], + "variable": [] # TODO Retrieve from the task data + }, + "id": task.HumanTaskModel.task_guid, + "name": task.HumanTaskModel.task_name, + "assignee": task.user_username_column, + "created": datetime.utcfromtimestamp(task.HumanTaskModel.created_at_in_seconds).isoformat() + 'Z', + "due": None, # TODO + "followUp": None, # TODO + "delegationState": None, + "description": None, + "executionId": task.HumanTaskModel.process_instance_id, + "owner": None, + "parentTaskId": None, + "priority": 50, #TODO + "processDefinitionId": task.ProcessModelInfo.process_id, + "processInstanceId": task.HumanTaskModel.process_instance_id, + "taskDefinitionKey": task.HumanTaskModel.task_id, + "caseExecutionId": None, + "caseInstanceId": None, + "caseDefinitionId": None, + "suspended": False, + "formKey": None, + "camundaFormRef": None, + "tenantId": None # TODO + } + + tasks.append(task_data) + + assignees = [ + { + "_links": { + "self": { + "href": f"/user/{task.user_username_column}" + } + }, + "_embedded": None, + "id": task.user_username_column, + "firstName": task.user_displayname_column, # Replace with actual data + "lastName": "", # Replace with actual data + "email": task.user_email_column # Replace with actual data + } + for task in human_tasks.items + ] + + process_definitions = [ + { + "_links": {}, + "_embedded": None, + "id": task.ProcessModelInfo.id, + "key": task.ProcessModelInfo.process_id, # Replace with actual data + "category": "http://bpmn.io/schema/bpmn", + "description": task.ProcessModelInfo.description, + "name": task.ProcessModelInfo.display_name, + "versionTag": "1", # TODO Replace with actual version if available + "version": 1, # TODO Replace with actual version if available + "resource": f"{task.ProcessModelInfo.display_name}.bpmn", + "deploymentId": task.ProcessModelInfo.id, + "diagram": None, + "suspended": False, + "contextPath": None + } + for task in human_tasks.items + ] + + response.append({ + "_links": {}, + "_embedded": { + "assignee": assignees, + "processDefinition": process_definitions, + "task": tasks + }, + "count": human_tasks.total + }) + + response.append({ # TODO Add additional information + "variables": [ + { + "name": "formName", + "label": "Form Name" + }, + { + "name": "applicationId", + "label": "Submission Id" + } + ], + "taskVisibleAttributes": { + "applicationId": True, + "assignee": True, + "taskTitle": True, + "createdDate": True, + "dueDate": True, + "followUp": True, + "priority": True, + "groups": True + } + }) + + return make_response(jsonify(response), 200) + + +def format_human_task_response(human_task: HumanTaskModel, user_model: UserModel) -> dict: + """ + Format the human_task into the required response structure. + """ + return { + "id": human_task.task_guid, + "name": human_task.task_title or human_task.task_name, + "assignee": user_model.username, + "created": datetime.utcfromtimestamp(human_task.created_at_in_seconds).isoformat() + "Z" if human_task.created_at_in_seconds else None, + "due": None, #TODO + "followUp": None, #TODO + "description": human_task.task_name, # Assuming task_name serves as the description + "parentTaskId": None, # No clear parent task id field in the model + "priority": 50, # Default to 50 since there's no priority field in the model + "processDefinitionId": human_task.bpmn_process_identifier, # Mapping to bpmn_process_identifier + "processInstanceId": human_task.process_instance_id, + "taskDefinitionKey": human_task.task_id, # Mapping taskDefinitionKey to task_id + "tenantId": None # TODO + } + diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py index 6bbbc7077..65902f0a5 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/messages_controller.py @@ -98,6 +98,8 @@ def message_send( ) -> flask.wrappers.Response: receiver_message = MessageService.run_process_model_from_message(modified_message_name, body, execution_mode) process_instance = ProcessInstanceModel.query.filter_by(id=receiver_message.process_instance_id).first() + + response_json = { "task_data": process_instance.get_data(), "process_instance": ProcessInstanceModelSchema().dump(process_instance), 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 fecfdf7a0..38f6e52b4 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -836,3 +836,14 @@ def _munge_form_ui_schema_based_on_hidden_fields_in_task_data(form_ui_schema: di relevant_depth_of_ui_schema = relevant_depth_of_ui_schema[hidden_field_part] if len(hidden_field_parts) == ii + 1: relevant_depth_of_ui_schema["ui:widget"] = "hidden" + + +def _get_task_model_by_guid(task_guid: str, process_instance_id: int) -> TaskModel: + task_model: TaskModel | None = TaskModel.query.filter_by(guid=task_guid, process_instance_id=process_instance_id).first() + if task_model is None: + raise ApiError( + error_code="task_not_found", + message=f"Cannot find a task with guid '{task_guid}' for process instance '{process_instance_id}'", + status_code=400, + ) + return task_model \ No newline at end of file diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py index 8b0958479..50ea38987 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py @@ -1,3 +1,5 @@ +import uuid + from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode # black and ruff are in competition with each other in import formatting so ignore ruff @@ -5,6 +7,12 @@ import json from typing import Any +import time + +import copy +import json +import uuid +from hashlib import sha256 import flask.wrappers from flask import current_app @@ -72,7 +80,32 @@ def process_instance_start( current_app.logger.info(f"Instance created succesfully : {process_instance.id}") current_app.logger.info("running the instance") - return process_instance_run(process_model_identifier, process_instance.id, force_run, execution_mode) + process_instance_response = process_instance_run(process_model_identifier, process_instance.id, force_run, execution_mode) + + # Create a dummy task to hold the process instance data + blank_json = json.dumps({}) + blank_json_data_hash = sha256(blank_json.encode("utf8")).hexdigest() + json_data_hash = sha256(json.dumps(body).encode("utf8")).hexdigest() + # Find the task definition for the start event and use it + print("process_instance.bpmn_process_definition_id ", process_instance.bpmn_process_definition_id) + task_def_model: TaskDefinitionModel = TaskDefinitionModel.query.filter_by(typename='StartEvent', + bpmn_process_definition_id=process_instance.bpmn_process_definition_id).first() + + TaskModel( + guid=uuid.uuid4(), + bpmn_process_id=process_instance.bpmn_process_id, + process_instance_id=process_instance.id, + task_definition_id=task_def_model.id, + state='COMPLETED', + properties_json={}, + start_in_seconds=time.time(), + end_in_seconds=time.time(), + json_data_hash=json_data_hash, + python_env_data_hash=blank_json_data_hash, + data=body + ) + + return process_instance_response def process_instance_create( diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py index 1f5c2a694..d6f40c4fa 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_models_controller.py @@ -47,6 +47,7 @@ from SpiffWorkflow.bpmn.parser.BpmnParser import BpmnValidator # type: ignore from spiffworkflow_backend.services.custom_parser import MyCustomParser from lxml import etree # type: ignore +from spiffworkflow_backend.routes.process_instances_controller import process_instance_create def process_model_create_formsflow(upload: FileStorage) -> flask.wrappers.Response: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py index 2a7d2731f..dd74c81da 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/tasks_controller.py @@ -62,7 +62,7 @@ from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService from spiffworkflow_backend.services.task_service import TaskService - +import time def task_allows_guest( process_instance_id: int, @@ -377,11 +377,15 @@ def task_assign( ) human_task = human_tasks[0] + # formsflow.ai allows only one user per task. + human_task_users = HumanTaskUserModel.query.filter_by(ended_at_in_seconds=None, human_task=human_task).all() + for human_task_user in human_task_users: + human_task_user.ended_at_in_seconds = round(time.time()) for user_id in body["user_ids"]: human_task_user = HumanTaskUserModel.query.filter_by(user_id=user_id, human_task=human_task).first() if human_task_user is None: - human_task_user = HumanTaskUserModel(user_id=user_id, human_task=human_task) + human_task_user = HumanTaskUserModel(user_id=user_id, human_task=human_task, created_at_in_seconds=round(time.time())) db.session.add(human_task_user) SpiffworkflowBaseDBModel.commit_with_rollback_on_exception() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_token.py b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_token.py new file mode 100644 index 000000000..a5d8d96cf --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/scripts/get_token.py @@ -0,0 +1,31 @@ +import requests +from flask import current_app + + +def create_token() -> str: + """Create keycloak service token and return.""" + # Get Keycloak configuration from Flask app config + url: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS").get("uri") + client: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS").get("client_id") + secret: str = current_app.config.get("SPIFFWORKFLOW_BACKEND_AUTH_CONFIGS").get("client_secret") + + # Prepare the token request payload + token_url = f"{url}/protocol/openid-connect/token" + data = { + 'grant_type': 'client_credentials', + 'client_id': client, + 'client_secret': secret + } + + # Make the request to Keycloak to get the token + try: + response = requests.post(token_url, data=data) + response.raise_for_status() # Raise an error for bad status codes + + # Parse the access token from the response + token_data = response.json() + return token_data.get('access_token') + + except requests.exceptions.RequestException as e: + current_app.logger.error(f"Failed to retrieve token: {e}") + raise \ No newline at end of file diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index ce755790d..3b11a1465 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -94,7 +94,9 @@ class AuthorizationService: @classmethod def has_permission(cls, principals: list[PrincipalModel], permission: str, target_uri: str) -> bool: principal_ids = [p.id for p in principals] + print("principal_ids ", principal_ids) target_uri_normalized = target_uri.removeprefix(V1_API_PATH_PREFIX) + print("target_uri_normalized ", target_uri_normalized) permission_assignments = ( PermissionAssignmentModel.query.filter(PermissionAssignmentModel.principal_id.in_(principal_ids)) @@ -112,6 +114,7 @@ def has_permission(cls, principals: list[PrincipalModel], permission: str, targe ) .all() ) + print("permission_assignments ", permission_assignments) if len(permission_assignments) == 0: return False @@ -130,6 +133,7 @@ def has_permission(cls, principals: list[PrincipalModel], permission: str, targe @classmethod def user_has_permission(cls, user: UserModel, permission: str, target_uri: str) -> bool: principals = UserService.all_principals_for_user(user) + print("principals -->", principals) return cls.has_permission(principals, permission, target_uri) @classmethod @@ -349,7 +353,10 @@ def get_permission_from_http_method(cls, http_method: str) -> str | None: @classmethod def check_permission_for_request(cls) -> None: permission_string = cls.get_permission_from_http_method(request.method) + print("permission_string ", permission_string) if permission_string: + print("g.user ", g.user) + print("request.path ", request.path) has_permission = cls.user_has_permission( user=g.user, permission=permission_string, diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py index 0ca8ef8d7..c4828c468 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/message_service.py @@ -27,7 +27,7 @@ from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService from spiffworkflow_backend.services.user_service import UserService - +from spiffworkflow_backend.scripts.get_token import create_token class MessageServiceError(Exception): pass @@ -108,6 +108,7 @@ def correlate_send_message( message_instance_send.counterpart_id = message_instance_receive.id db.session.add(message_instance_send) db.session.commit() + if should_queue_process_instance(receiving_process_instance, execution_mode=execution_mode): queue_process_instance_if_appropriate(receiving_process_instance, execution_mode=execution_mode) return message_instance_receive 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 caa62ac24..37d6cf983 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -948,36 +948,53 @@ def get_potential_owner_ids_from_task(self, task: SpiffTask) -> PotentialOwnerId elif re.match(r"(process.?)initiator", task_lane, re.IGNORECASE): potential_owner_ids = [self.process_instance_model.process_initiator_id] else: - group_model = GroupModel.query.filter_by(identifier=task_lane).first() + group_model = self._find_or_create_group(task_lane) if group_model is not None: lane_assignment_id = group_model.id - if "lane_owners" in task.data and task_lane in task.data["lane_owners"]: + + if "candidate_group" in task.data: # Add capability to add group in script task. + group_model = self._find_or_create_group(task.data["candidate_group"]) + if group_model is not None: + lane_assignment_id = group_model.id + + elif "lane_owners" in task.data and task_lane in task.data["lane_owners"]: for username in task.data["lane_owners"][task_lane]: lane_owner_user = UserModel.query.filter_by(username=username).first() if lane_owner_user is not None: potential_owner_ids.append(lane_owner_user.id) - self.raise_if_no_potential_owners( - potential_owner_ids, - ( - "No users found in task data lane owner list for lane:" - f" {task_lane}. The user list used:" - f" {task.data['lane_owners'][task_lane]}" - ), - ) + #TODO in formsflow tasks can come first and users or groups created later + # self.raise_if_no_potential_owners( + # potential_owner_ids, + # ( + # "No users found in task data lane owner list for lane:" + # f" {task_lane}. The user list used:" + # f" {task.data['lane_owners'][task_lane]}" + # ), + # ) else: if group_model is None: raise (NoPotentialOwnersForTaskError(f"Could not find a group with name matching lane: {task_lane}")) potential_owner_ids = [i.user_id for i in group_model.user_group_assignments] - self.raise_if_no_potential_owners( - potential_owner_ids, - f"Could not find any users in group to assign to lane: {task_lane}", - ) + # TODO in formsflow tasks can come first and users or groups created later + # self.raise_if_no_potential_owners( + # potential_owner_ids, + # f"Could not find any users in group to assign to lane: {task_lane}", + # ) return { "potential_owner_ids": potential_owner_ids, "lane_assignment_id": lane_assignment_id, } + def _find_or_create_group(self, task_lane): + group_model = GroupModel.query.filter_by(identifier=task_lane).first() + if group_model is None: + group_model = GroupModel(name=task_lane, identifier=task_lane) + db.session.add(group_model) + db.session.commit() + db.session.refresh(group_model) + return group_model + def extract_metadata(self) -> None: # we are currently not getting the metadata extraction paths based on the version in git from the process instance. # it would make sense to do that if the shell-out-to-git performance cost was not too high. @@ -1043,6 +1060,7 @@ def _store_bpmn_process_definition( store_bpmn_definition_mappings: bool = False, full_bpmn_spec_dict: dict | None = None, ) -> BpmnProcessDefinitionModel: + # CHECK HERE process_bpmn_identifier = process_bpmn_properties["name"] process_bpmn_name = process_bpmn_properties["description"] @@ -1440,7 +1458,7 @@ def get_spec( # Add only the main file for now, for POC. # for file in files: - data = process_model_info.content.tobytes() + data = process_model_info.content#.tobytes() try: if process_model_info.type == FileType.bpmn.value: bpmn: etree.Element = SpecFileService.get_etree_from_xml_bytes(data) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py index 2d82d6afd..169c1d101 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/user_service.py @@ -130,6 +130,43 @@ def add_user_to_group(cls, user: UserModel, group: GroupModel) -> None: db.session.add(ugam) db.session.commit() + @classmethod + def create_user_from_token(cls, token_info): + """Create the user, group and principal from the token.""" + user_model: UserModel = cls.create_user( + username=token_info.get('preferred_username'), + service=token_info.get('iss'), + service_id=token_info.get('sub'), + email=token_info.get('email'), + display_name=token_info.get('name') + ) + cls.sync_user_with_token(token_info, user_model) + + @classmethod + def sync_user_with_token(cls, token_info, user_model): + if not token_info or not user_model: + return + # Create group if it doesn't exist + token_groups = token_info.get('groups') or token_info.get('roles') + for token_group in token_groups: + token_group = token_group.lstrip("/") + group: GroupModel = GroupModel.query.filter_by(identifier=token_group).one_or_none() + if not group: + group = GroupModel(identifier=token_group) + db.session.add(group) + # Create user group assignment for this user. + uga: UserGroupAssignmentModel = UserGroupAssignmentModel.query.filter_by(user_id=user_model.id).filter_by( + group_id=group.id).one_or_none() + if not uga: + uga = UserGroupAssignmentModel(user_id=user_model.id, group_id=group.id) + db.session.add(uga) + # Create principal for this group + principal: PrincipalModel = PrincipalModel.query.filter_by(group_id=group.id).one_or_none() + if not principal: + principal = PrincipalModel(group_id=group.id) + db.session.add(principal) + db.session.commit() + @classmethod def add_waiting_group_assignment( cls, username: str, group: GroupModel