
Overview
Qué es y qué hice
CLEyM es la plataforma de la Coordinación de Lenguas Extranjeras y Maternas del TecNM Campus Acapulco. Administra los cursos de inglés —cinco niveles de 90 horas (450 h en total, con meta de nivel B1)— y concentra grupos, estudiantes, docentes, pagos, calificaciones, asistencia y estadísticas para seis roles: Coordinador, Asistente, Académico, Recursos Financieros, Docente y Estudiante.
La primera versión estuvo en operación para más de 800 usuarios. CLEyM v2 es su reconstrucción desde cero sobre un stack moderno (Django 5, Python 3.13 y PostgreSQL 18): corrige los errores conocidos de la v1 y añade acceso con la cuenta institucional, notificaciones por correo y WhatsApp, una API REST y paneles pensados para cada rol.
Mi papel
Diseñé y desarrollé la v2 de principio a fin: el modelo de datos, la lógica de negocio, la interfaz (escritorio y móvil), las pruebas y la documentación del proyecto.
- 14 apps de Django organizadas por dominio, con unos 30 modelos y 40 migraciones.
- Cuatro paneles por rol (operativo, financiero, docente y estudiante) en lugar de una pantalla de inicio genérica.
- Inscripción en dos pasos con reserva temporal de cupo y validación de comprobantes de pago.
- Planeación de periodos, calificaciones y asistencia, calendario de actividades y seguimiento de alertas académicas.
- Analítica y exportación a Excel y PDF con encabezado institucional.
- API REST documentada con OpenAPI, notificaciones asíncronas (correo y WhatsApp) y acceso con la cuenta institucional TecNM.
The Challenge
Problema
El punto de partida: una v1 con deuda técnica
La v1 se construyó en aproximadamente un mes. Funcionaba, pero acumuló módulos incompletos, errores conocidos y «soluciones temporales»: triggers de PostgreSQL que copiaban datos entre tablas, registros duplicados al cambiar a un estudiante de grupo, exportaciones a PDF y Excel que fallaban con datos nulos o con nombres de un solo apellido, listados de grupos sin orden, estadísticas con datos inexistentes y una restauración de contraseña frágil.
Un proceso con muchas manos
Inscribirse involucra a varios roles: el estudiante elige grupo y sube su comprobante, Recursos Financieros lo valida, el docente califica y pasa asistencia, y la coordinación planea los periodos y vigila los números. Cada rol necesita ver solo lo suyo, y el sistema tiene que impedir estados incoherentes: un grupo con más inscritos que lugares, alguien que se salta niveles o un pago aceptado sin su inscripción.
La restricción
No había código fuente de la v1 disponible. El reto era reconstruirla a partir del Informe Técnico de Residencia Profesional (enero 2025) —modelo de datos, roles y flujos— sin repetir sus errores y sumando integraciones nuevas.
Approach
Cómo lo abordé
Avancé por fases (diez, de la 0 a la 9) con un plan escrito desde el primer día en docs/PLAN.md, apoyado en cinco principios:
- Reconstruir, no parchar. Un modelo de dominio fiel al informe, con convenciones limpias de Django y sin triggers: la lógica de negocio vive en el código, en servicios transaccionales que se pueden probar.
- Corregir primero lo conocido. Catalogué 12 errores de la v1 (duplicados al cambiar de grupo, exportaciones con nulos, un solo apellido, orden de listados, estadísticas, salto de niveles, seguridad…) y cada uno terminó como una restricción de base de datos, un servicio o una prueba.
- Server-rendered + HTMX. Plantillas de Django con Bootstrap 5 y HTMX para modales y fragmentos que se refrescan solos; sin SPA. Chart.js solo donde una gráfica se lee mejor que una tabla.
- Diseñar por rol, no por pantalla. Cada rol entra a su propio panel, con jerarquía de información, un máximo de seis indicadores y alertas que siempre llevan a la vista ya filtrada.
- Reglas de negocio configurables. Bloquear saltos de nivel, la asistencia mínima (85 %), el cupo por defecto (25) y la duración de la reserva (90 s) se definen en ajustes, no en el código.
UX / Architecture
Flujos + estructura
Seis roles, cuatro experiencias
La raíz del sitio ya no es una pantalla genérica: un router envía a cada persona a su panel.
- Panel operativo (Coordinador, Asistente, Académico): seis indicadores —el sexto cambia según el rol—, alertas accionables (pagos por validar, alumnos con datos incompletos, grupos sin docente) y composición de grupos.
- Panel financiero (Recursos Financieros; el Coordinador en solo lectura): ingresos, pagos realizados, pendientes y vencidos, y antigüedad de lo pendiente.
- Panel docente: solo sus grupos, próximas clases, calificaciones por capturar y asistencia.
- «Mi CLEyM» (Estudiante), pensado primero para móvil: próxima clase, progreso en los cinco niveles y tres medidores —asistencia, calificación y avance— sin una sola gráfica.
Inscripción en dos pasos
- Catálogo: el estudiante ve los grupos visibles del nivel que le toca cursar, con docente, horario, aula, modalidad y lugares disponibles.
- Reserva y comprobante: aparta su lugar por 90 segundos, sube la foto o captura del comprobante y envía la solicitud.
- Validación: Recursos Financieros confirma, o rechaza con anotaciones sobre el comprobante; al aceptar, la inscripción se crea sola.
El pago pasa por PENDIENTE → ACEPTADO o RECHAZADO, y un rechazado vuelve a PENDIENTE cuando el estudiante reenvía.
Planeación de periodos
Coordinación y asistentes crean un periodo con un asistente por pasos que propone evaluaciones, cierre, nombre y clave; luego trabajan en un espacio con niveles, grupos y un checklist antes de publicar. Un calendario escolar de doce meses pinta cada periodo —clases, inscripción y evaluaciones— y las fases se calculan por fechas, sin estados manuales.
Calendario, seguimiento y campus
- Calendario de actividades: rejilla semanal para estudiantes y docentes; para la coordinación, un mapa de calor hora × día con cuántos grupos coinciden y cuáles no tienen horario.
- Seguimiento académico: alertas por reprobación, inasistencia y falta de continuidad, con estados y bitácora.
- Campus: mapa interactivo del plantel con buscador, «mi próxima clase» y ficha de aula.
Estructura del código
Cada dominio es una app: cuentas, academico, pagos, notificaciones, reportes, api, paneles, seguimiento, campus, materiales, avisos, traductor, juegos y core. Las apps de lectura (paneles, traductor) no definen modelos: solo agregan datos de las demás.
Catálogo de grupos
Solo los grupos visibles del nivel que le toca cursar
Reserva de lugar · 90 s
select_for_update() y una restricción única por estudiante y periodo
Pago PENDIENTE
Lo revisa Recursos Financieros
ACEPTADO
Un servicio crea la inscripción y confirma la reserva en la misma transacción
RECHAZADO
Con anotaciones sobre el comprobante
Vuelve a PENDIENTE

