Skip to content

Commit

Permalink
Merge pull request #2306 from auslin-aot/fwf-3769-form-validation-cha…
Browse files Browse the repository at this point in the history
…nges

Bugfix: Added Form validation & changes on capturing process version
  • Loading branch information
arun-s-aot authored Oct 29, 2024
2 parents 3e57935 + d1ddbfe commit 646b7c0
Show file tree
Hide file tree
Showing 9 changed files with 260 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,12 @@ def status_code(self):
class BusinessException(Exception):
"""Exception that adds error code and error."""

def __init__(self, error_code: ErrorCodeMixin, details=None, detail_message=None):
def __init__(self, error_code: ErrorCodeMixin, details=None, detail_message=None, include_details=False):
super().__init__(error_code.message)
self.message = error_code.message

# Include the detailed message in the main message if include_details is True
self.message = detail_message if include_details and detail_message else error_code.message

self.code = error_code.code
self.status_code = error_code.status_code
if detail_message:
Expand Down
2 changes: 1 addition & 1 deletion forms-flow-api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ exceptiongroup==1.2.2
flask-jwt-oidc==0.7.0
flask-marshmallow==1.2.1
flask-restx==1.3.0
formsflow_api_utils @ git+https://github.com/AOT-Technologies/forms-flow-ai.git@develop#subdirectory=forms-flow-api-utils
formsflow_api_utils @ git+https://github.com/auslin-aot/forms-flow-ai.git@fwf-3769-form-validation-changes#subdirectory=forms-flow-api-utils
gunicorn==23.0.0
h11==0.14.0
h2==4.1.0
Expand Down
2 changes: 1 addition & 1 deletion forms-flow-api/requirements/prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ markupsafe
PyJWT
redis
lxml
git+https://github.com/AOT-Technologies/forms-flow-ai.git@develop#subdirectory=forms-flow-api-utils
git+https://github.com/auslin-aot/forms-flow-ai.git@fwf-3769-form-validation-changes#subdirectory=forms-flow-api-utils
5 changes: 5 additions & 0 deletions forms-flow-api/src/formsflow_api/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ class BusinessErrorCode(ErrorCodeMixin, Enum):
"Cannot update a published process",
HTTPStatus.BAD_REQUEST,
)
FORM_INVALID_OPERATION = (
"Cannot update a published form",
HTTPStatus.BAD_REQUEST,
)
FORM_VALIDATION_FAILED = "FORM_VALIDATION_FAILED.", HTTPStatus.BAD_REQUEST

def __new__(cls, message, status_code):
"""Constructor."""
Expand Down
2 changes: 0 additions & 2 deletions forms-flow-api/src/formsflow_api/models/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,6 @@ def subquery_for_getting_latest_process(cls):
db.session.query(
cls.parent_process_key,
func.max(cls.major_version).label("latest_major_version"),
func.max(cls.minor_version).label("latest_minor_version"),
func.max(cls.id).label("latest_id"),
)
.group_by(cls.parent_process_key)
Expand All @@ -165,7 +164,6 @@ def find_all_process( # pylint: disable=too-many-arguments, too-many-positional
subquery,
(cls.parent_process_key == subquery.c.parent_process_key)
& (cls.major_version == subquery.c.latest_major_version)
& (cls.minor_version == subquery.c.latest_minor_version)
& (cls.id == subquery.c.latest_id),
)

Expand Down
43 changes: 43 additions & 0 deletions forms-flow-api/src/formsflow_api/services/form_process_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,47 @@ def export( # pylint:disable=too-many-locals

raise BusinessException(BusinessErrorCode.INVALID_FORM_PROCESS_MAPPER_ID)

@classmethod
def is_valid_field(cls, field: str, pattern: str) -> bool:
"""Checks if the given field matches the provided regex pattern."""
return bool(re.fullmatch(pattern, field))

@classmethod
def validate_title_name_path(cls, title: str, path: str, name: str):
"""Validates the title, path, and name fields."""
title_pattern = r"(?=.*[A-Za-z])[A-Za-z0-9 ]+"
path_name = r"(?=.*[A-Za-z])[A-Za-z0-9]+"

invalid_fields = []

error_messages = {
"title": "Title: Only contain alphanumeric characters and spaces, and must include at least one letter.",
"path": "Path: Only contain alphanumeric characters, no spaces, and must include at least one letter.",
"name": "Name: Only contain alphanumeric characters, no spaces, and must include at least one letter.",
}

# Validate title
if title and not cls.is_valid_field(title, title_pattern):
invalid_fields.append("title")

# Validate path and name
for field_name, field_value in (("path", path), ("name", name)):
if field_value and not cls.is_valid_field(field_value, path_name):
invalid_fields.append(field_name)

# Determine overall validity
is_valid = len(invalid_fields) == 0
if not is_valid:
# Generate detailed validation error message
error_message = ",\n ".join(
error_messages[field] for field in invalid_fields
)
raise BusinessException(
BusinessErrorCode.FORM_VALIDATION_FAILED,
detail_message=error_message,
include_details=True,
)

@staticmethod
def validate_form_name_path_title(request):
"""Validate a form name by calling the external validation API."""
Expand All @@ -661,6 +702,8 @@ def validate_form_name_path_title(request):
if not (title or name or path):
raise BusinessException(BusinessErrorCode.INVALID_FORM_VALIDATION_INPUT)

FormProcessMapperService.validate_title_name_path(title, path, name)

# Combine them into query parameters dictionary
query_params = f"title={title}&name={name}&path={path}&select=title,path,name"

Expand Down
84 changes: 50 additions & 34 deletions forms-flow-api/src/formsflow_api/services/import_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from flask import current_app
from formsflow_api_utils.exceptions import BusinessException
from formsflow_api_utils.services.external import FormioService
from formsflow_api_utils.utils.enums import FormProcessMapperStatus
from formsflow_api_utils.utils.user_context import UserContext, user_context
from jsonschema import ValidationError, validate
from lxml import etree
Expand All @@ -16,7 +17,6 @@
FormHistory,
FormProcessMapper,
Process,
ProcessStatus,
ProcessType,
)
from formsflow_api.schemas import (
Expand Down Expand Up @@ -58,15 +58,27 @@ def create_authorization(self, data):
is_designer=True,
)

