"""Shared Feishu approval form ``display_condition`` evaluator. Every Feishu-backed approval flow (`laptop_replacement`, `cloud_scope_request`, `dns_record_request`, `domain_registration`, `vpn_application`, `announcement_request`, `ai_tool_application`, `sso_apply`, `api_key_request`) fetches the form definition from Feishu's `get_approval_definition` API, which returns each widget's ``display_condition`` — a rule that gates the widget's visibility based on another widget's selected value. Feishu's own mobile UI honours these rules; Helix must honour them too so: (1) The applicant doesn't see fields they aren't supposed to fill (front-end). (2) ``required`` is not enforced on hidden widgets at submit time — without this, every conditional-widget template 400's on ``APPROVAL_FIELD_REQUIRED`` for the "happy path" submission that didn't trigger the gate. (3) The Feishu form payload Helix submits doesn't include answers for hidden widgets, since Feishu rejects answers for widgets the rule excluded. (4) The audit ``form_snapshot`` only records widgets the applicant actually saw. Feishu does NOT publish a canonical schema for ``display_condition``. The shapes seen in production templates are: Form 1 — top-level ``{conditions: [...], operator: "AND"|"OR"}``. Form 2 — ``{condition_groups: [{conditions: [...]}, ...]}`` — OR between groups, AND within each group. Form 3 — bare single condition dict (``{widget_id, operator, value}``). Form 4 — bare list at root (legacy designer exports). Operators encountered: ``equal``/``equals``/``=``/``eq``/``is``/``selected``, ``not_equal``/``neq``/``!=``/``is_not``, ``in``/``contain``/``contains``/ ``includes``, ``not_in``/``not_contain``/``not_contains``/``excludes``. Comparison: radioV2 / checkboxV2 answers are option machine keys (e.g. ``m28se01b-z05xuezwbv8-0``), NOT display labels. ``display_condition.value`` also carries the same machine key, so a string-coerced equality comparison works directly across both. The TS mirror (``web/src/components/formSnapshot.ts::evaluateDisplayConditions``) uses identical semantics — keep both files in lockstep. **Fail-open** is the contract: any unrecognised shape, unknown operator, or missing trigger answer must return ``True`` (visible). A future Feishu schema variant will reveal a widget that should be hidden until the evaluator is extended; it must NEVER block legitimate submissions by hiding a widget it shouldn't. """ from __future__ import annotations import logging from collections.abc import Mapping from typing import Any, Protocol logger = logging.getLogger(__name__) class _WidgetLike(Protocol): """Duck-typed widget — every flow's widget shape carries an ``id`` / ``name`` and a ``display_condition``; we don't depend on a concrete class so this helper works for both the typed ``ApprovalTemplateWidget`` pydantic model AND the raw dict shape that cloud_scope / DNS / VPN flows use.""" id: str name: str | None display_condition: dict[str, Any] | None def _read(widget: Any, key: str, default: Any = None) -> Any: """Read an attribute OR dict key — supports both flows.""" if isinstance(widget, dict): return widget.get(key, default) return getattr(widget, key, default) def _eq(a: Any, b: Any) -> bool: """String-coerce equality — pinned to match the TS mirror.""" return str(a) == str(b) def _list_contains(haystack: list[Any], needle: Any) -> bool: return any(_eq(item, needle) for item in haystack) def _eval_single_condition( cond: dict[str, Any], answers: Mapping[str, Any], widgets_by_name: dict[str, Any], ) -> bool: # Feishu's real wire shape (reverse-engineered from # ``laptop_replacement`` 2026-05-12) uses ``source_widget.id`` / # ``compare_type`` / ``standard_value``. Earlier-designer exports # use ``widget_id`` / ``operator`` / ``value``. Accept either — # the format is keyed off whichever set of fields is present. trigger_id = cond.get("widget_id") or cond.get("field_id") if not trigger_id: source_widget = cond.get("source_widget") if isinstance(source_widget, dict): trigger_id = ( source_widget.get("id") or source_widget.get("custom_id") or source_widget.get("widget_id") ) if not trigger_id: trigger_name = cond.get("name") or cond.get("widget_name") if trigger_name and trigger_name in widgets_by_name: trigger_id = _read(widgets_by_name[trigger_name], "id") if not trigger_id: return True # fail-open on unknown trigger actual = answers.get(trigger_id) op = str( cond.get("operator") or cond.get("op") or cond.get("compare_type") or "equal" ).lower() # ``value`` (legacy) and ``standard_value`` (Feishu real wire) # carry the expected match. expected = cond.get("value") if expected is None: expected = cond.get("standard_value") expected_one = ( expected[0] if isinstance(expected, list) and len(expected) == 1 else expected ) if op in {"equal", "equals", "=", "eq", "is", "selected"}: expected_values = expected if isinstance(expected, list) else [expected_one] actual_values = actual if isinstance(actual, list) else [actual] return any(_eq(a, e) for a in actual_values for e in expected_values) if op in {"not_equal", "neq", "!=", "is_not", "not"}: expected_values = expected if isinstance(expected, list) else [expected_one] actual_values = actual if isinstance(actual, list) else [actual] return not any(_eq(a, e) for a in actual_values for e in expected_values) if op in {"in", "contain", "contains", "includes"}: if isinstance(actual, list): if isinstance(expected, list): return any(_list_contains(actual, e) for e in expected) return _list_contains(actual, expected_one) if isinstance(expected, list): return any(_eq(actual, e) for e in expected) return str(expected_one) in str(actual or "") if op in {"not_in", "not_contain", "not_contains", "excludes"}: if isinstance(actual, list): if isinstance(expected, list): return all(not _list_contains(actual, e) for e in expected) return not _list_contains(actual, expected_one) if isinstance(expected, list): return not any(_eq(actual, e) for e in expected) return str(expected_one) not in str(actual or "") # Unknown operator — fail-open. return True def _eval_dc( node: Any, answers: Mapping[str, Any], widgets_by_name: dict[str, Any], ) -> bool: if node is None: return True # Form 4: bare list at the root. AND across the contained conditions. if isinstance(node, list): return all(_eval_dc(n, answers, widgets_by_name) for n in node) if not isinstance(node, dict): return True # Form 5 (Feishu real wire — laptop_replacement 2026-05-12): a # node with ``conditional`` ("OR"/"AND") + either ``expressions`` # (leaf conditions) and/or ``conditions`` (nested group nodes, # recursive). The other side is typically ``null`` when one is # populated. Real prod payload looks like:: # # { # "conditional": "OR", # "conditions": [ # { "conditional": "AND", "conditions": null, # "expressions": [ # {"compare_type": "is", # "source_widget": {"id": "widgetXXX"}, # "standard_value": "m28...-0"}]}], # "expressions": null # } if "conditional" in node or "expressions" in node: op = str(node.get("conditional") or node.get("operator") or "AND").upper() evals: list[bool] = [] expressions = node.get("expressions") if isinstance(expressions, list): for expr in expressions: if isinstance(expr, dict): evals.append(_eval_single_condition(expr, answers, widgets_by_name)) nested = node.get("conditions") if isinstance(nested, list): for child in nested: if isinstance(child, dict): evals.append(_eval_dc(child, answers, widgets_by_name)) if not evals: return True return any(evals) if op == "OR" else all(evals) # Form 2: ``condition_groups`` — OR between groups, AND within. groups = node.get("condition_groups") if isinstance(groups, list) and groups: return any( all( _eval_single_condition(c, answers, widgets_by_name) for c in (g.get("conditions") or []) if isinstance(c, dict) ) for g in groups if isinstance(g, dict) ) # Form 1: top-level ``conditions`` array — AND across conditions unless # ``operator`` says otherwise. (Legacy designer; Feishu real wire # hits Form 5 above.) conditions = node.get("conditions") if isinstance(conditions, list): op = str(node.get("operator", "AND")).upper() evals = [ _eval_single_condition(c, answers, widgets_by_name) for c in conditions if isinstance(c, dict) ] return any(evals) if op == "OR" else all(evals) # Form 3: single condition dict at root — accept both legacy # (``widget_id`` / ``operator`` / ``value``) and Feishu real wire # (``source_widget.id`` / ``compare_type`` / ``standard_value``) # keying. if ( "widget_id" in node or "field_id" in node or "name" in node or "source_widget" in node ): return _eval_single_condition(node, answers, widgets_by_name) return True def evaluate_display_conditions( widgets: list[Any], answers: Mapping[str, Any], *, assume_visible_when_answers_unsourced: bool = False, ) -> dict[str, bool]: """Return ``{widget_id: is_visible}`` for every widget. ``widgets`` accepts both pydantic-typed widgets (``ApprovalTemplateWidget`` with ``.id`` / ``.name`` / ``.display_condition`` attributes) AND raw Feishu form-definition dicts (the ``"display_condition"`` / ``"id"`` / ``"name"`` keys). ``answers`` is widget-id keyed. Fail-open on every error path — a future Feishu schema variant will reveal a widget that should be hidden until the evaluator is extended, but it must NEVER block a legitimate submission by hiding a widget that should be visible. ``assume_visible_when_answers_unsourced`` — set to ``True`` by callers that DON'T have widget-id-keyed answers to compare against (cloud_scope / DNS / domain / VPN / announcement / AI tool / SSO flows — these pass an empty answers map because they store user input under field-key names, not widget IDs). With the flag, any widget whose ``display_condition`` references a trigger not present in ``answers`` defaults to visible — the correct semantic when we genuinely can't evaluate the gate, rather than hiding the widget by accident. ApprovalService (laptop_replacement and friends) AND the frontend ``EmployeeRequestPage`` submit-time builder pass widget-id-keyed answers and leave the flag at its default ``False`` so the gate evaluates strictly: a buyback_photo widget gated on ``是否回购 = 是`` correctly hides before the user answers buyback (the trigger value is genuinely ``None``, not just absent from a different keying scheme). """ widgets_by_name: dict[str, Any] = {} for w in widgets: name = _read(w, "name") if name: widgets_by_name[name] = w result: dict[str, bool] = {} for w in widgets: wid = _read(w, "id") if not wid: continue dc = _read(w, "display_condition") if not dc: result[wid] = True continue try: if assume_visible_when_answers_unsourced and _has_unresolved_trigger( dc, answers, widgets_by_name, ): result[wid] = True continue result[wid] = _eval_dc(dc, answers, widgets_by_name) except Exception as exc: # noqa: BLE001 — fail-open logger.warning( "display_condition eval failed for widget %s: %s — " "falling back to visible", wid, exc, ) result[wid] = True return result def _has_unresolved_trigger( node: Any, answers: Mapping[str, Any], widgets_by_name: dict[str, Any], ) -> bool: """Walk a display_condition tree and return True if ANY referenced trigger widget id is missing from the ``answers`` map. Used by flows that don't thread widget-id-keyed answers to decide "evaluator can't tell — assume visible" rather than letting the None-actual comparison hide the widget by accident. Handles every node shape ``_eval_dc`` recognises (Forms 1–5). """ if node is None: return False if isinstance(node, list): return any(_has_unresolved_trigger(n, answers, widgets_by_name) for n in node) if not isinstance(node, dict): return False # Form 5: Feishu real wire — ``conditional`` + ``expressions`` # (leaf) + ``conditions`` (nested). if "conditional" in node or "expressions" in node: expressions = node.get("expressions") if isinstance(expressions, list): for expr in expressions: if isinstance(expr, dict) and _condition_trigger_unresolved( expr, answers, widgets_by_name, ): return True nested = node.get("conditions") if isinstance(nested, list): for child in nested: if isinstance(child, dict) and _has_unresolved_trigger( child, answers, widgets_by_name, ): return True return False # Form 2: condition_groups (legacy). groups = node.get("condition_groups") if isinstance(groups, list): for g in groups: if isinstance(g, dict): for c in (g.get("conditions") or []): if isinstance(c, dict) and _condition_trigger_unresolved( c, answers, widgets_by_name, ): return True return False # Form 1: conditions array (legacy). conditions = node.get("conditions") if isinstance(conditions, list): return any( _condition_trigger_unresolved(c, answers, widgets_by_name) for c in conditions if isinstance(c, dict) ) # Form 3: single condition dict. if ( "widget_id" in node or "field_id" in node or "name" in node or "source_widget" in node ): return _condition_trigger_unresolved(node, answers, widgets_by_name) return False def _condition_trigger_unresolved( cond: dict[str, Any], answers: Mapping[str, Any], widgets_by_name: dict[str, Any], ) -> bool: trigger_id = cond.get("widget_id") or cond.get("field_id") if not trigger_id: source_widget = cond.get("source_widget") if isinstance(source_widget, dict): trigger_id = ( source_widget.get("id") or source_widget.get("custom_id") or source_widget.get("widget_id") ) if not trigger_id: trigger_name = cond.get("name") or cond.get("widget_name") if trigger_name and trigger_name in widgets_by_name: trigger_id = _read(widgets_by_name[trigger_name], "id") if not trigger_id: return True return trigger_id not in answers def is_widget_visible( widget: Any, answers: Mapping[str, Any], *, widgets_for_context: list[Any] | None = None, ) -> bool: """Single-widget convenience wrapper — for callers that only need the visibility of one widget without computing the full map. Passes the surrounding ``widgets_for_context`` so name-based trigger resolution works; callers that don't need name-based lookups can omit it. """ dc = _read(widget, "display_condition") if not dc: return True widgets_by_name: dict[str, Any] = {} if widgets_for_context: for w in widgets_for_context: name = _read(w, "name") if name: widgets_by_name[name] = w try: return _eval_dc(dc, answers, widgets_by_name) except Exception: # noqa: BLE001 return True