Feature/draft data in join table (#355)

* added a new model to store task draft data in a join table

* cleaned up using the join table for draft table w/ burnettk

* created new single migration for changes w/ burnettk

* added hidden form which autosaves without validations w/ burnettk

* change close button name since it does indeed save on close now

---------

Co-authored-by: jasquat <jasquat@users.noreply.github.com>
This commit is contained in:
jasquat 2023-06-28 12:53:39 -04:00 committed by GitHub
parent d50fb61bb9
commit 15b2947107
9 changed files with 213 additions and 51 deletions

View File

@ -0,0 +1,54 @@
"""empty message
Revision ID: 64adf34a98db
Revises: 881cdb50a567
Create Date: 2023-06-27 15:13:03.219908
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '64adf34a98db'
down_revision = '881cdb50a567'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('task_draft_data',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('process_instance_id', sa.Integer(), nullable=False),
sa.Column('task_definition_id_path', sa.String(length=255), nullable=False),
sa.Column('saved_form_data_hash', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['process_instance_id'], ['process_instance.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('process_instance_id', 'task_definition_id_path', name='process_instance_task_definition_unique')
)
with op.batch_alter_table('task_draft_data', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_task_draft_data_process_instance_id'), ['process_instance_id'], unique=False)
batch_op.create_index(batch_op.f('ix_task_draft_data_saved_form_data_hash'), ['saved_form_data_hash'], unique=False)
batch_op.create_index(batch_op.f('ix_task_draft_data_task_definition_id_path'), ['task_definition_id_path'], unique=False)
with op.batch_alter_table('task', schema=None) as batch_op:
batch_op.drop_index('ix_task_saved_form_data_hash')
batch_op.drop_column('saved_form_data_hash')
# ### 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.add_column(sa.Column('saved_form_data_hash', mysql.VARCHAR(collation='utf8mb4_0900_as_cs', length=255), nullable=True))
batch_op.create_index('ix_task_saved_form_data_hash', ['saved_form_data_hash'], unique=False)
with op.batch_alter_table('task_draft_data', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_task_draft_data_task_definition_id_path'))
batch_op.drop_index(batch_op.f('ix_task_draft_data_saved_form_data_hash'))
batch_op.drop_index(batch_op.f('ix_task_draft_data_process_instance_id'))
op.drop_table('task_draft_data')
# ### end Alembic commands ###

View File

@ -82,5 +82,8 @@ from spiffworkflow_backend.models.process_model_cycle import (
from spiffworkflow_backend.models.typeahead import (
TypeaheadModel,
) # noqa: F401
from spiffworkflow_backend.models.task_draft_data import (
TaskDraftDataModel,
) # noqa: F401
add_listeners()

View File

@ -63,7 +63,6 @@ class TaskModel(SpiffworkflowBaseDBModel):
json_data_hash: str = db.Column(db.String(255), nullable=False, index=True)
python_env_data_hash: str = db.Column(db.String(255), nullable=False, index=True)
saved_form_data_hash: str | None = db.Column(db.String(255), nullable=True, index=True)
start_in_seconds: float | None = db.Column(db.DECIMAL(17, 6))
end_in_seconds: float | None = db.Column(db.DECIMAL(17, 6))
@ -91,11 +90,6 @@ class TaskModel(SpiffworkflowBaseDBModel):
def json_data(self) -> dict:
return JsonDataModel.find_data_dict_by_hash(self.json_data_hash)
def get_saved_form_data(self) -> dict | None:
if self.saved_form_data_hash is not None:
return JsonDataModel.find_data_dict_by_hash(self.saved_form_data_hash)
return None
class Task:
HUMAN_TASK_TYPES = ["User Task", "Manual Task"]

View File

@ -0,0 +1,38 @@
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import ForeignKey
from sqlalchemy import UniqueConstraint
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
from spiffworkflow_backend.models.db import db
from spiffworkflow_backend.models.json_data import JsonDataModel # noqa: F401
from spiffworkflow_backend.models.process_instance import ProcessInstanceModel
@dataclass
class TaskDraftDataModel(SpiffworkflowBaseDBModel):
__tablename__ = "task_draft_data"
__table_args__ = (
UniqueConstraint(
"process_instance_id",
"task_definition_id_path",
name="process_instance_task_definition_unique",
),
)
id: int = db.Column(db.Integer, primary_key=True)
process_instance_id: int = db.Column(
ForeignKey(ProcessInstanceModel.id), nullable=False, index=True # type: ignore
)
# a colon delimited path of bpmn_process_definition_ids for a given task
task_definition_id_path: str = db.Column(db.String(255), nullable=False, index=True)
saved_form_data_hash: str | None = db.Column(db.String(255), nullable=True, index=True)
def get_saved_form_data(self) -> dict | None:
if self.saved_form_data_hash is not None:
return JsonDataModel.find_data_dict_by_hash(self.saved_form_data_hash)
return None

View File

@ -47,6 +47,7 @@ from spiffworkflow_backend.routes.process_api_blueprint import _find_principal_o
from spiffworkflow_backend.routes.process_api_blueprint import _find_process_instance_by_id_or_raise
from spiffworkflow_backend.routes.process_api_blueprint import _get_process_model
from spiffworkflow_backend.services.authorization_service import AuthorizationService
from spiffworkflow_backend.services.authorization_service import HumanTaskAlreadyCompletedError
from spiffworkflow_backend.services.authorization_service import HumanTaskNotFoundError
from spiffworkflow_backend.services.authorization_service import UserDoesNotHaveAccessToTaskError
from spiffworkflow_backend.services.file_system_service import FileSystemService
@ -285,8 +286,14 @@ def task_show(process_instance_id: int, task_guid: str = "next") -> flask.wrappe
except UserDoesNotHaveAccessToTaskError:
can_complete = False
task_draft_data = TaskService.task_draft_data_from_task_model(task_model, create_if_not_exists=True)
saved_form_data = None
if task_draft_data is not None:
saved_form_data = task_draft_data.get_saved_form_data()
task_model.data = task_model.get_data()
task_model.saved_form_data = task_model.get_saved_form_data()
task_model.saved_form_data = saved_form_data
task_model.process_model_display_name = process_model.display_name
task_model.process_model_identifier = process_model.id
task_model.typename = task_definition.typename
@ -480,15 +487,23 @@ def task_save_draft(
),
status_code=400,
)
AuthorizationService.assert_user_can_complete_task(process_instance.id, task_guid, principal.user)
try:
AuthorizationService.assert_user_can_complete_task(process_instance.id, task_guid, principal.user)
except HumanTaskAlreadyCompletedError:
return make_response(jsonify({"ok": True}), 200)
task_model = _get_task_model_from_guid_or_raise(task_guid, process_instance_id)
json_data_dict = TaskService.update_task_data_on_task_model_and_return_dict_if_updated(
task_model, body, "saved_form_data_hash"
)
if json_data_dict is not None:
JsonDataModel.insert_or_update_json_data_dict(json_data_dict)
db.session.add(task_model)
db.session.commit()
task_draft_data = TaskService.task_draft_data_from_task_model(task_model, create_if_not_exists=True)
if task_draft_data is not None:
json_data_dict = TaskService.update_task_data_on_task_model_and_return_dict_if_updated(
task_draft_data, body, "saved_form_data_hash"
)
if json_data_dict is not None:
JsonDataModel.insert_or_update_json_data_dict(json_data_dict)
db.session.add(task_draft_data)
db.session.commit()
return Response(
json.dumps(

View File

@ -42,6 +42,10 @@ class HumanTaskNotFoundError(Exception):
pass
class HumanTaskAlreadyCompletedError(Exception):
pass
class UserDoesNotHaveAccessToTaskError(Exception):
pass
@ -342,13 +346,18 @@ class AuthorizationService:
human_task = HumanTaskModel.query.filter_by(
task_id=task_guid,
process_instance_id=process_instance_id,
completed=False,
).first()
if human_task is None:
raise HumanTaskNotFoundError(
f"Could find an human task with task guid '{task_guid}' for process instance '{process_instance_id}'"
)
if human_task.completed:
raise HumanTaskAlreadyCompletedError(
f"Human task with task guid '{task_guid}' for process instance '{process_instance_id}' has already"
" been completed"
)
if user not in human_task.potential_owners:
raise UserDoesNotHaveAccessToTaskError(
f"User {user.username} does not have access to update"

View File

@ -26,6 +26,7 @@ from spiffworkflow_backend.models.spec_reference import SpecReferenceNotFoundErr
from spiffworkflow_backend.models.task import TaskModel # noqa: F401
from spiffworkflow_backend.models.task import TaskNotFoundError
from spiffworkflow_backend.models.task_definition import TaskDefinitionModel
from spiffworkflow_backend.models.task_draft_data import TaskDraftDataModel
from spiffworkflow_backend.services.process_instance_tmp_service import ProcessInstanceTmpService
@ -572,7 +573,9 @@ class TaskService:
return (bpmn_processes, task_models)
@classmethod
def full_bpmn_process_path(cls, bpmn_process: BpmnProcessModel) -> list[str]:
def full_bpmn_process_path(
cls, bpmn_process: BpmnProcessModel, definition_column: str = "bpmn_identifier"
) -> list[str]:
"""Returns a list of bpmn process identifiers pointing the given bpmn_process."""
bpmn_process_identifiers: list[str] = []
if bpmn_process.guid:
@ -586,10 +589,27 @@ class TaskService:
_task_models_of_parent_bpmn_processes,
) = TaskService.task_models_of_parent_bpmn_processes(task_model)
for parent_bpmn_process in parent_bpmn_processes:
bpmn_process_identifiers.append(parent_bpmn_process.bpmn_process_definition.bpmn_identifier)
bpmn_process_identifiers.append(bpmn_process.bpmn_process_definition.bpmn_identifier)
bpmn_process_identifiers.append(
getattr(parent_bpmn_process.bpmn_process_definition, definition_column)
)
bpmn_process_identifiers.append(getattr(bpmn_process.bpmn_process_definition, definition_column))
return bpmn_process_identifiers
@classmethod
def task_draft_data_from_task_model(
cls, task_model: TaskModel, create_if_not_exists: bool = False
) -> TaskDraftDataModel | None:
full_bpmn_process_id_path = cls.full_bpmn_process_path(task_model.bpmn_process, "id")
task_definition_id_path = f"{':'.join(map(str,full_bpmn_process_id_path))}:{task_model.task_definition_id}"
task_draft_data: TaskDraftDataModel | None = TaskDraftDataModel.query.filter_by(
process_instance_id=task_model.process_instance_id, task_definition_id_path=task_definition_id_path
).first()
if task_draft_data is None and create_if_not_exists:
task_draft_data = TaskDraftDataModel(
process_instance_id=task_model.process_instance_id, task_definition_id_path=task_definition_id_path
)
return task_draft_data
@classmethod
def bpmn_process_for_called_activity_or_top_level_process(cls, task_model: TaskModel) -> BpmnProcessModel:
"""Returns either the bpmn process for the call activity calling the process or the top level bpmn process.

View File

@ -606,3 +606,7 @@ hr {
.primary-file-text-suffix {
font-style: italic;
}
#hidden-form-for-autosave {
display: none;
}

View File

@ -34,6 +34,8 @@ export default function TaskShow() {
const [disabled, setDisabled] = useState(false);
const [taskData, setTaskData] = useState<any>(null);
const [autosaveOnFormChanges, setAutosaveOnFormChanges] =
useState<boolean>(true);
const { addError, removeError } = useAPIError();
@ -58,28 +60,6 @@ export default function TaskShow() {
if (!result.can_complete) {
navigateToInterstitial(result);
}
/* Disable call to load previous tasks -- do not display menu.
const url = `/v1.0/process-instances/for-me/${modifyProcessIdentifierForPathParam(
result.process_model_identifier
)}/${params.process_instance_id}/task-info`;
// if user is unauthorized to get process-instance task-info then don't do anything
// Checking like this so we can dynamically create the url with the correct process model
// instead of passing the process model identifier in through the params
HttpService.makeCallToBackend({
path: url,
successCallback: (tasks: any) => {
setDisabled(false);
setUserTasks(tasks);
},
onUnauthorized: () => {
setDisabled(false);
},
failureCallback: (error: any) => {
addError(error);
},
});
*/
};
HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}`,
@ -94,22 +74,38 @@ export default function TaskShow() {
// in order to implement a "Save and close" button. That button no longer saves (since we have auto-save), but the crazy
// frontend code to support that Save and close button is here, in case we need to reference that someday:
// https://github.com/sartography/spiff-arena/blob/182f56a1ad23ce780e8f5b0ed00efac3e6ad117b/spiffworkflow-frontend/src/routes/TaskShow.tsx#L329
const autoSaveTaskData = (formData: any) => {
const autoSaveTaskData = (formData: any, successCallback?: Function) => {
let successCallbackToUse = successCallback;
if (!successCallbackToUse) {
successCallbackToUse = doNothing;
}
HttpService.makeCallToBackend({
path: `/tasks/${params.process_instance_id}/${params.task_id}/save-draft`,
postBody: formData,
httpMethod: 'POST',
successCallback: doNothing,
successCallback: successCallbackToUse,
failureCallback: addError,
});
};
const sendAutosaveEvent = (eventDetails?: any) => {
(document.getElementById('hidden-form-for-autosave') as any).dispatchEvent(
new CustomEvent('submit', {
cancelable: true,
bubbles: true,
detail: eventDetails,
})
);
};
const addDebouncedTaskDataAutoSave = useDebouncedCallback(
(value: string) => {
autoSaveTaskData(value);
() => {
if (autosaveOnFormChanges) {
sendAutosaveEvent();
}
},
// delay in ms
1000
500
);
const processSubmitResult = (result: any) => {
@ -127,12 +123,25 @@ export default function TaskShow() {
}
};
const handleAutosaveFormSubmit = (formObject: any, event: any) => {
const dataToSubmit = formObject?.formData;
let successCallback = null;
if (event.detail && 'successCallback' in event.detail) {
successCallback = event.detail.successCallback;
}
autoSaveTaskData(
recursivelyChangeNullAndUndefined(dataToSubmit, null),
successCallback
);
};
const handleFormSubmit = (formObject: any, _event: any) => {
if (disabled) {
return;
}
const dataToSubmit = formObject?.formData;
if (!dataToSubmit) {
navigate(`/tasks`);
return;
@ -349,7 +358,9 @@ export default function TaskShow() {
};
const handleCloseButton = () => {
navigate(`/tasks`);
setAutosaveOnFormChanges(false);
const successCallback = () => navigate(`/tasks`);
sendAutosaveEvent({ successCallback });
};
const formElement = () => {
@ -404,9 +415,9 @@ export default function TaskShow() {
onClick={handleCloseButton}
disabled={disabled}
kind="secondary"
title="Save changes without submitting."
title="Save data as draft and close the form."
>
Close
Save and Close
</Button>
);
}
@ -437,16 +448,19 @@ export default function TaskShow() {
const widgets = { typeahead: TypeaheadWidget };
// we are using two forms here so we can have one that validates data and one that does not.
// this allows us to autosave form data without extra attributes and without validations
// but still requires validations when the user submits the form that they can edit.
return (
<Grid fullWidth condensed>
<Column sm={4} md={5} lg={8}>
<Form
id="our-very-own-form"
id="form-to-submit"
disabled={disabled}
formData={taskData}
onChange={(obj: any) => {
setTaskData(obj.formData);
addDebouncedTaskDataAutoSave(obj.formData);
addDebouncedTaskDataAutoSave();
}}
onSubmit={handleFormSubmit}
schema={jsonSchema}
@ -458,6 +472,17 @@ export default function TaskShow() {
>
{reactFragmentToHideSubmitButton}
</Form>
<Form
id="hidden-form-for-autosave"
formData={taskData}
onSubmit={handleAutosaveFormSubmit}
schema={jsonSchema}
uiSchema={formUiSchema}
widgets={widgets}
validator={validator}
noValidate
omitExtraData
/>
</Column>
</Grid>
);