Merge pull request #241 from sartography/feature/multiple_editor_users

Feature/multiple editor users
This commit is contained in:
jasquat 2023-05-04 11:40:28 -04:00 committed by GitHub
commit cc9c559b15
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 81 additions and 85 deletions

View File

@ -1245,6 +1245,13 @@ paths:
put:
operationId: spiffworkflow_backend.routes.process_models_controller.process_model_file_update
summary: save the contents to the given file
parameters:
- name: file_contents_hash
in: query
required: false
description: The hash of the file contents that originally came with the file.
schema:
type: string
tags:
- Process Model Files
requestBody:

View File

@ -1,12 +1,9 @@
"""File."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from typing import Optional
import marshmallow
from marshmallow import INCLUDE
from marshmallow import Schema
from typing import Any
from spiffworkflow_backend.helpers.spiff_enum import SpiffEnum
from spiffworkflow_backend.models.spec_reference import SpecReference
@ -74,10 +71,10 @@ class File:
type: str
last_modified: datetime
size: int
references: Optional[list[SpecReference]] = None
file_contents: Optional[bytes] = None
process_model_id: Optional[str] = None
process_group_id: Optional[str] = None
references: list[SpecReference] | None = None
file_contents: bytes | None = None
process_model_id: str | None = None
file_contents_hash: str | None = None
def __post_init__(self) -> None:
"""__post_init__."""
@ -91,7 +88,7 @@ class File:
content_type: str,
last_modified: datetime,
file_size: int,
) -> "File":
) -> File:
"""From_file_system."""
instance = cls(
name=file_name,
@ -102,28 +99,9 @@ class File:
)
return instance
class FileSchema(Schema):
"""FileSchema."""
class Meta:
"""Meta."""
model = File
fields = [
"id",
"name",
"content_type",
"last_modified",
"type",
"size",
"data_store",
"user_uid",
"url",
"file_contents",
"references",
"process_group_id",
"process_model_id",
]
unknown = INCLUDE
references = marshmallow.fields.List(marshmallow.fields.Nested("SpecReferenceSchema"))
@property
def serialized(self) -> dict[str, Any]:
dictionary = self.__dict__
if isinstance(self.file_contents, bytes):
dictionary["file_contents"] = self.file_contents.decode("utf-8")
return dictionary

View File

@ -87,7 +87,7 @@ class ProcessModelInfoSchema(Schema):
display_order = marshmallow.fields.Integer(allow_none=True)
primary_file_name = marshmallow.fields.String(allow_none=True)
primary_process_id = marshmallow.fields.String(allow_none=True)
files = marshmallow.fields.List(marshmallow.fields.Nested("FileSchema"))
files = marshmallow.fields.List(marshmallow.fields.Nested("File"))
fault_or_suspend_on_exception = marshmallow.fields.String()
exception_notification_addresses = marshmallow.fields.List(marshmallow.fields.String)
metadata_extraction_paths = marshmallow.fields.List(

View File

@ -298,8 +298,8 @@ def process_instance_report_show(
def process_instance_report_column_list(
process_model_identifier: Optional[str] = None,
) -> flask.wrappers.Response:
"""Process_instance_report_column_list."""
table_columns = ProcessInstanceReportService.builtin_column_options()
system_report_column_options = ProcessInstanceReportService.system_report_column_options()
columns_for_metadata_query = (
db.session.query(ProcessInstanceMetadataModel.key)
.order_by(ProcessInstanceMetadataModel.key)
@ -315,7 +315,7 @@ def process_instance_report_column_list(
columns_for_metadata_strings = [
{"Header": i[0], "accessor": i[0], "filterable": True} for i in columns_for_metadata
]
return make_response(jsonify(table_columns + columns_for_metadata_strings), 200)
return make_response(jsonify(table_columns + system_report_column_options + columns_for_metadata_strings), 200)
def process_instance_show_for_me(

View File

@ -2,6 +2,7 @@
import json
import os
import re
from hashlib import sha256
from typing import Any
from typing import Dict
from typing import Optional
@ -18,7 +19,6 @@ from werkzeug.datastructures import FileStorage
from spiffworkflow_backend.exceptions.api_error import ApiError
from spiffworkflow_backend.interfaces import IdToProcessGroupMapping
from spiffworkflow_backend.models.file import FileSchema
from spiffworkflow_backend.models.process_group import ProcessGroup
from spiffworkflow_backend.models.process_instance_report import (
ProcessInstanceReportModel,
@ -245,10 +245,13 @@ def process_model_list(
return make_response(jsonify(response_json), 200)
def process_model_file_update(modified_process_model_identifier: str, file_name: str) -> flask.wrappers.Response:
"""Process_model_file_update."""
def process_model_file_update(
modified_process_model_identifier: str, file_name: str, file_contents_hash: str
) -> flask.wrappers.Response:
message = f"User: {g.user.username} clicked save for"
return _create_or_update_process_model_file(modified_process_model_identifier, message, 200)
return _create_or_update_process_model_file(
modified_process_model_identifier, message, 200, file_contents_hash=file_contents_hash
)
def process_model_file_delete(modified_process_model_identifier: str, file_name: str) -> flask.wrappers.Response:
@ -293,24 +296,23 @@ def process_model_file_create(
def process_model_file_show(modified_process_model_identifier: str, file_name: str) -> Any:
"""Process_model_file_show."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
process_model = _get_process_model(process_model_identifier)
files = SpecFileService.get_files(process_model, file_name)
if len(files) == 0:
raise ApiError(
error_code="unknown file",
message=(
f"No information exists for file {file_name} it does not exist in workflow {process_model_identifier}."
),
error_code="process_model_file_not_found",
message=f"File {file_name} not found in workflow {process_model_identifier}.",
status_code=404,
)
file = files[0]
file_contents = SpecFileService.get_data(process_model, file.name)
file.file_contents = file_contents
file_contents_hash = sha256(file_contents).hexdigest()
file.file_contents_hash = file_contents_hash
file.process_model_id = process_model.id
return FileSchema().dump(file)
return make_response(jsonify(file), 200)
# {
@ -477,6 +479,7 @@ def _create_or_update_process_model_file(
modified_process_model_identifier: str,
message_for_git_commit: str,
http_status_to_return: int,
file_contents_hash: Optional[str] = None,
) -> flask.wrappers.Response:
"""_create_or_update_process_model_file."""
process_model_identifier = modified_process_model_identifier.replace(":", "/")
@ -498,6 +501,21 @@ def _create_or_update_process_model_file(
status_code=400,
)
if file_contents_hash is not None:
current_file_contents_bytes = SpecFileService.get_data(process_model, request_file.filename)
if current_file_contents_bytes and file_contents_hash:
current_file_contents_hash = sha256(current_file_contents_bytes).hexdigest()
if current_file_contents_hash != file_contents_hash:
raise ApiError(
error_code="process_model_file_has_changed",
message=(
f"Process model file: {request_file.filename} was already changed by someone else. If you made"
" changes you do not want to lose, click the Download button and make sure your changes are"
" in the resulting file. If you do not need your changes, you can safely reload this page."
),
status_code=409,
)
file = None
try:
file = SpecFileService.update_file(process_model, request_file.filename, request_file_contents)
@ -514,8 +532,4 @@ def _create_or_update_process_model_file(
file.process_model_id = process_model.id
_commit_and_push_to_git(f"{message_for_git_commit} {process_model_identifier}/{file.name}")
return Response(
json.dumps(FileSchema().dump(file)),
status=http_status_to_return,
mimetype="application/json",
)
return make_response(jsonify(file), http_status_to_return)

View File

@ -320,7 +320,7 @@ class ProcessInstanceReportService:
@classmethod
def builtin_column_options(cls) -> list[ReportMetadataColumn]:
"""Builtin_column_options."""
"""Columns that are actually in the process instance table."""
return_value: list[ReportMetadataColumn] = [
{"Header": "Id", "accessor": "id", "filterable": False},
{
@ -339,6 +339,15 @@ class ProcessInstanceReportService:
]
return return_value
@classmethod
def system_report_column_options(cls) -> list[ReportMetadataColumn]:
"""Columns that are used with certain system reports."""
return_value: list[ReportMetadataColumn] = [
{"Header": "Task", "accessor": "task_title", "filterable": False},
{"Header": "Waiting For", "accessor": "waiting_for", "filterable": False},
]
return return_value
@classmethod
def get_filter_value(cls, filters: list[FilterValue], filter_key: str) -> Any:
for filter in filters:

View File

@ -175,7 +175,6 @@ class SpecFileService(FileSystemService):
@classmethod
def update_file(cls, process_model_info: ProcessModelInfo, file_name: str, binary_data: bytes) -> File:
"""Update_file."""
SpecFileService.assert_valid_file_name(file_name)
cls.validate_bpmn_xml(file_name, binary_data)

View File

@ -113,7 +113,6 @@ class BaseTest:
process_group_id: str,
display_name: str = "",
) -> str:
"""Create_process_group."""
process_group = ProcessGroup(id=process_group_id, display_name=display_name, display_order=0, admin=False)
response = client.post(
"/v1.0/process-groups",
@ -138,7 +137,6 @@ class BaseTest:
primary_file_name: Optional[str] = None,
user: Optional[UserModel] = None,
) -> TestResponse:
"""Create_process_model."""
if process_model_id is not None:
# make sure we have a group
process_group_id, _ = os.path.split(process_model_id)

View File

@ -3,6 +3,7 @@ import io
import json
import os
import time
from hashlib import sha256
from typing import Any
from typing import Dict
@ -232,7 +233,6 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_primary_process_id_updates_via_xml."""
process_group_id = "test_group"
process_model_id = "sample"
process_model_identifier = f"{process_group_id}/{process_model_id}"
@ -258,10 +258,11 @@ class TestProcessApi(BaseTest):
updated_bpmn_file_data_string = bpmn_file_data_string.replace(old_string, new_string)
updated_bpmn_file_data_bytes = bytearray(updated_bpmn_file_data_string, "utf-8")
data = {"file": (io.BytesIO(updated_bpmn_file_data_bytes), bpmn_file_name)}
file_contents_hash = sha256(bpmn_file_data_bytes).hexdigest()
modified_process_model_id = process_model_identifier.replace("/", ":")
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}",
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}?file_contents_hash={file_contents_hash}",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -780,13 +781,12 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
modified_process_model_id = process_model_identifier.replace("/", ":")
data = {"key1": "THIS DATA"}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg?file_contents_hash=does_not_matter",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -803,13 +803,12 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
modified_process_model_id = process_model_identifier.replace("/", ":")
data = {"file": (io.BytesIO(b""), "random_fact.svg")}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg?file_contents_hash=does_not_matter",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -827,30 +826,22 @@ class TestProcessApi(BaseTest):
with_db_and_bpmn_file_cleanup: None,
with_super_admin_user: UserModel,
) -> None:
"""Test_process_model_file_update."""
process_group_id = "test_group"
process_group_description = "Test Group"
process_model_id = "random_fact"
process_model_id = "simple_form"
process_model_identifier = f"{process_group_id}/{process_model_id}"
self.create_process_group_with_api(client, with_super_admin_user, process_group_id, process_group_description)
self.create_process_model_with_api(
client,
bpmn_file_name = "simple_form.json"
load_test_spec(
process_model_id=process_model_identifier,
user=with_super_admin_user,
)
bpmn_file_name = "random_fact.bpmn"
original_file = load_test_spec(
process_model_id=process_model_id,
bpmn_file_name=bpmn_file_name,
process_model_source_directory="random_fact",
process_model_source_directory="simple_form",
)
bpmn_file_data_bytes = self.get_test_data_file_contents(bpmn_file_name, process_model_id)
file_contents_hash = sha256(bpmn_file_data_bytes).hexdigest()
modified_process_model_id = process_model_identifier.replace("/", ":")
new_file_contents = b"THIS_IS_NEW_DATA"
data = {"file": (io.BytesIO(new_file_contents), "random_fact.svg")}
data = {"file": (io.BytesIO(new_file_contents), bpmn_file_name)}
response = client.put(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/{bpmn_file_name}?file_contents_hash={file_contents_hash}",
data=data,
follow_redirects=True,
content_type="multipart/form-data",
@ -862,12 +853,11 @@ class TestProcessApi(BaseTest):
assert response.json["file_contents"] is not None
response = client.get(
f"/v1.0/process-models/{modified_process_model_id}/files/random_fact.svg",
f"/v1.0/process-models/{modified_process_model_id}/files/simple_form.json",
headers=self.logged_in_headers(with_super_admin_user),
)
assert response.status_code == 200
updated_file = json.loads(response.get_data(as_text=True))
assert original_file != updated_file
assert updated_file["file_contents"] == new_file_contents.decode()
def test_process_model_file_delete_when_bad_process_model(
@ -879,9 +869,6 @@ class TestProcessApi(BaseTest):
) -> None:
"""Test_process_model_file_update."""
process_model_identifier = self.create_group_and_model_with_bpmn(client, with_super_admin_user)
# self.create_spec_file(client, user=with_super_admin_user)
# process_model = load_test_spec("random_fact")
bad_process_model_identifier = f"x{process_model_identifier}"
modified_bad_process_model_identifier = bad_process_model_identifier.replace("/", ":")
response = client.delete(

View File

@ -120,6 +120,7 @@ export interface ProcessFile {
size: number;
type: string;
file_contents?: string;
file_contents_hash?: string;
}
export interface ProcessInstanceMetadata {

View File

@ -206,6 +206,9 @@ export default function ProcessModelEditDiagram() {
httpMethod = 'POST';
} else {
url += `/${fileNameWithExtension}`;
if (processModelFile && processModelFile.file_contents_hash) {
url += `?file_contents_hash=${processModelFile.file_contents_hash}`;
}
}
if (!fileNameWithExtension) {
handleShowFileNameEditor();