DEV Community

Daniel for Datalaria

Posted on Originally published at datalaria.com

Proyecto LifeOps (Parte 4): Motor de Informes Ejecutivos en Word (.docx) y Exportación Multi-Hoja en Excel (.xlsx)

En las entregas anteriores diseñamos la arquitectura backend con FastAPI y Supabase, la interfaz Glassmorphism con React y los módulos interactivos de deporte, biblioteca, cine y tareas Kanban.

Sin embargo, en el desarrollo de software existe un principio innegociable: la soberanía del dato.

La inmensa mayoría de las aplicaciones comerciales de suscripción mensual cometen el mismo pecado deliberado: te facilitan registrar información gratis, pero cuando deseas extraerla, te encuentras con formatos propietarios cerrados, exportaciones amputadas o muros de pago. Si el servicio quiebra o decide duplicar sus tarifas, tus datos quedan atrapados en un silo.

Al concebir LifeOps, me propuse una regla de oro: ningún dato registrado sería un rehén. El usuario debía poder descargar en cualquier instante un informe mensual maquetado con diseño corporativo en Word (.docx) listo para imprimir o enviar, un libro de auditoría completo en Excel (.xlsx) con pestañas independientes, y volcados en CSV con UTF-8 BOM compatibles al 100% con PowerBI y Microsoft Excel sin configuraciones previas.

Y todo ello implementado bajo una arquitectura Zero-Disk in-memory: sin tocar el disco del servidor ni dejar archivos temporales huérfanos. ¡Veamos cómo lo construí! 🚀

[!TIP]
Prueba la aplicación en vivo: Puedes generar y descargar tus propios informes en la versión de producción de LifeOps en https://datalaria.com/apps/lifeops/.


🗺️ Hoja de Ruta de la Serie LifeOps

Esta serie documenta el ciclo de vida completo del proyecto a través de 5 entregas estructuradas:

  1. 🟢 Parte 1: Arquitectura de un Sistema Operativo Personal y Backend con FastAPI + Supabase
  2. 🟢 Parte 2: Frontend React con Glassmorphism, Dashboard 360° y Sistema de Diseño Dark Mode
  3. 🟢 Parte 3: Módulos Core: Deporte, Biblioteca, Cine y Tablero Kanban Profesional
  4. 🟢 Parte 4 (Este artículo): Motor de Informes Ejecutivos en Word (.docx) y Exportación Multi-Hoja en Excel (.xlsx)
  5. Parte 5: Despliegue 24/7 en la Nube a Coste Cero ($0/mes), Optimización Móvil y PWA

1. Arquitectura Zero-Disk: Generación en Memoria RAM con io.BytesIO 🧠⚡

Muchos tutoriales web proponen un patrón peligroso para generar archivos descargables en el backend:

  1. Crear un archivo temporal en disco (ej. /tmp/informe_123.docx).
  2. Escribir el contenido.
  3. Servirlo con un enlace de descarga.
  4. Programar un cronjob o tarea en segundo plano para borrarlo después.

En un entorno cloud serverless o en contenedores efímeros (como el tier gratuito de Render.com), este enfoque es una receta para el desastre:

  • El almacenamiento del contenedor es limitado; si varias peticiones concurrentes generan archivos pesados, el disco se satura y la API se congela (Out of Disk Space).
  • Si el proceso falla a mitad de camino, los archivos huérfanos nunca se eliminan.
  • Dos peticiones simultáneas pueden sobreescribir rutas temporales si no se gestionan colisiones de nombres con extrema precaución.

En LifeOps adoptamos una arquitectura Zero-Disk:

┌─────────────────────────────────────────────────────────────┐
│ 1. Petición Frontend: POST /api/v1/reports/generate         │
└──────────────────────────────┬──────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────┐
│ 2. FastAPI: Consulta optimizada con JOINs en Supabase       │
│    activities(*, workouts, books, films) + tasks + projects │
└──────────────────────────────┬──────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────┐
│ 3. Motor in-memory: python-docx / openpyxl                  │
│    doc.save(buffer) donde buffer = io.BytesIO()             │
│    buffer.seek(0)                                           │
└──────────────────────────────┬──────────────────────────────┘
                               │
                               ▼
