se agregan las plantillas de aceptado/rechazado y su renderizador

plantilla/ con los dos HTML de correo y ejemplos.json (6 escenarios ficticios);
plantillas.py renderiza el subconjunto de Mustache que usan (variables con
escape de HTML, secciones e invertidas), en modo estricto: un dato que falta
da error en vez de un correo con huecos. tools/previsualizar_plantillas.py
genera las vistas previas y envía correos de prueba (--enviar). En la
plantilla de rechazo el RUC, la fila Comprobante y la tabla de guías son
opcionales, para el rechazo de un correo sin comprobante.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
GeorgeandClaude Sonnet 5.5 committed 2026-10-22 08:55:54 -05:00
1 parent d5a82ea025
commit 7ae2580565
7 files changed
+1012

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
"""
Renderizado de las plantillas HTML de correo (``plantilla/plantilla-*.html``).
Las plantillas usan un subconjunto de Mustache:
{{variable}} valor, con escape de HTML
{{#nombre}}...{{/nombre}} sección: se repite por cada elemento de una lista,
o se muestra una vez si el valor es verdadero
{{^nombre}}...{{/nombre}} sección invertida: se muestra si el valor es falso o vacío
El renderizado es **estricto**: toda variable o sección debe existir en el
contexto (usa ``None``, ``False`` o ``[]`` para ocultar algo). Así un dato que
falta provoca un error al renderizar, en vez de un correo con huecos.
No soporta ``{{{triple}}}``, parciales, comentarios ni nombres con punto; no se
usan en las plantillas.
"""
import html
import os
import re
CARPETA_PLANTILLAS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "plantilla")
_ETIQUETA = re.compile(r"\{\{\s*([#^/]?)\s*([A-Za-z_]\w*)\s*\}\}")
class PlantillaError(ValueError):
"""La plantilla está mal formada o el contexto no tiene algún dato que necesita."""
# ============================================================================
def _analizar(texto: str) -> list:
"""Convierte el texto en un árbol de nodos: ``("texto", s)``, ``("var", nombre)`` o ``("sec", nombre, hijos, invertida)``."""
raiz: list = []
pila: list[tuple[str, list, bool]] = [] # (nombre, hijos, invertida) de las secciones abiertas
actual = raiz
posicion = 0
for m in _ETIQUETA.finditer(texto):
if m.start() > posicion:
actual.append(("texto", texto[posicion:m.start()]))
posicion = m.end()
marca, nombre = m.group(1), m.group(2)
if marca == "":
actual.append(("var", nombre))
elif marca in ("#", "^"):
hijos: list = []
actual.append(("sec", nombre, hijos, marca == "^"))
pila.append((nombre, actual, marca == "^"))
actual = hijos
else: # cierre
linea = texto.count("\n", 0, m.start()) + 1
if not pila or pila[-1][0] != nombre:
raise PlantillaError(f"Línea {linea}: '{{{{/{nombre}}}}}' no corresponde a ninguna sección abierta")
actual = pila.pop()[1]
if pila:
raise PlantillaError(f"La sección '{pila[-1][0]}' no se cierra")
if posicion < len(texto):
actual.append(("texto", texto[posicion:]))
return raiz
def _buscar(nombre: str, pila: list):
for marco in reversed(pila):
if isinstance(marco, dict) and nombre in marco:
return marco[nombre]
raise PlantillaError(f"Falta '{nombre}' en el contexto de la plantilla")
def _generar(nodos: list, pila: list) -> str:
salida: list[str] = []
for nodo in nodos:
tipo = nodo[0]
if tipo == "texto":
salida.append(nodo[1])
elif tipo == "var":
valor = _buscar(nodo[1], pila)
salida.append("" if valor is None else html.escape(str(valor), quote=True))
else:
_, nombre, hijos, invertida = nodo
valor = _buscar(nombre, pila)
if invertida:
if not valor:
salida.append(_generar(hijos, pila))
elif isinstance(valor, list):
for elemento in valor:
salida.append(_generar(hijos, pila + [elemento]))
elif valor:
salida.append(_generar(hijos, pila + [valor] if isinstance(valor, dict) else pila))
return "".join(salida)
# ============================================================================
def renderizar(texto: str, contexto: dict) -> str:
"""Renderiza ``texto`` (plantilla Mustache) con ``contexto``. Lanza ``PlantillaError`` si falta algún dato."""
return _generar(_analizar(texto), [contexto])
def nombres_usados(texto: str) -> set[str]:
"""Todos los nombres (variables y secciones) que usa la plantilla."""
def recorrer(nodos):
for n in nodos:
if n[0] == "var":
yield n[1]
elif n[0] == "sec":
yield n[1]
yield from recorrer(n[2])
return set(recorrer(_analizar(texto)))
def cargar_plantilla(nombre: str) -> str:
"""Lee ``plantilla/plantilla-<nombre>.html`` (por ejemplo ``aceptado`` o ``rechazado``)."""
ruta = os.path.join(CARPETA_PLANTILLAS, f"plantilla-{nombre}.html")
with open(ruta, encoding="utf-8") as f:
return f.read()
def renderizar_plantilla(nombre: str, contexto: dict) -> str:
"""Carga la plantilla ``nombre`` y la renderiza con ``contexto``."""
return renderizar(cargar_plantilla(nombre), contexto)