From badb356740910c5d43a0a5e6a8da57f72c341f75 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Wed, 20 May 2026 19:21:08 +0200 Subject: [PATCH] fix(founding-wizard): nested IF-Bloecke korrekt aufloesen (innermost-first) --- .../founding_wizard/template_renderer.py | 26 +++++++++---- .../tests/test_founding_wizard.py | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/backend-compliance/compliance/services/founding_wizard/template_renderer.py b/backend-compliance/compliance/services/founding_wizard/template_renderer.py index d61260b7..2b8f65bd 100644 --- a/backend-compliance/compliance/services/founding_wizard/template_renderer.py +++ b/backend-compliance/compliance/services/founding_wizard/template_renderer.py @@ -15,13 +15,19 @@ from __future__ import annotations import re from typing import Any -# Pattern fuer {{#IF FLAG}}...{{/IF}} und {{#IF NOT FLAG}}...{{/IF}} -# Greedy / non-overlapping. Inhalt darf alles enthalten ausser einem geschlossenen {{/IF}}. -IF_BLOCK = re.compile( - r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}(.*?)\{\{/IF\}\}", +# Innerste {{#IF FLAG}}...{{/IF}}-Bloecke (Content enthaelt KEIN weiteres {{#IF). +# Iteratives Anwenden loest Verschachtelung von innen nach aussen sauber auf. +IF_INNERMOST = re.compile( + r"\{\{#IF\s+(NOT\s+)?([A-Z_][A-Z0-9_]*)\}\}" + r"((?:(?!\{\{#IF).)*?)" # Content: kein weiteres {{#IF + r"\{\{/IF\}\}", re.DOTALL, ) VAR_PATTERN = re.compile(r"\{\{\s*([A-Z_][A-Z0-9_]*)\s*\}\}") +# Fallback: orphan IF-Tags die nach Iteration uebrig sind (z.B. unbalanced template) raus. +ORPHAN_IF_TAG = re.compile( + r"\{\{/IF\}\}|\{\{#IF\s+(?:NOT\s+)?[A-Z_][A-Z0-9_]*\}\}" +) def _is_truthy(val: Any) -> bool: @@ -55,7 +61,9 @@ def render_template(template: str, context: dict[str, Any]) -> str: """ output = template - for _ in range(10): # max 10 Levels Nesting + # IF-Bloecke iterativ aufloesen — innerste zuerst, dann eine Ebene hoeher, usw. + # Bis zu 20 Iterationen reichen fuer realistisches Nesting. + for _ in range(20): def replace_if(match: re.Match[str]) -> str: negated = bool(match.group(1)) flag_name = match.group(2) @@ -66,11 +74,15 @@ def render_template(template: str, context: dict[str, Any]) -> str: condition = not condition return content if condition else "" - new_output = IF_BLOCK.sub(replace_if, output) + new_output = IF_INNERMOST.sub(replace_if, output) if new_output == output: break output = new_output + # Falls noch orphan IF-Tags uebrig sind (z.B. unbalanced template): entfernen + # damit sie nicht im Word-Output landen. + output = ORPHAN_IF_TAG.sub("", output) + def replace_var(match: re.Match[str]) -> str: name = match.group(1) val = context.get(name) @@ -90,6 +102,6 @@ def find_undefined_placeholders(template: str, context: dict[str, Any]) -> list[ placeholders: set[str] = set() for match in VAR_PATTERN.finditer(template): placeholders.add(match.group(1)) - for match in IF_BLOCK.finditer(template): + for match in IF_INNERMOST.finditer(template): placeholders.add(match.group(2)) return sorted([p for p in placeholders if p not in context]) diff --git a/backend-compliance/tests/test_founding_wizard.py b/backend-compliance/tests/test_founding_wizard.py index a51ec8e2..b7256439 100644 --- a/backend-compliance/tests/test_founding_wizard.py +++ b/backend-compliance/tests/test_founding_wizard.py @@ -68,6 +68,43 @@ class TestTemplateRenderer: result = render_template(template, {"A": True, "B": True}) assert result == "A-on+B-on" + def test_nested_outer_false_inner_true(self): + """Bug-Regression: nested IF in outer-false darf nicht den falschen close-tag matchen.""" + template = "{{#IF OUTER}}outer-{{#IF INNER}}inner{{/IF}}-end{{/IF}}AFTER" + result = render_template(template, {"OUTER": False, "INNER": True}) + assert result == "AFTER" + assert "{{/IF}}" not in result + assert "outer" not in result + + def test_consecutive_if_blocks(self): + """Bug-Regression: 2 aufeinanderfolgende IF-Bloecke.""" + template = "{{#IF A}}a{{/IF}}{{#IF B}}b{{/IF}}" + result = render_template(template, {"A": False, "B": True}) + assert result == "b" + assert "{{" not in result + + def test_orphan_if_tag_removed(self): + """Orphan {{/IF}} aufraeumen.""" + template = "Text{{/IF}}mehr" + result = render_template(template, {}) + assert "{{/IF}}" not in result + + def test_real_go_gf_pattern(self): + """Realistic Pattern aus GO-GF Template.""" + template = ( + "{{#IF HAS_CEO_DESIGNATION}}Mit CEO {{CEO_NAME}}{{#IF HAS_SHA}} und SHA{{/IF}}.{{/IF}}" + "{{#IF NOT HAS_CEO_DESIGNATION}}Kein CEO. Eskalation nach § 6.{{/IF}}" + ) + # Fall: kein CEO, kein SHA + r1 = render_template(template, {"HAS_CEO_DESIGNATION": False, "HAS_SHA": False}) + assert r1 == "Kein CEO. Eskalation nach § 6." + # Fall: CEO + SHA + r2 = render_template(template, {"HAS_CEO_DESIGNATION": True, "HAS_SHA": True, "CEO_NAME": "Benjamin"}) + assert r2 == "Mit CEO Benjamin und SHA." + # Fall: CEO ohne SHA + r3 = render_template(template, {"HAS_CEO_DESIGNATION": True, "HAS_SHA": False, "CEO_NAME": "Benjamin"}) + assert r3 == "Mit CEO Benjamin." + def test_find_undefined_placeholders(self): template = "{{X}} {{Y}} {{#IF Z}}.{{/IF}}" undefined = find_undefined_placeholders(template, {"X": "1"})