┌─────────────────────────────────────────────────────────────┐
│ 4. Respuesta HTTP Streaming Directo:                        │
│    Response(content=buffer.getvalue(), media_type=docx)     │
│    Header: Content-Disposition: attachment; filename=...    │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

El documento se construye en la memoria RAM, se empaqueta como un flujo binario en un buffer io.BytesIO, se transmite por la red hacia el navegador y se destruye automáticamente en cuanto el colector de basura de Python libera la variable. Cero escrituras en disco, cero riesgos de colisión y máxima velocidad.


2. El Motor de Documentos Word (.docx) con python-docx 📄✨

Para la generación de documentos Word ejecutivos, la librería estándar en Python es python-docx. No obstante, los documentos generados por defecto suelen verse sosos, con tipografías anticuadas y tablas sin márgenes internos.

Para darle un acabado de nivel directivo (C-Level Executive), implementé un conjunto de funciones auxiliares que manipulan directamente el árbol XML subyacente de Word (docx.oxml):

from docx import Document
from docx.shared import Inches, Pt, RGBColor
from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls

# Paleta de Identidad LifeOps
COLOR_PRIMARY_HEX = "0B0F17"    # Obsidiana oscuro
COLOR_EMERALD_HEX = "10B981"    # Esmeralda
COLOR_BG_LIGHT_HEX = "F8FAFC"   # Fondo de celda suave

def set_cell_background(cell, fill_hex: str):
    """Aplica color de fondo sólido a una celda de tabla en Word."""
    tcPr = cell._tc.get_or_add_tcPr()
    shd = parse_xml(f'<w:shd {nsdecls("w")} w:fill="{fill_hex}"/>')
    tcPr.append(shd)

def set_cell_margins(cell, top=100, bottom=100, left=150, right=150):
    """Establece padding interno en la celda (en twips, 20 twips = 1 pt)."""
    tcPr = cell._tc.get_or_add_tcPr()
    tcMar = parse_xml(
        f'<w:tcMar {nsdecls("w")}>'
        f'<w:top w:w="{top}" w:type="dxa"/>'
        f'<w:bottom w:w="{bottom}" w:type="dxa"/>'
        f'<w:left w:w="{left}" w:type="dxa"/>'
        f'<w:right w:w="{right}" w:type="dxa"/>'
        f'</w:tcMar>'
    )
    tcPr.append(tcMar)
Enter fullscreen mode Exit fullscreen mode

2.1. Plantillas Ejecutivas Disponibles

El motor soporta tres tipos de informes especializados:

  1. Informe Mensual Integral (monthly_summary): El dossier 360° más completo. Combina métricas de salud deportiva, lista de lecturas terminadas, catálogo de cine y estado del portafolio de proyectos.
  2. Dossier de Rendimiento Deportivo (sport_performance): Diseñado para el análisis de entrenamientos: tabla de volumen en km, ritmos medios, calorías acumuladas y marcas personales (PB).
  3. Estado de Portafolio de Proyectos (project_status): Enfocado en gestión profesional: control de entregables, tareas vencidas y la bitácora cronológica completa extraída del campo JSONB.

Catálogo de plantillas ejecutivas en Word (.docx) con selector y desglose de métricas

2.2. El Ensamblado en Memoria

El punto de entrada del generador ejecuta la construcción y vuelca el binario en el buffer:

import io

def generate_docx_report(template_type: str, user_email: str, date_from, date_to, data: dict) -> io.BytesIO:
    doc = Document()

    # Márgenes ejecutivos uniformes (2 cm)
    for section in doc.sections:
        section.top_margin = Inches(0.8)
        section.bottom_margin = Inches(0.8)
        section.left_margin = Inches(0.8)
        section.right_margin = Inches(0.8)

    if template_type == "sport_performance":
        _build_sport_report(doc, user_email, date_from, date_to, data)
    elif template_type == "project_status":
        _build_project_report(doc, user_email, date_from, date_to, data)
    else:
        _build_monthly_integral_report(doc, user_email, date_from, date_to, data)

    # Volcado a RAM
    buffer = io.BytesIO()
    doc.save(buffer)
    buffer.seek(0)
    return buffer
