From df078008cdbd67139f0914b68fc2e6cf772008e0 Mon Sep 17 00:00:00 2001 From: ruzaqiarkan-eng <289478799+ruzaqiarkan-eng@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:32:16 +0000 Subject: [PATCH 1/2] refactor: decentralized contract architecture for action handlers Refactored the monolithic 1440-line `contracts.py` into a decentralized system where each `ActionHandler` owns its metadata contract (UI definitions, operations, policies). Key changes: - Created `base_contract.py` with `ActionContract` and `OperationContract` data classes and reusable presets. - Created `contract_utils.py` for shared utility functions and constants. - Created `contract_dto.py` for standardized frontend API generation. - Enhanced `ActionHandler` base class with contract protocol methods. - Enhanced `HandlerRegistry` to aggregate and cache decentralized contracts. - Migrated all 13 built-in action handlers to implement the contract protocol. - Transformed `contracts.py` into a thin backward-compatible facade using module-level `__getattr__`. This improvement co-locates execution logic with metadata, adheres to the Single Responsibility and Open/Closed principles, and eliminates extensive duplication in operation contracts. --- .../ruleflow/core/action_handlers/__init__.py | 204 ++- .../core/action_handlers/assignment.py | 39 + .../core/action_handlers/base_contract.py | 185 +++ .../core/action_handlers/condition.py | 39 + .../core/action_handlers/create_doc.py | 218 +++ .../ruleflow/core/action_handlers/loop.py | 38 + .../ruleflow/core/action_handlers/process.py | 87 ++ .../core/action_handlers/query_records.py | 262 ++++ .../core/action_handlers/simple_actions.py | 223 +++ .../ruleflow/core/action_handlers/sub_rule.py | 69 + .../ruleflow/core/action_handlers/switch.py | 33 + flexirule/ruleflow/core/contract_dto.py | 72 + flexirule/ruleflow/core/contract_utils.py | 98 ++ flexirule/ruleflow/core/contracts.py | 1335 +---------------- 14 files changed, 1644 insertions(+), 1258 deletions(-) create mode 100644 flexirule/ruleflow/core/action_handlers/base_contract.py create mode 100644 flexirule/ruleflow/core/contract_dto.py create mode 100644 flexirule/ruleflow/core/contract_utils.py diff --git a/flexirule/ruleflow/core/action_handlers/__init__.py b/flexirule/ruleflow/core/action_handlers/__init__.py index 93f4e71d..a0323639 100644 --- a/flexirule/ruleflow/core/action_handlers/__init__.py +++ b/flexirule/ruleflow/core/action_handlers/__init__.py @@ -25,6 +25,7 @@ def execute(self, action, context, engine): from typing import TYPE_CHECKING, Any, ClassVar, Optional if TYPE_CHECKING: + from flexirule.ruleflow.core.action_handlers.base_contract import ActionContract, OperationContract from flexirule.ruleflow.core.engine import RuleEngine @@ -38,6 +39,90 @@ class ActionHandler(ABC): action_type: str | None = None # Must be set by subclass + @classmethod + def get_action_contract(cls) -> "ActionContract": + """Return the action type's top-level contract. + + Override in subclass. Default returns a minimal contract. + """ + from flexirule.ruleflow.core.action_handlers.base_contract import ActionContract + + return ActionContract( + action_type=cls.action_type, + required_fields=[], + has_next_true=True, + has_next_false=False, + terminal=False, + ) + + @classmethod + def get_operation_contracts(cls) -> dict[str, "OperationContract"]: + """Return operation-level contracts keyed by operation name. + + Override in handlers with operations (Query Records, Document Action, etc.). + Default returns empty dict (no operations). + """ + return {} + + @classmethod + def get_runtime_policy(cls, operation=None, process_operation=None) -> dict: + """Resolve runtime policy for this handler + optional operation. + + Override for handlers with complex policy resolution (e.g., Process). + Default derives policy from the action contract + operation policies. + """ + contract_dict = cls.get_action_contract().to_dict() + policy: dict = { + "allowed_mutations": list(contract_dict.get("allowed_mutations", []) or []), + "allowed_return_types": list(contract_dict.get("allowed_return_types", []) or []), + "default_return_type": contract_dict.get("default_return_type"), + "field_labels": dict(contract_dict.get("field_labels", {}) or {}), + "show_return_type": contract_dict.get("show_return_type"), + "require_return_type": contract_dict.get("require_return_type", False), + "show_return_variable": contract_dict.get("show_return_variable"), + "require_return_variable": contract_dict.get("require_return_variable", False), + } + + if operation: + op_policy = dict( + (contract_dict.get("operation_policies", {}) or {}).get(operation, {}) or {} + ) + + # Merge operation contract action_overrides into policy if relevant + op_contracts = cls.get_operation_contracts() + if operation in op_contracts: + op_contract = op_contracts[operation] + for field_def in op_contract.action_overrides: + fieldname = field_def.get("fieldname") + if fieldname == "mutation_mode" and field_def.get("options"): + op_policy["allowed_mutations"] = field_def["options"] + elif fieldname == "return_type": + if field_def.get("options"): + op_policy["allowed_return_types"] = field_def["options"] + if field_def.get("default"): + op_policy["default_return_type"] = field_def["default"] + if "read_only" in field_def: + op_policy["show_return_type"] = not field_def.get("read_only", False) + op_policy["require_return_type"] = field_def.get("reqd", False) + + for key in ("allowed_mutations", "allowed_return_types"): + if op_policy.get(key): + policy[key] = list(op_policy[key]) + if op_policy.get("default_return_type"): + policy["default_return_type"] = op_policy["default_return_type"] + if op_policy.get("show_return_type") is not None: + policy["show_return_type"] = op_policy.get("show_return_type") + if op_policy.get("require_return_type") is not None: + policy["require_return_type"] = op_policy.get("require_return_type") + if op_policy.get("show_return_variable") is not None: + policy["show_return_variable"] = op_policy.get("show_return_variable") + if op_policy.get("require_return_variable") is not None: + policy["require_return_variable"] = op_policy.get("require_return_variable") + if op_policy.get("field_labels"): + policy["field_labels"].update(op_policy.get("field_labels", {})) + + return policy + @abstractmethod def execute(self, action, context: dict, engine: "RuleEngine") -> tuple[Any, str | None]: """ @@ -331,6 +416,8 @@ class HandlerRegistry: """ _handlers: ClassVar[dict] = {} + _contract_cache: ClassVar[dict | None] = None + _op_contract_cache: ClassVar[dict | None] = None _initialized: bool = False @classmethod @@ -345,8 +432,12 @@ def register(cls, handler: ActionHandler) -> None: ValueError: If handler has no action_type set """ if not handler.action_type: - raise ValueError(f"Handler {handler.__class__.__name__} must set 'action_type' class attribute") + raise ValueError( + f"Handler {handler.__class__.__name__} must set 'action_type' class attribute" + ) cls._handlers[handler.action_type] = handler + cls._contract_cache = None + cls._op_contract_cache = None @classmethod def get(cls, action_type: str) -> ActionHandler | None: @@ -384,6 +475,117 @@ def action_types(cls) -> list: cls._ensure_initialized() return list(cls._handlers.keys()) + @classmethod + def get_action_contract(cls, action_type: str) -> dict: + """Get contract for a specific action type.""" + from flexirule.ruleflow.core.contract_utils import normalize_action_type + + cls._ensure_initialized() + normalized = normalize_action_type(action_type, cls.action_types()) + handler = cls.get(normalized) + if handler: + return handler.get_action_contract().to_dict() + return { + "required_fields": [], + "has_next_true": True, + "has_next_false": False, + "terminal": False, + "css": {}, + "node_type": "action", + "category": "Other", + "configurable": True, + } + + @classmethod + def get_all_contracts(cls) -> dict[str, dict]: + """Aggregate all action contracts. Cached for performance.""" + if cls._contract_cache is not None: + return cls._contract_cache + + cls._ensure_initialized() + cls._contract_cache = { + at: h.get_action_contract().to_dict() for at, h in cls._handlers.items() + } + return cls._contract_cache + + @classmethod + def get_operation_contract(cls, operation: str) -> dict: + """Get operation contract from the owning handler.""" + cls._ensure_initialized() + + # Check all handlers to find which one owns this operation + for handler in cls._handlers.values(): + contracts = handler.get_operation_contracts() + if operation in contracts: + return contracts[operation].to_dict() + + # Fallback: check if the operation IS an action type + from flexirule.ruleflow.core.contract_utils import normalize_action_type + + normalized_at = normalize_action_type(operation, cls.action_types()) + if normalized_at in cls._handlers: + handler = cls._handlers[normalized_at] + base_contract = handler.get_action_contract().to_dict() + return { + "Rule": [], + "Rule Action": [ + {"fieldname": "action_type", "default": normalized_at}, + {"fieldname": "operation", "default": operation if operation != normalized_at else None}, + { + "fieldname": "description", + "description": base_contract.get("description", f"Executes {operation}"), + }, + ] + + [{"fieldname": f, "reqd": 1} for f in base_contract.get("required_fields", [])], + "Validation": base_contract.get("validation", {}), + } + + return { + "Rule": [], + "Rule Action": [{"fieldname": "description", "description": f"Unknown operation: {operation}"}], + "Validation": {}, + } + + @classmethod + def get_all_operation_contracts(cls) -> dict[str, dict]: + """Aggregate all operation contracts from all handlers.""" + if cls._op_contract_cache is not None: + return cls._op_contract_cache + + cls._ensure_initialized() + result = {} + + # Add basic operation contracts for handlers without specific operations + for at, handler in cls._handlers.items(): + # If handler has explicit operations, they will be added below + ops = handler.get_operation_contracts() + if not ops: + # Add a default operation contract for the action type itself + result[at] = cls.get_operation_contract(at) + else: + for op_name, oc in ops.items(): + result[op_name] = oc.to_dict() + + cls._op_contract_cache = result + return result + + @classmethod + def get_effective_policy( + cls, + action_type: str | None, + operation: str | None = None, + process_operation: dict | None = None, + ) -> dict: + """Delegate policy resolution to the handler.""" + from flexirule.ruleflow.core.contract_utils import normalize_action_type + + cls._ensure_initialized() + normalized_at = normalize_action_type(action_type, cls.action_types()) + handler = cls.get(normalized_at) + if handler: + return handler.get_runtime_policy(operation, process_operation) + return {} + @classmethod def _ensure_initialized(cls) -> None: """ diff --git a/flexirule/ruleflow/core/action_handlers/assignment.py b/flexirule/ruleflow/core/action_handlers/assignment.py index 267f699c..7a0f7fff 100644 --- a/flexirule/ruleflow/core/action_handlers/assignment.py +++ b/flexirule/ruleflow/core/action_handlers/assignment.py @@ -9,6 +9,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) from flexirule.ruleflow.core.action_plan_cache import get_action_plan from flexirule.ruleflow.core.context_manager import ContextManager from flexirule.ruleflow.core.exceptions import MethodExecutionError @@ -21,6 +25,41 @@ class AssignmentHandler(ActionHandler): action_type = "Assignment" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Assignment", + required_fields=["config"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-list-ol", "color": "#14b8a6"}, + field_labels={ + "config": "Assignments", + }, + node_type="assignment", + category="Data Actions", + configurable=True, + config_component="AssignmentConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Assignment": OperationContract( + operation="Assignment", + action_overrides=[ + {"fieldname": "action_type", "default": "Assignment"}, + {"fieldname": "config", "reqd": 1, "description": "Array of assignments (JSON)"}, + { + "fieldname": "description", + "description": "Updates document fields or context variables in batch", + }, + ], + validation={"backend": "validate_assignment"}, + ) + } + def execute(self, action, context, engine): """ Execute Assignment action - sequential batch mutations. diff --git a/flexirule/ruleflow/core/action_handlers/base_contract.py b/flexirule/ruleflow/core/action_handlers/base_contract.py new file mode 100644 index 00000000..258d7835 --- /dev/null +++ b/flexirule/ruleflow/core/action_handlers/base_contract.py @@ -0,0 +1,185 @@ +# Copyright (c) 2025, FlexiRule and contributors +# For license information, please see license.txt + +from __future__ import annotations + +from typing import Any + + +class ActionContract: + """Data class representing an action type's full contract.""" + + def __init__( + self, + action_type: str, + *, + required_fields: list[str] | None = None, + has_next_true: bool = True, + has_next_false: bool = False, + terminal: bool = False, + css: dict | None = None, + node_type: str = "action", + category: str = "Other", + configurable: bool = True, + config_component: str | None = None, + field_labels: dict | None = None, + operation_label: str | None = None, + operation_options: list[str] | None = None, + allowed_mutations: list[str] | None = None, + allowed_return_types: list[str] | None = None, + default_return_type: str | None = None, + show_return_type: bool | None = None, + require_return_type: bool | None = None, + show_return_variable: bool | None = None, + require_return_variable: bool | None = None, + operation_policies: dict | None = None, + mandatory_fields: dict | None = None, + validation: dict | None = None, + dynamic_fields: bool = False, + **extras, + ): + self.action_type = action_type + self.required_fields = required_fields or [] + self.has_next_true = has_next_true + self.has_next_false = has_next_false + self.terminal = terminal + self.css = css or {} + self.node_type = node_type + self.category = category + self.configurable = configurable + self.config_component = config_component + self.field_labels = field_labels + self.operation_label = operation_label + self.operation_options = operation_options + self.allowed_mutations = allowed_mutations + self.allowed_return_types = allowed_return_types + self.default_return_type = default_return_type + self.show_return_type = show_return_type + self.require_return_type = require_return_type + self.show_return_variable = show_return_variable + self.require_return_variable = require_return_variable + self.operation_policies = operation_policies + self.mandatory_fields = mandatory_fields + self.validation = validation + self.dynamic_fields = dynamic_fields + self.extras = extras + + def to_dict(self) -> dict[str, Any]: + """Serialize to the same dict format as legacy ACTION_TYPE_CONTRACT entries.""" + res = { + "required_fields": self.required_fields, + "has_next_true": self.has_next_true, + "has_next_false": self.has_next_false, + "terminal": self.terminal, + "css": self.css, + "node_type": self.node_type, + "category": self.category, + "configurable": self.configurable, + "require_return_type": self.require_return_type if self.require_return_type is not None else False, + } + + if self.config_component: + res["config_component"] = self.config_component + + if self.field_labels is not None: + res["field_labels"] = self.field_labels + if self.operation_label: + res["operation_label"] = self.operation_label + if self.operation_options: + res["operation_options"] = self.operation_options + if self.allowed_mutations is not None: + res["allowed_mutations"] = self.allowed_mutations + if self.allowed_return_types is not None: + res["allowed_return_types"] = self.allowed_return_types + if self.default_return_type is not None: + res["default_return_type"] = self.default_return_type + if self.show_return_type is not None: + res["show_return_type"] = self.show_return_type + + if self.show_return_variable is not None: + res["show_return_variable"] = self.show_return_variable + if self.require_return_variable is not None: + res["require_return_variable"] = self.require_return_variable + if self.operation_policies: + res["operation_policies"] = self.operation_policies + if self.mandatory_fields: + res["mandatory_fields"] = self.mandatory_fields + if self.validation: + res["validation"] = self.validation + if self.dynamic_fields: + res["dynamic_fields"] = self.dynamic_fields + + res.update(self.extras) + return res + + +class OperationContract: + """Data class representing an operation's field overrides.""" + + def __init__( + self, + operation: str, + *, + rule_overrides: list[dict] | None = None, + action_overrides: list[dict] | None = None, + validation: dict | None = None, + ): + self.operation = operation + self.rule_overrides = rule_overrides or [] + self.action_overrides = action_overrides or [] + self.validation = validation or {} + + def to_dict(self) -> dict[str, Any]: + """Serialize to the same dict format as legacy OPERATION_CONTRACTS entries.""" + return { + "Rule": self.rule_overrides, + "Rule Action": self.action_overrides, + "Validation": self.validation, + } + + +# ── Reusable field override presets ────────────────────────────── + +STANDARD_DOCTYPE_LINK_FILTERS = "[['DocType','issingle','=',0],['DocType','istable','=',0]]" +SINGLE_ALLOWED_DOCTYPE_LINK_FILTERS = "[['DocType','istable','=',0]]" + + +def reference_doctype_override(*, reqd=1, link_filters=None): + """Standard reference_doctype override used by Query Records, Document Action, etc.""" + return { + "fieldname": "reference_doctype", + "reqd": reqd, + "link_filters": link_filters or STANDARD_DOCTYPE_LINK_FILTERS, + } + + +def config_depends_on_doctype(*, reqd=1, description=None): + """Standard config override that depends on reference_doctype selection.""" + override = { + "fieldname": "config", + "depends_on": "eval:doc.reference_doctype", + "reqd": reqd, + } + if description: + override["description"] = description + return override + + +def aggregate_operation_overrides(operation: str, description: str): + """Factory for Sum/Average/Min/Max which share identical structure.""" + return OperationContract( + operation=operation, + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": operation}, + reference_doctype_override(), + config_depends_on_doctype(), + {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, + {"fieldname": "description", "description": description}, + ], + validation={"backend": "validate_aggregate_query"}, + ) diff --git a/flexirule/ruleflow/core/action_handlers/condition.py b/flexirule/ruleflow/core/action_handlers/condition.py index dbe63e4e..90048c97 100644 --- a/flexirule/ruleflow/core/action_handlers/condition.py +++ b/flexirule/ruleflow/core/action_handlers/condition.py @@ -11,6 +11,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) from flexirule.ruleflow.core.condition_payload import get_condition_payload @@ -19,6 +23,41 @@ class ConditionHandler(ActionHandler): action_type = "Condition" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Condition", + required_fields=["config"], + has_next_true=True, + has_next_false=True, + terminal=False, + css={"icon": "fa fa-code-fork", "color": "#3b82f6"}, + validation={"frontend": "validate_condition"}, + field_labels={ + "compiled_expression": "Compiled Expression (Python)", + "config": "Condition Builder Config", + }, + node_type="condition", + category="Control Flow", + configurable=True, + config_component="ConditionStep", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Condition": OperationContract( + operation="Condition", + action_overrides=[ + {"fieldname": "action_type", "default": "Condition"}, + {"fieldname": "config", "reqd": 1}, + {"fieldname": "next_step_if_false", "mandatory_depends_on": "eval:doc.parent.is_active===1"}, + {"fieldname": "description", "description": "Evaluates a condition to branch execution"}, + ], + validation={"frontend": "validate_condition"}, + ) + } + def execute(self, action, context, engine): """ Execute condition evaluation. diff --git a/flexirule/ruleflow/core/action_handlers/create_doc.py b/flexirule/ruleflow/core/action_handlers/create_doc.py index 9866e243..80ebfb44 100644 --- a/flexirule/ruleflow/core/action_handlers/create_doc.py +++ b/flexirule/ruleflow/core/action_handlers/create_doc.py @@ -16,6 +16,11 @@ from frappe.model import child_table_fields, default_fields, table_fields from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, + reference_doctype_override, +) from flexirule.ruleflow.core.action_plan_cache import get_action_plan from flexirule.ruleflow.core.permissions import can_skip_permissions from flexirule.ruleflow.utils.mapping import apply_input_mapping @@ -26,6 +31,219 @@ class DocumentActionHandler(ActionHandler): action_type = "Document Action" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Document Action", + required_fields=["reference_doctype", "operation"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-file-text", "color": "#059669"}, + operation_label="Document Mode", + operation_options=["Create New", "Update Existing", "Delete Record", "Create ToDo", "Add Comment"], + allowed_mutations=[ + "Set Doc Field", + "Set Context Variable", + ], + allowed_return_types=[ + "Single Record", + "Full Document", + "Yes / No", + ], + default_return_type="Single Record", + show_return_type=True, + operation_policies={ + "Create New": { + "allowed_return_types": ["Single Record", "Full Document"], + "default_return_type": "Single Record", + "allowed_mutations": ["Set Context Variable", "Update Context Variable"], + "show_return_type": True, + "require_return_type": True, + "field_labels": {"return_type": "Created Document Output"}, + }, + "Update Existing": { + "allowed_return_types": ["Single Record", "Full Document"], + "default_return_type": "Single Record", + "allowed_mutations": ["Set Context Variable", "Update Context Variable", "Set Doc Field"], + "show_return_type": True, + "require_return_type": True, + "field_labels": {"return_type": "Updated Document Output"}, + }, + "Delete Record": { + "allowed_return_types": ["Yes / No"], + "default_return_type": "Yes / No", + "allowed_mutations": ["Set Context Variable"], + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Deletion Result Type"}, + }, + "Create ToDo": { + "allowed_return_types": ["Single Record"], + "default_return_type": "Single Record", + "allowed_mutations": ["Set Context Variable", "Update Context Variable"], + "show_return_type": False, + "require_return_type": False, + "required_config_keys": ["assigned_to", "description"], + "field_labels": {"return_type": "ToDo Output Type"}, + }, + "Add Comment": { + "allowed_return_types": ["Single Record"], + "default_return_type": "Single Record", + "allowed_mutations": ["Set Context Variable", "Update Context Variable"], + "show_return_type": False, + "require_return_type": False, + "required_config_keys": ["comment_text"], + "field_labels": {"return_type": "Comment Output Type"}, + }, + }, + field_labels={ + "operation": "Document Mode", + "reference_doctype": "Target DocType", + "reference_docname": "Target Record", + "mutation_mode": "Result Handling", + "return_type": "Result Type", + }, + node_type="documentaction", + category="Data Actions", + configurable=True, + config_component="DocumentActionConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Create New": OperationContract( + operation="Create New", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["Before Insert", "Validate", "Before Save"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + { + "fieldname": "debug_mode", + "mandatory_depends_on": "eval:doc.trigger_type==='DocType Event'", + "description": "Enable detailed logging for document events", + }, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Document Action"}, + {"fieldname": "operation", "default": "Create New"}, + reference_doctype_override(), + { + "fieldname": "config", + "depends_on": "eval:doc.reference_doctype", + "reqd": 1, + "description": "⚠️ Document data to create", + }, + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Update Context Variable"], + "reqd": 1, + }, + {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, + { + "fieldname": "skip_permissions", + "hidden": "eval:!frappe.user.has_role('System Manager')", + "read_only_depends_on": "eval:!frappe.user.has_role('System Manager')", + }, + { + "fieldname": "permission_audit_reason", + "mandatory_depends_on": "skip_permissions", + "hidden": "eval:!doc.skip_permissions", + }, + {"fieldname": "description", "description": "⚠️ Creates a new document record"}, + ], + validation={"backend": "validate_create_document"}, + ), + "Update Existing": OperationContract( + operation="Update Existing", + action_overrides=[ + {"fieldname": "action_type", "default": "Document Action"}, + {"fieldname": "operation", "default": "Update Existing"}, + reference_doctype_override(), + {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, + { + "fieldname": "config", + "depends_on": "eval:doc.reference_doctype", + "reqd": 1, + "description": "⚠️ Fields to update", + }, + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Update Context Variable", "Set Doc Field"], + "reqd": 1, + }, + {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, + {"fieldname": "description", "description": "⚠️ Updates an existing document record"}, + ], + validation={"backend": "validate_update_document"}, + ), + "Delete Record": OperationContract( + operation="Delete Record", + action_overrides=[ + {"fieldname": "action_type", "default": "Document Action"}, + {"fieldname": "operation", "default": "Delete Record"}, + reference_doctype_override(), + {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, + {"fieldname": "return_type", "default": "Yes / No", "read_only": 1}, + {"fieldname": "description", "description": "⚠️ Permanently deletes a document record"}, + ], + validation={"backend": "validate_delete_document"}, + ), + "Create ToDo": OperationContract( + operation="Create ToDo", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Document Action"}, + {"fieldname": "operation", "default": "Create ToDo"}, + reference_doctype_override(), + { + "fieldname": "config", + "depends_on": "eval:doc.reference_doctype", + "reqd": 1, + "description": "⚠️ ToDo details (description, assigned_to, etc.)", + }, + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Update Context Variable"], + "reqd": 1, + }, + {"fieldname": "return_type", "default": "Single Record", "read_only": 1}, + {"fieldname": "description", "description": "⚠️ Creates a ToDo task for users"}, + ], + validation={"backend": "validate_create_todo"}, + ), + "Add Comment": OperationContract( + operation="Add Comment", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Document Action"}, + {"fieldname": "operation", "default": "Add Comment"}, + reference_doctype_override(), + {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, + { + "fieldname": "config", + "depends_on": "eval:doc.reference_doctype", + "reqd": 1, + "description": "⚠️ Comment content and settings", + }, + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Update Context Variable"], + "reqd": 1, + }, + {"fieldname": "return_type", "default": "Single Record", "read_only": 1}, + {"fieldname": "description", "description": "⚠️ Adds a comment to the document"}, + ], + validation={"backend": "validate_add_comment"}, + ), + } + def execute(self, action, context, engine): """Execute document creation/update based on mode.""" plan = get_action_plan(engine.rule, action) diff --git a/flexirule/ruleflow/core/action_handlers/loop.py b/flexirule/ruleflow/core/action_handlers/loop.py index 9d9f1bc6..76dae65e 100644 --- a/flexirule/ruleflow/core/action_handlers/loop.py +++ b/flexirule/ruleflow/core/action_handlers/loop.py @@ -10,6 +10,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) class LoopHandler(ActionHandler): @@ -17,6 +21,40 @@ class LoopHandler(ActionHandler): action_type = "Loop" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Loop", + required_fields=["config", "return_variable"], # config must have iterator + has_next_true=True, # Loop body + has_next_false=True, # Loop exit + terminal=False, + css={"icon": "fa fa-refresh", "color": "#f59e0b"}, + field_labels={ + "return_variable": "Item Alias", + }, + show_return_variable=True, + require_return_variable=True, + node_type="loop", + category="Control Flow", + configurable=True, + config_component="LoopConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Loop": OperationContract( + operation="Loop", + action_overrides=[ + {"fieldname": "action_type", "default": "Loop"}, + {"fieldname": "config", "reqd": 1, "description": "Loop configuration (iterator)"}, + {"fieldname": "return_variable", "reqd": 1, "description": "Variable name for current item"}, + {"fieldname": "description", "description": "Iterates over a collection"}, + ], + ) + } + def execute(self, action, context, engine): """ Execute loop iteration logic. diff --git a/flexirule/ruleflow/core/action_handlers/process.py b/flexirule/ruleflow/core/action_handlers/process.py index 0d40a265..2c43231e 100644 --- a/flexirule/ruleflow/core/action_handlers/process.py +++ b/flexirule/ruleflow/core/action_handlers/process.py @@ -12,6 +12,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) from flexirule.ruleflow.core.exceptions import MethodExecutionError from flexirule.ruleflow.core.process_runtime_v2 import ProcessOperationExecutor from flexirule.ruleflow.utils.mapping import apply_input_mapping @@ -23,6 +27,89 @@ class ProcessHandler(ActionHandler): action_type = "Process" executor = ProcessOperationExecutor() + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Process", + required_fields=["process_name", "operation"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-cog", "color": "#8b5cf6"}, + dynamic_fields=True, + field_labels={ + "operation": "Process Operation", + "mutation_mode": "Result Handling", + "return_type": "Result Type", + }, + allowed_mutations=[ + "Set Context Variable", + "Update Context Variable", + "Append to Context Variable", + "Set Doc Field", + "Update Doc Field", + "Batch Database Set", + ], + allowed_return_types=[ + "Yes / No", + "Single Record", + "List of Values", + "List of Records", + "Full Document", + ], + show_return_type=True, + node_type="process", + category="Processes", + configurable=True, + config_component="ProcessConfig", + ) + + @classmethod + def get_runtime_policy(cls, operation=None, process_operation=None) -> dict: + """Resolve runtime policy for Process including metadata-driven inference.""" + from flexirule.ruleflow.core.contracts import infer_process_operation_policy + + # Base policy from contract + policy = super().get_runtime_policy(operation, process_operation) + + # Complex resolution for Process V2 + if operation and process_operation: + from flexirule.ruleflow.core.process_contract_v2 import ( + resolve_process_operation_contract_v2, + ) + + process_name = ( + process_operation.get("parent") if isinstance(process_operation, dict) else None + ) + if process_name: + contract_v2 = resolve_process_operation_contract_v2( + process_name, + operation, + process_operation, + strict=True, + ) + op_policy = contract_v2.get("policy") or {} + for key, value in op_policy.items(): + if value is not None: + if key in ("allowed_mutations", "allowed_return_types"): + policy[key] = list(value) + elif key == "field_labels": + policy["field_labels"].update(value) + else: + policy[key] = value + elif process_operation: + dynamic_policy = infer_process_operation_policy(process_operation) + for key, value in dynamic_policy.items(): + if value is not None: + if key in ("allowed_mutations", "allowed_return_types"): + policy[key] = list(value) + elif key == "field_labels": + policy["field_labels"].update(value) + else: + policy[key] = value + + return policy + def execute(self, action, context, engine): """ Execute a Process operation. diff --git a/flexirule/ruleflow/core/action_handlers/query_records.py b/flexirule/ruleflow/core/action_handlers/query_records.py index ea945ba3..d47e618b 100644 --- a/flexirule/ruleflow/core/action_handlers/query_records.py +++ b/flexirule/ruleflow/core/action_handlers/query_records.py @@ -18,6 +18,13 @@ from frappe.utils import add_days, get_first_day, get_last_day, getdate, nowdate from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, + aggregate_operation_overrides, + config_depends_on_doctype, + reference_doctype_override, +) from flexirule.ruleflow.core.permissions import can_skip_permissions from flexirule.ruleflow.utils.mapping import apply_input_mapping @@ -27,6 +34,261 @@ class QueryRecordsHandler(ActionHandler): action_type = "Query Records" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Query Records", + required_fields=["reference_doctype", "operation"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-search", "color": "#0891b2"}, + operation_label="Query Mode", + operation_options=[ + "Query List", + "Query Doc", + "Exist Record", + "Query Report", + "Count", + "Sum", + "Average", + "Min", + "Max", + "Group By", + ], + allowed_mutations=[ + "Set Context Variable", + "Append to Context Variable", + "Update Context Variable", + ], + allowed_return_types=[ + "Yes / No", + "Single Record", + "List of Values", + "List of Records", + ], + default_return_type="List of Records", + show_return_type=True, + mandatory_fields={ + "Exist Record": ["reference_doctype"], + }, + operation_policies={ + "Query List": { + "allowed_return_types": ["List of Records"], + "default_return_type": "List of Records", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Rows Output Type"}, + }, + "Query Doc": { + "allowed_return_types": ["Single Record", "Full Document"], + "default_return_type": "Single Record", + "show_return_type": True, + "require_return_type": True, + "field_labels": {"return_type": "Record Output Type"}, + }, + "Exist Record": { + "allowed_return_types": ["Yes / No"], + "default_return_type": "Yes / No", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Boolean Output Type"}, + }, + "Query Report": { + "allowed_return_types": ["List of Records"], + "default_return_type": "List of Records", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Report Output Type"}, + }, + "Count": { + "allowed_return_types": ["List of Values"], + "default_return_type": "List of Values", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Metric Output Type"}, + }, + "Sum": { + "allowed_return_types": ["List of Values"], + "default_return_type": "List of Values", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Metric Output Type"}, + }, + "Average": { + "allowed_return_types": ["List of Values"], + "default_return_type": "List of Values", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Metric Output Type"}, + }, + "Min": { + "allowed_return_types": ["List of Values"], + "default_return_type": "List of Values", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Metric Output Type"}, + }, + "Max": { + "allowed_return_types": ["List of Values"], + "default_return_type": "List of Values", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Metric Output Type"}, + }, + "Group By": { + "allowed_return_types": ["List of Records"], + "default_return_type": "List of Records", + "show_return_type": False, + "require_return_type": False, + "field_labels": {"return_type": "Grouped Output Type"}, + }, + }, + field_labels={ + "operation": "Query Mode", + "reference_doctype": "Target DocType", + "reference_docname": "Target Record", + "mutation_mode": "Result Handling", + "return_type": "Result Type", + }, + node_type="query", + category="Data Actions", + configurable=True, + config_component="QueryRecordsConfig", + ) + + @classmethod + def get_operation_contracts(cls): + query_records_rule_overrides = [ + { + "fieldname": "trigger_event", + "options": [ + "Before Save", + "After Insert", + "After Save", + "Validate", + "On Submit", + "On Change", + ], + }, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ] + + return { + "Query List": OperationContract( + operation="Query List", + rule_overrides=query_records_rule_overrides, + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Query List"}, + reference_doctype_override(), + config_depends_on_doctype(description="Query configuration (filters, sorting)"), + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Append to Context Variable", "Update Context Variable"], + "reqd": 1, + }, + {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, + { + "fieldname": "timeout", + "hidden": "eval:doc.parent.execution_mode!=='Asynchronous'", + "description": "Only available for async rules", + }, + {"fieldname": "description", "description": "Queries multiple records from a DocType"}, + ], + validation={"backend": "validate_query_list"}, + ), + "Query Doc": OperationContract( + operation="Query Doc", + rule_overrides=query_records_rule_overrides, + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Query Doc"}, + {"fieldname": "reference_doctype", "reqd": 0, "link_filters": "[['DocType','istable','=',0]]"}, + config_depends_on_doctype(), + { + "fieldname": "mutation_mode", + "options": ["Set Context Variable", "Update Context Variable"], + "reqd": 1, + }, + {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, + {"fieldname": "description", "description": "Queries a single record using filters"}, + ], + validation={"backend": "validate_query_doc"}, + ), + "Exist Record": OperationContract( + operation="Exist Record", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["Validate", "Before Save"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Exist Record"}, + reference_doctype_override(), + config_depends_on_doctype(), + {"fieldname": "return_type", "default": "Yes / No", "read_only": 1}, + {"fieldname": "description", "description": "Checks if records exist matching criteria"}, + ], + validation={"backend": "validate_exist_record"}, + ), + "Query Report": OperationContract( + operation="Query Report", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Query Report"}, + reference_doctype_override(), + config_depends_on_doctype(), + {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, + { + "fieldname": "description", + "description": "Queries records using a custom report configuration", + }, + ], + validation={"backend": "validate_query_report"}, + ), + "Count": OperationContract( + operation="Count", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Count"}, + reference_doctype_override(), + config_depends_on_doctype(), + {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, + {"fieldname": "description", "description": "Counts records matching criteria"}, + ], + validation={"backend": "validate_count_records"}, + ), + "Sum": aggregate_operation_overrides("Sum", "Calculates sum of a numeric field"), + "Average": aggregate_operation_overrides("Average", "Calculates average of a numeric field"), + "Min": aggregate_operation_overrides("Min", "Finds minimum value of a field"), + "Max": aggregate_operation_overrides("Max", "Finds maximum value of a field"), + "Group By": OperationContract( + operation="Group By", + rule_overrides=[ + {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, + {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, + ], + action_overrides=[ + {"fieldname": "action_type", "default": "Query Records"}, + {"fieldname": "operation", "default": "Group By"}, + reference_doctype_override(), + config_depends_on_doctype(), + {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, + {"fieldname": "description", "description": "Groups records by specified fields"}, + ], + validation={"backend": "validate_group_by_query"}, + ), + } + def execute(self, action, context, engine): """Execute a query based on the configured mode (operation field).""" mode = action.operation diff --git a/flexirule/ruleflow/core/action_handlers/simple_actions.py b/flexirule/ruleflow/core/action_handlers/simple_actions.py index 01f36419..c76f5e7d 100644 --- a/flexirule/ruleflow/core/action_handlers/simple_actions.py +++ b/flexirule/ruleflow/core/action_handlers/simple_actions.py @@ -14,6 +14,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) from flexirule.ruleflow.core.action_plan_cache import get_action_plan from flexirule.ruleflow.core.engine import SafeFrappeAPI from flexirule.ruleflow.utils.field_resolver import parse_field_list @@ -24,6 +28,53 @@ class StopHandler(ActionHandler): action_type = "Stop" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Stop", + required_fields=["operation"], + has_next_true=False, + has_next_false=False, + terminal=True, + css={"icon": "fa fa-stop", "color": "#ef4444"}, + operation_label="Terminal Mode", + operation_options=["Success", "Error"], + mandatory_fields={ + "Error": ["value_template"], + }, + field_labels={"operation": "Terminal Mode"}, + node_type="stop", + category="Control Flow", + configurable=False, + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Success": OperationContract( + operation="Success", + action_overrides=[ + {"fieldname": "action_type", "default": "Stop"}, + {"fieldname": "operation", "default": "Success"}, + {"fieldname": "description", "description": "Terminates rule execution successfully"}, + ], + ), + "Error": OperationContract( + operation="Error", + action_overrides=[ + {"fieldname": "action_type", "default": "Stop"}, + {"fieldname": "operation", "default": "Error"}, + { + "fieldname": "value_template", + "reqd": 1, + "description": "⚠️ Error message that will be raised", + }, + {"fieldname": "description", "description": "Terminates rule execution with an error"}, + ], + validation={"backend": "validate_stop_error"}, + ), + } + def execute(self, action, context, engine): """ Stop action terminal behavior. @@ -57,6 +108,34 @@ class WaitHandler(ActionHandler): action_type = "Wait" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Wait", + required_fields=[], # config.duration optional + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-clock-o", "color": "#64748b"}, + field_labels={"operation": "Wait Mode"}, + node_type="wait", + category="Control Flow", + configurable=True, + config_component="WaitConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Wait": OperationContract( + operation="Wait", + action_overrides=[ + {"fieldname": "action_type", "default": "Wait"}, + {"fieldname": "description", "description": "Pauses execution for a specified duration"}, + ], + ) + } + def execute(self, action, context, engine): """ Wait action - pauses execution for specified duration. @@ -84,6 +163,42 @@ class RaiseErrorHandler(ActionHandler): action_type = "Raise Error" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Raise Error", + required_fields=["value_template"], + has_next_true=False, + has_next_false=False, + terminal=True, + css={"icon": "fa fa-exclamation-triangle", "color": "#dc2626"}, + field_labels={ + "config": "Error Details (JSON)", + }, + node_type="raise-error", + category="Control Flow", + configurable=True, + config_component="RaiseErrorConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Raise Error": OperationContract( + operation="Raise Error", + action_overrides=[ + {"fieldname": "action_type", "default": "Raise Error"}, + { + "fieldname": "value_template", + "reqd": 1, + "description": "⚠️ Error message that will be raised", + }, + {"fieldname": "description", "description": "Raises an exception to abort current operation"}, + ], + validation={"backend": "validate_raise_error"}, + ) + } + def execute(self, action, context, engine): """ Raise Error action - throws ValidationError with Jinja message. @@ -123,6 +238,87 @@ class NotifyHandler(ActionHandler): action_type = "Notify" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Notify", + required_fields=["value_template", "operation"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-bell", "color": "#0ea5e9"}, + operation_label="Notification Type", + operation_options=["Toast", "System", "Email", "System Notification", "Provider"], + operation_policies={ + "Email": { + "required_config_keys": ["subject", "recipients"], + }, + "System Notification": { + "required_config_keys": ["subject"], + }, + "Provider": { + "required_config_keys": ["provider", "recipient"], + }, + }, + field_labels={"operation": "Notification Type"}, + node_type="notify", + category="Notifications", + configurable=True, + config_component="NotifyConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Toast": OperationContract( + operation="Toast", + action_overrides=[ + {"fieldname": "action_type", "default": "Notify"}, + {"fieldname": "operation", "default": "Toast"}, + {"fieldname": "value_template", "reqd": 1}, + {"fieldname": "description", "description": "Shows a temporary notification to the user"}, + ], + ), + "System": OperationContract( + operation="System", + action_overrides=[ + {"fieldname": "action_type", "default": "Notify"}, + {"fieldname": "operation", "default": "System"}, + {"fieldname": "value_template", "reqd": 1}, + {"fieldname": "description", "description": "Sends a system notification"}, + ], + ), + "Email": OperationContract( + operation="Email", + action_overrides=[ + {"fieldname": "action_type", "default": "Notify"}, + {"fieldname": "operation", "default": "Email"}, + {"fieldname": "value_template", "reqd": 1}, + {"fieldname": "description", "description": "Sends an email notification"}, + ], + validation={"backend": "validate_email_notification"}, + ), + "System Notification": OperationContract( + operation="System Notification", + action_overrides=[ + {"fieldname": "action_type", "default": "Notify"}, + {"fieldname": "operation", "default": "System Notification"}, + {"fieldname": "value_template", "reqd": 1}, + {"fieldname": "description", "description": "Creates a system notification record"}, + ], + ), + "Provider": OperationContract( + operation="Provider", + action_overrides=[ + {"fieldname": "action_type", "default": "Notify"}, + {"fieldname": "operation", "default": "Provider"}, + {"fieldname": "value_template", "reqd": 1}, + {"fieldname": "description", "description": "Sends notification via external provider"}, + ], + validation={"backend": "validate_provider_notification"}, + ), + } + MODE_TO_EMAIL = "Email" MODE_TOAST = "Toast" MODE_REALTIME = "System" @@ -352,6 +548,33 @@ class EntryActionHandler(ActionHandler): action_type = "Entry Action" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Entry Action", + required_fields=[], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-play", "color": "#22c55e"}, + field_labels={}, + node_type="start", + category="Control Flow", + configurable=False, + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Entry Action": OperationContract( + operation="Entry Action", + action_overrides=[ + {"fieldname": "action_type", "default": "Entry Action"}, + {"fieldname": "description", "description": "Entry point for rule execution flow"}, + ], + ) + } + def execute(self, action, context, engine): """Entry Action - simply passes through to next step.""" return None, action.next_step_if_true diff --git a/flexirule/ruleflow/core/action_handlers/sub_rule.py b/flexirule/ruleflow/core/action_handlers/sub_rule.py index 772aa008..80eca201 100644 --- a/flexirule/ruleflow/core/action_handlers/sub_rule.py +++ b/flexirule/ruleflow/core/action_handlers/sub_rule.py @@ -15,6 +15,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) from flexirule.ruleflow.core.exceptions import CycleDetectedError, MethodExecutionError from flexirule.ruleflow.core.permissions import can_skip_permissions from flexirule.ruleflow.utils.mapping import apply_input_mapping, apply_output_mapping @@ -86,6 +90,71 @@ class SubRuleHandler(ActionHandler): action_type = "Sub-Rule" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Sub-Rule", + required_fields=["rule"], + has_next_true=True, + has_next_false=False, + terminal=False, + css={"icon": "fa fa-cube", "color": "#ec4899"}, + field_labels={ + "rule": "Sub-Rule Name", + "skip_conditions": "Skip Compatibility Check", + "return_type": "Sub-Rule Result Type", + }, + allowed_mutations=[ + "Set Context Variable", + "Update Context Variable", + "Append to Context Variable", + ], + allowed_return_types=[ + "Single Record", + "List of Records", + ], + default_return_type="Single Record", + show_return_type=True, + node_type="sub-rule", + category="Control Flow", + configurable=True, + config_component="SubRuleConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Sub-Rule": OperationContract( + operation="Sub-Rule", + action_overrides=[ + {"fieldname": "action_type", "default": "Sub-Rule"}, + { + "fieldname": "rule", + "reqd": 1, + "options": "Rule", + "link_filters": "[['Rule','trigger_type','=','Callable Event'],['Rule','exposed_as_subrule','=',1],['Rule','is_active','=',1]]", + }, + { + "fieldname": "skip_conditions", + "default": 1, + "description": "⚠️ Bypasses sub-rule's trigger conditions", + }, + { + "fieldname": "skip_permissions", + "read_only_depends_on": "eval:!frappe.user.has_role('System Manager')", + "description": "⚠️ Requires audit reason when enabled", + }, + { + "fieldname": "permission_audit_reason", + "mandatory_depends_on": "skip_permissions", + "hidden": "eval:!doc.skip_permissions", + }, + {"fieldname": "description", "description": "Executes another rule as a subroutine"}, + ], + validation={"backend": "validate_sub_rule"}, + ) + } + def execute(self, action, context, engine): """ Execute a Sub-Rule with proper isolation and cycle detection. diff --git a/flexirule/ruleflow/core/action_handlers/switch.py b/flexirule/ruleflow/core/action_handlers/switch.py index ffacf492..780146f4 100644 --- a/flexirule/ruleflow/core/action_handlers/switch.py +++ b/flexirule/ruleflow/core/action_handlers/switch.py @@ -10,6 +10,10 @@ from frappe import _ from flexirule.ruleflow.core.action_handlers import ActionHandler, HandlerRegistry +from flexirule.ruleflow.core.action_handlers.base_contract import ( + ActionContract, + OperationContract, +) class SwitchHandler(ActionHandler): @@ -17,6 +21,35 @@ class SwitchHandler(ActionHandler): action_type = "Switch" + @classmethod + def get_action_contract(cls): + return ActionContract( + action_type="Switch", + required_fields=["config"], # config must have cases + has_next_true=False, # Uses cases instead + has_next_false=True, # Default case + terminal=False, + css={"icon": "fa fa-random", "color": "#06b6d4"}, + node_type="switch", + category="Control Flow", + configurable=True, + config_component="SwitchConfig", + ) + + @classmethod + def get_operation_contracts(cls): + return { + "Switch": OperationContract( + operation="Switch", + action_overrides=[ + {"fieldname": "action_type", "default": "Switch"}, + {"fieldname": "config", "reqd": 1}, + {"fieldname": "next_step_if_false", "description": "Default case (no match)"}, + {"fieldname": "description", "description": "Multi-way branch based on expression value"}, + ], + ) + } + def execute(self, action, context, engine): """ Execute switch/case logic. diff --git a/flexirule/ruleflow/core/contract_dto.py b/flexirule/ruleflow/core/contract_dto.py new file mode 100644 index 00000000..1364155a --- /dev/null +++ b/flexirule/ruleflow/core/contract_dto.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025, FlexiRule and contributors +# For license information, please see license.txt + +from __future__ import annotations + +from flexirule.ruleflow.core.contract_utils import ( + MUTATION_MODE_OPTIONS, + RETURN_TYPE_OPTIONS, + TRIGGER_TYPE_CONTRACT, +) + + +class ContractDTOBuilder: + """Builds the frontend-safe contract DTO from the handler registry.""" + + @staticmethod + def build() -> dict: + from flexirule.ruleflow.core.action_handlers import HandlerRegistry + + return { + "action_type_contract": HandlerRegistry.get_all_contracts(), + "operation_contract": HandlerRegistry.get_all_operation_contracts(), + "trigger_type_contract": TRIGGER_TYPE_CONTRACT, + "runtime_field_aliases": { + "Sub-Rule": { + "sub_rule_name": "rule", + }, + }, + "release_disabled_action_types": [], # Can be extended if needed + "return_type_options": RETURN_TYPE_OPTIONS, + "mutation_mode_options": MUTATION_MODE_OPTIONS, + "action_types_with_reference_context": _infer_reference_context_types(), + "action_types_with_return_schema": _infer_return_schema_types(), + "config_modal_types": _infer_config_modal_types(), + } + + +def _infer_reference_context_types(): + """Infer which action types use reference_doctype from their contracts.""" + from flexirule.ruleflow.core.action_handlers import HandlerRegistry + + result = set() + for action_type, contract in HandlerRegistry.get_all_contracts().items(): + if "reference_doctype" in contract.get("required_fields", []): + result.add(action_type) + + # Manually add those that might not have it in required_fields but use it + # Based on legacy contracts.py: ACTION_TYPES_WITH_REFERENCE_CONTEXT = {"Query Records", "Document Action", "Process", "Assignment"} + # Process and Assignment don't always have it in required_fields + result.update({"Process", "Assignment"}) + return sorted(result) + + +def _infer_return_schema_types(): + """Infer which action types have return schema.""" + # Based on legacy contracts.py: ACTION_TYPES_WITH_RETURN_SCHEMA = {"Process", "Query Records", "Document Action"} + return sorted({"Process", "Query Records", "Document Action"}) + + +def _infer_config_modal_types(): + """Infer which action types have config modals from their contracts.""" + from flexirule.ruleflow.core.action_handlers import HandlerRegistry + + return sorted( + at for at, c in HandlerRegistry.get_all_contracts().items() + if c.get("configurable") + ) + + +def get_contract_dto() -> dict: + """API-facing function for contract DTO.""" + return ContractDTOBuilder.build() diff --git a/flexirule/ruleflow/core/contract_utils.py b/flexirule/ruleflow/core/contract_utils.py new file mode 100644 index 00000000..0b217d7f --- /dev/null +++ b/flexirule/ruleflow/core/contract_utils.py @@ -0,0 +1,98 @@ +# Copyright (c) 2025, FlexiRule and contributors +# For license information, please see license.txt + +from __future__ import annotations + +import frappe + + +def normalize_action_type(action_type: str | None, action_types: list[str] | None = None) -> str: + """Normalize machine/case variants to canonical action type keys.""" + if not action_type: + return "" + + raw = str(action_type).strip() + + if action_types is None: + from flexirule.ruleflow.core.action_handlers import HandlerRegistry + action_types = HandlerRegistry.action_types() + + if raw in action_types: + return raw + + normalized = " ".join(raw.replace("_", " ").replace("-", " ").lower().split()) + for canonical in action_types: + if canonical.lower() == normalized: + return canonical + + compact = normalized.replace(" ", "") + for canonical in action_types: + if canonical.lower().replace(" ", "") == compact: + return canonical + + return raw + + +def apply_field_overrides(base_fields: list, overrides: list) -> list: + """Apply field overrides to base field definitions.""" + field_map = {f["fieldname"]: f for f in base_fields} + + for override in overrides: + fieldname = override["fieldname"] + if fieldname in field_map: + existing = field_map[fieldname] + merged = existing.copy() + merged.update(override) + field_map[fieldname] = merged + else: + field_map[fieldname] = override.copy() + + return list(field_map.values()) + + +# Trigger Type Contract +TRIGGER_TYPE_CONTRACT = { + "DocType Event": { + "required_fields": ["document_type", "trigger_event"], + "optional_fields": ["trigger_condition", "compiled_expression"], + "hidden_fields": [], + }, + "Scheduler Event": { + "required_fields": [], + "optional_fields": ["document_type"], + "hidden_fields": ["trigger_event", "trigger_condition", "compiled_expression"], + }, + "Callable Event": { + "required_fields": [], + "optional_fields": ["document_type", "trigger_condition", "compiled_expression"], + "hidden_fields": ["trigger_event"], + }, +} + +RETURN_TYPE_OPTIONS = [ + "Yes / No", + "Single Record", + "List of Values", + "List of Records", + "Full Document", +] + +MUTATION_MODE_OPTIONS = [ + "Set Doc Field", + "Update Doc Field", + "Set Context Variable", + "Update Context Variable", + "Append to Context Variable", + "Batch Database Set", +] + +def get_trigger_type_contract(trigger_type: str) -> dict: + """Get contract for a trigger type.""" + return TRIGGER_TYPE_CONTRACT.get( + trigger_type, + { + "required_fields": [], + "optional_fields": [], + "hidden_fields": [], + }, + ) diff --git a/flexirule/ruleflow/core/contracts.py b/flexirule/ruleflow/core/contracts.py index a00a7591..6fe13ec2 100644 --- a/flexirule/ruleflow/core/contracts.py +++ b/flexirule/ruleflow/core/contracts.py @@ -2,1049 +2,61 @@ # For license information, please see license.txt """ -Unified Backend/Frontend Contract for FlexiRule Action Types +Unified Backend/Frontend Contract for FlexiRule Action Types (Facade) -CANONICAL SOURCE OF TRUTH: -This file (flexirule/ruleflow/core/contracts.py) is the canonical source of truth -for all action contracts, required fields, and operation policies. - -SYNC PROTOCOL: -1. Backend validation (Rule.validate) uses these definitions directly. -2. The frontend (flexirule/public/js/flexirule/core/contracts.js) MUST be kept - in sync with this file to ensure consistent UI status and real-time validation. -3. Use 'get_contract_dto' API to transfer these definitions to the frontend. +This module acts as a compatibility facade delegating to the decentralized +HandlerRegistry and contract utility modules. """ from __future__ import annotations -import json from typing import Any -# Action Type Contract -# Each action type defines: -# - required_fields: Fields that must be set for this action type -# - has_next_true: Whether next_step_if_true is valid -# - has_next_false: Whether next_step_if_false is valid -# - terminal: Whether this action ends the flow -# - validation: Additional validation rules - -ACTION_TYPE_CONTRACT: dict[str, dict[str, Any]] = { - "Entry Action": { - "required_fields": [], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-play", "color": "#22c55e"}, - "field_labels": {}, - "node_type": "start", - "category": "Control Flow", - "configurable": False, - }, - "Condition": { - "required_fields": ["config"], - "has_next_true": True, - "has_next_false": True, - "terminal": False, - "css": {"icon": "fa fa-code-fork", "color": "#3b82f6"}, - "validation": {"frontend": "validate_condition"}, - "field_labels": { - "compiled_expression": "Compiled Expression (Python)", - "config": "Condition Builder Config", - }, - "node_type": "condition", - "category": "Control Flow", - "configurable": True, - "config_component": "ConditionStep", - }, - "Process": { - "required_fields": ["process_name", "operation"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-cog", "color": "#8b5cf6"}, - "dynamic_fields": True, - "field_labels": { - "operation": "Process Operation", - "mutation_mode": "Result Handling", - "return_type": "Result Type", - }, - "allowed_mutations": [ - "Set Context Variable", - "Update Context Variable", - "Append to Context Variable", - "Set Doc Field", - "Update Doc Field", - "Batch Database Set", - ], - "allowed_return_types": [ - "Yes / No", - "Single Record", - "List of Values", - "List of Records", - "Full Document", - ], - "show_return_type": True, - "require_return_type": False, - "node_type": "process", - "category": "Processes", - "configurable": True, - "config_component": "ProcessConfig", - }, - "Loop": { - "required_fields": ["config", "return_variable"], # config must have iterator - "has_next_true": True, # Loop body - "has_next_false": True, # Loop exit - "terminal": False, - "css": {"icon": "fa fa-refresh", "color": "#f59e0b"}, - "field_labels": { - "return_variable": "Item Alias", - }, - "show_return_variable": True, - "require_return_variable": True, - "node_type": "loop", - "category": "Control Flow", - "configurable": True, - "config_component": "LoopConfig", - }, - "Stop": { - "required_fields": ["operation"], - "has_next_true": False, - "has_next_false": False, - "terminal": True, - "css": {"icon": "fa fa-stop", "color": "#ef4444"}, - "operation_label": "Terminal Mode", - "operation_options": ["Success", "Error"], - "mandatory_fields": { - "Error": ["value_template"], - }, - "field_labels": {"operation": "Terminal Mode"}, - "node_type": "stop", - "category": "Control Flow", - "configurable": False, - }, - "Switch": { - "required_fields": ["config"], # config must have cases - "has_next_true": False, # Uses cases instead - "has_next_false": True, # Default case - "terminal": False, - "css": {"icon": "fa fa-random", "color": "#06b6d4"}, - "node_type": "switch", - "category": "Control Flow", - "configurable": True, - "config_component": "SwitchConfig", - }, - "Wait": { - "required_fields": [], # config.duration optional - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-clock-o", "color": "#64748b"}, - "field_labels": {"operation": "Wait Mode"}, - "node_type": "wait", - "category": "Control Flow", - "configurable": True, - "config_component": "WaitConfig", - }, - "Sub-Rule": { - "required_fields": ["rule"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-cube", "color": "#ec4899"}, - "field_labels": { - "rule": "Sub-Rule Name", - "skip_conditions": "Skip Compatibility Check", - "return_type": "Sub-Rule Result Type", - }, - "allowed_mutations": [ - "Set Context Variable", - "Update Context Variable", - "Append to Context Variable", - ], - "allowed_return_types": [ - "Single Record", - "List of Records", - ], - "default_return_type": "Single Record", - "show_return_type": True, - "require_return_type": False, - "node_type": "sub-rule", - "category": "Control Flow", - "configurable": True, - "config_component": "SubRuleConfig", - }, - "Assignment": { - "required_fields": ["config"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-list-ol", "color": "#14b8a6"}, - "field_labels": { - "config": "Assignments", - }, - "node_type": "assignment", - "category": "Data Actions", - "configurable": True, - "config_component": "AssignmentConfig", - }, - "Notify": { - "required_fields": ["value_template", "operation"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-bell", "color": "#0ea5e9"}, - "operation_label": "Notification Type", - "operation_options": ["Toast", "System", "Email", "System Notification", "Provider"], - "operation_policies": { - "Email": { - "required_config_keys": ["subject", "recipients"], - }, - "System Notification": { - "required_config_keys": ["subject"], - }, - "Provider": { - "required_config_keys": ["provider", "recipient"], - }, - }, - "field_labels": {"operation": "Notification Type"}, - "node_type": "notify", - "category": "Notifications", - "configurable": True, - "config_component": "NotifyConfig", - }, - "Raise Error": { - "required_fields": ["value_template"], - "has_next_true": False, - "has_next_false": False, - "terminal": True, - "css": {"icon": "fa fa-exclamation-triangle", "color": "#dc2626"}, - "field_labels": { - "config": "Error Details (JSON)", - }, - "node_type": "raise-error", - "category": "Control Flow", - "configurable": True, - "config_component": "RaiseErrorConfig", - }, - "Query Records": { - "required_fields": ["reference_doctype", "operation"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-search", "color": "#0891b2"}, - "operation_label": "Query Mode", - "operation_options": [ - "Query List", - "Query Doc", - "Exist Record", - "Query Report", - "Count", - "Sum", - "Average", - "Min", - "Max", - "Group By", - ], - "allowed_mutations": [ - "Set Context Variable", - "Append to Context Variable", - "Update Context Variable", - ], - "allowed_return_types": [ - "Yes / No", - "Single Record", - "List of Values", - "List of Records", - ], - "default_return_type": "List of Records", - "show_return_type": True, - "require_return_type": False, - "mandatory_fields": { - "Exist Record": ["reference_doctype"], - }, - "operation_policies": { - "Query List": { - "allowed_return_types": ["List of Records"], - "default_return_type": "List of Records", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Rows Output Type"}, - }, - "Query Doc": { - "allowed_return_types": ["Single Record", "Full Document"], - "default_return_type": "Single Record", - "show_return_type": True, - "require_return_type": True, - "field_labels": {"return_type": "Record Output Type"}, - }, - "Exist Record": { - "allowed_return_types": ["Yes / No"], - "default_return_type": "Yes / No", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Boolean Output Type"}, - }, - "Query Report": { - "allowed_return_types": ["List of Records"], - "default_return_type": "List of Records", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Report Output Type"}, - }, - "Count": { - "allowed_return_types": ["List of Values"], - "default_return_type": "List of Values", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Metric Output Type"}, - }, - "Sum": { - "allowed_return_types": ["List of Values"], - "default_return_type": "List of Values", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Metric Output Type"}, - }, - "Average": { - "allowed_return_types": ["List of Values"], - "default_return_type": "List of Values", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Metric Output Type"}, - }, - "Min": { - "allowed_return_types": ["List of Values"], - "default_return_type": "List of Values", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Metric Output Type"}, - }, - "Max": { - "allowed_return_types": ["List of Values"], - "default_return_type": "List of Values", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Metric Output Type"}, - }, - "Group By": { - "allowed_return_types": ["List of Records"], - "default_return_type": "List of Records", - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Grouped Output Type"}, - }, - }, - "field_labels": { - "operation": "Query Mode", - "reference_doctype": "Target DocType", - "reference_docname": "Target Record", - "mutation_mode": "Result Handling", - "return_type": "Result Type", - }, - "node_type": "query", - "category": "Data Actions", - "configurable": True, - "config_component": "QueryRecordsConfig", - }, - "Document Action": { - "required_fields": ["reference_doctype", "operation"], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - "css": {"icon": "fa fa-file-text", "color": "#059669"}, - "operation_label": "Document Mode", - "operation_options": ["Create New", "Update Existing", "Delete Record", "Create ToDo", "Add Comment"], - "allowed_mutations": [ - "Set Doc Field", - "Set Context Variable", - ], - "allowed_return_types": [ - "Single Record", - "Full Document", - "Yes / No", - ], - "default_return_type": "Single Record", - "show_return_type": True, - "require_return_type": False, - "operation_policies": { - "Create New": { - "allowed_return_types": ["Single Record", "Full Document"], - "default_return_type": "Single Record", - "allowed_mutations": ["Set Context Variable", "Update Context Variable"], - "show_return_type": True, - "require_return_type": True, - "field_labels": {"return_type": "Created Document Output"}, - }, - "Update Existing": { - "allowed_return_types": ["Single Record", "Full Document"], - "default_return_type": "Single Record", - "allowed_mutations": ["Set Context Variable", "Update Context Variable", "Set Doc Field"], - "show_return_type": True, - "require_return_type": True, - "field_labels": {"return_type": "Updated Document Output"}, - }, - "Delete Record": { - "allowed_return_types": ["Yes / No"], - "default_return_type": "Yes / No", - "allowed_mutations": ["Set Context Variable"], - "show_return_type": False, - "require_return_type": False, - "field_labels": {"return_type": "Deletion Result Type"}, - }, - "Create ToDo": { - "allowed_return_types": ["Single Record"], - "default_return_type": "Single Record", - "allowed_mutations": ["Set Context Variable", "Update Context Variable"], - "show_return_type": False, - "require_return_type": False, - "required_config_keys": ["assigned_to", "description"], - "field_labels": {"return_type": "ToDo Output Type"}, - }, - "Add Comment": { - "allowed_return_types": ["Single Record"], - "default_return_type": "Single Record", - "allowed_mutations": ["Set Context Variable", "Update Context Variable"], - "show_return_type": False, - "require_return_type": False, - "required_config_keys": ["comment_text"], - "field_labels": {"return_type": "Comment Output Type"}, - }, - }, - "field_labels": { - "operation": "Document Mode", - "reference_doctype": "Target DocType", - "reference_docname": "Target Record", - "mutation_mode": "Result Handling", - "return_type": "Result Type", - }, - "node_type": "documentaction", - "category": "Data Actions", - "configurable": True, - "config_component": "DocumentActionConfig", - }, -} - -# Operation Contracts -# Each operation defines field overrides for Rule/Rule Action DocTypes and validation scripts -# -# Field Override Properties: -# - Standard Frappe properties: reqd, options, default, description, link_filters, etc. -# - Dynamic visibility: depends_on (already supported), hidden -# - Dynamic requirements: mandatory_depends_on -# - Dynamic editability: read_only_depends_on -# -# Expressions use eval: syntax and have access to: -# - doc: The current action document -# - parent: The parent rule document (e.g., parent.is_active, parent.execution_mode) -# - frappe: Frappe utilities (e.g., frappe.user.has_role()) -# -# Examples: -# - "mandatory_depends_on": "eval:doc.parent.is_active===1" # Required when rule is active -# - "hidden": "eval:doc.parent.execution_mode!=='Asynchronous'" # Hide for sync rules -# - "read_only_depends_on": "eval:!frappe.user.has_role('System Manager')" # Read-only for non-admins -# -OPERATION_CONTRACTS: dict[str, dict[str, Any]] = { - # Entry Action operations - "Entry Action": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Entry Action"}, - {"fieldname": "description", "description": "Entry point for rule execution flow"}, - ], - "Validation": {}, - }, - # Condition operations - "Condition": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Condition"}, - {"fieldname": "config", "reqd": 1}, - {"fieldname": "next_step_if_false", "mandatory_depends_on": "eval:doc.parent.is_active===1"}, - {"fieldname": "description", "description": "Evaluates a condition to branch execution"}, - ], - "Validation": {"frontend": "validate_condition"}, - }, - # Stop operations - "Success": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Stop"}, - {"fieldname": "operation", "default": "Success"}, - {"fieldname": "description", "description": "Terminates rule execution successfully"}, - ], - "Validation": {}, - }, - "Error": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Stop"}, - {"fieldname": "operation", "default": "Error"}, - {"fieldname": "value_template", "reqd": 1, "description": "⚠️ Error message that will be raised"}, - {"fieldname": "description", "description": "Terminates rule execution with an error"}, - ], - "Validation": {"backend": "validate_stop_error"}, - }, - # Raise Error operations - "Raise Error": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Raise Error"}, - {"fieldname": "value_template", "reqd": 1, "description": "⚠️ Error message that will be raised"}, - {"fieldname": "description", "description": "Raises an exception to abort current operation"}, - ], - "Validation": {"backend": "validate_raise_error"}, - }, - # Wait operations - "Wait": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Wait"}, - {"fieldname": "description", "description": "Pauses execution for a specified duration"}, - ], - "Validation": {}, - }, - # Sub-Rule operations - "Sub-Rule": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Sub-Rule"}, - { - "fieldname": "rule", - "reqd": 1, - "options": "Rule", - "link_filters": "[['Rule','trigger_type','=','Callable Event'],['Rule','exposed_as_subrule','=',1],['Rule','is_active','=',1]]", - }, - { - "fieldname": "skip_conditions", - "default": 1, - "description": "⚠️ Bypasses sub-rule's trigger conditions", - }, - { - "fieldname": "skip_permissions", - "read_only_depends_on": "eval:!frappe.user.has_role('System Manager')", - "description": "⚠️ Requires audit reason when enabled", - }, - { - "fieldname": "permission_audit_reason", - "mandatory_depends_on": "skip_permissions", - "hidden": "eval:!doc.skip_permissions", - }, - {"fieldname": "description", "description": "Executes another rule as a subroutine"}, - ], - "Validation": {"backend": "validate_sub_rule"}, - }, - "Assignment": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Assignment"}, - {"fieldname": "config", "reqd": 1, "description": "Array of assignments (JSON)"}, - { - "fieldname": "description", - "description": "Updates document fields or context variables in batch", - }, - ], - "Validation": {"backend": "validate_assignment"}, - }, - # Notify operations - "Toast": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Notify"}, - {"fieldname": "operation", "default": "Toast"}, - {"fieldname": "value_template", "reqd": 1}, - {"fieldname": "description", "description": "Shows a temporary notification to the user"}, - ], - "Validation": {}, - }, - "System": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Notify"}, - {"fieldname": "operation", "default": "System"}, - {"fieldname": "value_template", "reqd": 1}, - {"fieldname": "description", "description": "Sends a system notification"}, - ], - "Validation": {}, - }, - "Email": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Notify"}, - {"fieldname": "operation", "default": "Email"}, - {"fieldname": "value_template", "reqd": 1}, - {"fieldname": "description", "description": "Sends an email notification"}, - ], - "Validation": {"backend": "validate_email_notification"}, - }, - "System Notification": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Notify"}, - {"fieldname": "operation", "default": "System Notification"}, - {"fieldname": "value_template", "reqd": 1}, - {"fieldname": "description", "description": "Creates a system notification record"}, - ], - "Validation": {}, - }, - "Provider": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Notify"}, - {"fieldname": "operation", "default": "Provider"}, - {"fieldname": "value_template", "reqd": 1}, - {"fieldname": "description", "description": "Sends notification via external provider"}, - ], - "Validation": {"backend": "validate_provider_notification"}, - }, - # Query Records operations - "Query List": { - "Rule": [ - { - "fieldname": "trigger_event", - "options": [ - "Before Save", - "After Insert", - "After Save", - "Validate", - "On Submit", - "On Change", - ], - }, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Query List"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - { - "fieldname": "config", - "depends_on": "eval:doc.reference_doctype", - "reqd": 1, - "description": "Query configuration (filters, sorting)", - }, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Append to Context Variable", "Update Context Variable"], - "reqd": 1, - }, - {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, - { - "fieldname": "timeout", - "hidden": "eval:doc.parent.execution_mode!=='Asynchronous'", - "description": "Only available for async rules", - }, - {"fieldname": "description", "description": "Queries multiple records from a DocType"}, - ], - "Validation": {"backend": "validate_query_list"}, - }, - "Query Doc": { - "Rule": [ - { - "fieldname": "trigger_event", - "options": [ - "Before Save", - "After Insert", - "After Save", - "Validate", - "On Submit", - "On Change", - ], - }, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Query Doc"}, - { - "fieldname": "reference_doctype", - "reqd": 0, - "link_filters": "[['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Update Context Variable"], - "reqd": 1, - }, - {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, - {"fieldname": "description", "description": "Queries a single record using filters"}, - ], - "Validation": {"backend": "validate_query_doc"}, - }, - "Exist Record": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Exist Record"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "Yes / No", "read_only": 1}, - {"fieldname": "description", "description": "Checks if records exist matching criteria"}, - ], - "Validation": {"backend": "validate_exist_record"}, - }, - # Query Records operations (continued) - "Query Report": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Query Report"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, - { - "fieldname": "description", - "description": "Queries records using a custom report configuration", - }, - ], - "Validation": {"backend": "validate_query_report"}, - }, - "Count": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Count"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, - {"fieldname": "description", "description": "Counts records matching criteria"}, - ], - "Validation": {"backend": "validate_count_records"}, - }, - "Sum": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Sum"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, - {"fieldname": "description", "description": "Calculates sum of a numeric field"}, - ], - "Validation": {"backend": "validate_aggregate_query"}, - }, - "Average": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Average"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, - {"fieldname": "description", "description": "Calculates average of a numeric field"}, - ], - "Validation": {"backend": "validate_aggregate_query"}, - }, - "Min": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Min"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, - {"fieldname": "description", "description": "Finds minimum value of a field"}, - ], - "Validation": {"backend": "validate_aggregate_query"}, - }, - "Max": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Validate", "Before Save", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Max"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Values", "read_only": 1}, - {"fieldname": "description", "description": "Finds maximum value of a field"}, - ], - "Validation": {"backend": "validate_aggregate_query"}, - }, - "Group By": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Query Records"}, - {"fieldname": "operation", "default": "Group By"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "config", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, - {"fieldname": "description", "description": "Groups records by specified fields"}, - ], - "Validation": {"backend": "validate_group_by_query"}, - }, - # Document Action operations - "Create New": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["Before Insert", "Validate", "Before Save"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - { - "fieldname": "debug_mode", - "mandatory_depends_on": "eval:doc.trigger_type==='DocType Event'", - "description": "Enable detailed logging for document events", - }, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Document Action"}, - {"fieldname": "operation", "default": "Create New"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - { - "fieldname": "config", - "depends_on": "eval:doc.reference_doctype", - "reqd": 1, - "description": "⚠️ Document data to create", - }, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Update Context Variable"], - "reqd": 1, - }, - {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, - { - "fieldname": "skip_permissions", - "hidden": "eval:!frappe.user.has_role('System Manager')", - "read_only_depends_on": "eval:!frappe.user.has_role('System Manager')", - }, - { - "fieldname": "permission_audit_reason", - "mandatory_depends_on": "skip_permissions", - "hidden": "eval:!doc.skip_permissions", - }, - {"fieldname": "description", "description": "⚠️ Creates a new document record"}, - ], - "Validation": {"backend": "validate_create_document"}, - }, - "Update Existing": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Document Action"}, - {"fieldname": "operation", "default": "Update Existing"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - { - "fieldname": "config", - "depends_on": "eval:doc.reference_doctype", - "reqd": 1, - "description": "⚠️ Fields to update", - }, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Update Context Variable", "Set Doc Field"], - "reqd": 1, - }, - {"fieldname": "return_type", "options": ["Single Record", "Full Document"], "reqd": 1}, - {"fieldname": "description", "description": "⚠️ Updates an existing document record"}, - ], - "Validation": {"backend": "validate_update_document"}, - }, - "Delete Record": { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": "Document Action"}, - {"fieldname": "operation", "default": "Delete Record"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - {"fieldname": "return_type", "default": "Yes / No", "read_only": 1}, - {"fieldname": "description", "description": "⚠️ Permanently deletes a document record"}, - ], - "Validation": {"backend": "validate_delete_document"}, - }, - "Create ToDo": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Document Action"}, - {"fieldname": "operation", "default": "Create ToDo"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - { - "fieldname": "config", - "depends_on": "eval:doc.reference_doctype", - "reqd": 1, - "description": "⚠️ ToDo details (description, assigned_to, etc.)", - }, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Update Context Variable"], - "reqd": 1, - }, - {"fieldname": "return_type", "default": "Single Record", "read_only": 1}, - {"fieldname": "description", "description": "⚠️ Creates a ToDo task for users"}, - ], - "Validation": {"backend": "validate_create_todo"}, - }, - "Add Comment": { - "Rule": [ - {"fieldname": "trigger_event", "options": ["After Save", "On Submit", "On Change"]}, - {"fieldname": "trigger_type", "options": ["DocType Event", "Callable Event"]}, - ], - "Rule Action": [ - {"fieldname": "action_type", "default": "Document Action"}, - {"fieldname": "operation", "default": "Add Comment"}, - { - "fieldname": "reference_doctype", - "reqd": 1, - "link_filters": "[['DocType','issingle','=',0],['DocType','istable','=',0]]", - }, - {"fieldname": "reference_docname", "depends_on": "eval:doc.reference_doctype", "reqd": 1}, - { - "fieldname": "config", - "depends_on": "eval:doc.reference_doctype", - "reqd": 1, - "description": "⚠️ Comment content and settings", - }, - { - "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Update Context Variable"], - "reqd": 1, - }, - {"fieldname": "return_type", "default": "Single Record", "read_only": 1}, - {"fieldname": "description", "description": "⚠️ Adds a comment to the document"}, - ], - "Validation": {"backend": "validate_add_comment"}, - }, -} - - -def _parse_json_object(value: Any) -> dict[str, Any]: - if isinstance(value, dict): - return value - if isinstance(value, str): - try: - parsed = json.loads(value) - return parsed if isinstance(parsed, dict) else {} - except Exception: - return {} - return {} - - -def get_process_operation_overrides(process_operation: dict | None = None) -> dict[str, Any]: - """Parse optional operation-level action overrides from Process metadata.""" - if not isinstance(process_operation, dict): - return {} - overrides = _parse_json_object(process_operation.get("action_overrides")) - if not overrides: - return {} +from flexirule.ruleflow.core.action_handlers import HandlerRegistry +from flexirule.ruleflow.core.contract_dto import get_contract_dto +from flexirule.ruleflow.core.contract_utils import ( + MUTATION_MODE_OPTIONS, + RETURN_TYPE_OPTIONS, + TRIGGER_TYPE_CONTRACT, + apply_field_overrides, + get_trigger_type_contract, + normalize_action_type, +) + + +# Injected into the module as properties via __getattr__ +def __getattr__(name): + if name == "ACTION_TYPE_CONTRACT": + return HandlerRegistry.get_all_contracts() + if name == "OPERATION_CONTRACTS": + return HandlerRegistry.get_all_operation_contracts() + if name == "ACTION_TYPES_WITH_REFERENCE_CONTEXT": + return {"Query Records", "Document Action", "Process", "Assignment"} + if name == "ACTION_TYPES_WITH_RETURN_SCHEMA": + return {"Process", "Query Records", "Document Action"} + if name == "CONFIG_MODAL_TYPES": + return { + "Process", + "Condition", + "Assignment", + "Stop", + "Raise Error", + "Notify", + "Wait", + "Sub-Rule", + "Query Records", + "Document Action", + "Loop", + } + if name == "RELEASE_DISABLED_ACTION_TYPES": + return set() - policy = overrides.get("policy") - fields = overrides.get("fields") - return { - "policy": policy if isinstance(policy, dict) else {}, - "fields": fields if isinstance(fields, dict) else {}, - } + raise AttributeError(f"module {__name__} has no attribute {name}") def get_operation_contract(operation: str, process_operation: dict | None = None) -> dict: """Get contract for a known operation.""" - if operation in OPERATION_CONTRACTS: - return OPERATION_CONTRACTS[operation].copy() - - # Fallback to action type based contract - action_type = None - for at, contract in ACTION_TYPE_CONTRACT.items(): - if operation in (contract.get("operation_options") or []): - action_type = at - break - if not action_type and operation in ACTION_TYPE_CONTRACT: - action_type = operation - - if action_type: - # Generate basic contract from action type - base_contract = get_contract(action_type) - return { - "Rule": [], - "Rule Action": [ - {"fieldname": "action_type", "default": action_type}, - {"fieldname": "operation", "default": operation if operation != action_type else None}, - { - "fieldname": "description", - "description": base_contract.get("description", f"Executes {operation}"), - }, - ] - + [{"fieldname": f, "reqd": 1} for f in base_contract.get("required_fields", [])], - "Validation": base_contract.get("validation", {}), - } - - return { - "Rule": [], - "Rule Action": [{"fieldname": "description", "description": f"Unknown operation: {operation}"}], - "Validation": {}, - } + return HandlerRegistry.get_operation_contract(operation) def get_operation_field_overrides( @@ -1056,6 +68,7 @@ def get_operation_field_overrides( # Process operations can contribute generic field-level overrides through metadata. if doctype == "Rule Action" and isinstance(process_operation, dict): + from flexirule.ruleflow.core.contracts import get_process_operation_overrides process_overrides = get_process_operation_overrides(process_operation) for fieldname, field_config in (process_overrides.get("fields") or {}).items(): if not fieldname or not isinstance(field_config, dict): @@ -1065,101 +78,9 @@ def get_operation_field_overrides( return overrides -def apply_field_overrides(base_fields: list, overrides: list) -> list: - """Apply field overrides to base field definitions. - - Field overrides can include: - - Standard Frappe field properties (reqd, options, default, etc.) - - Dynamic properties: hidden, mandatory_depends_on, read_only_depends_on - - Custom properties: description, link_filters, etc. - - These overrides are applied on top of base DocType field definitions - to customize behavior per operation. - """ - field_map = {f["fieldname"]: f for f in base_fields} - - for override in overrides: - fieldname = override["fieldname"] - if fieldname in field_map: - # Merge override into existing field, preserving base properties - # but allowing overrides to take precedence - existing = field_map[fieldname] - merged = existing.copy() - merged.update(override) - field_map[fieldname] = merged - else: - # Add new field from override - field_map[fieldname] = override.copy() - - return list(field_map.values()) - - -ACTION_TYPES_WITH_REFERENCE_CONTEXT = {"Query Records", "Document Action", "Process", "Assignment"} -ACTION_TYPES_WITH_RETURN_SCHEMA = {"Process", "Query Records", "Document Action"} -CONFIG_MODAL_TYPES = { - "Process", - "Condition", - "Assignment", - "Stop", - "Raise Error", - "Notify", - "Wait", - "Sub-Rule", - "Query Records", - "Document Action", - "Loop", -} - -RELEASE_DISABLED_ACTION_TYPES: set[str] = set() -RETURN_TYPE_OPTIONS = [ - "Yes / No", - "Single Record", - "List of Values", - "List of Records", - "Full Document", -] -MUTATION_MODE_OPTIONS = [ - "Set Doc Field", - "Update Doc Field", - "Set Context Variable", - "Update Context Variable", - "Append to Context Variable", - "Batch Database Set", -] - -# Trigger Type Contract -# Defines which fields are required, optional, or hidden for each trigger_type. -TRIGGER_TYPE_CONTRACT = { - "DocType Event": { - "required_fields": ["document_type", "trigger_event"], - "optional_fields": ["trigger_condition", "compiled_expression"], - "hidden_fields": [], - }, - "Scheduler Event": { - "required_fields": [], - "optional_fields": ["document_type"], - "hidden_fields": ["trigger_event", "trigger_condition", "compiled_expression"], - }, - "Callable Event": { - "required_fields": [], - "optional_fields": ["document_type", "trigger_condition", "compiled_expression"], - "hidden_fields": ["trigger_event"], - }, -} - - def get_contract(action_type: str) -> dict: - """Get contract for an action type, with defaults for unknown types""" - action_type = normalize_action_type(action_type) - return ACTION_TYPE_CONTRACT.get( - action_type, - { - "required_fields": [], - "has_next_true": True, - "has_next_false": False, - "terminal": False, - }, - ) + """Get contract for an action type.""" + return HandlerRegistry.get_action_contract(action_type) def is_terminal_action(action_type: str) -> bool: @@ -1173,67 +94,51 @@ def get_required_fields(action_type: str) -> list: def is_release_disabled_action(action_type: str) -> bool: - """Check if an action type is intentionally disabled for the current release.""" - action_type = normalize_action_type(action_type) - return action_type in RELEASE_DISABLED_ACTION_TYPES - + """Check if an action type is intentionally disabled.""" + return False -def normalize_action_type(action_type: str | None) -> str: - """Normalize machine/case variants to canonical action type keys.""" - if not action_type: - return "" - raw = str(action_type).strip() - if raw in ACTION_TYPE_CONTRACT: - return raw - - normalized = " ".join(raw.replace("_", " ").replace("-", " ").lower().split()) - for canonical in ACTION_TYPE_CONTRACT: - if canonical.lower() == normalized: - return canonical - - compact = normalized.replace(" ", "") - for canonical in ACTION_TYPE_CONTRACT: - if canonical.lower().replace(" ", "") == compact: - return canonical +def get_effective_action_policy( + action_type: str | None, + operation: str | None = None, + process_operation: dict | None = None, +) -> dict: + """Resolve action policy including operation-level overrides.""" + return HandlerRegistry.get_effective_policy(action_type, operation, process_operation) - return raw +def _parse_json_object(value: Any) -> dict[str, Any]: + import json + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + return {} -def get_trigger_type_contract(trigger_type: str) -> dict: - """Get contract for a trigger type.""" - return TRIGGER_TYPE_CONTRACT.get( - trigger_type, - { - "required_fields": [], - "optional_fields": [], - "hidden_fields": [], - }, - ) +def get_process_operation_overrides(process_operation: dict | None = None) -> dict[str, Any]: + """Parse optional operation-level action overrides from Process metadata.""" + if not isinstance(process_operation, dict): + return {} + overrides = _parse_json_object(process_operation.get("action_overrides")) + if not overrides: + return {} -def get_contract_dto() -> dict: - """Export a frontend-safe contract payload from backend single source of truth.""" + policy = overrides.get("policy") + fields = overrides.get("fields") return { - "action_type_contract": ACTION_TYPE_CONTRACT, - "operation_contract": OPERATION_CONTRACTS, - "trigger_type_contract": TRIGGER_TYPE_CONTRACT, - "runtime_field_aliases": { - "Sub-Rule": { - "sub_rule_name": "rule", - }, - }, - "release_disabled_action_types": sorted(RELEASE_DISABLED_ACTION_TYPES), - "return_type_options": RETURN_TYPE_OPTIONS, - "mutation_mode_options": MUTATION_MODE_OPTIONS, - "action_types_with_reference_context": sorted(ACTION_TYPES_WITH_REFERENCE_CONTEXT), - "action_types_with_return_schema": sorted(ACTION_TYPES_WITH_RETURN_SCHEMA), - "config_modal_types": sorted(CONFIG_MODAL_TYPES), + "policy": policy if isinstance(policy, dict) else {}, + "fields": fields if isinstance(fields, dict) else {}, } def infer_process_operation_policy(process_operation: dict | None) -> dict: """Infer runtime/UI policy from Process Operation metadata.""" + import json policy: dict = {} if not isinstance(process_operation, dict): return policy @@ -1354,87 +259,3 @@ def infer_process_operation_policy(process_operation: dict | None) -> dict: policy[key] = value return policy - - -def get_effective_action_policy( - action_type: str | None, - operation: str | None = None, - process_operation: dict | None = None, -) -> dict: - """Resolve action policy including operation-level overrides.""" - contract = get_contract(action_type or "") - - # First, try to get operation-specific contract - op_contract = {} - if operation: - op_contract = get_operation_contract(operation, process_operation) - - policy: dict = { - "allowed_mutations": list(contract.get("allowed_mutations", []) or []), - "allowed_return_types": list(contract.get("allowed_return_types", []) or []), - "default_return_type": contract.get("default_return_type"), - "field_labels": dict(contract.get("field_labels", {}) or {}), - "show_return_type": contract.get("show_return_type"), - "require_return_type": contract.get("require_return_type", False), - "show_return_variable": contract.get("show_return_variable"), - "require_return_variable": contract.get("require_return_variable", False), - } - - # Override with operation-specific policies - op_policy = {} - if operation: - # Check legacy operation_policies in action type contract - op_policy = dict((contract.get("operation_policies", {}) or {}).get(operation, {}) or {}) - - # Override with new operation contract policies if available - if op_contract and op_contract.get("Rule Action"): - # Extract policy-relevant fields from operation contract - for field_def in op_contract["Rule Action"]: - fieldname = field_def.get("fieldname") - if fieldname == "mutation_mode" and field_def.get("options"): - op_policy["allowed_mutations"] = field_def["options"] - elif fieldname == "return_type": - if field_def.get("options"): - op_policy["allowed_return_types"] = field_def["options"] - if field_def.get("default"): - op_policy["default_return_type"] = field_def["default"] - if "read_only" in field_def: - op_policy["show_return_type"] = not field_def.get("read_only", False) - op_policy["require_return_type"] = field_def.get("reqd", False) - if action_type == "Process" and process_operation and operation: - from flexirule.ruleflow.core.process_contract_v2 import resolve_process_operation_contract_v2 - - process_name = process_operation.get("parent") if isinstance(process_operation, dict) else None - if process_name: - contract_v2 = resolve_process_operation_contract_v2( - process_name, - operation, - process_operation, - strict=True, - ) - for key, value in (contract_v2.get("policy") or {}).items(): - if value is not None: - op_policy[key] = value - elif process_operation: - dynamic_policy = infer_process_operation_policy(process_operation) - for key, value in dynamic_policy.items(): - if value is not None: - op_policy[key] = value - - for key in ("allowed_mutations", "allowed_return_types"): - if op_policy.get(key): - policy[key] = list(op_policy[key]) - if op_policy.get("default_return_type"): - policy["default_return_type"] = op_policy["default_return_type"] - if op_policy.get("show_return_type") is not None: - policy["show_return_type"] = op_policy.get("show_return_type") - if op_policy.get("require_return_type") is not None: - policy["require_return_type"] = op_policy.get("require_return_type") - if op_policy.get("show_return_variable") is not None: - policy["show_return_variable"] = op_policy.get("show_return_variable") - if op_policy.get("require_return_variable") is not None: - policy["require_return_variable"] = op_policy.get("require_return_variable") - if op_policy.get("field_labels"): - policy["field_labels"].update(op_policy.get("field_labels", {})) - - return policy From ecbc77558105ec8fdbe479dfee4732d26950ee58 Mon Sep 17 00:00:00 2001 From: ruzaqiarkan-eng <289478799+ruzaqiarkan-eng@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:40:46 +0000 Subject: [PATCH 2/2] fix: linter and mypy issues in decentralized contract architecture - Ran `ruff format` on all modified files to fix formatting issues. - Fixed mypy error in `ActionHandler.get_action_contract` by ensuring `action_type` is a string. - Cleaned up redundant imports and long lines in handlers for better readability and lint compliance. --- .../ruleflow/core/action_handlers/__init__.py | 14 ++++---------- .../ruleflow/core/action_handlers/base_contract.py | 4 +++- .../ruleflow/core/action_handlers/condition.py | 5 ++++- .../ruleflow/core/action_handlers/create_doc.py | 8 +++++++- flexirule/ruleflow/core/action_handlers/loop.py | 6 +++++- flexirule/ruleflow/core/action_handlers/process.py | 4 +--- .../ruleflow/core/action_handlers/query_records.py | 12 ++++++++++-- .../core/action_handlers/simple_actions.py | 5 ++++- flexirule/ruleflow/core/contract_dto.py | 5 +---- flexirule/ruleflow/core/contract_utils.py | 2 ++ flexirule/ruleflow/core/contracts.py | 3 +++ flexirule/ruleflow/core/evaluator.py | 4 +--- flexirule/ruleflow/core/runtime_eval.py | 2 +- 13 files changed, 46 insertions(+), 28 deletions(-) diff --git a/flexirule/ruleflow/core/action_handlers/__init__.py b/flexirule/ruleflow/core/action_handlers/__init__.py index a0323639..ee2d6cfb 100644 --- a/flexirule/ruleflow/core/action_handlers/__init__.py +++ b/flexirule/ruleflow/core/action_handlers/__init__.py @@ -48,7 +48,7 @@ def get_action_contract(cls) -> "ActionContract": from flexirule.ruleflow.core.action_handlers.base_contract import ActionContract return ActionContract( - action_type=cls.action_type, + action_type=cls.action_type or "", required_fields=[], has_next_true=True, has_next_false=False, @@ -84,9 +84,7 @@ def get_runtime_policy(cls, operation=None, process_operation=None) -> dict: } if operation: - op_policy = dict( - (contract_dict.get("operation_policies", {}) or {}).get(operation, {}) or {} - ) + op_policy = dict((contract_dict.get("operation_policies", {}) or {}).get(operation, {}) or {}) # Merge operation contract action_overrides into policy if relevant op_contracts = cls.get_operation_contracts() @@ -432,9 +430,7 @@ def register(cls, handler: ActionHandler) -> None: ValueError: If handler has no action_type set """ if not handler.action_type: - raise ValueError( - f"Handler {handler.__class__.__name__} must set 'action_type' class attribute" - ) + raise ValueError(f"Handler {handler.__class__.__name__} must set 'action_type' class attribute") cls._handlers[handler.action_type] = handler cls._contract_cache = None cls._op_contract_cache = None @@ -503,9 +499,7 @@ def get_all_contracts(cls) -> dict[str, dict]: return cls._contract_cache cls._ensure_initialized() - cls._contract_cache = { - at: h.get_action_contract().to_dict() for at, h in cls._handlers.items() - } + cls._contract_cache = {at: h.get_action_contract().to_dict() for at, h in cls._handlers.items()} return cls._contract_cache @classmethod diff --git a/flexirule/ruleflow/core/action_handlers/base_contract.py b/flexirule/ruleflow/core/action_handlers/base_contract.py index 258d7835..4e4c2f25 100644 --- a/flexirule/ruleflow/core/action_handlers/base_contract.py +++ b/flexirule/ruleflow/core/action_handlers/base_contract.py @@ -75,7 +75,9 @@ def to_dict(self) -> dict[str, Any]: "node_type": self.node_type, "category": self.category, "configurable": self.configurable, - "require_return_type": self.require_return_type if self.require_return_type is not None else False, + "require_return_type": self.require_return_type + if self.require_return_type is not None + else False, } if self.config_component: diff --git a/flexirule/ruleflow/core/action_handlers/condition.py b/flexirule/ruleflow/core/action_handlers/condition.py index 90048c97..4fbdf98d 100644 --- a/flexirule/ruleflow/core/action_handlers/condition.py +++ b/flexirule/ruleflow/core/action_handlers/condition.py @@ -51,7 +51,10 @@ def get_operation_contracts(cls): action_overrides=[ {"fieldname": "action_type", "default": "Condition"}, {"fieldname": "config", "reqd": 1}, - {"fieldname": "next_step_if_false", "mandatory_depends_on": "eval:doc.parent.is_active===1"}, + { + "fieldname": "next_step_if_false", + "mandatory_depends_on": "eval:doc.parent.is_active===1", + }, {"fieldname": "description", "description": "Evaluates a condition to branch execution"}, ], validation={"frontend": "validate_condition"}, diff --git a/flexirule/ruleflow/core/action_handlers/create_doc.py b/flexirule/ruleflow/core/action_handlers/create_doc.py index 80ebfb44..50664130 100644 --- a/flexirule/ruleflow/core/action_handlers/create_doc.py +++ b/flexirule/ruleflow/core/action_handlers/create_doc.py @@ -41,7 +41,13 @@ def get_action_contract(cls): terminal=False, css={"icon": "fa fa-file-text", "color": "#059669"}, operation_label="Document Mode", - operation_options=["Create New", "Update Existing", "Delete Record", "Create ToDo", "Add Comment"], + operation_options=[ + "Create New", + "Update Existing", + "Delete Record", + "Create ToDo", + "Add Comment", + ], allowed_mutations=[ "Set Doc Field", "Set Context Variable", diff --git a/flexirule/ruleflow/core/action_handlers/loop.py b/flexirule/ruleflow/core/action_handlers/loop.py index 76dae65e..91217661 100644 --- a/flexirule/ruleflow/core/action_handlers/loop.py +++ b/flexirule/ruleflow/core/action_handlers/loop.py @@ -49,7 +49,11 @@ def get_operation_contracts(cls): action_overrides=[ {"fieldname": "action_type", "default": "Loop"}, {"fieldname": "config", "reqd": 1, "description": "Loop configuration (iterator)"}, - {"fieldname": "return_variable", "reqd": 1, "description": "Variable name for current item"}, + { + "fieldname": "return_variable", + "reqd": 1, + "description": "Variable name for current item", + }, {"fieldname": "description", "description": "Iterates over a collection"}, ], ) diff --git a/flexirule/ruleflow/core/action_handlers/process.py b/flexirule/ruleflow/core/action_handlers/process.py index 2c43231e..a22df7af 100644 --- a/flexirule/ruleflow/core/action_handlers/process.py +++ b/flexirule/ruleflow/core/action_handlers/process.py @@ -78,9 +78,7 @@ def get_runtime_policy(cls, operation=None, process_operation=None) -> dict: resolve_process_operation_contract_v2, ) - process_name = ( - process_operation.get("parent") if isinstance(process_operation, dict) else None - ) + process_name = process_operation.get("parent") if isinstance(process_operation, dict) else None if process_name: contract_v2 = resolve_process_operation_contract_v2( process_name, diff --git a/flexirule/ruleflow/core/action_handlers/query_records.py b/flexirule/ruleflow/core/action_handlers/query_records.py index d47e618b..e57298ee 100644 --- a/flexirule/ruleflow/core/action_handlers/query_records.py +++ b/flexirule/ruleflow/core/action_handlers/query_records.py @@ -185,7 +185,11 @@ def get_operation_contracts(cls): config_depends_on_doctype(description="Query configuration (filters, sorting)"), { "fieldname": "mutation_mode", - "options": ["Set Context Variable", "Append to Context Variable", "Update Context Variable"], + "options": [ + "Set Context Variable", + "Append to Context Variable", + "Update Context Variable", + ], "reqd": 1, }, {"fieldname": "return_type", "default": "List of Records", "read_only": 1}, @@ -204,7 +208,11 @@ def get_operation_contracts(cls): action_overrides=[ {"fieldname": "action_type", "default": "Query Records"}, {"fieldname": "operation", "default": "Query Doc"}, - {"fieldname": "reference_doctype", "reqd": 0, "link_filters": "[['DocType','istable','=',0]]"}, + { + "fieldname": "reference_doctype", + "reqd": 0, + "link_filters": "[['DocType','istable','=',0]]", + }, config_depends_on_doctype(), { "fieldname": "mutation_mode", diff --git a/flexirule/ruleflow/core/action_handlers/simple_actions.py b/flexirule/ruleflow/core/action_handlers/simple_actions.py index c76f5e7d..92738b97 100644 --- a/flexirule/ruleflow/core/action_handlers/simple_actions.py +++ b/flexirule/ruleflow/core/action_handlers/simple_actions.py @@ -193,7 +193,10 @@ def get_operation_contracts(cls): "reqd": 1, "description": "⚠️ Error message that will be raised", }, - {"fieldname": "description", "description": "Raises an exception to abort current operation"}, + { + "fieldname": "description", + "description": "Raises an exception to abort current operation", + }, ], validation={"backend": "validate_raise_error"}, ) diff --git a/flexirule/ruleflow/core/contract_dto.py b/flexirule/ruleflow/core/contract_dto.py index 1364155a..83650bad 100644 --- a/flexirule/ruleflow/core/contract_dto.py +++ b/flexirule/ruleflow/core/contract_dto.py @@ -61,10 +61,7 @@ def _infer_config_modal_types(): """Infer which action types have config modals from their contracts.""" from flexirule.ruleflow.core.action_handlers import HandlerRegistry - return sorted( - at for at, c in HandlerRegistry.get_all_contracts().items() - if c.get("configurable") - ) + return sorted(at for at, c in HandlerRegistry.get_all_contracts().items() if c.get("configurable")) def get_contract_dto() -> dict: diff --git a/flexirule/ruleflow/core/contract_utils.py b/flexirule/ruleflow/core/contract_utils.py index 0b217d7f..a25b6102 100644 --- a/flexirule/ruleflow/core/contract_utils.py +++ b/flexirule/ruleflow/core/contract_utils.py @@ -15,6 +15,7 @@ def normalize_action_type(action_type: str | None, action_types: list[str] | Non if action_types is None: from flexirule.ruleflow.core.action_handlers import HandlerRegistry + action_types = HandlerRegistry.action_types() if raw in action_types: @@ -86,6 +87,7 @@ def apply_field_overrides(base_fields: list, overrides: list) -> list: "Batch Database Set", ] + def get_trigger_type_contract(trigger_type: str) -> dict: """Get contract for a trigger type.""" return TRIGGER_TYPE_CONTRACT.get( diff --git a/flexirule/ruleflow/core/contracts.py b/flexirule/ruleflow/core/contracts.py index 6fe13ec2..b9257c56 100644 --- a/flexirule/ruleflow/core/contracts.py +++ b/flexirule/ruleflow/core/contracts.py @@ -69,6 +69,7 @@ def get_operation_field_overrides( # Process operations can contribute generic field-level overrides through metadata. if doctype == "Rule Action" and isinstance(process_operation, dict): from flexirule.ruleflow.core.contracts import get_process_operation_overrides + process_overrides = get_process_operation_overrides(process_operation) for fieldname, field_config in (process_overrides.get("fields") or {}).items(): if not fieldname or not isinstance(field_config, dict): @@ -109,6 +110,7 @@ def get_effective_action_policy( def _parse_json_object(value: Any) -> dict[str, Any]: import json + if isinstance(value, dict): return value if isinstance(value, str): @@ -139,6 +141,7 @@ def get_process_operation_overrides(process_operation: dict | None = None) -> di def infer_process_operation_policy(process_operation: dict | None) -> dict: """Infer runtime/UI policy from Process Operation metadata.""" import json + policy: dict = {} if not isinstance(process_operation, dict): return policy diff --git a/flexirule/ruleflow/core/evaluator.py b/flexirule/ruleflow/core/evaluator.py index a30f9040..442a5cd2 100644 --- a/flexirule/ruleflow/core/evaluator.py +++ b/flexirule/ruleflow/core/evaluator.py @@ -185,9 +185,7 @@ def _evaluate_single(self, condition: dict, doc, row=None) -> bool: except Exception as e: condition_repr = json.dumps(condition, default=str)[:500] - message = ( - f"Single condition evaluation failed.\n" f"Condition: {condition_repr}\n" f"Error: {e!s}" - ) + message = f"Single condition evaluation failed.\nCondition: {condition_repr}\nError: {e!s}" frappe.logger("flexirule.eval").warning(message) frappe.log_error( title="FlexiRule Condition Evaluation Error", diff --git a/flexirule/ruleflow/core/runtime_eval.py b/flexirule/ruleflow/core/runtime_eval.py index a77bfdf9..a2c94f0b 100644 --- a/flexirule/ruleflow/core/runtime_eval.py +++ b/flexirule/ruleflow/core/runtime_eval.py @@ -108,7 +108,7 @@ def _log_eval_failure(func_name: str, expression: str, exc: Exception) -> None: extremely large compiled expressions. """ truncated_expr = expression[:500] + ("…" if len(expression) > 500 else "") - message = f"FlexiRule {func_name} failed.\n" f"Expression: {truncated_expr}\n" f"Error: {exc!s}" + message = f"FlexiRule {func_name} failed.\nExpression: {truncated_expr}\nError: {exc!s}" frappe.logger("flexirule.eval").warning(message) frappe.log_error(