Design
UI + sistema visual
Identidad «editorial» con densidad institucional
El sistema visual mezcla tipografía editorial con una interfaz compacta de trabajo: Fraunces para titulares y cifras clave, Manrope para el resto de la interfaz, y una paleta cálida con mostaza y terracota sobre neutros. Los botones tienen borde marcado y una micro-interacción de «prensado», las tarjetas llevan sombra dura y todo funciona en modo claro y oscuro con los temas de Bootstrap. Una guía de estilos interna (/dev/estilos/, solo con DEBUG) reúne los componentes para revisar la identidad sin tocar pantallas reales.
Paneles que se leen de un vistazo
Los paneles se diseñaron con reglas explícitas: una zona primaria, una secundaria y una colapsable; máximo seis indicadores; ninguna alerta sin acción; gráficas solo si aportan; como meta, que quepan en una laptop de 1366×768 sin scroll; y un refresco automático con HTMX cada 60 s para que se sientan vivos pero tranquilos.
Un acceso con personalidad
El login es un diseño de dos columnas con un carrusel de consejos para aprender inglés: siete ilustraciones Open Doodles (CC0) recoloreadas a terracota, texto que se escribe letra por letra con la palabra clave resaltada, y un dibujo progresivo de cada ilustración que revela la tinta siguiendo su trazo mediante máscaras SVG. Optimicé los SVG de 690 KB a 390 KB, difiero la carga de las siguientes ilustraciones y respeto prefers-reduced-motion.
Móvil y accesibilidad
- Navegación inferior con hoja «Más», menú del estudiante por secciones y tablas que se convierten en tarjetas.
- Criterios de accesibilidad desde el diseño —contraste AA, foco visible, landmarks y
aria-liveen las zonas que se refrescan con HTMX— y una copia oculta del texto del carrusel para lectores de pantalla. - Validación de contraseña en vivo que muestra una sola regla a la vez, y un registro por pasos con verificación de duplicados en cada paso.
Development
Stack + arquitectura técnica
Capas del código
Django 5.1 sobre PostgreSQL 18 (psycopg 3), con la configuración dividida en base, dev y prod mediante django-environ. Cada app sigue la misma separación: modelos → servicios (escrituras, siempre dentro de transaction.atomic) → selectores (lecturas y agregaciones) → vistas basadas en clases con mixins de acceso por rol → plantillas. Así las reglas de negocio no se quedan atrapadas en la vista ni en la base de datos.
Concurrencia: reserva temporal de cupo
Cuando un grupo tiene un solo lugar, dos estudiantes pueden intentar inscribirse al mismo tiempo. apartar_cupo bloquea la fila del grupo con select_for_update(), cuenta la disponibilidad ya incluyendo las reservas activas y crea una ReservaCupo que vence a los 90 segundos. Una restricción única parcial garantiza una sola reserva activa por estudiante y periodo.
Notificaciones asíncronas
notificar(evento, usuario, contexto) consulta las preferencias del usuario, registra una Notificacion por canal aceptado y encola el envío con transaction.on_commit; una tarea de Celery con reintentos lo despacha por correo (django-anymail) o por WhatsApp (Meta Cloud API). Los eventos incluyen pago registrado, aceptado o rechazado, inscripción confirmada, calificación publicada, datos faltantes, periodo abierto y aviso urgente. En desarrollo las tareas corren en el momento, sin worker ni Redis.
Acceso institucional y seguridad
Con django-allauth: botón «Entrar con la cuenta institucional TecNM» (OIDC o Google Workspace), restricción por dominio de correo, enlace con cuentas existentes y mapeo a rol. Se mantiene el acceso local con número de control o RFC para estudiantes externos. Los cambios en pagos, avisos, campus y datos académicos se auditan con django-simple-history, y las cuentas pueden activar la verificación en dos pasos con un código enviado al correo. Un comando, importar_sii, carga número de control y carrera desde un export del SII.
API, reportes y herramientas
Una API REST de solo lectura con Django REST Framework y OpenAPI (drf-spectacular), exportadores a XLSX (openpyxl) y PDF (reportlab) con encabezado TecNM, y un traductor contextual de 55 idiomas que resuelve modismos en vez de traducir literal.
Plantillas Django + HTMX
Bootstrap 5 · fragmentos que se refrescan solos
Vistas basadas en clases
Mixins de acceso por rol
Servicios
Escrituras, siempre dentro de transaction.atomic
Selectores
Lecturas y agregaciones
Modelos y PostgreSQL
Restricciones únicas y parciales; sin triggers
@transaction.atomic
def apartar_cupo(estudiante: Estudiante, grupo: Grupo) -> ReservaCupo:
# Bloquea la fila del grupo: dos solicitudes por el último lugar
# se serializan aquí en vez de que ambas «ganen».
grupo = Grupo.objects.select_for_update().get(pk=grupo.pk)
...
activa = (
ReservaCupo.objects.select_for_update()
.filter(estudiante=estudiante, periodo=periodo, estado=EstadoReserva.ACTIVA)
.first()
)
if activa is not None and not activa.vencida and activa.grupo_id == grupo.id:
activa.expira = ahora + dt.timedelta(seconds=segundos) # doble clic: renueva
activa.save(update_fields=["expira", "actualizado"])
return activa
...
if disponibilidad(grupo, ahora=ahora).disponibles <= 0:
raise CupoNoDisponible("Ya no hay cupo disponible en este grupo.")
return ReservaCupo.objects.create(
estudiante=estudiante, grupo=grupo, periodo=periodo,
expira=ahora + dt.timedelta(seconds=segundos), estado=EstadoReserva.ACTIVA,
)class Meta:
constraints = [
# Una sola reserva ACTIVA por estudiante y periodo
models.UniqueConstraint(
fields=["estudiante", "periodo"],
condition=models.Q(estado=EstadoReserva.ACTIVA),
name="unica_reserva_activa_por_estudiante_y_periodo",
),
]- Python
- Django
- Django REST
- PostgreSQL
- HTMX
- Bootstrap
- Celery
- Redis
- JavaScript
Challenges
Problemas → soluciones
Nueve problemas que marcaron el proyecto, de la concurrencia a la forma de probar.
Problema
Carrera por el último lugar. Dos estudiantes podían ver «1 lugar disponible» y, al pagar, quedar ambos dentro del grupo.
Solución
Reserva temporal de 90 s con select_for_update(), restricción única parcial por estudiante y periodo, y una disponibilidad que resta las reservas activas no vencidas: una reserva vencida deja de contar sola, sin depender de ninguna limpieza (la marca EXPIRADA es solo higiene). En pantalla, un contador y un modal no descartable cuando el tiempo se agota.
Problema
Triggers y duplicados de la v1. La inscripción se creaba mediante triggers de PostgreSQL y cambiar de grupo duplicaba registros.
Solución
Sin triggers: un servicio transaccional crea o actualiza la Inscripcion al aceptar el pago —y marca CONFIRMADA la reserva en la misma transacción—, y una UniqueConstraint(estudiante, grupo, periodo) más un cambio de grupo transaccional eliminan los duplicados.
Problema
El catálogo parpadeaba. Refrescar el catálogo de grupos cada 4 s para mostrar los lugares casi en vivo recreaba el DOM y dejaba los íconos en blanco.
Solución
La vista compara una huella del contenido con la anterior (guardada en sesión) y responde 204 si nada cambió, así HTMX no reemplaza nada; además se vuelven a pintar los íconos Lucide tras cada htmx:afterSettle.
Problema
Rechazar sin explicar. Un rechazo con solo un comentario de texto no señala qué parte del comprobante está mal.
Solución
Anotaciones sobre el comprobante (resaltador, dibujo, círculo y flecha, con Fabric.js) guardadas como coordenadas relativas de 0 a 1 para verse bien en cualquier pantalla, y ligadas al hash SHA-256 de esa versión: al reenviar no reaparecen sobre el archivo nuevo, pero las versiones anteriores se pueden reabrir desde la bitácora.
Problema
Alumnos en riesgo recalculados en cada carga. Era un conteo efímero, sin historial ni forma de darle seguimiento a cada caso.
Solución
Una app de Seguimiento: alertas individuales y persistidas, con tres reglas determinísticas (reprobación, inasistencia por debajo de 85 % y falta de continuidad), estados Pendiente → En seguimiento → Resuelta o Descartada, bitácora de cada cambio y una restricción única para no duplicar alertas. Una alerta resuelta no se reabre sola.
Problema
Exportaciones frágiles. Los PDF y Excel fallaban con datos nulos, y las constancias con estudiantes de un solo apellido.
Solución
Una capa exportadora que normaliza None, Decimal y fechas antes de escribir cada celda, un nombre_completo() centralizado tolerante al segundo apellido vacío y pruebas con registros nulos e incompletos.
Problema
Reglas de contraseña difíciles de seguir. Cinco validadores distintos pueden fallar a la vez y la persona solo se entera al enviar el formulario.
Solución
Un endpoint que ejecuta el validate_password() real del servidor sobre las cinco reglas y muestra solo la primera que falla; funciona en las cuatro pantallas que piden contraseña y, si falla JavaScript, el formulario valida igual al enviarse.
Problema
Un mapa del campus sin escala. Las coordenadas de los edificios se trazaron a ojo sobre el plano oficial.
Solución
Un editor para la coordinación (Fabric.js) que permite mover y redimensionar edificios y agregar o quitar puntos de interés, y guarda todo en una sola transacción con historial.
Problema
Pruebas que dependen de la base de pruebas. Las pruebas transaccionales vacían las tablas al terminar, incluidos los edificios que siembra una migración, y con la base reutilizada fallaban 11 pruebas de Campus a partir de la segunda corrida.
Solución
Por ahora se corre con pytest --create-db (con lo que pasan las 37 de Campus) y quedó documentado el arreglo pendiente: que esas pruebas creen sus propios edificios con una fixture, o usar serialized_rollback.
Results
Resultado técnico / métricas
CLEyM v2 tiene completas las fases 0 a 7 del plan: entorno, núcleo y modelo de datos, pagos, inscripción y progreso académico, reportes y estadísticas, API REST con tablero, notificaciones y autenticación institucional. Faltan la fase 8 (seguridad y despliegue) y la 9 (pruebas y documentación).
- 14apps de Django, una por dominio
- 868pruebas automatizadas
- 205plantillas HTML
- +18 000líneas de Python (sin migraciones ni pruebas)
- 6 → 4roles atendidos con cuatro paneles a su medida
- 12errores de la v1 catalogados al reconstruir
La v2 todavía no está desplegada. La fase 8 contempla Gunicorn y Nginx sobre Ubuntu, HTTPS con HSTS, copias de seguridad e integración continua, además de cerrar los puntos de seguridad pendientes: cifrado de campos sensibles y auditoría completa.
El proyecto lleva tres documentos vivos que fui escribiendo al construirlo: el plan por fases, una bitácora de avances y la especificación de los dashboards por rol.
Reflection
Qué aprendí
Lo que me llevo
- La lógica en servicios, no en triggers. Un servicio transaccional se lee, se prueba y se depura; un trigger escondido en la base de datos, no.
- Preferir el estado derivado al guardado. El estado de un grupo (Oculto, Lleno, Próximo, Activo…) y el vencimiento de una reserva se calculan, nunca se guardan: no pueden quedar desincronizados.
- La concurrencia solo aparece si la pruebas. Las carreras por un lugar no se ven navegando; hicieron falta pruebas transaccionales explícitas.
- Diseñar para cada rol cambia todo. Un panel útil para el coordinador estorba al estudiante; separar las experiencias simplificó cada una.
- Documentar mientras construyo. El plan, la bitácora de avances y la especificación de dashboards me permiten retomar el trabajo y revisar cientos de archivos modificados sin perder el hilo.
Lo que sigue
Cerrar las fases 8 y 9: auditoría completa, cifrado de campos sensibles, despliegue con HTTPS y copias de seguridad, cobertura de pruebas y un informe de la v2. También estabilizar las pruebas de Campus.
Live Project
Ver proyecto
Enlace disponible próximamente.