Enter fullscreen mode Exit fullscreen mode

3. El Motor de Libros Multi-Hoja en Excel (.xlsx) con openpyxl 📊📗

Tener un informe en Word es perfecto para lectura ejecutiva, pero para realizar análisis cuantitativo, filtros dinámicos o crear dashboards en PowerBI, el formato idóneo es Excel (.xlsx).

Con openpyxl, creamos un libro de trabajo unificado que consolida toda la base de datos del usuario en 5 pestañas temáticas perfectamente formateadas:

from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter

def export_full_excel(user_id: str) -> io.BytesIO:
    wb = Workbook()
    wb.remove(wb.active)  # Eliminar hoja vacía por defecto

    # Estilos Corporativos
    header_fill = PatternFill(start_color="1E293B", end_color="1E293B", fill_type="solid")
    header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
    data_font = Font(name="Calibri", size=10)
    thin_border = Border(
        left=Side(style="thin", color="E2E8F0"),
        right=Side(style="thin", color="E2E8F0"),
        top=Side(style="thin", color="E2E8F0"),
        bottom=Side(style="thin", color="E2E8F0"),
    )

    sheets_config = [
        ("🏃 Deporte & Fitness", _fetch_sport_data),
        ("📚 Biblioteca de Libros", _fetch_books_data),
        ("🎬 Cine & Series", _fetch_films_data),
        ("📋 Tablero de Tareas", _fetch_tasks_data),
        ("💼 Portafolio Proyectos", _fetch_projects_data),
    ]

    for title, fetcher in sheets_config:
        ws = wb.create_sheet(title=title)
        ws.views.sheetView[0].showGridLines = True  # Cuadrícula siempre visible

        data = fetcher(user_id)
        if data:
            headers = [h.replace("_", " ").upper() for h in data[0].keys()]
            ws.append(headers)

            # Estilizar Cabecera
            for cell in ws[1]:
                cell.fill = header_fill
                cell.font = header_font
                cell.alignment = Alignment(horizontal="center", vertical="center")
            ws.row_dimensions[1].height = 24

            # Volcar Filas con Bordes
            for row_idx, item in enumerate(data, start=2):
                ws.append(list(item.values()))
                for col_idx in range(1, len(headers) + 1):
                    c = ws.cell(row=row_idx, column=col_idx)
                    c.font = data_font
                    c.border = thin_border

            # Auto-ajuste de anchura de columnas inteligente
            for col in ws.columns:
                max_len = max(len(str(cell.value or "")) for cell in col)
                col_letter = get_column_letter(col[0].column)
                ws.column_dimensions[col_letter].width = max(max_len + 4, 12)

    buffer = io.BytesIO()
    wb.save(buffer)
    buffer.seek(0)
    return buffer
Enter fullscreen mode Exit fullscreen mode

Detalles que marcan la diferencia:

  • showGridLines = True: Por defecto, Excel desactiva las líneas de cuadrícula en hojas generadas con fondos personalizados. Forzar esta propiedad garantiza una lectura cómoda.
  • Auto-fit de columnas: Calcula la longitud máxima de cada celda y le añade un margen de seguridad de 4 caracteres, evitando los molestos textos truncados o números con ###.

Exportación de Libro Maestro Excel (.xlsx) con 5 pestañas temáticas consolidadas


4. Backups en CSV: El Secreto del UTF-8 BOM (\ufeff) 🛡️

Para analistas de datos que prefieren archivos planos o pipelines automatizados en Python/R, LifeOps ofrece exportaciones en CSV por entidad.

Sin embargo, hay un problema histórico que todo ingeniero de datos conoce: Microsoft Excel en Windows tiene un comportamiento pésimo abriendo archivos CSV codificados en UTF-8 estándar, provocando que palabras con tildes o eñes (como "Kilómetros", "Diseño" o "Película") se conviertan en caracteres ininteligibles (mojibake).

La solución técnica es tan elegante como poco conocida: anteponer el Byte Order Mark (BOM) UTF-8 (\ufeff) y utilizar el punto y coma (;) como delimitador:

import io
import csv

def export_entity_csv(entity: str, user_id: str) -> bytes:
    data = fetcher(user_id)
    output = io.StringIO()

    # 1. Inyectar BOM para que Excel detecte UTF-8 sin preguntar
    output.write("\ufeff")

    if data:
        writer = csv.DictWriter(output, fieldnames=list(data[0].keys()), delimiter=";", quoting=csv.QUOTE_MINIMAL)
        writer.writeheader()
        for row in data:
            writer.writerow(row)

    return output.getvalue().encode("utf-8")
Enter fullscreen mode Exit fullscreen mode

Al hacer doble clic en el archivo descargado, Excel lo abre perfecto al instante, con acentos nítidos y cada dato en su columna correspondiente.

Módulos de exportación en CSV con codificación UTF-8 BOM y delimitador por punto y coma


5. Protección de Recursos: Rate Limiting Anti-Abuso con slowapi ⏱️

La generación de documentos en Word y libros multi-hoja en Excel es un proceso intensivo en ciclos de CPU y consumo temporal de memoria RAM. Si un usuario o un script automatizado lanzase 50 peticiones simultáneas de descarga, podría saturar el contenedor gratuito en Render.

Para blindar la infraestructura, integramos Rate Limiting por IP y usuario con la librería slowapi:

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

# Límite estricto para informes Word (.docx) y libros Excel (.xlsx)
@router.post("/generate")
@limiter.limit("10/minute")
def generate_report(request: Request, req: GenerateReportRequest, user = Depends(get_current_user)):
    ...

# Límite para exportaciones ligeras en CSV
@router.get("/export/csv")
@limiter.limit("20/minute")
def export_csv(request: Request, entity: str, user = Depends(get_current_user)):
    ...
Enter fullscreen mode Exit fullscreen mode

Si un cliente excede el umbral, la API responde con un código estándar HTTP 429 Too Many Requests, protegiendo la disponibilidad de la aplicación para el resto de usuarios.


6. La Experiencia en Frontend: Centro de Informes (ReportsPage.jsx) 💻

En el cliente React, construimos la página /reports con una interfaz modular:

  • Selector de Plantilla: Tarjetas interactivas que explican el contenido de cada informe.
  • Filtros de Período: Accesos directos a Este Mes, Mes Anterior o selección personalizada de fechas.
  • Descarga Streaming: Consumo del endpoint mediante fetch convirtiendo la respuesta en un Blob que dispara la descarga nativa del archivo en el navegador.

Centro de Informes y Exportación en LifeOps con cuenta activa datalaria@gmail.com

const response = await fetch(`${API_BASE_URL}/api/v1/reports/generate`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${token}`
  },
  body: JSON.stringify(requestPayload)
});

const blob = await response.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
link.click();
window.URL.revokeObjectURL(downloadUrl);
Enter fullscreen mode Exit fullscreen mode

Historial de informes generados con registro de fecha, volumen y tipo de documento


Conclusión y Próximos Pasos 🎯

Con el motor de informes completado, LifeOps ha dado el salto de ser una aplicación web reactiva a convertirse en un generador de activos documentales ejecutivos y portables.

Tenemos el backend, el frontend, los módulos interactivos y las herramientas de extracción de datos. Solo nos falta la pieza final para culminar el proyecto: el despliegue en producción a escala real.

En la Parte 5 (la entrega final de la serie), abordaremos:

  • Despliegue 24/7 a Coste Cero ($0/mes) en Render.com y Netlify, con reescrituras proxy y certificados SSL automáticos.
  • Optimización Móvil Ergonómica: Barra inferior de navegación táctil (Bottom Navigation Bar), menú deslizable Off-Canvas y modales flotantes con React.createPortal.
  • Transformación en PWA (Progressive Web App): Instalación directa en pantalla de inicio sin pasar por las tiendas de apps.

Referencias y Enlaces de Interés 🔗

¡Nos vemos en la entrega final! ¿Sueles exportar tus datos a Excel o Word en tus aplicaciones personales? Déjame tu experiencia en los comentarios. 👇

Top comments (0)