def get_latest_version_workflow(self, process_name, include_status=False):
def get_latest_version_workflow(self, process_name):
"""Get latest version of workflow by process name."""
process = Process.get_latest_version_by_key(process_name)
# If process not found, consider as initial version
if not process:
return (1, 0, None) if include_status else (1, 0)
if include_status:
return process.major_version, process.minor_version, process.status
return process.major_version, process.minor_version
return (1, 0, None, None)
return (
process.major_version,
process.minor_version,
process.status,
process.status_changed,
)

def determine_process_version_by_key(self, name):
"""Finding the process version by process key."""
major_version, minor_version, status, status_changed = (
self.get_latest_version_workflow(name)
)
return ProcessService.determine_process_version(
status, status_changed, major_version, minor_version
)

def get_latest_version_form(self, parent_form_id):
"""Get latest version of form by parent ID."""
Expand Down Expand Up @@ -141,19 +153,25 @@ def validate_input_data(self, request):
raise BusinessException(BusinessErrorCode.INVALID_INPUT) from err
return request_data, file

def validate_form_exists(self, form_json, tenant_key, validate_path_only=False):
"""Validate form already exists."""
def validate_form(
self, form_json, tenant_key, validate_path_only=False, mapper=None
):
"""Validate form already exists & title/path/name validation."""
title = form_json.get("title")
name = form_json.get("name")
path = form_json.get("path")
# Validate form title, name, path
FormProcessMapperService.validate_title_name_path(title, path, name)
# Add 'tenantkey-' from 'path' and 'name'
if current_app.config.get("MULTI_TENANCY_ENABLED"):
if not validate_path_only:
name = f"{tenant_key}-name"
path = f"{tenant_key}-path"

