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:
1 parent
d5a82ea025
commit
7ae2580565
7 files changed
+1012
No files matched your search
+123
@@ -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)
|
||||
Reference in new issue
Block a user