# Build query params based on validation type
if validate_path_only:
if validate_path_only and mapper:
# In case of edit import validate title in mapper table & path in mapper table.
self.validate_form_title(title, mapper)
query_params = f"path={path}&select=title,path,name,_id"
else:
query_params = (
Expand All @@ -163,13 +181,11 @@ def validate_form_exists(self, form_json, tenant_key, validate_path_only=False):
response = self.get_form_by_query(query_params)
return response

def validate_form_title(self, form_json, mapper):
def validate_form_title(self, title, mapper):
"""Validate form tile in the form_process_mapper table."""
# Exclude the current mapper from the query
current_app.logger.info(f"Validation for form title...{form_json.get('title')}")
mappers = FormProcessMapper.find_forms_by_title(
form_json.get("title"), exclude_id=mapper.id
)
current_app.logger.info(f"Validation for form title...{title}")
mappers = FormProcessMapper.find_forms_by_title(title, exclude_id=mapper.id)
if mappers:
current_app.logger.debug(f"Other mappers matching the title- {mappers}")
raise BusinessException(BusinessErrorCode.FORM_EXISTS)
Expand All @@ -178,11 +194,8 @@ def validate_form_title(self, form_json, mapper):
def validate_edit_form_exists(self, form_json, mapper, tenant_key):
"""Validate form exists on edit import."""
current_app.logger.info("Validating form exists in import edit...")
# Validate title in mapper table.
self.validate_form_title(form_json, mapper)
# Validate path exists in formio.
response = self.validate_form_exists(
form_json, tenant_key, validate_path_only=True
response = self.validate_form(
form_json, tenant_key, validate_path_only=True, mapper=mapper
)
# If response is not empty, check if the form_id is not the same as the mapper form_id
# Then the path is taken by another form
Expand Down Expand Up @@ -236,7 +249,9 @@ def save_process_data( # pylint: disable=too-many-arguments, too-many-positiona
f"Capturing version for process {name} in edit import..."
)
if selected_workflow_version:
major_version, minor_version = self.get_latest_version_workflow(name)
major_version, minor_version, _, _ = self.get_latest_version_workflow(
name
)
if selected_workflow_version == "major":
major_version += 1
minor_version = 0
Expand All @@ -246,14 +261,9 @@ def save_process_data( # pylint: disable=too-many-arguments, too-many-positiona
# If selected workflow version not specified
# Then update version as major if latest process data is published
# Otherwise update version as minor
major_version, minor_version, status = self.get_latest_version_workflow(
name, include_status=True
major_version, minor_version = self.determine_process_version_by_key(
name
)
if status and status == ProcessStatus.PUBLISHED:
major_version += 1
minor_version = 0
else:
minor_version += 1
# Save workflow as draft
process_data = updated_xml.encode("utf-8")
process = Process(
Expand Down Expand Up @@ -491,7 +501,7 @@ def import_form_workflow(
raise BusinessException(BusinessErrorCode.INVALID_FILE_TYPE)
form_json = file_data.get("forms")[0].get("content")
workflow_data, process_type = self.get_process_details(file_data)
validate_form_response = self.validate_form_exists(form_json, tenant_key)
validate_form_response = self.validate_form(form_json, tenant_key)
if validate_form_response:
raise BusinessException(BusinessErrorCode.FORM_EXISTS)
if action == "validate":
Expand All @@ -510,6 +520,10 @@ def import_form_workflow(
mapper_id = edit_request.get("mapper_id")
# mapper is required for edit. Add validation
mapper = FormProcessMapperService().validate_mapper(mapper_id, tenant_key)

if mapper.status == FormProcessMapperStatus.ACTIVE.value:
# Raise an exception if the user try to update published form
raise BusinessException(BusinessErrorCode.FORM_INVALID_OPERATION)
form_id = mapper.form_id
if valid_file == ".json":
file_data = self.read_json_data(file)
Expand Down Expand Up @@ -542,7 +556,7 @@ def import_form_workflow(
# Validate form exists
self.validate_edit_form_exists(form_json, mapper, tenant_key)
if action == "validate":
major, minor = self.get_latest_version_workflow(
major, minor = self.determine_process_version_by_key(
mapper.process_key
)
form_major, form_minor = self.get_latest_version_form(
Expand All @@ -551,8 +565,8 @@ def import_form_workflow(
return self.version_response(
form_major=form_major + 1,
form_minor=form_minor + 1,
workflow_major=major + 1,
workflow_minor=minor + 1,
workflow_major=major,
workflow_minor=minor,
)
if action == "import":
skip_form = edit_request.get("form", {}).get("skip")
Expand Down Expand Up @@ -591,12 +605,14 @@ def import_form_workflow(
elif valid_file == ".bpmn":
current_app.logger.info("Workflow validated successfully.")
if action == "validate":
major, minor = self.get_latest_version_workflow(mapper.process_key)
major, minor = self.determine_process_version_by_key(
mapper.process_key
)
return self.version_response(
form_major=None,
form_minor=None,
workflow_major=major + 1,
workflow_minor=minor + 1,
workflow_major=major,
workflow_minor=minor,
)
if action == "import":
selected_workflow_version = edit_request.get("workflow", {}).get(
Expand Down
32 changes: 27 additions & 5 deletions forms-flow-api/src/formsflow_api/services/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,17 @@ def get_process_by_key(cls, process_key):
current_app.logger.debug(f"Get process data for process key: {process_key}")
process = Process.get_latest_version_by_key(process_key)
if process:
return processSchema.dump(process)
process_data = processSchema.dump(process)
# Determine version numbers based on the process status
major_version, minor_version = cls.determine_process_version(
process.status,
process.status_changed,
process.major_version,
process.minor_version,
)
process_data["majorVersion"] = major_version
process_data["minorVersion"] = minor_version
return process_data
raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND)

@classmethod
Expand All @@ -234,6 +244,17 @@ def validate_process_by_id(cls, process_id):
raise BusinessException(BusinessErrorCode.PROCESS_ID_NOT_FOUND)
return process

@classmethod
def determine_process_version(
cls, status, status_changed, major_version, minor_version
):
"""Determine process version."""
current_app.logger.debug("Identifying process version..")
is_unpublished = status == ProcessStatus.DRAFT and status_changed
major_version = major_version + 1 if is_unpublished else major_version
minor_version = 0 if is_unpublished else minor_version + 1
return major_version, minor_version

@classmethod
@user_context
def update_process(cls, process_id, process_data, process_type, **kwargs):
Expand Down Expand Up @@ -276,11 +297,12 @@ def update_process(cls, process_id, process_data, process_type, **kwargs):
raise BusinessException(BusinessErrorCode.PROCESS_EXISTS)

# Determine version numbers based on the process status
is_unpublished = process.status == "Draft" and process.status_changed
major_version = (
process.major_version + 1 if is_unpublished else process.major_version
major_version, minor_version = cls.determine_process_version(
process.status,
process.status_changed,
process.major_version,
process.minor_version,
)
minor_version = 0 if is_unpublished else process.minor_version + 1

# Create a new process instance with updated data
process_dict = {
Expand Down
Loading

0 comments on commit 646b7c0

Please sign in to comment.