commit a683957af6f6219ca54eec05a3b9337c0b89a2ee Author: Valeria López Date: Wed Oct 15 23:45:32 2025 -0600 Primer commit - Sistema de asistencia MAC diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..524d6ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,74 @@ +# Git +.git +.gitignore +.gitattributes + +# Documentation +*.md +README.md +LICENSE +COMO_USAR_ESCANER_USB.md + +# Node modules +**/node_modules +frontend/node_modules +frontend/dist +frontend/.vite + +# Python +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# IDEs +.vscode +.idea +*.swp +*.swo +*~ +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Database +*.sqlite3 +*.db +db.sqlite3 + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Static files (se generan en build) +backend/staticfiles/ +backend/static/ +backend/media/ + +# OS +Thumbs.db +.DS_Store + +# Docker +docker-compose.override.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9ae7dfd --- /dev/null +++ b/.env.example @@ -0,0 +1,58 @@ +# ============================================== +# CONFIGURACIÓN DE ENTORNO - Sistema de Asistencia MAC +# ============================================== +# IMPORTANTE: Copie este archivo como .env y configure los valores apropiados +# ⚠️ NUNCA suba el archivo .env a Git (ya está en .gitignore) + +# ============================================== +# Django Settings +# ============================================== +# DEBUG: False en producción, True solo para desarrollo +DEBUG=False + +# SECRET_KEY: Genere una clave secreta única y aleatoria +# Puede generarla con: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +SECRET_KEY=your-secret-key-here-CHANGE-THIS-IN-PRODUCTION + +# ALLOWED_HOSTS: Dominios permitidos (separados por coma) +# Ejemplo: ALLOWED_HOSTS=midominio.com,www.midominio.com,192.168.1.100 +ALLOWED_HOSTS=localhost,127.0.0.1 + +# ============================================== +# Security Settings (Producción) +# ============================================== +RATELIMIT_ENABLE=True +SECURE_SSL_REDIRECT=True +SESSION_COOKIE_SECURE=True +CSRF_COOKIE_SECURE=True + +# CORS Settings +# Agregue los orígenes permitidos separados por coma +CORS_ALLOWED_ORIGINS=https://midominio.com +CSRF_TRUSTED_ORIGINS=https://midominio.com + +# ============================================== +# Database - PostgreSQL +# ============================================== +# ⚠️ IMPORTANTE: Cambie estas credenciales por valores seguros en producción +DB_NAME=mac_attendance +DB_USER=mac_user +DB_PASSWORD=CHANGE-THIS-SECURE-PASSWORD-IN-PRODUCTION +DB_HOST=db +DB_PORT=5432 + +# Email Settings (Opcional) +# EMAIL_HOST=smtp.gmail.com +# EMAIL_PORT=587 +# EMAIL_USE_TLS=True +# EMAIL_HOST_USER=your-email@gmail.com +# EMAIL_HOST_PASSWORD=your-app-password + +# Production Settings (Cambiar en producción) +# DEBUG=False +# SECRET_KEY=your-secure-random-secret-key-here +# ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com +# RATELIMIT_ENABLE=True +# SECURE_SSL_REDIRECT=True +# SESSION_COOKIE_SECURE=True +# CSRF_COOKIE_SECURE=True diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1db36f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,115 @@ +# ============================================== +# Python +# ============================================== +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.pyc +*.pyo +*.pyd +.Python +pip-log.txt +pip-delete-this-directory.txt + +# Django +backend/db.sqlite3 +backend/db.sqlite3-journal +backend/logs/ +backend/staticfiles/ +backend/media/ +*.log + +# Testing y Coverage +.pytest_cache/ +.tox/ +.coverage +.coverage.* +htmlcov/ +coverage.xml +*.cover +.hypothesis/ +.mypy_cache/ +.dmypy.json +dmypy.json + +# ============================================== +# Node / Frontend +# ============================================== +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/build/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# ============================================== +# IDEs y Editores +# ============================================== +.vscode/ +.idea/ +*.swp +*.swo +*~ +*.sublime-project +*.sublime-workspace +.vs/ + +# ============================================== +# Sistema Operativo +# ============================================== +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# ============================================== +# Docker +# ============================================== +docker-compose.override.yml + +# ============================================== +# Archivos Sensibles y Credenciales +# ============================================== +.env +backend/.env +.env.local +.env.development.local +.env.test.local +.env.production.local +*.secret +*.pem +*.key +*.crt + +# ============================================== +# Uploads y Media +# ============================================== +uploads/ +media/ + +# ============================================== +# Backups +# ============================================== +*.sql +*.sql.gz +*.backup +backup/ +backups/ + +# ============================================== +# Archivos Temporales +# ============================================== +*.tmp +*.temp +.cache/ +tmp/ +temp/ diff --git a/GUIA_DESPLIEGUE.md b/GUIA_DESPLIEGUE.md new file mode 100644 index 0000000..d1f8c2d --- /dev/null +++ b/GUIA_DESPLIEGUE.md @@ -0,0 +1,512 @@ +# Guía de Despliegue a Producción +## Sistema de Asistencia MAC - FES Acatlán + +Esta guía describe los pasos necesarios para desplegar el Sistema de Asistencia MAC en un entorno de producción. + +--- + +## 📋 Requisitos del Servidor + +### Hardware Mínimo Recomendado +- **CPU:** 2 cores +- **RAM:** 4 GB +- **Disco:** 20 GB SSD +- **Red:** Conexión estable a internet + +### Software Requerido +- **Sistema Operativo:** Ubuntu 20.04+ / Debian 11+ / CentOS 8+ +- **Docker:** Versión 20.10+ +- **Docker Compose:** Versión 2.0+ +- **Dominio:** (Opcional) Para acceso público con SSL + +### Puertos Necesarios +- **80** (HTTP) - Nginx +- **443** (HTTPS) - Nginx con SSL (opcional pero recomendado) +- **5432** (PostgreSQL) - Solo si necesita acceso externo a la base de datos + +--- + +## 🚀 Instalación Paso a Paso + +### 1. Preparar el Servidor + +#### Actualizar el sistema +```bash +sudo apt update && sudo apt upgrade -y +``` + +#### Instalar Docker +```bash +# Instalar dependencias +sudo apt install -y apt-transport-https ca-certificates curl software-properties-common + +# Agregar repositorio oficial de Docker +curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +# Instalar Docker +sudo apt update +sudo apt install -y docker-ce docker-ce-cli containerd.io + +# Verificar instalación +docker --version +``` + +#### Instalar Docker Compose +```bash +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + +sudo chmod +x /usr/local/bin/docker-compose + +# Verificar instalación +docker-compose --version +``` + +#### Agregar usuario al grupo Docker (opcional) +```bash +sudo usermod -aG docker $USER +newgrp docker +``` + +--- + +### 2. Clonar el Proyecto + +```bash +# Crear directorio para el proyecto +sudo mkdir -p /opt/mac-attendance +cd /opt/mac-attendance + +# Clonar el repositorio +git clone . + +# O si recibiste el proyecto comprimido +# Descomprimir el archivo en /opt/mac-attendance +``` + +--- + +### 3. Configurar Variables de Entorno + +```bash +# Copiar archivo de ejemplo +cp .env.example .env + +# Editar con nano o vim +nano .env +``` + +#### Configuración CRÍTICA de Seguridad + +**⚠️ IMPORTANTE:** Cambie TODOS estos valores antes de desplegar: + +```env +# ============================================== +# Django Settings +# ============================================== +DEBUG=False + +# Generar SECRET_KEY único (ejecutar este comando en Python): +# python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +SECRET_KEY= + +# Dominio o IP del servidor (separados por coma) +ALLOWED_HOSTS=midominio.com,www.midominio.com,IP_DEL_SERVIDOR + +# ============================================== +# Security Settings +# ============================================== +RATELIMIT_ENABLE=True +SECURE_SSL_REDIRECT=True # Solo si tiene SSL configurado +SESSION_COOKIE_SECURE=True # Solo si tiene SSL configurado +CSRF_COOKIE_SECURE=True # Solo si tiene SSL configurado + +# CORS y CSRF +CORS_ALLOWED_ORIGINS=https://midominio.com +CSRF_TRUSTED_ORIGINS=https://midominio.com + +# ============================================== +# Database - PostgreSQL +# ============================================== +DB_NAME=mac_attendance +DB_USER=mac_user +DB_PASSWORD= # Cambiar por contraseña fuerte +DB_HOST=db +DB_PORT=5432 +``` + +#### Generar SECRET_KEY Seguro + +```bash +# Ejecutar dentro del contenedor después de construirlo la primera vez +docker-compose run --rm backend python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" + +# Copiar el resultado y pegarlo en SECRET_KEY del archivo .env +``` + +--- + +### 4. Configurar PostgreSQL + +Edite las credenciales de PostgreSQL en `docker-compose.yml` para que coincidan con su `.env`: + +```bash +nano docker-compose.yml +``` + +Busque la sección de `db` y actualice: +```yaml +environment: + - POSTGRES_DB=mac_attendance + - POSTGRES_USER=mac_user + - POSTGRES_PASSWORD= # Debe coincidir con DB_PASSWORD +``` + +--- + +### 5. Construir e Iniciar los Contenedores + +```bash +# Construir las imágenes +docker-compose build + +# Iniciar los servicios en segundo plano +docker-compose up -d + +# Verificar que todos los contenedores están corriendo +docker-compose ps +``` + +**Salida esperada:** +``` +NAME STATUS +pagina-mac-og-backend-1 Up +pagina-mac-og-db-1 Up (healthy) +pagina-mac-og-nginx-1 Up +``` + +--- + +### 6. Aplicar Migraciones de Base de Datos + +```bash +# Aplicar migraciones +docker-compose exec backend python manage.py migrate + +# Verificar que no hay errores +``` + +--- + +### 7. Crear Superusuario (Administrador) + +```bash +# Crear superusuario para acceder al panel de administración +docker-compose exec backend python manage.py createsuperuser +``` + +Siga las instrucciones e ingrese: +- **Username:** admin (o el que prefiera) +- **Email:** admin@mac.unam.mx (o su email) +- **Password:** (contraseña segura) + +--- + +### 8. Recolectar Archivos Estáticos + +```bash +# Recolectar archivos estáticos de Django +docker-compose exec backend python manage.py collectstatic --noinput +``` + +--- + +### 9. Verificar el Despliegue + +```bash +# Ver logs en tiempo real +docker-compose logs -f + +# Verificar logs del backend +docker-compose logs backend + +# Verificar logs de nginx +docker-compose logs nginx + +# Verificar logs de PostgreSQL +docker-compose logs db +``` + +#### Probar la Aplicación + +1. Abrir navegador en: `http://IP_DEL_SERVIDOR` o `http://midominio.com` +2. Debería ver la página de login +3. Acceder al admin en: `http://IP_DEL_SERVIDOR/admin/` +4. Login con el superusuario creado + +--- + +## 🔒 Configuración SSL (HTTPS) - Recomendado + +### Opción 1: Con Let's Encrypt (Certbot) + +```bash +# Instalar Certbot +sudo apt install -y certbot python3-certbot-nginx + +# Obtener certificado SSL +sudo certbot --nginx -d midominio.com -d www.midominio.com + +# El certificado se renovará automáticamente +``` + +### Opción 2: Con Certificado Propio + +1. Copiar certificados SSL a `/opt/mac-attendance/ssl/` +2. Modificar `docker/nginx.conf` para incluir SSL +3. Reiniciar nginx: `docker-compose restart nginx` + +--- + +## 🔧 Configuración de Firewall + +```bash +# Permitir puertos necesarios +sudo ufw allow 22/tcp # SSH +sudo ufw allow 80/tcp # HTTP +sudo ufw allow 443/tcp # HTTPS +sudo ufw enable + +# Verificar reglas +sudo ufw status +``` + +--- + +## 📊 Backup y Recuperación + +### Backup de Base de Datos + +**Crear backup automático (recomendado):** + +```bash +# Crear script de backup +sudo nano /opt/scripts/backup-mac-attendance.sh +``` + +Contenido del script: +```bash +#!/bin/bash +BACKUP_DIR="/opt/backups/mac-attendance" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/mac_attendance_$DATE.sql" + +mkdir -p $BACKUP_DIR +docker-compose -f /opt/mac-attendance/docker-compose.yml exec -T db pg_dump -U mac_user mac_attendance > $BACKUP_FILE +gzip $BACKUP_FILE + +# Mantener solo los últimos 30 días +find $BACKUP_DIR -type f -mtime +30 -delete + +echo "Backup completado: $BACKUP_FILE.gz" +``` + +```bash +# Dar permisos de ejecución +sudo chmod +x /opt/scripts/backup-mac-attendance.sh + +# Agregar a cron para ejecutar diariamente a las 2 AM +sudo crontab -e +``` + +Agregar línea: +``` +0 2 * * * /opt/scripts/backup-mac-attendance.sh >> /var/log/mac-backup.log 2>&1 +``` + +### Restaurar Backup + +```bash +# Detener la aplicación +cd /opt/mac-attendance +docker-compose down + +# Restaurar backup +gunzip backup_file.sql.gz +cat backup_file.sql | docker-compose exec -T db psql -U mac_user mac_attendance + +# Reiniciar aplicación +docker-compose up -d +``` + +--- + +## 🔄 Actualización del Sistema + +### Actualizar a Nueva Versión + +```bash +cd /opt/mac-attendance + +# Hacer backup antes de actualizar +docker-compose exec db pg_dump -U mac_user mac_attendance > backup_pre_update.sql + +# Detener contenedores +docker-compose down + +# Obtener nueva versión +git pull origin main +# O descomprimir nueva versión + +# Reconstruir contenedores +docker-compose build + +# Iniciar contenedores +docker-compose up -d + +# Aplicar nuevas migraciones +docker-compose exec backend python manage.py migrate + +# Recolectar archivos estáticos +docker-compose exec backend python manage.py collectstatic --noinput + +# Verificar logs +docker-compose logs -f +``` + +--- + +## 📈 Monitoreo + +### Ver Estado de Contenedores + +```bash +docker-compose ps +``` + +### Ver Uso de Recursos + +```bash +docker stats +``` + +### Ver Logs + +```bash +# Todos los logs +docker-compose logs -f + +# Solo backend +docker-compose logs -f backend + +# Últimas 100 líneas +docker-compose logs --tail=100 backend +``` + +### Reiniciar Servicios + +```bash +# Reiniciar todo +docker-compose restart + +# Reiniciar solo backend +docker-compose restart backend + +# Reiniciar solo nginx +docker-compose restart nginx +``` + +--- + +## ⚠️ Solución de Problemas + +### Error: "502 Bad Gateway" + +**Causa:** Backend no está respondiendo. + +**Solución:** +```bash +# Verificar logs del backend +docker-compose logs backend + +# Reiniciar backend +docker-compose restart backend + +# Si persiste, reconstruir +docker-compose down +docker-compose up -d --build +``` + +### Error: "Connection refused" en PostgreSQL + +**Causa:** Base de datos no está lista. + +**Solución:** +```bash +# Verificar estado de PostgreSQL +docker-compose logs db + +# Reiniciar base de datos +docker-compose restart db + +# Esperar a que esté "healthy" +docker-compose ps +``` + +### Error: "CSRF verification failed" + +**Causa:** Configuración incorrecta de dominios en .env + +**Solución:** +```bash +# Verificar que ALLOWED_HOSTS y CSRF_TRUSTED_ORIGINS incluyen su dominio +nano .env + +# Reiniciar backend +docker-compose restart backend +``` + +--- + +## 📋 Checklist de Producción + +Antes de poner en producción, verificar: + +- [ ] **Seguridad:** + - [ ] DEBUG=False en .env + - [ ] SECRET_KEY único y aleatorio + - [ ] DB_PASSWORD seguro y único + - [ ] ALLOWED_HOSTS configurado correctamente + - [ ] SSL/HTTPS habilitado (recomendado) + - [ ] Firewall configurado + +- [ ] **Base de Datos:** + - [ ] PostgreSQL corriendo y saludable + - [ ] Migraciones aplicadas + - [ ] Backup automático configurado + +- [ ] **Aplicación:** + - [ ] Superusuario creado + - [ ] Archivos estáticos recolectados + - [ ] Aplicación accesible en navegador + - [ ] Login funcionando correctamente + +- [ ] **Monitoreo:** + - [ ] Logs configurados + - [ ] Alertas configuradas (opcional) + - [ ] Backup verificado y probado + +--- + +## 📞 Soporte Técnico + +Para problemas durante el despliegue: +1. Revisar logs: `docker-compose logs -f` +2. Consultar documentación: `docs/` +3. Contactar a soporte técnico MAC + +--- + +**Última actualización:** Octubre 2025 +**Versión del documento:** 1.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b55677c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Valeria López + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f8b804 --- /dev/null +++ b/README.md @@ -0,0 +1,429 @@ +# Sistema de Asistencia MAC - FES Acatlán + +Sistema de gestión de asistencia para ponencias y eventos académicos de Matemáticas Aplicadas y Computación (MAC). + +## 🚀 Características + +- **Gestión de Eventos**: Crear y administrar ponencias, talleres, seminarios +- **Registro de Asistencia**: Manual, por código de barras o usuarios externos +- **Panel de Estudiantes**: Consulta de estadísticas y eventos disponibles +- **Panel de Asistentes**: Administración completa y registro de asistencias +- **Usuarios Externos**: Sistema de aprobación para asistentes externos +- **Estadísticas**: Seguimiento de porcentaje de asistencia por estudiante +- **Seguridad Avanzada**: JWT, rate limiting, auditoría completa +- **Sistema de Auditoría**: Registro automático de eventos de seguridad + +## 📋 Requisitos Previos + +### Opción 1: Con Docker (Recomendado) +- Docker y Docker Compose instalados +- Puertos 80 y 5432 disponibles + +### Opción 2: Instalación Manual +- Python 3.11+ +- Node.js 18+ +- npm o yarn +- PostgreSQL 15+ + +## 🛠️ Instalación + +### Opción 1: Con Docker (Recomendado) + +#### Producción + +```bash +# Clonar el repositorio +git clone +cd pagina-mac-og + +# Copiar variables de entorno +cp .env.example .env + +# IMPORTANTE: Editar .env y cambiar las credenciales para producción +# Cambiar: SECRET_KEY, DB_PASSWORD, etc. + +# Construir e iniciar contenedores +docker-compose up --build -d + +# Ver logs +docker-compose logs -f + +# Acceder a la aplicación +# http://localhost +``` + +El sistema estará disponible en `http://localhost` con: +- **Frontend**: Servido por Nginx en puerto 80 +- **Backend**: API REST en `/api/` +- **PostgreSQL**: Base de datos (puerto 5432 expuesto) + +#### Desarrollo + +```bash +# Usar configuración de desarrollo +docker-compose -f docker-compose.dev.yml up --build -d + +# Ver logs +docker-compose -f docker-compose.dev.yml logs -f backend + +# Acceder al contenedor backend +docker-compose -f docker-compose.dev.yml exec backend bash + +# Ejecutar tests +docker-compose -f docker-compose.dev.yml exec backend tox -e test + +# Ver documentación completa de desarrollo +# docs/DESARROLLO.md +``` + +**Características del entorno de desarrollo:** +- ✅ Hot reload automático (Django runserver) +- ✅ Todas las herramientas de testing y calidad de código +- ✅ PostgreSQL con puerto expuesto para acceso desde host +- ✅ Debugging con ipdb +- ✅ Ver `docs/DESARROLLO.md` para más detalles + +### Opción 2: Instalación Manual + +### Backend (Django) + +1. Crear entorno virtual: +```bash +cd backend +python -m venv venv +``` + +2. Activar entorno virtual: +- Windows: `venv\Scripts\activate` +- Linux/Mac: `source venv/bin/activate` + +3. Instalar dependencias: +```bash +pip install -r requirements.txt +``` + +**⚠️ IMPORTANTE**: Si acabas de clonar el repositorio o actualizaste con nuevas funcionalidades de importación/exportación, ejecuta: +```bash +pip install django-import-export openpyxl tablib +``` + +4. Configurar variables de entorno: +```bash +cp .env.example .env +# Editar .env con tus configuraciones +``` + +5. Ejecutar migraciones: +```bash +python manage.py migrate +``` + +6. Crear superusuario (opcional): +```bash +python manage.py createsuperuser +``` + +7. Ejecutar servidor: +```bash +python manage.py runserver +``` + +El backend estará disponible en `http://127.0.0.1:8000` + +### Frontend (React + Vite) + +1. Instalar dependencias: +```bash +cd frontend +npm install +``` + +2. Ejecutar servidor de desarrollo: +```bash +npm run dev +``` + +El frontend estará disponible en `http://localhost:5173` + +## 📥 Importación y Exportación de Datos + +El sistema incluye funcionalidades de importación/exportación de estudiantes y asistentes mediante archivos Excel (.xlsx) o CSV. + +### Formato de Archivos para Importación + +Los archivos deben tener **exactamente 2 columnas**: + +| account_number | full_name | +|----------------|-----------| +| 1234567 | Juan Pérez García | +| 7654321 | María López Sánchez | + +### Desde el Panel de Admin de Django + +1. Ve a `http://127.0.0.1:8000/admin/` +2. Selecciona **Estudiantes** o **Asistentes (Perfiles)** +3. Haz clic en **"Importar"** en la esquina superior derecha +4. Selecciona tu archivo Excel (.xlsx) o CSV +5. Revisa los cambios propuestos +6. Confirma la importación + +### Exportación + +1. Selecciona los registros que deseas exportar +2. En el menú de acciones, selecciona **"📊 Exportar estudiantes/asistentes seleccionados"** +3. Haz clic en **"Ir"** +4. Se descargará un archivo Excel con los datos + +### Creación Manual + +También puedes crear estudiantes y asistentes manualmente: +1. Ve al panel de admin de Django +2. Selecciona **Estudiantes** o **Asistentes (Perfiles)** +3. Haz clic en **"Agregar estudiante"** o **"Agregar asistente"** +4. Completa los campos: + - Número de cuenta (7 dígitos) + - Nombre completo +5. Guarda - el sistema creará automáticamente el usuario de Django asociado + +## 📁 Estructura del Proyecto + +``` +mac_attendance/ +├── backend/ +│ ├── attendance/ # App de registro de asistencias +│ ├── authentication/ # App de autenticación y auditoría +│ ├── events/ # App de eventos y usuarios externos +│ ├── mac_attendance/ # Configuración principal y middleware +│ ├── scripts/ # Scripts de utilidad +│ │ ├── check_production.py # Verificar config de producción +│ │ └── test_ratelimit.py # Probar rate limiting +│ ├── static/ # Archivos estáticos +│ ├── media/ # Archivos subidos +│ ├── logs/ # Archivos de log (no trackeados) +│ ├── requirements.txt # Dependencias Python +│ └── .env.example # Ejemplo de variables de entorno +├── frontend/ +│ ├── src/ +│ │ ├── components/ # Componentes React +│ │ ├── contexts/ # Contextos (AuthContext) +│ │ └── services/ # Servicios API +│ └── package.json # Dependencias Node +├── docker/ # Archivos Docker +│ ├── Dockerfile.backend # Dockerfile producción +│ ├── Dockerfile.backend.dev # Dockerfile desarrollo +│ ├── Dockerfile.frontend # Dockerfile frontend +│ └── nginx.conf # Configuración Nginx +├── docs/ # Documentación del proyecto +│ ├── SECURITY.md # Guía de seguridad completa +│ ├── RATE_LIMITING.md # Documentación rate limiting +│ ├── AUDIT.md # Sistema de auditoría +│ ├── POSTGRESQL_MIGRATION.md # Migración a PostgreSQL +│ ├── DESARROLLO.md # Guía de desarrollo completa +│ └── ESTRUCTURA_PROYECTO.md # Estructura del proyecto +├── docker-compose.yml # Docker Compose producción +├── docker-compose.dev.yml # Docker Compose desarrollo +├── .env.example # Ejemplo de variables de entorno +└── README.md +``` + +## 🔐 Configuración Inicial + +### Crear Superusuario (Administrador) + +Después de iniciar los contenedores, crea un superusuario para acceder al panel de administración: + +```bash +# Con Docker +docker-compose exec backend python manage.py createsuperuser + +# O en desarrollo +docker-compose -f docker-compose.dev.yml exec backend python manage.py createsuperuser +``` + +Sigue las instrucciones para crear: +- Username +- Email +- Password + +### Acceso al Sistema + +**Panel de Administración Django:** +- URL: `http://localhost/admin/` +- Usuario: El superusuario que acabas de crear + +**Aplicación Web:** +- URL: `http://localhost/` +- Los usuarios (asistentes y estudiantes) deben ser creados desde el panel de administración +- Usuarios externos pueden registrarse desde el formulario público + +## 📊 Modelos Principales + +### UserProfile +- Tipo de usuario (estudiante/asistente) +- Número de cuenta (7 dígitos) +- Información personal + +### Event +- Título, descripción, ponente +- Fecha, hora de inicio/fin +- Modalidad (presencial/online/híbrido) +- Capacidad máxima + +### Attendance +- Estudiante o usuario externo +- Evento asociado +- Método de registro (manual/barcode/external) +- Registrado por (asistente) + +### ExternalUser +- Usuarios externos pendientes de aprobación +- Información de institución y motivo +- ID temporal único + +## 🔧 Configuración Adicional + +### Variables de Entorno (.env) +```env +SECRET_KEY=tu-clave-secreta +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 +CORS_ALLOWED_ORIGINS=http://localhost:5173 +``` + +### CORS +El backend está configurado para aceptar peticiones desde: +- `http://localhost:5173` (desarrollo) +- `http://127.0.0.1:5173` (desarrollo) + +## 🔒 Seguridad + +Este proyecto implementa múltiples capas de seguridad: + +### Autenticación y Autorización +- ✅ **JWT (JSON Web Tokens)** para autenticación stateless +- ✅ **Control de acceso basado en roles** (estudiante/asistente) +- ✅ **Tokens de corta duración** (1 hora) con refresh tokens (7 días) + +### Protección contra Ataques +- ✅ **Rate Limiting**: Límites en todos los endpoints críticos + - Login: 5 intentos/minuto por IP + - Registro externo: 3/hora por IP + - Ver `docs/RATE_LIMITING.md` para detalles +- ✅ **Headers de seguridad** HTTP (HSTS, X-Frame-Options, etc.) +- ✅ **Sanitización automática** de datos sensibles en logs + +### Sistema de Auditoría +- ✅ **Registro automático** de eventos de seguridad +- ✅ **Trazabilidad completa**: IP, user agent, timestamp +- ✅ **Logs inmutables** consultables desde Django Admin +- ✅ Ver `docs/AUDIT.md` para documentación completa + +### Documentación de Seguridad +- 📄 `docs/SECURITY.md` - Guía de seguridad y checklist de producción +- 📄 `docs/RATE_LIMITING.md` - Configuración de rate limiting +- 📄 `docs/AUDIT.md` - Sistema de auditoría + +## 🧪 Testing y Calidad de Código + +El proyecto incluye un entorno completo de testing y calidad de código usando **Tox**. + +### Ejecutar Tests + +```bash +# Con Docker (desarrollo) +docker-compose -f docker-compose.dev.yml exec backend tox -e test + +# Tests rápidos +docker-compose -f docker-compose.dev.yml exec backend tox -e test-fast + +# Con cobertura +docker-compose -f docker-compose.dev.yml exec backend tox -e coverage +``` + +### Herramientas Disponibles + +- **Testing**: pytest, pytest-django, pytest-cov, factory-boy +- **Linting**: flake8, pylint, black, isort +- **Type Checking**: mypy con stubs para Django/DRF +- **Seguridad**: bandit, safety +- **Métricas**: radon (complejidad ciclomática) + +Ver `docs/DESARROLLO.md` para documentación completa. + +## 🐘 PostgreSQL + +El sistema usa PostgreSQL como base de datos para producción. + +### Acceso desde Host + +⚠️ **Configurar credenciales en archivo .env antes de usar:** + +```bash +Host: localhost +Port: 5432 +Database: [DB_NAME del .env] +User: [DB_USER del .env] +Password: [DB_PASSWORD del .env] +``` + +### Comandos Útiles + +```bash +# Conectarse con psql +psql -h localhost -p 5432 -U mac_user -d mac_attendance + +# Backup +docker-compose exec db pg_dump -U mac_user mac_attendance > backup.sql + +# Restore +cat backup.sql | docker-compose exec -T db psql -U mac_user mac_attendance +``` + +Ver `docs/POSTGRESQL_MIGRATION.md` para más detalles. + +## 📚 Documentación + +- `docs/DESARROLLO.md` - Guía completa de desarrollo +- `docs/SECURITY.md` - Guía de seguridad y checklist de producción +- `docs/RATE_LIMITING.md` - Configuración de rate limiting +- `docs/AUDIT.md` - Sistema de auditoría +- `docs/POSTGRESQL_MIGRATION.md` - Migración a PostgreSQL +- `docs/ESTRUCTURA_PROYECTO.md` - Estructura del proyecto + +## 🚧 Mejoras Futuras + +- [x] Implementar JWT para autenticación +- [x] Sistema de auditoría y logging +- [x] Rate limiting en endpoints +- [x] Dockerización del proyecto +- [x] Migración a PostgreSQL +- [x] Entorno de desarrollo con Tox +- [ ] Agregar exportación de reportes (CSV/PDF) +- [ ] Implementar lector de códigos de barras +- [ ] Notificaciones por email +- [ ] Panel de estadísticas avanzadas +- [ ] Cobertura de tests > 80% + +## 📝 Licencia + +Este proyecto está bajo la Licencia MIT. + +## 👥 Contribución + +1. Fork el proyecto +2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`) +3. Commit tus cambios (`git commit -m 'Agregar nueva funcionalidad'`) +4. Push a la rama (`git push origin feature/nueva-funcionalidad`) +5. Abre un Pull Request + +## 📧 Soporte + +Para preguntas o soporte técnico sobre el sistema, contactar a: +- Matemáticas Aplicadas y Computación (MAC) +- FES Acatlán - UNAM + +--- + +**Desarrollado para:** Matemáticas Aplicadas y Computación (MAC) +**Institución:** FES Acatlán - UNAM +**Año:** 2025 \ No newline at end of file diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..ef8395f --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,31 @@ +# EditorConfig ayuda a mantener estilos de código consistentes +# entre diferentes editores e IDEs +# https://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,pyi}] +indent_style = space +indent_size = 4 +max_line_length = 120 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.{json,js,jsx,ts,tsx}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false +max_line_length = off + +[Makefile] +indent_style = tab diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..23945e6 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,12 @@ +# Django Settings +DEBUG=True +SECRET_KEY=cambia-esto-por-una-clave-secreta-muy-larga-y-aleatoria +ALLOWED_HOSTS=localhost,127.0.0.1,nginx,backend + +# Security Settings +RATELIMIT_ENABLE=False +SECURE_SSL_REDIRECT=False + +# CORS Settings +CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:5173 +CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1 diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 0000000..d3443db --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,61 @@ +[flake8] +# Configuración de Flake8 para verificación de estilo PEP8 + +# Longitud máxima de línea (PEP8 recomienda 79, pero muchos proyectos usan 88-120) +max-line-length = 120 + +# Complejidad ciclomática máxima (recomendado: 10-15) +max-complexity = 12 + +# Archivos y directorios a excluir +exclude = + .git, + __pycache__, + */migrations/*, + */venv/*, + */env/*, + .venv, + staticfiles, + media, + */node_modules/*, + .pytest_cache, + htmlcov, + dist, + build, + *.egg-info + +# Reglas a ignorar +ignore = + # E203: whitespace before ':' (conflicto con black) + E203, + # E501: line too long (ya lo controlamos con max-line-length) + E501, + # W503: line break before binary operator (conflicto con PEP8 actualizado) + W503, + # E402: module level import not at top of file (necesario en algunos casos) + # E402, + +# Reglas específicas por archivo +per-file-ignores = + # __init__.py puede tener imports sin usar + __init__.py:F401, + # settings.py puede tener líneas largas + settings.py:E501, + # tests pueden usar asserts + test_*.py:S101, + **/tests.py:S101 + +# Mostrar el código fuente de cada error +show-source = True + +# Mostrar el PEP relevante para cada error +show-pep8 = True + +# Contar número de errores +count = True + +# Estadísticas al final +statistics = True + +# Formato de salida +format = %(path)s:%(row)d:%(col)d: %(code)s %(text)s diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..1ea4d9b --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,88 @@ +# Configuración de pre-commit hooks +# Para instalar: pre-commit install +# Para ejecutar manualmente: pre-commit run --all-files + +repos: + # Hooks generales + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: ['--maxkb=1000'] + - id: check-json + - id: check-toml + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + - id: mixed-line-ending + args: ['--fix=lf'] + + # Black - Formateador de código + - repo: https://github.com/psf/black + rev: 24.8.0 + hooks: + - id: black + language_version: python3.11 + args: [--line-length=120] + + # isort - Ordenador de imports + - repo: https://github.com/PyCQA/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black, --line-length=120] + + # Flake8 - Linter PEP8 + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: [--config=.flake8] + additional_dependencies: + - flake8-bugbear + - flake8-comprehensions + - flake8-simplify + + # Bandit - Verificador de seguridad + - repo: https://github.com/PyCQA/bandit + rev: 1.8.0 + hooks: + - id: bandit + args: [-c, pyproject.toml] + additional_dependencies: ["bandit[toml]"] + + # Django specific checks + - repo: https://github.com/adamchainz/django-upgrade + rev: 1.21.0 + hooks: + - id: django-upgrade + args: [--target-version, "5.2"] + + # Pylint (opcional, puede ser lento) + # - repo: https://github.com/PyCQA/pylint + # rev: v3.3.2 + # hooks: + # - id: pylint + # args: [--rcfile=pyproject.toml] + # additional_dependencies: + # - pylint-django + +# Configuración global +default_language_version: + python: python3.11 + +# Etapas en las que ejecutar los hooks +default_stages: [commit, push] + +# Excluir archivos +exclude: | + (?x)^( + migrations/.*| + staticfiles/.*| + media/.*| + .*\.min\.(js|css) + )$ diff --git a/backend/LINTING.md b/backend/LINTING.md new file mode 100644 index 0000000..2d3c181 --- /dev/null +++ b/backend/LINTING.md @@ -0,0 +1,482 @@ +# Guía de Linting y Calidad de Código + +Esta guía explica cómo usar las herramientas de linting y formateo configuradas en el proyecto para mantener un código Python de alta calidad siguiendo PEP8 y mejores prácticas. + +## Tabla de Contenidos + +- [Herramientas Configuradas](#herramientas-configuradas) +- [Instalación](#instalación) +- [Uso Rápido](#uso-rápido) +- [Herramientas Individuales](#herramientas-individuales) +- [Pre-commit Hooks](#pre-commit-hooks) +- [Integración con Docker](#integración-con-docker) +- [CI/CD](#cicd) +- [Configuración de IDEs](#configuración-de-ides) + +## Herramientas Configuradas + +### Formateo Automático +- **Black**: Formateador de código opinionado (PEP8) +- **isort**: Ordenador de imports +- **autopep8**: Corrector automático de PEP8 + +### Linting (Análisis Estático) +- **Flake8**: Verificador de estilo PEP8 +- **pycodestyle**: Verificador oficial de PEP8 +- **Pylint**: Analizador estático completo +- **Bandit**: Verificador de seguridad + +### Type Checking +- **MyPy**: Verificador de tipos estáticos +- **django-stubs**: Type hints para Django +- **djangorestframework-stubs**: Type hints para DRF + +### Testing +- **pytest**: Framework de testing +- **pytest-django**: Plugin para Django +- **pytest-cov**: Cobertura de tests +- **coverage**: Reporte de cobertura + +## Instalación + +### Opción 1: Usando Make (Recomendado) + +```bash +# Instalar todas las dependencias de desarrollo +make install-dev +``` + +### Opción 2: Usando pip directamente + +```bash +# Instalar dependencias de producción +pip install -r requirements.txt + +# Instalar dependencias de desarrollo +pip install -r requirements-dev.txt + +# Instalar pre-commit hooks +pre-commit install +``` + +### Opción 3: En Docker + +```bash +# Construir imagen con herramientas de desarrollo +docker-compose exec backend pip install -r requirements-dev.txt +``` + +## Uso Rápido + +### Formatear Código Automáticamente + +```bash +# Usando Make +make format + +# O manualmente +bash scripts/format.sh +``` + +Este comando ejecutará automáticamente: +1. **isort** - Ordena los imports +2. **black** - Formatea el código +3. **autopep8** - Aplica correcciones adicionales de PEP8 + +### Verificar Calidad de Código + +```bash +# Usando Make +make lint + +# O manualmente +bash scripts/lint.sh +``` + +Este comando ejecutará: +1. Black (verificación sin modificar) +2. isort (verificación) +3. Flake8 (PEP8) +4. Pylint (análisis estático) +5. Bandit (seguridad) +6. MyPy (type checking) + +### Ver Todos los Comandos Disponibles + +```bash +make help +``` + +## Herramientas Individuales + +### Black - Formateador de Código + +```bash +# Verificar formato sin modificar +black --check --diff . + +# Formatear todo el código +black . + +# Formatear archivo específico +black path/to/file.py +``` + +**Configuración**: `pyproject.toml` - Longitud de línea: 120 + +### isort - Ordenador de Imports + +```bash +# Verificar imports sin modificar +isort --check-only --diff . + +# Ordenar imports +isort . + +# Ordenar archivo específico +isort path/to/file.py +``` + +**Configuración**: `pyproject.toml` - Compatible con Black + +### Flake8 - Verificador PEP8 + +```bash +# Verificar todo el proyecto +flake8 . + +# Verificar directorio específico +flake8 authentication/ + +# Verificar archivo específico +flake8 path/to/file.py + +# Ignorar reglas específicas +flake8 --ignore=E501,W503 . +``` + +**Configuración**: `.flake8` + +### Pylint - Análisis Estático + +```bash +# Analizar todo el código +pylint authentication attendance events mac_attendance + +# Analizar archivo específico +pylint path/to/file.py + +# Generar reporte de calificación +pylint --output-format=text authentication/ | tee pylint-report.txt +``` + +**Configuración**: `pyproject.toml` + +### Bandit - Verificador de Seguridad + +```bash +# Verificar seguridad en todo el proyecto +bandit -r . -c pyproject.toml + +# Verificar directorio específico +bandit -r authentication/ + +# Generar reporte detallado +bandit -r . -f json -o bandit-report.json +``` + +**Configuración**: `pyproject.toml` + +### MyPy - Type Checking + +```bash +# Verificar tipos +mypy --config-file=pyproject.toml . + +# Verificar archivo específico +mypy path/to/file.py + +# Generar reporte HTML +mypy --html-report mypy-report . +``` + +**Configuración**: `pyproject.toml` + +## Pre-commit Hooks + +Los pre-commit hooks ejecutan automáticamente las herramientas de linting antes de cada commit. + +### Instalar Hooks + +```bash +pre-commit install +``` + +### Ejecutar Hooks Manualmente + +```bash +# Ejecutar en archivos staged +pre-commit run + +# Ejecutar en todos los archivos +pre-commit run --all-files + +# Usando Make +make pre-commit-all +``` + +### Saltar Hooks (No Recomendado) + +```bash +git commit --no-verify -m "mensaje" +``` + +### Actualizar Hooks + +```bash +pre-commit autoupdate +``` + +## Integración con Docker + +### Ejecutar Linting en Docker + +```bash +# Formatear código +docker-compose exec backend bash scripts/format.sh + +# Verificar código +docker-compose exec backend bash scripts/lint.sh + +# Usando Make +docker-compose exec backend make format +docker-compose exec backend make lint +``` + +### Agregar al Dockerfile + +```dockerfile +# En Dockerfile.backend, agregar antes del CMD +COPY requirements-dev.txt . +RUN pip install -r requirements-dev.txt + +# Ejecutar linting en el build +RUN flake8 . || true +``` + +## CI/CD + +### GitHub Actions + +Crear `.github/workflows/lint.yml`: + +```yaml +name: Lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + pip install -r backend/requirements-dev.txt + - name: Run linters + run: | + cd backend + make lint +``` + +### GitLab CI + +Crear `.gitlab-ci.yml`: + +```yaml +lint: + stage: test + image: python:3.11 + script: + - cd backend + - pip install -r requirements-dev.txt + - make lint +``` + +## Configuración de IDEs + +### Visual Studio Code + +Crear `.vscode/settings.json`: + +```json +{ + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "python.linting.pylintEnabled": true, + "python.linting.banditEnabled": true, + "python.linting.mypyEnabled": true, + "python.formatting.provider": "black", + "python.formatting.blackArgs": ["--line-length", "120"], + "python.sortImports.args": ["--profile", "black"], + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + "python.linting.flake8Args": ["--config=backend/.flake8"], + "python.linting.pylintArgs": ["--rcfile=backend/pyproject.toml"] +} +``` + +### PyCharm + +1. **Configurar Black**: + - Settings → Tools → External Tools → Add + - Name: Black + - Program: `black` + - Arguments: `$FilePath$` + +2. **Configurar Flake8**: + - Settings → Tools → External Tools → Add + - Name: Flake8 + - Program: `flake8` + - Arguments: `$FilePath$` + +3. **Configurar File Watcher**: + - Settings → Tools → File Watchers → Add + - File type: Python + - Program: `black` + +## Métricas de Calidad + +### Cobertura de Tests + +```bash +# Ejecutar tests con cobertura +make coverage + +# Ver reporte HTML +open htmlcov/index.html +``` + +### Complejidad Ciclomática + +```bash +# Instalar radon +pip install radon + +# Analizar complejidad +radon cc . -a -nb + +# Generar reporte JSON +radon cc . -j > complexity-report.json +``` + +### Mantenibilidad + +```bash +# Índice de mantenibilidad +radon mi . -s + +# Mostrar solo archivos con baja mantenibilidad +radon mi . -s -n C +``` + +## Consejos y Mejores Prácticas + +### 1. Formateo Automático + +Ejecuta `make format` antes de cada commit para mantener el código formateado. + +### 2. Pre-commit Hooks + +Deja que los pre-commit hooks trabajen por ti. No los saltes a menos que sea absolutamente necesario. + +### 3. Gradual + +Si el proyecto tiene mucho código legacy, puedes aplicar linting gradualmente: + +```bash +# Solo en archivos modificados +git diff --name-only | xargs flake8 +``` + +### 4. Ignorar Reglas Específicas + +Si necesitas ignorar una regla en una línea específica: + +```python +# noqa: E501 +long_line = "Esta línea es muy larga pero es necesaria" # noqa: E501 + +# Para múltiples reglas +code = "something" # noqa: E501,W503 +``` + +### 5. Documentación + +Mantén docstrings en funciones importantes: + +```python +def calculate_attendance(student_id: int, event_id: int) -> float: + """ + Calcula el porcentaje de asistencia de un estudiante. + + Args: + student_id: ID del estudiante + event_id: ID del evento + + Returns: + Porcentaje de asistencia (0-100) + + Raises: + ValueError: Si el estudiante o evento no existe + """ + pass +``` + +## Solución de Problemas + +### Error: "command not found" + +Asegúrate de haber instalado las dependencias: +```bash +make install-dev +``` + +### Error: "pre-commit: command not found" + +```bash +pip install pre-commit +pre-commit install +``` + +### Conflictos entre Black e isort + +La configuración está ajustada para que sean compatibles. Si hay conflictos: +```bash +# Ejecutar en orden +isort . +black . +``` + +### Demasiados errores de Flake8 + +Puedes ajustar las reglas en `.flake8` o formatear automáticamente: +```bash +make format +``` + +## Referencias + +- [PEP 8 – Style Guide for Python Code](https://peps.python.org/pep-0008/) +- [Black Documentation](https://black.readthedocs.io/) +- [Flake8 Documentation](https://flake8.pycqa.org/) +- [Pylint Documentation](https://pylint.pycqa.org/) +- [isort Documentation](https://pycqa.github.io/isort/) +- [Pre-commit Documentation](https://pre-commit.com/) diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..6fcf293 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,107 @@ +# Makefile para comandos comunes del proyecto + +.PHONY: help install install-dev lint format test coverage clean run migrate shell + +# Variables +PYTHON := python +PIP := pip +MANAGE := $(PYTHON) manage.py + +# Colores para output +BLUE := \033[0;34m +GREEN := \033[0;32m +YELLOW := \033[1;33m +NC := \033[0m # No Color + +help: ## Mostrar ayuda + @echo "$(GREEN)Comandos disponibles:$(NC)" + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " $(BLUE)%-20s$(NC) %s\n", $$1, $$2}' + @echo "" + +install: ## Instalar dependencias de producción + @echo "$(YELLOW)Instalando dependencias de producción...$(NC)" + $(PIP) install -r requirements.txt + @echo "$(GREEN)✓ Dependencias instaladas$(NC)" + +install-dev: install ## Instalar dependencias de desarrollo + @echo "$(YELLOW)Instalando dependencias de desarrollo...$(NC)" + $(PIP) install -r requirements-dev.txt + @echo "$(GREEN)✓ Dependencias de desarrollo instaladas$(NC)" + @echo "" + @echo "$(YELLOW)Instalando pre-commit hooks...$(NC)" + pre-commit install + @echo "$(GREEN)✓ Pre-commit hooks instalados$(NC)" + +lint: ## Ejecutar linters (flake8, pylint, bandit) + @echo "$(YELLOW)Ejecutando análisis de código...$(NC)" + @bash scripts/lint.sh + +format: ## Formatear código automáticamente (black, isort, autopep8) + @echo "$(YELLOW)Formateando código...$(NC)" + @bash scripts/format.sh + +test: ## Ejecutar tests + @echo "$(YELLOW)Ejecutando tests...$(NC)" + pytest -v + +coverage: ## Ejecutar tests con cobertura + @echo "$(YELLOW)Ejecutando tests con cobertura...$(NC)" + pytest --cov=. --cov-report=html --cov-report=term-missing + @echo "" + @echo "$(GREEN)✓ Reporte de cobertura generado en htmlcov/index.html$(NC)" + +clean: ## Limpiar archivos temporales + @echo "$(YELLOW)Limpiando archivos temporales...$(NC)" + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name "*.coverage" -delete + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + rm -rf htmlcov/ .coverage 2>/dev/null || true + @echo "$(GREEN)✓ Archivos temporales eliminados$(NC)" + +run: ## Ejecutar servidor de desarrollo + @echo "$(YELLOW)Iniciando servidor de desarrollo...$(NC)" + $(MANAGE) runserver + +migrate: ## Aplicar migraciones + @echo "$(YELLOW)Aplicando migraciones...$(NC)" + $(MANAGE) migrate + +makemigrations: ## Crear migraciones + @echo "$(YELLOW)Creando migraciones...$(NC)" + $(MANAGE) makemigrations + +shell: ## Abrir shell de Django + $(MANAGE) shell_plus --ipython || $(MANAGE) shell + +superuser: ## Crear superusuario + $(MANAGE) createsuperuser + +check: ## Verificar proyecto Django + @echo "$(YELLOW)Verificando proyecto...$(NC)" + $(MANAGE) check + +collectstatic: ## Recolectar archivos estáticos + @echo "$(YELLOW)Recolectando archivos estáticos...$(NC)" + $(MANAGE) collectstatic --noinput + +security: ## Verificar vulnerabilidades de seguridad + @echo "$(YELLOW)Verificando vulnerabilidades...$(NC)" + safety check + bandit -r . -c pyproject.toml + +pre-commit-all: ## Ejecutar pre-commit en todos los archivos + @echo "$(YELLOW)Ejecutando pre-commit en todos los archivos...$(NC)" + pre-commit run --all-files + +setup: install-dev migrate ## Setup inicial del proyecto + @echo "" + @echo "$(GREEN)✓ Setup completado$(NC)" + @echo "" + @echo "Próximos pasos:" + @echo " 1. Crea un superusuario: make superuser" + @echo " 2. Ejecuta el servidor: make run" + @echo " 3. Revisa la ayuda: make help" diff --git a/backend/attendance/__init__.py b/backend/attendance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py new file mode 100644 index 0000000..52fbfe4 --- /dev/null +++ b/backend/attendance/admin.py @@ -0,0 +1,169 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import path +from django.http import HttpResponse +from import_export import resources, fields +from import_export.admin import ExportMixin +from .models import Attendance, AttendanceStats + + +class AttendanceStatsResource(resources.ModelResource): + """Recurso para exportar estadísticas de asistencia""" + account_number = fields.Field() + full_name = fields.Field() + cumple_requisito = fields.Field() + + class Meta: + model = AttendanceStats + fields = ('account_number', 'full_name', 'attended_events', 'total_events', + 'attendance_percentage', 'cumple_requisito') + export_order = fields + + def dehydrate_account_number(self, stats): + """Obtener número de cuenta del estudiante""" + return stats.student.account_number + + def dehydrate_full_name(self, stats): + """Obtener nombre completo del estudiante""" + return stats.student.full_name + + def dehydrate_cumple_requisito(self, stats): + """Verificar si cumple el requisito mínimo""" + return 'SÍ' if stats.meets_minimum_requirement() else 'NO' + +@admin.register(Attendance) +class AttendanceAdmin(admin.ModelAdmin): + list_display = ['attendee_name', 'attendee_identifier', 'event', 'timestamp', 'registration_method', 'get_registered_by', 'is_valid'] + list_filter = ['registration_method', 'registered_by', 'event__date', 'is_valid', 'event'] + search_fields = ['student__full_name', 'student__account_number', 'external_user__full_name', + 'external_user__temporary_id', 'event__title', 'registered_by__full_name', + 'registered_by__account_number'] + ordering = ['-timestamp'] + readonly_fields = ['timestamp', 'attendee_name', 'attendee_identifier'] + date_hierarchy = 'timestamp' + + fieldsets = ( + ('Información del Asistente', { + 'fields': ('student', 'external_user', 'attendee_name', 'attendee_identifier') + }), + ('Información del Evento', { + 'fields': ('event',) + }), + ('Registro', { + 'fields': ('registered_by', 'registration_method', 'timestamp', 'is_valid', 'notes') + }), + ) + + def get_registered_by(self, obj): + """Muestra el asistente que registró con formato mejorado""" + if obj.registered_by: + return format_html( + '👤 {}
Cuenta: {}', + obj.registered_by.full_name, + obj.registered_by.account_number + ) + return '-' + get_registered_by.short_description = 'Registrado por' + + def has_add_permission(self, request): + # Prevenir creación manual desde admin (debe hacerse desde la API) + return False + + def has_change_permission(self, request, obj=None): + # Solo superusuarios pueden editar asistencias (para correcciones excepcionales) + return request.user.is_superuser + + def has_delete_permission(self, request, obj=None): + # Solo superusuarios pueden eliminar asistencias + return request.user.is_superuser + +@admin.register(AttendanceStats) +class AttendanceStatsAdmin(ExportMixin, admin.ModelAdmin): + resource_class = AttendanceStatsResource + list_display = ['student', 'attended_events', 'total_events', 'attendance_percentage', 'get_cumple_requisito'] + ordering = ['-attendance_percentage'] + list_filter = ['attendance_percentage'] + search_fields = ['student__account_number', 'student__full_name'] + actions = ['export_selected_stats', 'export_students_with_certificate'] + + def get_cumple_requisito(self, obj): + """Mostrar si cumple el requisito mínimo""" + cumple = obj.meets_minimum_requirement() + color = '#28a745' if cumple else '#dc3545' + text = '✅ SÍ' if cumple else '❌ NO' + return format_html( + '{}', + color, text + ) + get_cumple_requisito.short_description = 'Cumple requisito' + + def export_selected_stats(self, request, queryset): + """Acción para exportar estadísticas seleccionadas""" + resource = AttendanceStatsResource() + dataset = resource.export(queryset) + + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="estadisticas_asistencia.xlsx"' + + self.message_user(request, f'Se exportaron {queryset.count()} estadísticas.') + return response + + export_selected_stats.short_description = "📊 Exportar estadísticas seleccionadas" + + def export_students_with_certificate(self, request, queryset): + """Acción para exportar solo estudiantes que cumplen el requisito mínimo""" + from authentication.models import SystemConfiguration + config = SystemConfiguration.get_config() + + # Filtrar solo los que cumplen el requisito + qualified_students = queryset.filter( + attendance_percentage__gte=config.minimum_attendance_percentage + ) + + # Crear el recurso y exportar + resource = AttendanceStatsResource() + dataset = resource.export(qualified_students) + + # Generar archivo Excel + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="estudiantes_con_constancia.xlsx"' + + self.message_user( + request, + f'Se exportaron {qualified_students.count()} estudiantes que cumplen con el {config.minimum_attendance_percentage}% de asistencia mínima.' + ) + + return response + + export_students_with_certificate.short_description = "📊 Exportar estudiantes que cumplen requisito para constancia" + + def get_export_formats(self): + """Formatos permitidos para exportar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def has_add_permission(self, request): + # Las estadísticas se generan automáticamente + return False + + def has_change_permission(self, request, obj=None): + # Las estadísticas son de solo lectura + return False + + def has_delete_permission(self, request, obj=None): + # Solo superusuarios pueden eliminar estadísticas + return request.user.is_superuser \ No newline at end of file diff --git a/backend/attendance/apps.py b/backend/attendance/apps.py new file mode 100644 index 0000000..ba31b45 --- /dev/null +++ b/backend/attendance/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AttendanceConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'attendance' diff --git a/backend/attendance/management/__init__.py b/backend/attendance/management/__init__.py new file mode 100644 index 0000000..a94f2a3 --- /dev/null +++ b/backend/attendance/management/__init__.py @@ -0,0 +1 @@ +# Management package diff --git a/backend/attendance/management/commands/__init__.py b/backend/attendance/management/commands/__init__.py new file mode 100644 index 0000000..b5a3a84 --- /dev/null +++ b/backend/attendance/management/commands/__init__.py @@ -0,0 +1 @@ +# Commands package diff --git a/backend/attendance/management/commands/recalculate_stats.py b/backend/attendance/management/commands/recalculate_stats.py new file mode 100644 index 0000000..c100e67 --- /dev/null +++ b/backend/attendance/management/commands/recalculate_stats.py @@ -0,0 +1,35 @@ +from django.core.management.base import BaseCommand +from attendance.models import AttendanceStats +from authentication.models import UserProfile + + +class Command(BaseCommand): + help = 'Recalcula las estadísticas de asistencia de todos los estudiantes usando bloques de horario' + + def handle(self, *args, **options): + self.stdout.write('Recalculando estadísticas de asistencia...\n') + + # Obtener todos los estudiantes + students = UserProfile.objects.filter(user_type='student') + total_students = students.count() + + self.stdout.write(f'Se encontraron {total_students} estudiantes\n') + + updated = 0 + for student in students: + # Obtener o crear las estadísticas + stats, created = AttendanceStats.objects.get_or_create(student=student) + + # Actualizar con la nueva lógica + stats.update_stats() + + updated += 1 + + if updated % 10 == 0: + self.stdout.write(f'Procesados {updated}/{total_students} estudiantes...') + + self.stdout.write( + self.style.SUCCESS( + f'\nEstadisticas recalculadas exitosamente para {updated} estudiantes' + ) + ) diff --git a/backend/attendance/migrations/0001_initial.py b/backend/attendance/migrations/0001_initial.py new file mode 100644 index 0000000..202e272 --- /dev/null +++ b/backend/attendance/migrations/0001_initial.py @@ -0,0 +1,51 @@ +# Generated by Django 5.2.6 on 2025-09-24 00:40 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authentication', '0001_initial'), + ('events', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Attendance', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Hora de registro')), + ('registration_method', models.CharField(choices=[('manual', 'Registro Manual'), ('barcode', 'Código de Barras'), ('external', 'Usuario Externo')], default='manual', max_length=10, verbose_name='Método de registro')), + ('notes', models.TextField(blank=True, null=True, verbose_name='Notas adicionales')), + ('is_valid', models.BooleanField(default=True, verbose_name='Asistencia válida')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='events.event', verbose_name='Evento')), + ('external_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='events.externaluser', verbose_name='Usuario Externo')), + ('registered_by', models.ForeignKey(limit_choices_to={'user_type': 'teacher'}, on_delete=django.db.models.deletion.CASCADE, related_name='registered_attendances', to='authentication.userprofile', verbose_name='Registrado por')), + ('student', models.ForeignKey(blank=True, limit_choices_to={'user_type': 'student'}, null=True, on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile', verbose_name='Estudiante')), + ], + options={ + 'verbose_name': 'Asistencia', + 'verbose_name_plural': 'Asistencias', + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='AttendanceStats', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('total_events', models.IntegerField(default=0, verbose_name='Total de eventos')), + ('attended_events', models.IntegerField(default=0, verbose_name='Eventos asistidos')), + ('attendance_percentage', models.FloatField(default=0.0, verbose_name='Porcentaje de asistencia')), + ('last_updated', models.DateTimeField(auto_now=True, verbose_name='Última actualización')), + ('student', models.OneToOneField(limit_choices_to={'user_type': 'student'}, on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile', verbose_name='Estudiante')), + ], + options={ + 'verbose_name': 'Estadísticas de asistencia', + 'verbose_name_plural': 'Estadísticas de asistencia', + }, + ), + ] diff --git a/backend/attendance/migrations/0002_alter_attendance_registered_by.py b/backend/attendance/migrations/0002_alter_attendance_registered_by.py new file mode 100644 index 0000000..26f11e3 --- /dev/null +++ b/backend/attendance/migrations/0002_alter_attendance_registered_by.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-01 02:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attendance', '0001_initial'), + ('authentication', '0003_rename_teacher_assistant_alter_userprofile_user_type'), + ] + + operations = [ + migrations.AlterField( + model_name='attendance', + name='registered_by', + field=models.ForeignKey(limit_choices_to={'user_type': 'assistant'}, on_delete=django.db.models.deletion.CASCADE, related_name='registered_attendances', to='authentication.userprofile', verbose_name='Registrado por'), + ), + ] diff --git a/backend/attendance/migrations/0003_alter_attendance_external_user.py b/backend/attendance/migrations/0003_alter_attendance_external_user.py new file mode 100644 index 0000000..21b2863 --- /dev/null +++ b/backend/attendance/migrations/0003_alter_attendance_external_user.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-02 00:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('attendance', '0002_alter_attendance_registered_by'), + ('authentication', '0006_externaluser'), + ] + + operations = [ + migrations.AlterField( + model_name='attendance', + name='external_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='authentication.externaluser', verbose_name='Usuario Externo'), + ), + ] diff --git a/backend/attendance/migrations/__init__.py b/backend/attendance/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/attendance/models.py b/backend/attendance/models.py new file mode 100644 index 0000000..45902ce --- /dev/null +++ b/backend/attendance/models.py @@ -0,0 +1,291 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone +from datetime import datetime, time as dt_time, timedelta +from authentication.models import UserProfile, ExternalUser +from events.models import Event + +class Attendance(models.Model): + REGISTRATION_METHODS = ( + ('manual', 'Registro Manual'), + ('barcode', 'Código de Barras'), + ('external', 'Usuario Externo'), + ) + + # Puede ser estudiante regular o externo + student = models.ForeignKey( + UserProfile, + on_delete=models.CASCADE, + blank=True, + null=True, + limit_choices_to={'user_type': 'student'}, + verbose_name="Estudiante" + ) + external_user = models.ForeignKey( + ExternalUser, + on_delete=models.CASCADE, + blank=True, + null=True, + verbose_name="Usuario Externo" + ) + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + verbose_name="Evento" + ) + timestamp = models.DateTimeField( + auto_now_add=True, + verbose_name="Hora de registro" + ) + registered_by = models.ForeignKey( + UserProfile, + on_delete=models.CASCADE, + related_name='registered_attendances', + limit_choices_to={'user_type': 'assistant'}, + verbose_name="Registrado por" + ) + registration_method = models.CharField( + max_length=10, + choices=REGISTRATION_METHODS, + default='manual', + verbose_name="Método de registro" + ) + notes = models.TextField( + blank=True, + null=True, + verbose_name="Notas adicionales" + ) + is_valid = models.BooleanField( + default=True, + verbose_name="Asistencia válida" + ) + + class Meta: + verbose_name = "Asistencia" + verbose_name_plural = "Asistencias" + ordering = ['-timestamp'] + + def clean(self): + # Debe tener estudiante O usuario externo, pero no ambos + if not self.student and not self.external_user: + raise ValidationError("Debe especificar un estudiante o usuario externo.") + + if self.student and self.external_user: + raise ValidationError("No puede tener ambos: estudiante y usuario externo.") + + # Validar que el registrador sea un asistente + if self.registered_by.user_type != 'assistant': + raise ValidationError("Solo los asistentes pueden registrar asistencias.") + + # Validar que el evento esté en curso (entre start_time y end_time) + from authentication.models import SystemConfiguration + config = SystemConfiguration.get_config() + + now = timezone.now() + event_date = self.event.date + event_start = datetime.combine(event_date, self.event.start_time) + event_end = datetime.combine(event_date, self.event.end_time) + + # Hacer timezone-aware si es necesario + if timezone.is_naive(event_start): + event_start = timezone.make_aware(event_start) + if timezone.is_naive(event_end): + event_end = timezone.make_aware(event_end) + + # Usar configuración global del sistema + registration_start = event_start - timedelta(minutes=config.minutes_before_event) + registration_end = event_start + timedelta(minutes=config.minutes_after_start) + + if now < registration_start: + raise ValidationError( + f"No se puede registrar asistencia antes del evento. " + f"El evento inicia el {event_date.strftime('%d/%m/%Y')} a las {self.event.start_time.strftime('%H:%M')}. " + f"Puedes registrar desde {config.minutes_before_event} minutos antes." + ) + + if now > registration_end: + raise ValidationError( + f"No se puede registrar asistencia después del tiempo límite. " + f"El evento inició el {event_date.strftime('%d/%m/%Y')} a las {self.event.start_time.strftime('%H:%M')}. " + f"El tiempo límite de registro es {config.minutes_after_start} minutos después del inicio." + ) + + # Validar que no haya duplicados + if self.student: + existing = Attendance.objects.filter( + student=self.student, + event=self.event, + is_valid=True + ) + if self.pk: + existing = existing.exclude(pk=self.pk) + if existing.exists(): + raise ValidationError("Este estudiante ya tiene asistencia registrada para este evento.") + + if self.external_user: + existing = Attendance.objects.filter( + external_user=self.external_user, + event=self.event, + is_valid=True + ) + if self.pk: + existing = existing.exclude(pk=self.pk) + if existing.exists(): + raise ValidationError("Este usuario externo ya tiene asistencia registrada para este evento.") + + # Validar eventos simultáneos (solo para estudiantes regulares) + if self.student: + overlapping_events = Event.objects.filter( + date=self.event.date, + start_time__lt=self.event.end_time, + end_time__gt=self.event.start_time, + is_active=True + ).exclude(id=self.event.id) + + existing_attendance = Attendance.objects.filter( + student=self.student, + event__in=overlapping_events, + is_valid=True + ).exists() + + if existing_attendance: + raise ValidationError( + "El estudiante ya tiene asistencia registrada en un evento simultáneo." + ) + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + # Actualizar estadísticas si es estudiante regular + if self.student: + self.update_student_stats() + + def update_student_stats(self): + """Actualizar las estadísticas de asistencia del estudiante""" + stats, created = AttendanceStats.objects.get_or_create( + student=self.student, + defaults={ + 'total_events': 0, + 'attended_events': 0, + 'attendance_percentage': 0.0 + } + ) + stats.update_stats() + + @property + def attendee_name(self): + """Nombre del asistente (estudiante o externo)""" + if self.student: + return self.student.full_name + elif self.external_user: + return self.external_user.full_name + return "Desconocido" + + @property + def attendee_identifier(self): + """Identificador del asistente""" + if self.student: + return self.student.account_number + elif self.external_user: + return self.external_user.account_number + return "N/A" + + def __str__(self): + return f"{self.attendee_name} - {self.event.title}" + +class AttendanceStats(models.Model): + student = models.OneToOneField( + UserProfile, + on_delete=models.CASCADE, + limit_choices_to={'user_type': 'student'}, + verbose_name="Estudiante" + ) + total_events = models.IntegerField( + default=0, + verbose_name="Total de eventos" + ) + attended_events = models.IntegerField( + default=0, + verbose_name="Eventos asistidos" + ) + attendance_percentage = models.FloatField( + default=0.0, + verbose_name="Porcentaje de asistencia" + ) + last_updated = models.DateTimeField( + auto_now=True, + verbose_name="Última actualización" + ) + + class Meta: + verbose_name = "Estadísticas de asistencia" + verbose_name_plural = "Estadísticas de asistencia" + + def update_stats(self): + """Actualizar las estadísticas de asistencia""" + from events.models import Event # Importar aquí para evitar circular imports + + # Obtener todos los eventos activos + all_events = Event.objects.filter(is_active=True).order_by('date', 'start_time') + + # Agrupar eventos por bloques de horario (misma fecha y horarios que se solapan) + event_slots = {} # {(fecha, hora_inicio, hora_fin): [lista de eventos]} + + for event in all_events: + # Crear una clave única para el bloque horario + slot_key = (event.date, event.start_time, event.end_time) + + # Buscar si hay un slot existente que se solape con este evento + found_slot = False + for existing_slot in list(event_slots.keys()): + existing_date, existing_start, existing_end = existing_slot + + # Verificar si es el mismo día y hay solapamiento de horarios + if event.date == existing_date: + # Hay solapamiento si el inicio de uno es menor al fin del otro + if (event.start_time < existing_end and event.end_time > existing_start): + event_slots[existing_slot].append(event) + found_slot = True + break + + # Si no encontramos un slot existente, crear uno nuevo + if not found_slot: + event_slots[slot_key] = [event] + + # El total de "bloques" es la cantidad de slots únicos + total_slots = len(event_slots) + + # Obtener asistencias del estudiante + student_attendances = Attendance.objects.filter( + student=self.student, + is_valid=True + ).values_list('event_id', flat=True) + + # Contar cuántos bloques tiene asistencia + attended_slots = 0 + for slot_events in event_slots.values(): + # Si asistió a al menos uno de los eventos del bloque, cuenta + event_ids = [e.id for e in slot_events] + if any(event_id in student_attendances for event_id in event_ids): + attended_slots += 1 + + self.total_events = total_slots + self.attended_events = attended_slots + + # Calcular porcentaje + if total_slots > 0: + self.attendance_percentage = round((attended_slots / total_slots) * 100, 2) + else: + self.attendance_percentage = 0.0 + + self.save() + + def meets_minimum_requirement(self): + """Verifica si cumple con el requisito mínimo de asistencia global""" + from authentication.models import SystemConfiguration + config = SystemConfiguration.get_config() + return self.attendance_percentage >= config.minimum_attendance_percentage + + def __str__(self): + return f"Stats: {self.student.full_name} - {self.attendance_percentage}%" \ No newline at end of file diff --git a/backend/attendance/urls.py b/backend/attendance/urls.py new file mode 100644 index 0000000..214a160 --- /dev/null +++ b/backend/attendance/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.register_attendance, name='register_attendance'), + path('stats/', views.get_student_stats, name='student_stats'), + path('recent/', views.get_recent_attendances, name='recent_attendances'), + path('my/', views.get_my_attendances, name='my_attendances'), + path('external/my/', views.get_external_user_attendances, name='external_user_attendances'), + path('external/stats/', views.get_external_user_stats, name='external_user_stats'), +] \ No newline at end of file diff --git a/backend/attendance/views.py b/backend/attendance/views.py new file mode 100644 index 0000000..78f817e --- /dev/null +++ b/backend/attendance/views.py @@ -0,0 +1,387 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from django.shortcuts import get_object_or_404 +from django_ratelimit.decorators import ratelimit +from authentication.models import UserProfile, ExternalUser +from events.models import Event +from .models import Attendance, AttendanceStats + +@api_view(['GET', 'POST']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='60/m', method='POST', block=True) +def register_attendance(request): + """Registrar asistencia - Solo asistentes: 60 registros por minuto""" + if request.method == 'GET': + return Response({ + 'message': 'API de registro de asistencia activa', + 'methods': ['POST'], + 'required_fields': ['event_id', 'account_number'] + }) + + # Verificar que el usuario autenticado sea asistente + try: + registrar_profile = request.user.userprofile + if registrar_profile.user_type != 'assistant': + return Response({ + 'error': 'Solo los asistentes pueden registrar asistencias' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Usuario sin perfil válido' + }, status=status.HTTP_403_FORBIDDEN) + + event_id = request.data.get('event_id') + account_number = request.data.get('account_number') + + if not event_id or not account_number: + return Response({ + 'error': 'Se requiere event_id y account_number' + }, status=status.HTTP_400_BAD_REQUEST) + + # Buscar evento + try: + event = Event.objects.get(id=event_id, is_active=True) + except Event.DoesNotExist: + return Response({ + 'error': 'Evento no encontrado' + }, status=status.HTTP_404_NOT_FOUND) + + # Buscar estudiante regular o usuario externo + student_profile = None + external_user = None + attendee_name = None + + # Primero buscar en estudiantes regulares + try: + student_profile = UserProfile.objects.get( + account_number=account_number, + user_type='student' + ) + attendee_name = student_profile.full_name + except UserProfile.DoesNotExist: + # Si no es estudiante, buscar en usuarios externos + try: + external_user = ExternalUser.objects.get( + account_number=account_number, + status='approved' + ) + attendee_name = external_user.full_name + except ExternalUser.DoesNotExist: + return Response({ + 'error': f'Usuario con número de cuenta {account_number} no encontrado o no aprobado' + }, status=status.HTTP_404_NOT_FOUND) + + # Usar el asistente autenticado como registrador + assistant_profile = registrar_profile + + # Verificar si ya tiene asistencia + if student_profile: + if Attendance.objects.filter(student=student_profile, event=event, is_valid=True).exists(): + return Response({ + 'error': 'El estudiante ya tiene asistencia registrada para este evento' + }, status=status.HTTP_400_BAD_REQUEST) + elif external_user: + if Attendance.objects.filter(external_user=external_user, event=event, is_valid=True).exists(): + return Response({ + 'error': 'El usuario externo ya tiene asistencia registrada para este evento' + }, status=status.HTTP_400_BAD_REQUEST) + + # Crear asistencia + try: + attendance = Attendance.objects.create( + student=student_profile, + external_user=external_user, + event=event, + registered_by=assistant_profile, + registration_method='manual' + ) + + return Response({ + 'message': f'Asistencia registrada para {attendee_name}', + 'attendance_id': attendance.id, + 'event': event.title, + 'registered_by': assistant_profile.full_name, + 'attendee_type': 'student' if student_profile else 'external' + }, status=status.HTTP_201_CREATED) + + except Exception as e: + return Response({ + 'error': f'Error al crear asistencia: {str(e)}' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='30/m', method='GET', block=True) +def get_student_stats(request): + """Obtener estadísticas de estudiante: 30 consultas por minuto""" + account_number = request.GET.get('account_number') + + if not account_number: + return Response({'error': 'Se requiere account_number'}, status=400) + + # Verificar permisos: estudiantes solo pueden ver sus propias estadísticas + try: + requester_profile = request.user.userprofile + + # Si es estudiante, solo puede consultar sus propias estadísticas + if requester_profile.user_type == 'student': + if requester_profile.account_number != account_number: + return Response({ + 'error': 'Solo puedes consultar tus propias estadísticas' + }, status=status.HTTP_403_FORBIDDEN) + # Asistentes pueden ver estadísticas de cualquier estudiante + elif requester_profile.user_type != 'assistant': + return Response({ + 'error': 'No tienes permisos para consultar estadísticas' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Usuario sin perfil válido' + }, status=status.HTTP_403_FORBIDDEN) + + try: + student_profile = UserProfile.objects.get( + account_number=account_number, + user_type='student' + ) + stats, created = AttendanceStats.objects.get_or_create( + student=student_profile + ) + stats.update_stats() + + return Response({ + 'total_events': stats.total_events, + 'attended_events': stats.attended_events, + 'attendance_percentage': stats.attendance_percentage + }) + except UserProfile.DoesNotExist: + return Response({'error': 'Estudiante no encontrado'}, status=404) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='60/m', method='GET', block=True) +def get_recent_attendances(request): + """Obtener asistencias recientes - Solo asistentes: 60 consultas por minuto""" + # Solo asistentes pueden ver asistencias recientes + try: + requester_profile = request.user.userprofile + if requester_profile.user_type != 'assistant': + return Response({ + 'error': 'Solo los asistentes pueden consultar asistencias recientes' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Usuario sin perfil válido' + }, status=status.HTTP_403_FORBIDDEN) + + recent = Attendance.objects.select_related('student', 'event').order_by('-timestamp')[:5] + + data = [] + for attendance in recent: + data.append({ + 'attendee_name': attendance.attendee_name, + 'event_title': attendance.event.title, + 'timestamp': attendance.timestamp.strftime('%H:%M') + }) + + return Response(data) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='60/m', method='GET', block=True) +def get_my_attendances(request): + """Obtener mis asistencias - Estudiantes pueden ver sus propias asistencias: 60 consultas por minuto""" + account_number = request.GET.get('account_number') + + if not account_number: + return Response({'error': 'Se requiere account_number'}, status=400) + + # Verificar permisos: estudiantes solo pueden ver sus propias asistencias + try: + requester_profile = request.user.userprofile + + # Si es estudiante, solo puede consultar sus propias asistencias + if requester_profile.user_type == 'student': + if requester_profile.account_number != account_number: + return Response({ + 'error': 'Solo puedes consultar tus propias asistencias' + }, status=status.HTTP_403_FORBIDDEN) + # Asistentes pueden ver asistencias de cualquier estudiante + elif requester_profile.user_type != 'assistant': + return Response({ + 'error': 'No tienes permisos para consultar asistencias' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Usuario sin perfil válido' + }, status=status.HTTP_403_FORBIDDEN) + + try: + student_profile = UserProfile.objects.get( + account_number=account_number, + user_type='student' + ) + + # Obtener todas las asistencias válidas del estudiante + attendances = Attendance.objects.filter( + student=student_profile, + is_valid=True + ).select_related('event').order_by('-timestamp') + + data = [] + for attendance in attendances: + data.append({ + 'id': attendance.id, + 'event': attendance.event.id, + 'event_title': attendance.event.title, + 'event_date': attendance.event.date.strftime('%Y-%m-%d'), + 'timestamp': attendance.timestamp.strftime('%Y-%m-%d %H:%M:%S') + }) + + return Response(data) + except UserProfile.DoesNotExist: + return Response({'error': 'Estudiante no encontrado'}, status=404) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='60/m', method='GET', block=True) +def get_external_user_attendances(request): + """Obtener asistencias de usuario externo: 60 consultas por minuto""" + account_number = request.GET.get('account_number') + + if not account_number: + return Response({'error': 'Se requiere account_number'}, status=400) + + # Normalizar el username para comparar (eliminar prefijo "ext_") + user_account = request.user.username.replace('ext_', '') + + # Verificar que el usuario externo solo pueda ver sus propias asistencias + if user_account != account_number: + # Verificar si es asistente (pueden ver todas) + try: + requester_profile = request.user.userprofile + if requester_profile.user_type != 'assistant': + return Response({ + 'error': 'Solo puedes consultar tus propias asistencias' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Solo puedes consultar tus propias asistencias' + }, status=status.HTTP_403_FORBIDDEN) + + try: + external_user = ExternalUser.objects.get( + account_number=account_number, + status='approved' + ) + + # Obtener todas las asistencias válidas del usuario externo + attendances = Attendance.objects.filter( + external_user=external_user, + is_valid=True + ).select_related('event').order_by('-timestamp') + + data = [] + for attendance in attendances: + data.append({ + 'id': attendance.id, + 'event': attendance.event.id, + 'event_title': attendance.event.title, + 'event_date': attendance.event.date.strftime('%Y-%m-%d'), + 'timestamp': attendance.timestamp.strftime('%Y-%m-%d %H:%M:%S') + }) + + return Response(data) + except ExternalUser.DoesNotExist: + return Response({'error': 'Usuario externo no encontrado o no aprobado'}, status=404) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='30/m', method='GET', block=True) +def get_external_user_stats(request): + """Obtener estadísticas de usuario externo: 30 consultas por minuto""" + account_number = request.GET.get('account_number') + + if not account_number: + return Response({'error': 'Se requiere account_number'}, status=400) + + # Normalizar el username para comparar (eliminar prefijo "ext_") + user_account = request.user.username.replace('ext_', '') + + # Verificar que el usuario externo solo pueda ver sus propias estadísticas + if user_account != account_number: + # Verificar si es asistente (pueden ver todas) + try: + requester_profile = request.user.userprofile + if requester_profile.user_type != 'assistant': + return Response({ + 'error': 'Solo puedes consultar tus propias estadísticas' + }, status=status.HTTP_403_FORBIDDEN) + except: + return Response({ + 'error': 'Solo puedes consultar tus propias estadísticas' + }, status=status.HTTP_403_FORBIDDEN) + + try: + external_user = ExternalUser.objects.get( + account_number=account_number, + status='approved' + ) + + # Obtener todos los eventos activos + all_events = Event.objects.filter(is_active=True).order_by('date', 'start_time') + + # Agrupar eventos por bloques de horario (misma fecha y horarios que se solapan) + # Usar la misma lógica que AttendanceStats.update_stats() + event_slots = {} # {(fecha, hora_inicio, hora_fin): [lista de eventos]} + + for event in all_events: + # Crear una clave única para el bloque horario + slot_key = (event.date, event.start_time, event.end_time) + + # Buscar si hay un slot existente que se solape con este evento + found_slot = False + for existing_slot in list(event_slots.keys()): + existing_date, existing_start, existing_end = existing_slot + + # Verificar si es el mismo día y hay solapamiento de horarios + if event.date == existing_date: + # Hay solapamiento si el inicio de uno es menor al fin del otro + if (event.start_time < existing_end and event.end_time > existing_start): + event_slots[existing_slot].append(event) + found_slot = True + break + + # Si no encontramos un slot existente, crear uno nuevo + if not found_slot: + event_slots[slot_key] = [event] + + # El total de "bloques" es la cantidad de slots únicos + total_slots = len(event_slots) + + # Obtener asistencias del usuario externo + user_attendances = Attendance.objects.filter( + external_user=external_user, + is_valid=True + ).values_list('event_id', flat=True) + + # Contar cuántos bloques tiene asistencia + attended_slots = 0 + for slot_events in event_slots.values(): + # Si asistió a al menos uno de los eventos del bloque, cuenta + event_ids = [e.id for e in slot_events] + if any(event_id in user_attendances for event_id in event_ids): + attended_slots += 1 + + # Calcular porcentaje + attendance_percentage = round((attended_slots / total_slots) * 100, 2) if total_slots > 0 else 0.0 + + return Response({ + 'total_events': total_slots, + 'attended_events': attended_slots, + 'attendance_percentage': attendance_percentage + }) + except ExternalUser.DoesNotExist: + return Response({'error': 'Usuario externo no encontrado o no aprobado'}, status=404) diff --git a/backend/authentication/__init__.py b/backend/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py new file mode 100644 index 0000000..53e2535 --- /dev/null +++ b/backend/authentication/admin.py @@ -0,0 +1,607 @@ +from django.contrib import admin +from django.contrib.auth.models import User, Group +from django import forms +from django.utils.html import format_html +from django.http import HttpResponse +from import_export import resources, fields +from import_export.admin import ImportExportModelAdmin +from import_export.widgets import ForeignKeyWidget +from .models import UserProfile, Asistente, ExternalUser, SystemConfiguration, Student, AssistantProfile +from .audit import AuditLog + +# Ocultar modelos de Django que no se usan +admin.site.unregister(User) +admin.site.unregister(Group) + + +# ===== RECURSOS PARA IMPORT/EXPORT ===== + +class StudentResource(resources.ModelResource): + """Recurso para importar/exportar SOLO estudiantes""" + + class Meta: + model = UserProfile + fields = ('account_number', 'full_name') + export_order = ('account_number', 'full_name') + import_id_fields = ['account_number'] + skip_unchanged = True + report_skipped = True + + def before_import_row(self, row, **kwargs): + """Validar y limpiar datos antes de importar""" + # Limpiar espacios + row['account_number'] = str(row.get('account_number', '')).strip() + row['full_name'] = str(row.get('full_name', '')).strip() + # Asignar automáticamente user_type como student + row['user_type'] = 'student' + + def after_save_instance(self, instance, using_transactions, dry_run): + """Crear usuario de Django si no existe y asegurar que sea estudiante""" + if not dry_run: + # Asegurar que sea estudiante + if instance.user_type != 'student': + instance.user_type = 'student' + instance.save() + + # Crear usuario de Django si no existe + if not instance.user_id: + user = User.objects.create_user( + username=instance.account_number, + first_name=instance.full_name, + password=None + ) + user.set_unusable_password() + user.save() + instance.user = user + instance.save() + + +class AssistantResource(resources.ModelResource): + """Recurso para importar/exportar SOLO asistentes""" + + class Meta: + model = UserProfile + fields = ('account_number', 'full_name') + export_order = ('account_number', 'full_name') + import_id_fields = ['account_number'] + skip_unchanged = True + report_skipped = True + + def before_import_row(self, row, **kwargs): + """Validar y limpiar datos antes de importar""" + # Limpiar espacios + row['account_number'] = str(row.get('account_number', '')).strip() + row['full_name'] = str(row.get('full_name', '')).strip() + # Asignar automáticamente user_type como assistant + row['user_type'] = 'assistant' + + def after_save_instance(self, instance, using_transactions, dry_run): + """Crear usuario de Django si no existe y asegurar que sea asistente""" + if not dry_run: + # Asegurar que sea asistente + if instance.user_type != 'assistant': + instance.user_type = 'assistant' + instance.save() + + # Crear usuario de Django si no existe + if not instance.user_id: + user = User.objects.create_user( + username=instance.account_number, + first_name=instance.full_name, + password=None + ) + user.set_unusable_password() + user.save() + instance.user = user + instance.save() + + # Crear automáticamente el registro de Asistente si no existe + from .models import Asistente + Asistente.objects.get_or_create( + user_profile=instance, + defaults={'can_manage_events': True} + ) + + +class UserProfileResource(resources.ModelResource): + """Recurso para importar/exportar UserProfile (LEGACY - no usar)""" + + class Meta: + model = UserProfile + fields = ('account_number', 'full_name', 'user_type') + import_id_fields = ['account_number'] + skip_unchanged = True + report_skipped = True + + def before_import_row(self, row, **kwargs): + """Validar y limpiar datos antes de importar""" + # Limpiar espacios + row['account_number'] = str(row.get('account_number', '')).strip() + row['full_name'] = str(row.get('full_name', '')).strip() + row['user_type'] = str(row.get('user_type', '')).strip().lower() + + def after_save_instance(self, instance, using_transactions, dry_run): + """Crear usuario de Django si no existe""" + if not dry_run and not instance.user_id: + user = User.objects.create_user( + username=instance.account_number, + first_name=instance.full_name, + password=None + ) + user.set_unusable_password() + user.save() + instance.user = user + instance.save() + + +class ExternalUserResource(resources.ModelResource): + """Recurso para exportar usuarios externos""" + approved_by_name = fields.Field() + + class Meta: + model = ExternalUser + fields = ('account_number', 'full_name', 'status', 'approved_by_name', 'created_at', 'rejection_reason') + export_order = fields + + def dehydrate_approved_by_name(self, external_user): + """Obtener nombre del aprobador""" + return external_user.approved_by.full_name if external_user.approved_by else '-' + + +class UserProfileForm(forms.ModelForm): + """Formulario personalizado para crear UserProfile sin contraseña""" + + class Meta: + model = UserProfile + fields = ['account_number', 'full_name', 'user_type'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Hacer el campo user_type obligatorio + self.fields['user_type'].required = True + self.fields['user_type'].empty_label = None # Quitar opción vacía del dropdown + + def save(self, commit=True): + profile = super().save(commit=False) + + # Si el perfil no tiene usuario asociado, crear uno automáticamente + if not profile.user_id: + # Crear usuario con username = número de cuenta, sin contraseña + user = User.objects.create_user( + username=profile.account_number, + first_name=profile.full_name, + password=None # Sin contraseña + ) + # Desactivar contraseña usable + user.set_unusable_password() + user.save() + profile.user = user + + if commit: + profile.save() + + return profile + + +class StudentAdmin(ImportExportModelAdmin): + """Admin SOLO para estudiantes""" + resource_class = StudentResource + list_display = ['account_number', 'full_name'] + search_fields = ['account_number', 'full_name'] + actions = ['export_selected_students'] + + fieldsets = ( + ('Información del Estudiante', { + 'fields': ('account_number', 'full_name'), + 'description': '📚 Importa el Excel con solo 2 columnas: account_number y full_name. El sistema automáticamente los creará como estudiantes.' + }), + ) + + def get_queryset(self, request): + """Mostrar SOLO estudiantes""" + qs = super().get_queryset(request) + return qs.filter(user_type='student') + + def get_import_formats(self): + """Formatos permitidos para importar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def get_export_formats(self): + """Formatos permitidos para exportar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def export_selected_students(self, request, queryset): + """Acción para exportar estudiantes seleccionados""" + resource = StudentResource() + dataset = resource.export(queryset) + + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="estudiantes.xlsx"' + + self.message_user(request, f'Se exportaron {queryset.count()} estudiantes.') + return response + + export_selected_students.short_description = "📊 Exportar estudiantes seleccionados" + + def save_model(self, request, obj, form, change): + """Asegurar que siempre sea estudiante y crear usuario si no existe""" + obj.user_type = 'student' + + # Si no tiene usuario, crear uno + if not obj.user_id: + user = User.objects.create_user( + username=obj.account_number, + first_name=obj.full_name, + password=None + ) + user.set_unusable_password() + user.save() + obj.user = user + + super().save_model(request, obj, form, change) + + +class AssistantProfileAdmin(ImportExportModelAdmin): + """Admin SOLO para asistentes""" + resource_class = AssistantResource + list_display = ['account_number', 'full_name'] + search_fields = ['account_number', 'full_name'] + actions = ['export_selected_assistants'] + + fieldsets = ( + ('Información del Asistente', { + 'fields': ('account_number', 'full_name'), + 'description': '👨‍🏫 Importa el Excel con solo 2 columnas: account_number y full_name. El sistema automáticamente los creará como asistentes.' + }), + ) + + def get_queryset(self, request): + """Mostrar SOLO asistentes""" + qs = super().get_queryset(request) + return qs.filter(user_type='assistant') + + def get_import_formats(self): + """Formatos permitidos para importar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def get_export_formats(self): + """Formatos permitidos para exportar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def export_selected_assistants(self, request, queryset): + """Acción para exportar asistentes seleccionados""" + resource = AssistantResource() + dataset = resource.export(queryset) + + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="asistentes.xlsx"' + + self.message_user(request, f'Se exportaron {queryset.count()} asistentes.') + return response + + export_selected_assistants.short_description = "📊 Exportar asistentes seleccionados" + + def save_model(self, request, obj, form, change): + """Asegurar que siempre sea asistente y crear usuario si no existe""" + obj.user_type = 'assistant' + + # Si no tiene usuario, crear uno + if not obj.user_id: + user = User.objects.create_user( + username=obj.account_number, + first_name=obj.full_name, + password=None + ) + user.set_unusable_password() + user.save() + obj.user = user + + super().save_model(request, obj, form, change) + + # Crear automáticamente el registro de Asistente si no existe + Asistente.objects.get_or_create( + user_profile=obj, + defaults={'can_manage_events': True} + ) + + +# Registrar los admins separados con proxy models +admin.site.register(Student, StudentAdmin) +admin.site.register(AssistantProfile, AssistantProfileAdmin) + + +# UserProfile está oculto del admin - se usan Student y AssistantProfile en su lugar + + +@admin.register(Asistente) +class AsistenteAdmin(admin.ModelAdmin): + list_display = ['get_asistente_info', 'get_numero_cuenta', 'get_registros_realizados', 'ver_alumnos_registrados', 'can_manage_events'] + list_filter = ['can_manage_events'] + search_fields = ['user_profile__full_name', 'user_profile__account_number'] + readonly_fields = ['user_profile', 'get_registros_realizados', 'get_ultimos_registros'] + actions = ['ver_reporte_registros'] + + fieldsets = ( + ('Asistente', { + 'fields': ('user_profile',), + 'description': '📋 Los permisos de asistente se crean automáticamente cuando se registra un nuevo asistente.' + }), + ('Permisos y Configuración', { + 'fields': ('can_manage_events',) + }), + ('Estadísticas de Registros', { + 'fields': ('get_registros_realizados', 'get_ultimos_registros'), + 'classes': ('collapse',) + }), + ) + + def has_add_permission(self, request): + """Ocultar botón de agregar - se crean automáticamente""" + return False + + def has_delete_permission(self, request, obj=None): + """No permitir eliminar permisos de asistente""" + return False + + def get_asistente_info(self, obj): + """Mostrar nombre del asistente""" + return obj.user_profile.full_name + get_asistente_info.short_description = 'Asistente' + + def get_numero_cuenta(self, obj): + """Mostrar número de cuenta""" + return obj.user_profile.account_number + get_numero_cuenta.short_description = 'Número de Cuenta' + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + """Filtrar el campo user_profile para mostrar solo asistentes""" + if db_field.name == "user_profile": + kwargs["queryset"] = UserProfile.objects.filter(user_type='assistant') + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + def get_registros_realizados(self, obj): + """Muestra el total de registros realizados por este asistente""" + from attendance.models import Attendance + count = Attendance.objects.filter(registered_by=obj.user_profile).count() + return f"📊 {count} registros" + get_registros_realizados.short_description = 'Total de registros' + + def get_ultimos_registros(self, obj): + """Muestra los últimos 5 registros realizados por este asistente""" + from django.utils.html import format_html + from attendance.models import Attendance + + registros = Attendance.objects.filter( + registered_by=obj.user_profile + ).select_related('student', 'external_user', 'event').order_by('-timestamp')[:5] + + if not registros: + return "Sin registros" + + html = '' + html += '' + + for reg in registros: + attendee = reg.student.full_name if reg.student else reg.external_user.full_name + html += f''' + + + + + + ''' + + html += '
FechaAsistenteEvento
{reg.timestamp.strftime("%d/%m/%Y %H:%M")}{attendee}{reg.event.title[:30]}...
' + return format_html(html) + get_ultimos_registros.short_description = 'Últimos 5 registros' + + def ver_alumnos_registrados(self, obj): + """Botón para ver todos los alumnos registrados por este asistente""" + from django.utils.html import format_html + from django.urls import reverse + + url = reverse('admin:attendance_attendance_changelist') + f'?registered_by__id__exact={obj.user_profile.id}' + return format_html( + '📋 Ver Alumnos', + url + ) + ver_alumnos_registrados.short_description = 'Alumnos Registrados' + + def ver_reporte_registros(self, request, queryset): + """Acción para ver reporte detallado de registros de asistentes seleccionados""" + from django.shortcuts import render + from attendance.models import Attendance + from django.db.models import Count, Q + + asistentes_data = [] + + for asistente in queryset: + registros = Attendance.objects.filter(registered_by=asistente.user_profile) + + estudiantes = registros.filter(student__isnull=False).select_related('student', 'event').order_by('-timestamp') + externos = registros.filter(external_user__isnull=False).select_related('external_user', 'event').order_by('-timestamp') + + asistentes_data.append({ + 'asistente': asistente, + 'total': registros.count(), + 'estudiantes': estudiantes, + 'externos': externos, + 'total_estudiantes': estudiantes.count(), + 'total_externos': externos.count(), + }) + + context = { + 'asistentes_data': asistentes_data, + 'title': 'Reporte de Registros por Asistente', + } + + return render(request, 'admin/asistente_reporte.html', context) + + ver_reporte_registros.short_description = "📊 Ver reporte detallado de registros" + + +@admin.register(ExternalUser) +class ExternalUserAdmin(ImportExportModelAdmin): + resource_class = ExternalUserResource + list_display = ['account_number', 'full_name', 'get_status', 'get_approved_by', 'created_at'] + list_filter = ['status', 'created_at', 'approved_by'] + search_fields = ['full_name', 'account_number', 'approved_by__full_name'] + ordering = ['-created_at'] + readonly_fields = ['created_at', 'processed_at'] + date_hierarchy = 'created_at' + actions = ['export_selected_external_users'] + + fieldsets = ( + ('Información Personal', { + 'fields': ('account_number', 'full_name') + }), + ('Estado de Aprobación', { + 'fields': ('status', 'approved_by', 'processed_at', 'rejection_reason', 'created_at') + }), + ) + + def get_export_formats(self): + """Solo exportar (no importar usuarios externos)""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def has_import_permission(self, request): + """No permitir importar usuarios externos (se crean desde la app)""" + return False + + def export_selected_external_users(self, request, queryset): + """Acción para exportar usuarios externos seleccionados""" + resource = ExternalUserResource() + dataset = resource.export(queryset) + + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="usuarios_externos.xlsx"' + + self.message_user(request, f'Se exportaron {queryset.count()} usuarios externos.') + return response + + export_selected_external_users.short_description = "📊 Exportar usuarios externos seleccionados" + + def get_status(self, obj): + """Muestra el estado con iconos y colores""" + status_icons = { + 'pending': ('⏳', '#FFA500', 'Pendiente'), + 'approved': ('✅', '#28a745', 'Aprobado'), + 'rejected': ('❌', '#dc3545', 'Rechazado') + } + icon, color, text = status_icons.get(obj.status, ('?', '#666', obj.status)) + return format_html( + '{} {}', + color, icon, text + ) + get_status.short_description = 'Estado' + + def get_approved_by(self, obj): + """Muestra quién aprobó/rechazó al usuario""" + if obj.approved_by: + color = '#28a745' if obj.status == 'approved' else '#dc3545' + action = 'Aprobado por' if obj.status == 'approved' else 'Rechazado por' + return format_html( + '👤 {}
{}', + color, + obj.approved_by.full_name, + action + ) + return '-' + get_approved_by.short_description = 'Procesado por' + + +@admin.register(AuditLog) +class AuditLogAdmin(admin.ModelAdmin): + list_display = ['timestamp', 'category', 'severity', 'action', 'username', 'ip_address', 'success', 'message'] + list_filter = ['category', 'severity', 'action', 'success', 'timestamp'] + search_fields = ['username', 'ip_address', 'message', 'path'] + readonly_fields = ['timestamp', 'category', 'severity', 'action', 'user', 'username', + 'ip_address', 'user_agent', 'path', 'method', 'message', + 'details', 'success', 'status_code'] + date_hierarchy = 'timestamp' + + def has_add_permission(self, request): + # No permitir crear logs manualmente desde el admin + return False + + def has_change_permission(self, request, obj=None): + # No permitir editar logs (son inmutables) + return False + + def has_delete_permission(self, request, obj=None): + # Solo superusuarios pueden eliminar logs + return request.user.is_superuser + + +@admin.register(SystemConfiguration) +class SystemConfigurationAdmin(admin.ModelAdmin): + list_display = ['get_config_name', 'minimum_attendance_percentage', 'minutes_before_event', 'minutes_after_start', 'updated_at', 'updated_by'] + readonly_fields = ['updated_at', 'updated_by'] + + fieldsets = ( + ('Configuración de Asistencia', { + 'fields': ('minimum_attendance_percentage',), + 'description': '⚙️ Este porcentaje se aplica a TODOS los estudiantes del sistema. Define el porcentaje mínimo de asistencia requerido para obtener la constancia.' + }), + ('Configuración de Tiempos de Registro', { + 'fields': ('minutes_before_event', 'minutes_after_start'), + 'description': ''' + ⏱️ Tiempos de registro de asistencia (aplica a TODAS las ponencias):

+ • Minutos antes del evento: Tiempo permitido para comenzar a registrar asistencia antes del inicio programado.
+ Ejemplo: Si el evento inicia a las 10:00 AM y configuras 10 minutos, se podrá registrar desde las 9:50 AM.

+ • Minutos después del inicio: Tiempo límite después del inicio para registrar asistencia.
+ Ejemplo: Si el evento inicia a las 10:00 AM y configuras 25 minutos, el límite será a las 10:25 AM.

+ Estos valores se aplican automáticamente a todos los eventos del sistema. + ''' + }), + ('Información de Auditoría', { + 'fields': ('updated_at', 'updated_by'), + 'classes': ('collapse',) + }), + ) + + def get_config_name(self, obj): + return "Configuración Global del Sistema" + get_config_name.short_description = 'Configuración' + + def has_add_permission(self, request): + # Solo permitir una configuración + return not SystemConfiguration.objects.exists() + + def has_delete_permission(self, request, obj=None): + # No permitir eliminar la configuración + return False + + def save_model(self, request, obj, form, change): + # Registrar quién actualizó la configuración + try: + obj.updated_by = request.user.profile + except: + pass + super().save_model(request, obj, form, change) \ No newline at end of file diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py new file mode 100644 index 0000000..8bab8df --- /dev/null +++ b/backend/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'authentication' diff --git a/backend/authentication/audit.py b/backend/authentication/audit.py new file mode 100644 index 0000000..72dbe16 --- /dev/null +++ b/backend/authentication/audit.py @@ -0,0 +1,222 @@ +""" +Sistema de auditoría para registrar eventos de seguridad +""" +from django.db import models +from django.contrib.auth.models import User +from django.utils import timezone +import json + + +class AuditLog(models.Model): + """ + Modelo para registrar eventos de auditoría del sistema + """ + + # Categorías de eventos + CATEGORY_CHOICES = [ + ('AUTH', 'Autenticación'), + ('ACCESS', 'Control de Acceso'), + ('DATA', 'Modificación de Datos'), + ('SECURITY', 'Evento de Seguridad'), + ('SYSTEM', 'Sistema'), + ] + + # Niveles de severidad + SEVERITY_CHOICES = [ + ('INFO', 'Información'), + ('WARNING', 'Advertencia'), + ('ERROR', 'Error'), + ('CRITICAL', 'Crítico'), + ] + + # Acciones comunes + ACTION_CHOICES = [ + ('LOGIN_SUCCESS', 'Login exitoso'), + ('LOGIN_FAILED', 'Login fallido'), + ('LOGOUT', 'Logout'), + ('TOKEN_REFRESH', 'Token refrescado'), + ('ACCESS_DENIED', 'Acceso denegado'), + ('RATE_LIMITED', 'Rate limit excedido'), + ('ATTENDANCE_CREATE', 'Asistencia registrada'), + ('ATTENDANCE_UPDATE', 'Asistencia actualizada'), + ('ATTENDANCE_DELETE', 'Asistencia eliminada'), + ('EVENT_CREATE', 'Evento creado'), + ('EVENT_UPDATE', 'Evento actualizado'), + ('EVENT_DELETE', 'Evento eliminado'), + ('EXTERNAL_USER_REGISTER', 'Usuario externo registrado'), + ('EXTERNAL_USER_APPROVE', 'Usuario externo aprobado'), + ('EXTERNAL_USER_REJECT', 'Usuario externo rechazado'), + ('PERMISSION_CHANGE', 'Cambio de permisos'), + ('DATA_EXPORT', 'Exportación de datos'), + ('OTHER', 'Otro'), + ] + + # Campos principales + timestamp = models.DateTimeField(default=timezone.now, db_index=True) + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, db_index=True) + severity = models.CharField(max_length=20, choices=SEVERITY_CHOICES, default='INFO', db_index=True) + action = models.CharField(max_length=50, choices=ACTION_CHOICES) + + # Usuario relacionado (puede ser None para eventos anónimos) + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='audit_logs' + ) + username = models.CharField(max_length=150, blank=True) # Backup si el usuario se elimina + + # Información de la solicitud + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + path = models.CharField(max_length=255, blank=True) + method = models.CharField(max_length=10, blank=True) + + # Detalles del evento + message = models.TextField() + details = models.JSONField(default=dict, blank=True) # Datos adicionales en JSON + + # Resultados + success = models.BooleanField(default=True) + status_code = models.IntegerField(null=True, blank=True) + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['-timestamp', 'category']), + models.Index(fields=['-timestamp', 'severity']), + models.Index(fields=['-timestamp', 'user']), + models.Index(fields=['ip_address', '-timestamp']), + ] + verbose_name = 'Registro de Auditoría' + verbose_name_plural = 'Registros de Auditoría' + + def __str__(self): + return f"[{self.timestamp}] {self.get_category_display()} - {self.message}" + + @classmethod + def log(cls, category, action, message, request=None, user=None, + severity='INFO', success=True, status_code=None, **details): + """ + Método de conveniencia para crear logs de auditoría + + Args: + category: Categoría del evento + action: Acción realizada + message: Mensaje descriptivo + request: Objeto request de Django (opcional) + user: Usuario (opcional, se extrae del request si no se provee) + severity: Nivel de severidad + success: Si la operación fue exitosa + status_code: Código HTTP de respuesta + **details: Datos adicionales a guardar en JSON + """ + # Extraer información del request + ip_address = None + user_agent = '' + path = '' + method = '' + + if request: + # Obtener IP real (considerando proxies) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip_address = x_forwarded_for.split(',')[0].strip() + else: + ip_address = request.META.get('REMOTE_ADDR') + + user_agent = request.META.get('HTTP_USER_AGENT', '')[:500] # Limitar tamaño + path = request.path + method = request.method + + # Si no se proveyó usuario, intentar obtenerlo del request + if not user and hasattr(request, 'user') and request.user.is_authenticated: + user = request.user + + # Obtener username + username = user.username if user else 'Anonymous' + + # Sanitizar detalles (no guardar datos sensibles) + sanitized_details = cls._sanitize_details(details) + + # Crear el log + return cls.objects.create( + category=category, + severity=severity, + action=action, + user=user, + username=username, + ip_address=ip_address, + user_agent=user_agent, + path=path, + method=method, + message=message, + details=sanitized_details, + success=success, + status_code=status_code, + ) + + @staticmethod + def _sanitize_details(details): + """ + Eliminar datos sensibles de los detalles antes de guardar + """ + sensitive_keys = ['password', 'token', 'secret', 'key', 'authorization'] + sanitized = {} + + for key, value in details.items(): + # Verificar si la clave contiene información sensible + if any(sensitive in key.lower() for sensitive in sensitive_keys): + sanitized[key] = '***REDACTED***' + elif isinstance(value, dict): + sanitized[key] = AuditLog._sanitize_details(value) + else: + # Limitar tamaño de strings + if isinstance(value, str) and len(value) > 1000: + sanitized[key] = value[:1000] + '...[truncated]' + else: + sanitized[key] = value + + return sanitized + + @classmethod + def get_user_activity(cls, user, days=30): + """ + Obtener actividad de un usuario en los últimos N días + """ + from datetime import timedelta + cutoff = timezone.now() - timedelta(days=days) + return cls.objects.filter(user=user, timestamp__gte=cutoff) + + @classmethod + def get_failed_logins(cls, ip_address=None, hours=24): + """ + Obtener intentos fallidos de login + """ + from datetime import timedelta + cutoff = timezone.now() - timedelta(hours=hours) + + query = cls.objects.filter( + action='LOGIN_FAILED', + timestamp__gte=cutoff + ) + + if ip_address: + query = query.filter(ip_address=ip_address) + + return query + + @classmethod + def get_security_events(cls, severity='WARNING', hours=24): + """ + Obtener eventos de seguridad recientes + """ + from datetime import timedelta + cutoff = timezone.now() - timedelta(hours=hours) + + return cls.objects.filter( + category='SECURITY', + severity__in=[severity, 'ERROR', 'CRITICAL'], + timestamp__gte=cutoff + ) \ No newline at end of file diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..44df097 --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.1 on 2025-09-23 04:57 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('account_number', models.CharField(max_length=7, unique=True, validators=[django.core.validators.RegexValidator(message='El número de cuenta debe tener exactamente 7 dígitos.', regex='^\\d{7}$')], verbose_name='Número de cuenta')), + ('user_type', models.CharField(choices=[('student', 'Estudiante'), ('teacher', 'Maestro')], max_length=10, verbose_name='Tipo de usuario')), + ('full_name', models.CharField(max_length=200, verbose_name='Nombre completo')), + ('career', models.CharField(blank=True, max_length=100, null=True, verbose_name='Carrera')), + ('semester', models.IntegerField(blank=True, null=True, verbose_name='Semestre')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Teacher', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('minimum_attendance_percentage', models.FloatField(default=80.0)), + ('can_manage_events', models.BooleanField(default=True)), + ('user_profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile')), + ], + ), + ] diff --git a/backend/authentication/migrations/0002_auditlog.py b/backend/authentication/migrations/0002_auditlog.py new file mode 100644 index 0000000..b538c7a --- /dev/null +++ b/backend/authentication/migrations/0002_auditlog.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.6 on 2025-09-30 07:53 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('category', models.CharField(choices=[('AUTH', 'Autenticación'), ('ACCESS', 'Control de Acceso'), ('DATA', 'Modificación de Datos'), ('SECURITY', 'Evento de Seguridad'), ('SYSTEM', 'Sistema')], db_index=True, max_length=20)), + ('severity', models.CharField(choices=[('INFO', 'Información'), ('WARNING', 'Advertencia'), ('ERROR', 'Error'), ('CRITICAL', 'Crítico')], db_index=True, default='INFO', max_length=20)), + ('action', models.CharField(choices=[('LOGIN_SUCCESS', 'Login exitoso'), ('LOGIN_FAILED', 'Login fallido'), ('LOGOUT', 'Logout'), ('TOKEN_REFRESH', 'Token refrescado'), ('ACCESS_DENIED', 'Acceso denegado'), ('RATE_LIMITED', 'Rate limit excedido'), ('ATTENDANCE_CREATE', 'Asistencia registrada'), ('ATTENDANCE_UPDATE', 'Asistencia actualizada'), ('ATTENDANCE_DELETE', 'Asistencia eliminada'), ('EVENT_CREATE', 'Evento creado'), ('EVENT_UPDATE', 'Evento actualizado'), ('EVENT_DELETE', 'Evento eliminado'), ('EXTERNAL_USER_REGISTER', 'Usuario externo registrado'), ('EXTERNAL_USER_APPROVE', 'Usuario externo aprobado'), ('EXTERNAL_USER_REJECT', 'Usuario externo rechazado'), ('PERMISSION_CHANGE', 'Cambio de permisos'), ('DATA_EXPORT', 'Exportación de datos'), ('OTHER', 'Otro')], max_length=50)), + ('username', models.CharField(blank=True, max_length=150)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('path', models.CharField(blank=True, max_length=255)), + ('method', models.CharField(blank=True, max_length=10)), + ('message', models.TextField()), + ('details', models.JSONField(blank=True, default=dict)), + ('success', models.BooleanField(default=True)), + ('status_code', models.IntegerField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='audit_logs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Registro de Auditoría', + 'verbose_name_plural': 'Registros de Auditoría', + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['-timestamp', 'category'], name='authenticat_timesta_506bb9_idx'), models.Index(fields=['-timestamp', 'severity'], name='authenticat_timesta_5cf4a0_idx'), models.Index(fields=['-timestamp', 'user'], name='authenticat_timesta_721c38_idx'), models.Index(fields=['ip_address', '-timestamp'], name='authenticat_ip_addr_d5daa5_idx')], + }, + ), + ] diff --git a/backend/authentication/migrations/0003_rename_teacher_assistant_alter_userprofile_user_type.py b/backend/authentication/migrations/0003_rename_teacher_assistant_alter_userprofile_user_type.py new file mode 100644 index 0000000..1f6fc7d --- /dev/null +++ b/backend/authentication/migrations/0003_rename_teacher_assistant_alter_userprofile_user_type.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.6 on 2025-10-01 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_auditlog'), + ] + + operations = [ + migrations.RenameModel( + old_name='Teacher', + new_name='Assistant', + ), + migrations.AlterField( + model_name='userprofile', + name='user_type', + field=models.CharField(choices=[('student', 'Estudiante'), ('assistant', 'Asistente')], max_length=10, verbose_name='Tipo de usuario'), + ), + ] diff --git a/backend/authentication/migrations/0004_rename_assistant_asistente.py b/backend/authentication/migrations/0004_rename_assistant_asistente.py new file mode 100644 index 0000000..ffce762 --- /dev/null +++ b/backend/authentication/migrations/0004_rename_assistant_asistente.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-01 03:17 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_rename_teacher_assistant_alter_userprofile_user_type'), + ] + + operations = [ + migrations.RenameModel( + old_name='Assistant', + new_name='Asistente', + ), + ] diff --git a/backend/authentication/migrations/0005_remove_userprofile_career_and_more.py b/backend/authentication/migrations/0005_remove_userprofile_career_and_more.py new file mode 100644 index 0000000..0d658c5 --- /dev/null +++ b/backend/authentication/migrations/0005_remove_userprofile_career_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.6 on 2025-10-01 19:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0004_rename_assistant_asistente'), + ] + + operations = [ + migrations.RemoveField( + model_name='userprofile', + name='career', + ), + migrations.RemoveField( + model_name='userprofile', + name='semester', + ), + ] diff --git a/backend/authentication/migrations/0006_externaluser.py b/backend/authentication/migrations/0006_externaluser.py new file mode 100644 index 0000000..2814bc2 --- /dev/null +++ b/backend/authentication/migrations/0006_externaluser.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-10-02 00:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0005_remove_userprofile_career_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ExternalUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=200, verbose_name='Nombre completo')), + ('account_number', models.CharField(max_length=7, unique=True, verbose_name='Número de cuenta')), + ('status', models.CharField(choices=[('pending', 'Pendiente'), ('approved', 'Aprobado'), ('rejected', 'Rechazado')], default='approved', max_length=10, verbose_name='Estado de aprobación')), + ('rejection_reason', models.TextField(blank=True, null=True, verbose_name='Motivo de rechazo')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='authentication.userprofile', verbose_name='Aprobado por')), + ], + options={ + 'verbose_name': 'Usuario Externo', + 'verbose_name_plural': 'Usuarios Externos', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/authentication/migrations/0007_migrate_externaluser_data.py b/backend/authentication/migrations/0007_migrate_externaluser_data.py new file mode 100644 index 0000000..0ad0bb7 --- /dev/null +++ b/backend/authentication/migrations/0007_migrate_externaluser_data.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.6 on 2025-10-02 00:21 + +from django.db import migrations + + +def migrate_external_users(apps, schema_editor): + """Migrar datos de events.ExternalUser a authentication.ExternalUser""" + # Obtener los modelos de las versiones históricas + EventsExternalUser = apps.get_model('events', 'ExternalUser') + AuthExternalUser = apps.get_model('authentication', 'ExternalUser') + + # Copiar todos los usuarios externos + for old_user in EventsExternalUser.objects.all(): + AuthExternalUser.objects.create( + id=old_user.id, + full_name=old_user.full_name, + account_number=old_user.account_number, + status=old_user.status, + approved_by=old_user.approved_by, + rejection_reason=old_user.rejection_reason, + created_at=old_user.created_at, + processed_at=old_user.processed_at, + ) + + +def reverse_migrate_external_users(apps, schema_editor): + """Revertir migración de datos""" + AuthExternalUser = apps.get_model('authentication', 'ExternalUser') + EventsExternalUser = apps.get_model('events', 'ExternalUser') + + # Copiar de vuelta + for new_user in AuthExternalUser.objects.all(): + EventsExternalUser.objects.create( + id=new_user.id, + full_name=new_user.full_name, + account_number=new_user.account_number, + status=new_user.status, + approved_by=new_user.approved_by, + rejection_reason=new_user.rejection_reason, + created_at=new_user.created_at, + processed_at=new_user.processed_at, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0006_externaluser'), + ('events', '0004_auto_20251001_1400'), + ] + + operations = [ + migrations.RunPython(migrate_external_users, reverse_migrate_external_users), + ] diff --git a/backend/authentication/migrations/0008_remove_asistente_minimum_attendance_percentage_and_more.py b/backend/authentication/migrations/0008_remove_asistente_minimum_attendance_percentage_and_more.py new file mode 100644 index 0000000..e580ad4 --- /dev/null +++ b/backend/authentication/migrations/0008_remove_asistente_minimum_attendance_percentage_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.6 on 2025-10-02 04:55 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0007_migrate_externaluser_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='asistente', + name='minimum_attendance_percentage', + ), + migrations.CreateModel( + name='SystemConfiguration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('minimum_attendance_percentage', models.FloatField(default=80.0, help_text='Porcentaje mínimo de asistencia requerido para obtener constancia (0-100)', validators=[django.core.validators.MinValueValidator(0.0), django.core.validators.MaxValueValidator(100.0)], verbose_name='Porcentaje mínimo de asistencia')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Última actualización')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='authentication.userprofile', verbose_name='Actualizado por')), + ], + options={ + 'verbose_name': 'Configuración del Sistema', + 'verbose_name_plural': 'Configuración del Sistema', + }, + ), + ] diff --git a/backend/authentication/migrations/0009_assistantprofile_student.py b/backend/authentication/migrations/0009_assistantprofile_student.py new file mode 100644 index 0000000..4f540b1 --- /dev/null +++ b/backend/authentication/migrations/0009_assistantprofile_student.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.6 on 2025-10-03 18:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0008_remove_asistente_minimum_attendance_percentage_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AssistantProfile', + fields=[ + ], + options={ + 'verbose_name': 'Asistente (Perfil)', + 'verbose_name_plural': 'Asistentes (Perfiles)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authentication.userprofile',), + ), + migrations.CreateModel( + name='Student', + fields=[ + ], + options={ + 'verbose_name': 'Estudiante', + 'verbose_name_plural': 'Estudiantes', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authentication.userprofile',), + ), + ] diff --git a/backend/authentication/migrations/0009_assistantprofile_student_alter_asistente_options_and_more.py b/backend/authentication/migrations/0009_assistantprofile_student_alter_asistente_options_and_more.py new file mode 100644 index 0000000..29dde5f --- /dev/null +++ b/backend/authentication/migrations/0009_assistantprofile_student_alter_asistente_options_and_more.py @@ -0,0 +1,54 @@ +# Generated by Django 5.2.6 on 2025-10-04 03:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0008_remove_asistente_minimum_attendance_percentage_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AssistantProfile', + fields=[ + ], + options={ + 'verbose_name': 'Asistente (Perfil)', + 'verbose_name_plural': 'Asistentes (Perfiles)', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authentication.userprofile',), + ), + migrations.CreateModel( + name='Student', + fields=[ + ], + options={ + 'verbose_name': 'Estudiante', + 'verbose_name_plural': 'Estudiantes', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('authentication.userprofile',), + ), + migrations.AlterModelOptions( + name='asistente', + options={'verbose_name': 'Permiso de Asistente', 'verbose_name_plural': 'Permisos de Asistentes'}, + ), + migrations.AlterField( + model_name='asistente', + name='can_manage_events', + field=models.BooleanField(default=True, verbose_name='Puede gestionar eventos'), + ), + migrations.AlterField( + model_name='asistente', + name='user_profile', + field=models.OneToOneField(limit_choices_to={'user_type': 'assistant'}, on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile'), + ), + ] diff --git a/backend/authentication/migrations/0010_merge_20251003_2150.py b/backend/authentication/migrations/0010_merge_20251003_2150.py new file mode 100644 index 0000000..b119320 --- /dev/null +++ b/backend/authentication/migrations/0010_merge_20251003_2150.py @@ -0,0 +1,14 @@ +# Generated by Django 5.2.6 on 2025-10-04 03:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0009_assistantprofile_student'), + ('authentication', '0009_assistantprofile_student_alter_asistente_options_and_more'), + ] + + operations = [ + ] diff --git a/backend/authentication/migrations/0011_auto_create_assistant_permissions.py b/backend/authentication/migrations/0011_auto_create_assistant_permissions.py new file mode 100644 index 0000000..86f5a9d --- /dev/null +++ b/backend/authentication/migrations/0011_auto_create_assistant_permissions.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.6 on 2025-10-08 19:13 + +from django.db import migrations + + +def create_assistant_permissions(apps, schema_editor): + """Crear automáticamente permisos para todos los asistentes existentes""" + UserProfile = apps.get_model('authentication', 'UserProfile') + Asistente = apps.get_model('authentication', 'Asistente') + + # Obtener todos los UserProfile de tipo 'assistant' + assistants = UserProfile.objects.filter(user_type='assistant') + + # Crear registro de Asistente para cada uno si no existe + for assistant in assistants: + Asistente.objects.get_or_create( + user_profile=assistant, + defaults={'can_manage_events': True} + ) + + +def reverse_migration(apps, schema_editor): + """No hacer nada al revertir - mantener los permisos creados""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0010_merge_20251003_2150'), + ] + + operations = [ + migrations.RunPython(create_assistant_permissions, reverse_migration), + ] diff --git a/backend/authentication/migrations/0012_systemconfiguration_minutes_after_start_and_more.py b/backend/authentication/migrations/0012_systemconfiguration_minutes_after_start_and_more.py new file mode 100644 index 0000000..81ba08a --- /dev/null +++ b/backend/authentication/migrations/0012_systemconfiguration_minutes_after_start_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-10-09 02:43 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0011_auto_create_assistant_permissions'), + ] + + operations = [ + migrations.AddField( + model_name='systemconfiguration', + name='minutes_after_start', + field=models.IntegerField(default=25, help_text='Tiempo límite en minutos después del inicio del evento para registrar asistencia (0-120)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(120)], verbose_name='Minutos después del inicio'), + ), + migrations.AddField( + model_name='systemconfiguration', + name='minutes_before_event', + field=models.IntegerField(default=10, help_text='Tiempo en minutos antes del inicio del evento para permitir el registro de asistencia (0-60)', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(60)], verbose_name='Minutos antes del evento'), + ), + ] diff --git a/backend/authentication/migrations/__init__.py b/backend/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/authentication/models.py b/backend/authentication/models.py new file mode 100644 index 0000000..060a349 --- /dev/null +++ b/backend/authentication/models.py @@ -0,0 +1,217 @@ +from django.contrib.auth.models import User +from django.db import models +from django.core.validators import RegexValidator, MinValueValidator, MaxValueValidator +from django.utils import timezone +from django.core.exceptions import ValidationError + +class UserProfile(models.Model): + USER_TYPES = ( + ('student', 'Estudiante'), + ('assistant', 'Asistente'), + ) + + user = models.OneToOneField(User, on_delete=models.CASCADE) + account_number = models.CharField( + max_length=7, + unique=True, + validators=[RegexValidator( + regex=r'^\d{7}$', + message='El número de cuenta debe tener exactamente 7 dígitos.' + )], + verbose_name="Número de cuenta" + ) + user_type = models.CharField( + max_length=10, + choices=USER_TYPES, + blank=False, + null=False, + verbose_name="Tipo de usuario" + ) + full_name = models.CharField( + max_length=200, + verbose_name="Nombre completo" + ) + + def __str__(self): + return f"{self.account_number} - {self.full_name}" + +class Asistente(models.Model): + user_profile = models.OneToOneField(UserProfile, on_delete=models.CASCADE, limit_choices_to={'user_type': 'assistant'}) + can_manage_events = models.BooleanField(default=True, verbose_name="Puede gestionar eventos") + + class Meta: + verbose_name = "Permiso de Asistente" + verbose_name_plural = "Permisos de Asistentes" + + def clean(self): + """Validar que el user_profile sea de tipo assistant""" + from django.core.exceptions import ValidationError + if self.user_profile and self.user_profile.user_type != 'assistant': + raise ValidationError({ + 'user_profile': 'Solo se pueden asignar permisos a usuarios de tipo Asistente. Este usuario es un Estudiante.' + }) + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + + def __str__(self): + return f"Permisos: {self.user_profile.full_name}" + +class ExternalUser(models.Model): + """Usuarios externos""" + APPROVAL_STATUS = ( + ('pending', 'Pendiente'), + ('approved', 'Aprobado'), + ('rejected', 'Rechazado'), + ) + + full_name = models.CharField(max_length=200, verbose_name="Nombre completo") + account_number = models.CharField( + max_length=7, + unique=True, + verbose_name="Número de cuenta" + ) + status = models.CharField( + max_length=10, + choices=APPROVAL_STATUS, + default='approved', + verbose_name="Estado de aprobación" + ) + approved_by = models.ForeignKey( + UserProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Aprobado por" + ) + rejection_reason = models.TextField( + blank=True, + null=True, + verbose_name="Motivo de rechazo" + ) + created_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + verbose_name = "Usuario Externo" + verbose_name_plural = "Usuarios Externos" + ordering = ['-created_at'] + + @property + def is_approved(self): + return self.status == 'approved' + + @property + def is_pending(self): + return self.status == 'pending' + + def approve(self, approved_by_user): + """Aprobar usuario externo""" + self.status = 'approved' + self.approved_by = approved_by_user + self.processed_at = timezone.now() + self.save() + + def reject(self, rejected_by_user, reason=""): + """Rechazar usuario externo""" + self.status = 'rejected' + self.approved_by = rejected_by_user + self.rejection_reason = reason + self.processed_at = timezone.now() + self.save() + + def __str__(self): + status_icons = { + 'pending': '⏳', + 'approved': '✅', + 'rejected': '❌' + } + icon = status_icons.get(self.status, '?') + return f"{icon} {self.full_name} - {self.account_number}" + + +class SystemConfiguration(models.Model): + """Configuración global del sistema""" + minimum_attendance_percentage = models.FloatField( + default=80.0, + validators=[MinValueValidator(0.0), MaxValueValidator(100.0)], + verbose_name="Porcentaje mínimo de asistencia", + help_text="Porcentaje mínimo de asistencia requerido para obtener constancia (0-100)" + ) + + # Configuración de tiempos de registro + minutes_before_event = models.IntegerField( + default=10, + validators=[MinValueValidator(0), MaxValueValidator(60)], + verbose_name="Minutos antes del evento", + help_text="Tiempo en minutos antes del inicio del evento para permitir el registro de asistencia (0-60)" + ) + + minutes_after_start = models.IntegerField( + default=25, + validators=[MinValueValidator(0), MaxValueValidator(120)], + verbose_name="Minutos después del inicio", + help_text="Tiempo límite en minutos después del inicio del evento para registrar asistencia (0-120)" + ) + + updated_at = models.DateTimeField(auto_now=True, verbose_name="Última actualización") + updated_by = models.ForeignKey( + UserProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name="Actualizado por" + ) + + class Meta: + verbose_name = "Configuración del Sistema" + verbose_name_plural = "Configuración del Sistema" + + def save(self, *args, **kwargs): + # Asegurar que solo exista una instancia de configuración + if not self.pk and SystemConfiguration.objects.exists(): + raise ValidationError("Solo puede existir una configuración del sistema.") + super().save(*args, **kwargs) + + @classmethod + def get_config(cls): + """Obtener o crear la configuración del sistema""" + config, created = cls.objects.get_or_create( + pk=1, + defaults={ + 'minimum_attendance_percentage': 80.0, + 'minutes_before_event': 10, + 'minutes_after_start': 25 + } + ) + return config + + def __str__(self): + return f"Configuración del Sistema - Asistencia mínima: {self.minimum_attendance_percentage}%" + + +# ===== PROXY MODELS PARA SEPARAR ESTUDIANTES Y ASISTENTES EN EL ADMIN ===== + +class Student(UserProfile): + """Proxy model para estudiantes""" + class Meta: + proxy = True + verbose_name = "Estudiante" + verbose_name_plural = "Estudiantes" + + def save(self, *args, **kwargs): + self.user_type = 'student' + super().save(*args, **kwargs) + + +class AssistantProfile(UserProfile): + """Proxy model para asistentes""" + class Meta: + proxy = True + verbose_name = "Asistente (Perfil)" + verbose_name_plural = "Asistentes (Perfiles)" + + def save(self, *args, **kwargs): + self.user_type = 'assistant' + super().save(*args, **kwargs) \ No newline at end of file diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py new file mode 100644 index 0000000..5c3d702 --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,64 @@ +from rest_framework import serializers +from django.contrib.auth import authenticate +from django.contrib.auth.models import User +from .models import UserProfile +import re + +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ['account_number', 'user_type', 'full_name'] + +class UserSerializer(serializers.ModelSerializer): + profile = UserProfileSerializer(source='userprofile', read_only=True) + + class Meta: + model = User + fields = ['id', 'username', 'email', 'first_name', 'last_name', 'profile', 'is_staff', 'is_superuser'] + +class LoginSerializer(serializers.Serializer): + account_number = serializers.CharField(max_length=20) + + def validate_account_number(self, value): + # Validar formato para números de cuenta (7 dígitos) + if not re.match(r'^\d{7}$', value): + raise serializers.ValidationError('El número de cuenta debe tener exactamente 7 dígitos.') + return value + + def validate(self, data): + account_number = data.get('account_number') + + if not account_number: + raise serializers.ValidationError('Debe incluir número de cuenta.') + + # Primero buscar en usuarios regulares (estudiantes/asistentes) + try: + profile = UserProfile.objects.get(account_number=account_number) + user = profile.user + if not user.is_active: + raise serializers.ValidationError('Esta cuenta está desactivada.') + data['user'] = user + return data + except UserProfile.DoesNotExist: + pass + + # Si no es usuario regular, buscar en usuarios externos + from authentication.models import ExternalUser + try: + external_user = ExternalUser.objects.get(account_number=account_number) + if external_user.status != 'approved': + raise serializers.ValidationError('Usuario externo no aprobado.') + + # Crear o buscar usuario de Django asociado + user, created = User.objects.get_or_create( + username=f'ext_{account_number}', + defaults={'first_name': external_user.full_name} + ) + data['user'] = user + data['external_user'] = external_user + return data + + except ExternalUser.DoesNotExist: + raise serializers.ValidationError('Número de cuenta no encontrado.') + + return data \ No newline at end of file diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py new file mode 100644 index 0000000..b812bc9 --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), + path('profile/', views.user_profile, name='profile'), + path('check-auth/', views.check_auth_status, name='check_auth'), + path('token/refresh/', views.refresh_token, name='token_refresh'), + path('token/verify/', views.verify_token, name='token_verify'), + path('system-config/', views.get_system_config, name='system_config'), +] \ No newline at end of file diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 0000000..fe73fb9 --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,136 @@ +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from django.contrib.auth import login, logout +from django.contrib.auth.models import User +from rest_framework_simplejwt.tokens import RefreshToken +from django_ratelimit.decorators import ratelimit +from django_ratelimit.exceptions import Ratelimited +from .models import UserProfile, Asistente +from .serializers import LoginSerializer, UserSerializer +from .audit import AuditLog + +@api_view(['POST']) +@permission_classes([AllowAny]) +@ratelimit(key='ip', rate='5/m', method='POST', block=True) +def login_view(request): + """Login con rate limiting: 5 intentos por minuto por IP""" + serializer = LoginSerializer(data=request.data) + account_number = request.data.get('account_number', 'unknown') + + if serializer.is_valid(): + user = serializer.validated_data['user'] + + # Generar tokens JWT + refresh = RefreshToken.for_user(user) + + # Log exitoso + AuditLog.log( + category='AUTH', + action='LOGIN_SUCCESS', + message=f'Login exitoso para cuenta {account_number}', + request=request, + user=user, + severity='INFO', + success=True, + status_code=200, + account_number=account_number + ) + + return Response({ + 'message': 'Login exitoso', + 'user': UserSerializer(user).data, + 'tokens': { + 'access': str(refresh.access_token), + 'refresh': str(refresh) + } + }) + + # Log fallido + AuditLog.log( + category='AUTH', + action='LOGIN_FAILED', + message=f'Intento de login fallido para cuenta {account_number}', + request=request, + severity='WARNING', + success=False, + status_code=400, + account_number=account_number, + errors=str(serializer.errors) + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def logout_view(request): + # Log logout + AuditLog.log( + category='AUTH', + action='LOGOUT', + message=f'Logout de usuario {request.user.username}', + request=request, + user=request.user, + severity='INFO', + success=True, + status_code=200 + ) + + logout(request) + return Response({'message': 'Logout exitoso'}) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def user_profile(request): + return Response(UserSerializer(request.user).data) + +@api_view(['GET']) +@permission_classes([AllowAny]) +@ratelimit(key='ip', rate='30/m', method='GET', block=True) +def check_auth_status(request): + """Verificar estado de autenticación: 30 consultas por minuto por IP""" + if request.user.is_authenticated: + return Response({ + 'is_authenticated': True, + 'user': UserSerializer(request.user).data + }) + return Response({'is_authenticated': False}) + +@api_view(['POST']) +@permission_classes([AllowAny]) +@ratelimit(key='ip', rate='10/m', method='POST', block=True) +def refresh_token(request): + """Refrescar access token: 10 intentos por minuto por IP""" + from rest_framework_simplejwt.exceptions import TokenError, InvalidToken + from rest_framework_simplejwt.serializers import TokenRefreshSerializer + + serializer = TokenRefreshSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + return Response(serializer.validated_data, status=status.HTTP_200_OK) + except TokenError as e: + return Response({'error': 'Token inválido o expirado'}, status=status.HTTP_401_UNAUTHORIZED) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def verify_token(request): + """Verificar si el token actual es válido""" + return Response({ + 'valid': True, + 'user': UserSerializer(request.user).data + }) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_system_config(request): + """Obtener la configuración global del sistema""" + from .models import SystemConfiguration + + config = SystemConfiguration.get_config() + + return Response({ + 'minimum_attendance_percentage': config.minimum_attendance_percentage, + 'minutes_before_event': config.minutes_before_event, + 'minutes_after_start': config.minutes_after_start + }) \ No newline at end of file diff --git a/backend/events/__init__.py b/backend/events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/admin.py b/backend/events/admin.py new file mode 100644 index 0000000..9b1af14 --- /dev/null +++ b/backend/events/admin.py @@ -0,0 +1,67 @@ +from django.contrib import admin +from django.utils.html import format_html +from import_export import resources +from import_export.admin import ImportExportModelAdmin +from .models import Event + + +class EventResource(resources.ModelResource): + """Recurso para importar/exportar eventos""" + + class Meta: + model = Event + fields = ('title', 'speaker', 'date', 'start_time', 'end_time', 'event_type', + 'modality', 'location', 'description', 'is_active') + import_id_fields = ['title', 'date', 'start_time'] + skip_unchanged = True + report_skipped = True + + def before_import_row(self, row, **kwargs): + """Limpiar datos antes de importar""" + row['title'] = str(row.get('title', '')).strip() + row['speaker'] = str(row.get('speaker', '')).strip() + row['location'] = str(row.get('location', '')).strip() + + + +@admin.register(Event) +class EventAdmin(ImportExportModelAdmin): + resource_class = EventResource + list_display = ['title', 'speaker', 'date', 'start_time', 'modality', 'location', 'is_active'] + list_filter = ['event_type', 'modality', 'date', 'is_active'] + search_fields = ['title', 'speaker', 'location'] + date_hierarchy = 'date' + ordering = ['date', 'start_time'] + readonly_fields = ['created_at'] + actions = ['export_selected_events'] + + def get_import_formats(self): + """Formatos permitidos para importar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def get_export_formats(self): + """Formatos permitidos para exportar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def export_selected_events(self, request, queryset): + """Acción para exportar eventos seleccionados""" + from django.http import HttpResponse + resource = EventResource() + dataset = resource.export(queryset) + + from import_export.formats.base_formats import XLSX + xlsx_format = XLSX() + export_data = xlsx_format.export_data(dataset) + + response = HttpResponse( + export_data, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="eventos.xlsx"' + + self.message_user(request, f'Se exportaron {queryset.count()} eventos.') + return response + + export_selected_events.short_description = "📊 Exportar eventos seleccionados" \ No newline at end of file diff --git a/backend/events/apps.py b/backend/events/apps.py new file mode 100644 index 0000000..20f48f2 --- /dev/null +++ b/backend/events/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'events' diff --git a/backend/events/migrations/0001_initial.py b/backend/events/migrations/0001_initial.py new file mode 100644 index 0000000..8b71e98 --- /dev/null +++ b/backend/events/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.2.6 on 2025-09-24 00:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Título de la ponencia')), + ('description', models.TextField(verbose_name='Descripción')), + ('event_type', models.CharField(choices=[('conference', 'Conferencia'), ('workshop', 'Taller'), ('panel', 'Mesa Redonda'), ('seminar', 'Seminario')], default='conference', max_length=20, verbose_name='Tipo de evento')), + ('modality', models.CharField(choices=[('presencial', 'Presencial'), ('online', 'En línea'), ('hybrid', 'Híbrido')], default='presencial', max_length=15, verbose_name='Modalidad')), + ('speaker', models.CharField(max_length=200, verbose_name='Ponente')), + ('date', models.DateField(verbose_name='Fecha')), + ('start_time', models.TimeField(verbose_name='Hora de inicio')), + ('end_time', models.TimeField(verbose_name='Hora de fin')), + ('location', models.CharField(help_text='Para eventos presenciales: aula/salón. Para eventos en línea: enlace o plataforma', max_length=200, verbose_name='Ubicación/Plataforma')), + ('max_capacity', models.IntegerField(default=100, verbose_name='Capacidad máxima')), + ('is_active', models.BooleanField(default=True, verbose_name='Evento activo')), + ('requires_registration', models.BooleanField(default=False, verbose_name='Requiere registro previo')), + ('meeting_link', models.URLField(blank=True, help_text='Para eventos en línea o híbridos', null=True, verbose_name='Enlace de reunión')), + ('meeting_id', models.CharField(blank=True, help_text='Código de sala/reunión', max_length=50, null=True, verbose_name='ID de reunión')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(limit_choices_to={'user_type': 'teacher'}, on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile', verbose_name='Creado por')), + ], + options={ + 'verbose_name': 'Evento/Ponencia', + 'verbose_name_plural': 'Eventos/Ponencias', + 'ordering': ['date', 'start_time'], + }, + ), + migrations.CreateModel( + name='ExternalUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=200, verbose_name='Nombre completo')), + ('email', models.EmailField(max_length=254, verbose_name='Correo electrónico')), + ('phone', models.CharField(blank=True, max_length=15, null=True, verbose_name='Teléfono')), + ('institution', models.CharField(max_length=200, verbose_name='Institución de procedencia')), + ('position', models.CharField(blank=True, max_length=100, null=True, verbose_name='Cargo/Posición')), + ('reason', models.TextField(verbose_name='Motivo de asistencia')), + ('temporary_id', models.CharField(max_length=20, unique=True, verbose_name='ID temporal')), + ('status', models.CharField(choices=[('pending', 'Pendiente'), ('approved', 'Aprobado'), ('rejected', 'Rechazado')], default='pending', max_length=10, verbose_name='Estado de aprobación')), + ('rejection_reason', models.TextField(blank=True, null=True, verbose_name='Motivo de rechazo')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('processed_at', models.DateTimeField(blank=True, null=True)), + ('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='authentication.userprofile', verbose_name='Aprobado por')), + ], + options={ + 'verbose_name': 'Usuario Externo', + 'verbose_name_plural': 'Usuarios Externos', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/events/migrations/0002_alter_event_created_by.py b/backend/events/migrations/0002_alter_event_created_by.py new file mode 100644 index 0000000..5b7a6d6 --- /dev/null +++ b/backend/events/migrations/0002_alter_event_created_by.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-01 02:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_rename_teacher_assistant_alter_userprofile_user_type'), + ('events', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='created_by', + field=models.ForeignKey(limit_choices_to={'user_type': 'assistant'}, on_delete=django.db.models.deletion.CASCADE, to='authentication.userprofile', verbose_name='Creado por'), + ), + ] diff --git a/backend/events/migrations/0003_remove_externaluser_email_and_more.py b/backend/events/migrations/0003_remove_externaluser_email_and_more.py new file mode 100644 index 0000000..91587c0 --- /dev/null +++ b/backend/events/migrations/0003_remove_externaluser_email_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-10-01 19:09 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0002_alter_event_created_by'), + ] + + operations = [ + migrations.RemoveField( + model_name='externaluser', + name='email', + ), + migrations.RemoveField( + model_name='externaluser', + name='institution', + ), + migrations.RemoveField( + model_name='externaluser', + name='phone', + ), + migrations.RemoveField( + model_name='externaluser', + name='position', + ), + migrations.RemoveField( + model_name='externaluser', + name='reason', + ), + ] diff --git a/backend/events/migrations/0004_auto_20251001_1400.py b/backend/events/migrations/0004_auto_20251001_1400.py new file mode 100644 index 0000000..be832c6 --- /dev/null +++ b/backend/events/migrations/0004_auto_20251001_1400.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.6 on 2025-10-01 20:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_remove_externaluser_email_and_more'), + ] + + operations = [ + # Renombrar temporary_id a account_number + migrations.RenameField( + model_name='externaluser', + old_name='temporary_id', + new_name='account_number', + ), + # Cambiar max_length de account_number de 20 a 7 + migrations.AlterField( + model_name='externaluser', + name='account_number', + field=models.CharField(max_length=7, unique=True, verbose_name='Número de cuenta'), + ), + # Cambiar el default de status a 'approved' + migrations.AlterField( + model_name='externaluser', + name='status', + field=models.CharField(choices=[('pending', 'Pendiente'), ('approved', 'Aprobado'), ('rejected', 'Rechazado')], default='approved', max_length=10, verbose_name='Estado de aprobación'), + ), + ] diff --git a/backend/events/migrations/0005_delete_externaluser.py b/backend/events/migrations/0005_delete_externaluser.py new file mode 100644 index 0000000..d906efa --- /dev/null +++ b/backend/events/migrations/0005_delete_externaluser.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-02 00:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('attendance', '0003_alter_attendance_external_user'), + ('events', '0004_auto_20251001_1400'), + ] + + operations = [ + migrations.DeleteModel( + name='ExternalUser', + ), + ] diff --git a/backend/events/migrations/0006_alter_event_created_by.py b/backend/events/migrations/0006_alter_event_created_by.py new file mode 100644 index 0000000..0d326d2 --- /dev/null +++ b/backend/events/migrations/0006_alter_event_created_by.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.6 on 2025-10-08 19:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0010_merge_20251003_2150'), + ('events', '0005_delete_externaluser'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='created_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='authentication.userprofile', verbose_name='Creado por'), + ), + ] diff --git a/backend/events/migrations/0007_remove_event_created_by.py b/backend/events/migrations/0007_remove_event_created_by.py new file mode 100644 index 0000000..ff02d9e --- /dev/null +++ b/backend/events/migrations/0007_remove_event_created_by.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-08 19:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_alter_event_created_by'), + ] + + operations = [ + migrations.RemoveField( + model_name='event', + name='created_by', + ), + ] diff --git a/backend/events/migrations/__init__.py b/backend/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/models.py b/backend/events/models.py new file mode 100644 index 0000000..4469361 --- /dev/null +++ b/backend/events/models.py @@ -0,0 +1,136 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.utils import timezone +from authentication.models import UserProfile + +class Event(models.Model): + EVENT_TYPES = ( + ('conference', 'Conferencia'), + ('workshop', 'Taller'), + ('panel', 'Mesa Redonda'), + ('seminar', 'Seminario'), + ) + + MODALITY_CHOICES = ( + ('presencial', 'Presencial'), + ('online', 'En línea'), + ('hybrid', 'Híbrido'), + ) + + title = models.CharField( + max_length=200, + verbose_name="Título de la ponencia" + ) + description = models.TextField( + verbose_name="Descripción" + ) + event_type = models.CharField( + max_length=20, + choices=EVENT_TYPES, + default='conference', + verbose_name="Tipo de evento" + ) + modality = models.CharField( + max_length=15, + choices=MODALITY_CHOICES, + default='presencial', + verbose_name="Modalidad" + ) + speaker = models.CharField( + max_length=200, + verbose_name="Ponente" + ) + date = models.DateField(verbose_name="Fecha") + start_time = models.TimeField(verbose_name="Hora de inicio") + end_time = models.TimeField(verbose_name="Hora de fin") + location = models.CharField( + max_length=200, + verbose_name="Ubicación/Plataforma", + help_text="Para eventos presenciales: aula/salón. Para eventos en línea: enlace o plataforma" + ) + max_capacity = models.IntegerField( + default=100, + verbose_name="Capacidad máxima" + ) + is_active = models.BooleanField( + default=True, + verbose_name="Evento activo" + ) + requires_registration = models.BooleanField( + default=False, + verbose_name="Requiere registro previo" + ) + meeting_link = models.URLField( + blank=True, + null=True, + verbose_name="Enlace de reunión", + help_text="Para eventos en línea o híbridos" + ) + meeting_id = models.CharField( + max_length=50, + blank=True, + null=True, + verbose_name="ID de reunión", + help_text="Código de sala/reunión" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['date', 'start_time'] + verbose_name = "Evento/Ponencia" + verbose_name_plural = "Eventos/Ponencias" + + def clean(self): + # Validar que la hora de fin sea después de la hora de inicio + if self.start_time and self.end_time: + if self.start_time >= self.end_time: + raise ValidationError("La hora de fin debe ser posterior a la hora de inicio.") + + # Validar que la fecha/hora del evento no haya terminado + if self.date and self.end_time: + from datetime import datetime + event_end = datetime.combine(self.date, self.end_time) + + # Hacer timezone-aware si es necesario + if timezone.is_naive(event_end): + event_end = timezone.make_aware(event_end) + + # Solo validar si el evento ya terminó completamente + if event_end < timezone.now(): + raise ValidationError("No se puede crear un evento que ya finalizó.") + + # Validar que eventos en línea tengan enlace + if self.modality in ['online', 'hybrid'] and not self.meeting_link: + raise ValidationError("Los eventos en línea o híbridos requieren un enlace de reunión.") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + @property + def duration_minutes(self): + """Duración del evento en minutos""" + if self.start_time and self.end_time: + start_datetime = timezone.datetime.combine(self.date, self.start_time) + end_datetime = timezone.datetime.combine(self.date, self.end_time) + duration = end_datetime - start_datetime + return duration.total_seconds() / 60 + return 0 + + @property + def is_happening_now(self): + """Verifica si el evento está ocurriendo ahora""" + now = timezone.now() + if self.date == now.date(): + current_time = now.time() + return self.start_time <= current_time <= self.end_time + return False + + @property + def is_online(self): + """Verifica si el evento es en línea""" + return self.modality in ['online', 'hybrid'] + + def __str__(self): + modality_icon = "🖥️" if self.is_online else "🏢" + return f"{modality_icon} {self.title} - {self.date} {self.start_time}" \ No newline at end of file diff --git a/backend/events/serializers.py b/backend/events/serializers.py new file mode 100644 index 0000000..5c80a74 --- /dev/null +++ b/backend/events/serializers.py @@ -0,0 +1,20 @@ +from rest_framework import serializers +from .models import Event +from authentication.models import ExternalUser + +class EventSerializer(serializers.ModelSerializer): + class Meta: + model = Event + fields = [ + 'id', 'title', 'description', 'event_type', 'modality', + 'speaker', 'date', 'start_time', 'end_time', 'location', + 'max_capacity', 'is_active', 'meeting_link' + ] + +class ExternalUserSerializer(serializers.ModelSerializer): + class Meta: + model = ExternalUser + fields = [ + 'id', 'full_name', 'account_number', 'status', + 'created_at', 'processed_at' + ] \ No newline at end of file diff --git a/backend/events/urls.py b/backend/events/urls.py new file mode 100644 index 0000000..a950d5c --- /dev/null +++ b/backend/events/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.EventListView.as_view(), name='event_list'), + path('external/register/', views.register_external_user, name='register_external'), + path('external/search/', views.search_external_users, name='search_external'), + path('external//approve/', views.approve_external_user, name='approve_external'), +] \ No newline at end of file diff --git a/backend/events/views.py b/backend/events/views.py new file mode 100644 index 0000000..f7d1fc7 --- /dev/null +++ b/backend/events/views.py @@ -0,0 +1,167 @@ +from rest_framework import generics, status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.response import Response +from django_ratelimit.decorators import ratelimit +from django.utils import timezone +from django.db import models +from .models import Event +from authentication.models import ExternalUser +from .serializers import EventSerializer, ExternalUserSerializer +import re + +class EventListView(generics.ListCreateAPIView): + queryset = Event.objects.filter(is_active=True) + serializer_class = EventSerializer + + def get_permissions(self): + """Permitir lectura pública, pero creación solo para autenticados""" + if self.request.method == 'POST': + return [IsAuthenticated()] + return [AllowAny()] + + def get_queryset(self): + return Event.objects.filter(is_active=True).order_by('date', 'start_time') + + def perform_create(self, serializer): + """Verificar que solo asistentes puedan crear eventos""" + try: + user_profile = self.request.user.userprofile + if user_profile.user_type != 'assistant': + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied('Solo los asistentes pueden crear eventos') + except: + from rest_framework.exceptions import PermissionDenied + raise PermissionDenied('Usuario sin perfil válido') + + serializer.save() + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='30/m', method='POST', block=True) +def register_external_user(request): + """Crear un usuario externo - Solo asistentes: 30 creaciones por minuto""" + # Verificar que el usuario sea asistente + try: + user_profile = request.user.userprofile + if user_profile.user_type != 'assistant': + return Response({'error': 'Solo los asistentes pueden crear usuarios externos'}, status=403) + except: + return Response({'error': 'Usuario sin perfil válido'}, status=403) + + data = request.data + account_number = data.get('account_number') + full_name = data.get('full_name') + + if not account_number or not full_name: + return Response({'error': 'Número de cuenta y nombre completo son requeridos'}, status=400) + + # Validar formato de número de cuenta (7 dígitos) + if not re.match(r'^\d{7}$', account_number): + return Response({'error': 'El número de cuenta debe tener exactamente 7 dígitos'}, status=400) + + # Verificar que no exista en usuarios regulares + from authentication.models import UserProfile + if UserProfile.objects.filter(account_number=account_number).exists(): + return Response({'error': 'Este número de cuenta ya está registrado como usuario regular'}, status=400) + + # Verificar que no exista en usuarios externos + if ExternalUser.objects.filter(account_number=account_number).exists(): + return Response({'error': 'Este número de cuenta ya está registrado como usuario externo'}, status=400) + + try: + external_user = ExternalUser.objects.create( + full_name=full_name, + account_number=account_number, + status='approved', + approved_by=user_profile + ) + + return Response({ + 'message': 'Usuario externo creado exitosamente', + 'account_number': account_number, + 'full_name': full_name, + 'status': 'approved' + }, status=status.HTTP_201_CREATED) + + except Exception as e: + return Response({ + 'error': f'Error al crear usuario externo: {str(e)}' + }, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='60/m', method='GET', block=True) +def search_external_users(request): + """Buscar usuarios externos por nombre o número de cuenta - Solo asistentes: 60 búsquedas por minuto""" + # Verificar que el usuario sea asistente + try: + user_profile = request.user.userprofile + if user_profile.user_type != 'assistant': + return Response({'error': 'Solo los asistentes pueden buscar usuarios externos'}, status=403) + except: + return Response({'error': 'Usuario sin perfil válido'}, status=403) + + search_query = request.GET.get('q', '').strip() + + if not search_query: + return Response({'error': 'Parámetro de búsqueda "q" requerido'}, status=400) + + # Buscar por número de cuenta o nombre (contiene) + external_users = ExternalUser.objects.filter( + status='approved' + ).filter( + models.Q(account_number__icontains=search_query) | + models.Q(full_name__icontains=search_query) + )[:10] # Limitar a 10 resultados + + results = [] + for user in external_users: + results.append({ + 'id': user.id, + 'account_number': user.account_number, + 'full_name': user.full_name, + 'created_at': user.created_at.strftime('%Y-%m-%d') + }) + + return Response({ + 'count': len(results), + 'results': results + }) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@ratelimit(key='user', rate='30/m', method='POST', block=True) +def approve_external_user(request, user_id): + """Aprobar/rechazar usuario externo - Solo asistentes: 30 acciones por minuto""" + # Verificar que el usuario sea asistente + try: + user_profile = request.user.userprofile + if user_profile.user_type != 'assistant': + return Response({'error': 'Solo los asistentes pueden aprobar usuarios externos'}, status=403) + except: + return Response({'error': 'Usuario sin perfil válido'}, status=403) + + try: + external_user = ExternalUser.objects.get(id=user_id) + action = request.data.get('action') # 'approve' o 'reject' + + if action == 'approve': + external_user.status = 'approved' + external_user.approved_by = user_profile + external_user.processed_at = timezone.now() + external_user.save() + return Response({'message': 'Usuario aprobado'}) + elif action == 'reject': + reason = request.data.get('reason', '') + external_user.status = 'rejected' + external_user.approved_by = user_profile + external_user.rejection_reason = reason + external_user.processed_at = timezone.now() + external_user.save() + return Response({'message': 'Usuario rechazado'}) + else: + return Response({'error': 'Acción inválida'}, status=400) + + except ExternalUser.DoesNotExist: + return Response({'error': 'Usuario no encontrado'}, status=404) \ No newline at end of file diff --git a/backend/mac_attendance/__init__.py b/backend/mac_attendance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/mac_attendance/asgi.py b/backend/mac_attendance/asgi.py new file mode 100644 index 0000000..53c276f --- /dev/null +++ b/backend/mac_attendance/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for mac_attendance project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings') + +application = get_asgi_application() diff --git a/backend/mac_attendance/exceptions.py b/backend/mac_attendance/exceptions.py new file mode 100644 index 0000000..9a06056 --- /dev/null +++ b/backend/mac_attendance/exceptions.py @@ -0,0 +1,26 @@ +""" +Manejadores de excepciones personalizados para el proyecto +""" +from rest_framework.views import exception_handler +from rest_framework.response import Response +from rest_framework import status +from django_ratelimit.exceptions import Ratelimited + + +def custom_exception_handler(exc, context): + """ + Manejador de excepciones personalizado que incluye soporte para + excepciones de rate limiting + """ + # Manejar excepciones de rate limiting + if isinstance(exc, Ratelimited): + data = { + 'error': 'Demasiadas solicitudes. Por favor, intenta más tarde.', + 'detail': 'Has excedido el límite de solicitudes permitidas. Espera unos momentos antes de intentar nuevamente.' + } + return Response(data, status=status.HTTP_429_TOO_MANY_REQUESTS) + + # Llamar al manejador de excepciones por defecto de DRF para otras excepciones + response = exception_handler(exc, context) + + return response \ No newline at end of file diff --git a/backend/mac_attendance/middleware.py b/backend/mac_attendance/middleware.py new file mode 100644 index 0000000..d01c274 --- /dev/null +++ b/backend/mac_attendance/middleware.py @@ -0,0 +1,126 @@ +""" +Middleware de auditoría para capturar eventos de seguridad +""" +import re +from django.utils.deprecation import MiddlewareMixin +from authentication.audit import AuditLog +from django_ratelimit.exceptions import Ratelimited + + +class AuditMiddleware: + """ + Middleware para registrar eventos de seguridad automáticamente + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + # Procesar la solicitud + response = self.get_response(request) + + # Registrar eventos de seguridad basados en código de respuesta + self._log_security_events(request, response) + + return response + + def _log_security_events(self, request, response): + """ + Registrar eventos de seguridad basados en códigos de respuesta HTTP + """ + status_code = response.status_code + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + + # 401 Unauthorized - Acceso no autorizado + if status_code == 401: + AuditLog.log( + category='SECURITY', + action='ACCESS_DENIED', + message=f'Intento de acceso no autorizado a {request.path}', + request=request, + user=user, + severity='WARNING', + success=False, + status_code=401, + path=request.path, + method=request.method + ) + + # 403 Forbidden - Permisos insuficientes + elif status_code == 403: + AuditLog.log( + category='SECURITY', + action='ACCESS_DENIED', + message=f'Acceso denegado (permisos insuficientes) a {request.path}', + request=request, + user=user, + severity='WARNING', + success=False, + status_code=403, + path=request.path, + method=request.method + ) + + # 429 Too Many Requests - Rate limit exceeded + elif status_code == 429: + AuditLog.log( + category='SECURITY', + action='RATE_LIMITED', + message=f'Rate limit excedido para {request.path}', + request=request, + user=user, + severity='WARNING', + success=False, + status_code=429, + path=request.path, + method=request.method + ) + + def process_exception(self, request, exception): + """ + Capturar excepciones de rate limiting + """ + if isinstance(exception, Ratelimited): + user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None + + AuditLog.log( + category='SECURITY', + action='RATE_LIMITED', + message=f'Rate limit exception en {request.path}', + request=request, + user=user, + severity='WARNING', + success=False, + status_code=429, + path=request.path, + method=request.method, + exception=str(exception) + ) + + return None # Permitir que Django maneje la excepción normalmente + + +class DisableCSRFOnAPIMiddleware(MiddlewareMixin): + """ + Middleware para deshabilitar CSRF en rutas de API + Ya que usamos JWT para autenticación en la API REST + """ + + def process_request(self, request): + """ + Marcar requests de API como exentos de CSRF + """ + from django.conf import settings + + # Obtener patrones de exención de CSRF + csrf_exempt_urls = getattr(settings, 'CSRF_EXEMPT_URLS', []) + + # Verificar si la ruta actual debe estar exenta + path = request.path_info.lstrip('/') + + for pattern in csrf_exempt_urls: + if re.match(pattern, path): + setattr(request, '_dont_enforce_csrf_checks', True) + break + + return None \ No newline at end of file diff --git a/backend/mac_attendance/settings.py b/backend/mac_attendance/settings.py new file mode 100644 index 0000000..91a0569 --- /dev/null +++ b/backend/mac_attendance/settings.py @@ -0,0 +1,269 @@ +""" +Django settings for mac_attendance project. + +Generated by 'django-admin startproject' using Django 5.2.6. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.2/ref/settings/ +""" + +from pathlib import Path +from decouple import config + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = config('SECRET_KEY') + +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1').split(',') + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt', + 'corsheaders', + 'import_export', + 'authentication', + 'events', + 'attendance', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'mac_attendance.middleware.DisableCSRFOnAPIMiddleware', # Exentar API de CSRF (usamos JWT) + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'mac_attendance.middleware.AuditMiddleware', # Auditoría de seguridad +] + +# Rate Limiting Configuration +RATELIMIT_ENABLE = config('RATELIMIT_ENABLE', default=True, cast=bool) +RATELIMIT_USE_CACHE = 'default' # Usar cache por defecto (memory en dev) + +ROOT_URLCONF = 'mac_attendance.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'mac_attendance.wsgi.application' + +# Database Configuration - PostgreSQL +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME', default='mac_attendance'), + 'USER': config('DB_USER', default='mac_user'), + 'PASSWORD': config('DB_PASSWORD', default='mac_password_2024_secure'), + 'HOST': config('DB_HOST', default='db'), + 'PORT': config('DB_PORT', default='5432'), + 'CONN_MAX_AGE': 600, # Conexiones persistentes para mejor rendimiento + 'OPTIONS': { + 'connect_timeout': 10, + } + } +} + +# Cache Configuration (para rate limiting) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'ratelimit-cache', + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +LANGUAGE_CODE = 'es-mx' +TIME_ZONE = 'America/Mexico_City' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Security Settings for Production +if not DEBUG: + # HTTPS/SSL Settings + SECURE_SSL_REDIRECT = config('SECURE_SSL_REDIRECT', default=True, cast=bool) + SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=True, cast=bool) + CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=True, cast=bool) + + # HSTS (HTTP Strict Transport Security) + SECURE_HSTS_SECONDS = config('SECURE_HSTS_SECONDS', default=31536000, cast=int) # 1 año + SECURE_HSTS_INCLUDE_SUBDOMAINS = config('SECURE_HSTS_INCLUDE_SUBDOMAINS', default=True, cast=bool) + SECURE_HSTS_PRELOAD = config('SECURE_HSTS_PRELOAD', default=True, cast=bool) + + # Security Headers + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_BROWSER_XSS_FILTER = True + X_FRAME_OPTIONS = 'DENY' + + # Proxy Settings (para uso detrás de nginx/apache) + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# CORS settings +CORS_ALLOWED_ORIGINS = config( + 'CORS_ALLOWED_ORIGINS', + default='http://localhost,http://127.0.0.1,http://localhost:5173,http://127.0.0.1:5173' +).split(',') + +CORS_ALLOW_CREDENTIALS = True + +# CSRF Settings - Exentar rutas de API ya que usamos JWT +CSRF_TRUSTED_ORIGINS = config( + 'CSRF_TRUSTED_ORIGINS', + default='http://localhost,http://127.0.0.1' +).split(',') + +# Exentar todas las rutas de API del CSRF check (usamos JWT para seguridad) +CSRF_EXEMPT_URLS = [r'^api/.*$'] + +# Django REST Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', # Mantener para Django admin + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'EXCEPTION_HANDLER': 'mac_attendance.exceptions.custom_exception_handler', +} + +# JWT Settings +from datetime import timedelta + +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': False, + 'UPDATE_LAST_LOGIN': True, + + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + + 'AUTH_HEADER_TYPES': ('Bearer',), + 'AUTH_HEADER_NAME': 'HTTP_AUTHORIZATION', + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', +} + +# Logging Configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {asctime} {message}', + 'style': '{', + }, + 'audit': { + 'format': '[AUDIT] {asctime} {levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + 'file': { + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'formatter': 'verbose', + }, + 'security_file': { + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'security.log', + 'formatter': 'audit', + }, + 'audit_file': { + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'audit.log', + 'formatter': 'audit', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['console', 'file'] if not DEBUG else ['console'], + 'level': 'INFO' if not DEBUG else 'DEBUG', + }, + 'django.security': { + 'handlers': ['console', 'security_file'] if not DEBUG else ['console'], + 'level': 'WARNING', + 'propagate': False, + }, + 'authentication.audit': { + 'handlers': ['console', 'audit_file'] if not DEBUG else ['console'], + 'level': 'INFO', + 'propagate': False, + }, + # Logger para debugging de rate limiting + 'django_ratelimit': { + 'handlers': ['console', 'security_file'] if not DEBUG else ['console'], + 'level': 'WARNING', + 'propagate': False, + }, + }, +} + +# Crear directorio de logs si no existe +import os +LOGS_DIR = BASE_DIR / 'logs' +if not os.path.exists(LOGS_DIR): + os.makedirs(LOGS_DIR) + diff --git a/backend/mac_attendance/urls.py b/backend/mac_attendance/urls.py new file mode 100644 index 0000000..9aedc15 --- /dev/null +++ b/backend/mac_attendance/urls.py @@ -0,0 +1,275 @@ +""" +URL configuration for mac_attendance project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.http import JsonResponse +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_root(request): + """API root endpoint - Returns comprehensive API information (Protected)""" + return JsonResponse({ + 'name': 'Sistema de Asistencia MAC - API', + 'version': '1.0.0', + 'description': 'API REST para el Sistema de Asistencia MAC - FES Acatlán UNAM', + 'documentation': { + 'main': '/api/docs/', + 'swagger': '/api/swagger/', + 'redoc': '/api/redoc/', + }, + 'endpoints': { + 'authentication': { + 'login': { + 'url': '/api/auth/login/', + 'method': 'POST', + 'description': 'Iniciar sesión con número de cuenta', + 'auth_required': False, + }, + 'logout': { + 'url': '/api/auth/logout/', + 'method': 'POST', + 'description': 'Cerrar sesión', + 'auth_required': True, + }, + 'profile': { + 'url': '/api/auth/profile/', + 'method': 'GET', + 'description': 'Obtener perfil de usuario', + 'auth_required': True, + }, + 'check_auth': { + 'url': '/api/auth/check-auth/', + 'method': 'GET', + 'description': 'Verificar estado de autenticación', + 'auth_required': False, + }, + 'token_refresh': { + 'url': '/api/auth/token/refresh/', + 'method': 'POST', + 'description': 'Refrescar access token', + 'auth_required': False, + }, + 'system_config': { + 'url': '/api/auth/system-config/', + 'method': 'GET', + 'description': 'Obtener configuración del sistema', + 'auth_required': True, + }, + }, + 'events': { + 'list': { + 'url': '/api/events/', + 'method': 'GET', + 'description': 'Listar todos los eventos', + 'auth_required': False, + }, + 'register_external': { + 'url': '/api/events/external/register/', + 'method': 'POST', + 'description': 'Registrar usuario externo', + 'auth_required': True, + }, + 'search_external': { + 'url': '/api/events/external/search/', + 'method': 'GET', + 'description': 'Buscar usuarios externos', + 'auth_required': True, + }, + 'approve_external': { + 'url': '/api/events/external//approve/', + 'method': 'POST', + 'description': 'Aprobar usuario externo', + 'auth_required': True, + }, + }, + 'attendance': { + 'register': { + 'url': '/api/attendance/', + 'method': 'POST', + 'description': 'Registrar asistencia a evento', + 'auth_required': True, + }, + 'stats': { + 'url': '/api/attendance/stats/', + 'method': 'GET', + 'description': 'Obtener estadísticas de asistencia', + 'auth_required': True, + }, + 'recent': { + 'url': '/api/attendance/recent/', + 'method': 'GET', + 'description': 'Obtener asistencias recientes', + 'auth_required': True, + }, + 'my_attendances': { + 'url': '/api/attendance/my/', + 'method': 'GET', + 'description': 'Obtener mis asistencias', + 'auth_required': True, + }, + }, + }, + 'authentication': { + 'type': 'JWT Bearer Token', + 'header': 'Authorization: Bearer ', + 'token_lifetime': '1 hour', + 'refresh_token_lifetime': '7 days', + }, + 'status': 'operational', + 'server_time': request.build_absolute_uri(), + }) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_docs(request): + """API documentation endpoint (Protected)""" + from django.http import HttpResponse + html_content = """ + + + + + + API Documentation - Sistema MAC + + + +
+

📚 API Documentation

+

Sistema de Asistencia MAC - FES Acatlán UNAM

+

Version: 1.0.0 | Base URL: /api/

+
+ +
+

🔐 Authentication Endpoints

+ +

POST /api/auth/login/

+

Iniciar sesión con número de cuenta

+
{
+  "account_number": "1234567"
+}
+ +

GET /api/auth/profile/ AUTH

+

Obtener perfil del usuario autenticado

+ +

POST /api/auth/token/refresh/

+

Refrescar access token

+
+ +
+

📅 Events Endpoints

+ +

GET /api/events/

+

Listar todos los eventos activos

+ +

POST /api/events/external/register/ AUTH

+

Registrar un usuario externo para eventos

+
+ +
+

✅ Attendance Endpoints

+ +

POST /api/attendance/ AUTH

+

Registrar asistencia a un evento

+ +

GET /api/attendance/stats/ AUTH

+

Obtener estadísticas de asistencia del usuario

+ +

GET /api/attendance/my/ AUTH

+

Obtener mis asistencias registradas

+
+ +
+

🔑 Authentication

+

La API utiliza JWT (JSON Web Tokens) para autenticación.

+

Para endpoints que requieren autenticación, incluye el header:

+
Authorization: Bearer <your_access_token>
+
+ +
+

📖 Quick Links

+ +
+ + + """ + return HttpResponse(html_content) + +urlpatterns = [ + path('', api_root, name='api_root'), + path('api/', api_root, name='api_root_alt'), + path('api/docs/', api_docs, name='api_docs'), + path('admin/', admin.site.urls), + path('api/auth/', include('authentication.urls')), + path('api/events/', include('events.urls')), + path('api/attendance/', include('attendance.urls')), +] \ No newline at end of file diff --git a/backend/mac_attendance/wsgi.py b/backend/mac_attendance/wsgi.py new file mode 100644 index 0000000..108a41d --- /dev/null +++ b/backend/mac_attendance/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for mac_attendance project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..e67f6b1 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7fc32cf --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,145 @@ +# Configuración centralizada para herramientas de desarrollo Python + +[tool.black] +# Black - Formateador de código opinionado +line-length = 120 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # Directorios a excluir + \.git + | \.venv + | venv + | env + | __pycache__ + | migrations + | staticfiles + | media + | node_modules + | dist + | build +)/ +''' + +[tool.isort] +# isort - Ordenador de imports +profile = "black" +line_length = 120 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +skip_glob = [ + "*/migrations/*", + "*/venv/*", + "*/env/*", + ".venv/*", + "staticfiles/*", + "media/*" +] +known_django = "django" +known_first_party = ["authentication", "attendance", "events", "mac_attendance"] +sections = ["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] + +[tool.pytest.ini_options] +# pytest - Framework de testing +DJANGO_SETTINGS_MODULE = "mac_attendance.settings" +python_files = ["tests.py", "test_*.py", "*_tests.py"] +addopts = [ + "--verbose", + "--strict-markers", + "--tb=short", + "--cov=.", + "--cov-report=html", + "--cov-report=term-missing", +] +testpaths = ["tests"] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "integration: marks tests as integration tests", + "unit: marks tests as unit tests", +] + +[tool.coverage.run] +# Coverage - Cobertura de código +source = ["."] +omit = [ + "*/migrations/*", + "*/tests/*", + "*/test_*.py", + "*/__pycache__/*", + "*/venv/*", + "*/env/*", + ".venv/*", + "manage.py", + "*/wsgi.py", + "*/asgi.py", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", +] + +[tool.mypy] +# mypy - Type checker +python_version = "3.11" +check_untyped_defs = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +plugins = ["mypy_django_plugin.main", "mypy_drf_plugin.main"] + +[[tool.mypy.overrides]] +module = "*.migrations.*" +ignore_errors = true + +[tool.django-stubs] +django_settings_module = "mac_attendance.settings" + +[tool.pylint.main] +# pylint - Analizador estático +load-plugins = ["pylint_django"] +django-settings-module = "mac_attendance.settings" + +[tool.pylint.format] +max-line-length = 120 + +[tool.pylint.messages_control] +disable = [ + "C0111", # missing-docstring + "C0103", # invalid-name + "R0903", # too-few-public-methods + "R0913", # too-many-arguments +] + +[tool.pylint.design] +max-attributes = 10 +max-args = 7 + +[tool.bandit] +# bandit - Verificador de seguridad +exclude_dirs = [ + "/tests/", + "/migrations/", + "/venv/", + "/.venv/", +] +skips = ["B101", "B601"] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..3e2567e --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,40 @@ +# Herramientas de desarrollo y calidad de código + +# Linting y formateo de código +flake8==7.1.1 # Verificador de estilo PEP8 +pycodestyle==2.12.1 # Verificador de estilo PEP8 (antes pep8) +autopep8==2.3.1 # Corrector automático de PEP8 +black==24.8.0 # Formateador de código opinionado +isort==5.13.2 # Ordenador de imports + +# Análisis estático y complejidad +pylint==3.3.2 # Analizador de código estático +mccabe==0.7.0 # Complejidad ciclomática +radon==6.0.1 # Métricas de código + +# Type checking +mypy==1.13.0 # Verificador de tipos estáticos +django-stubs==5.1.1 # Type stubs para Django +djangorestframework-stubs==3.15.1 # Type stubs para DRF + +# Seguridad +bandit==1.8.0 # Verificador de seguridad +safety==3.6.2 # Verificador de vulnerabilidades + +# Testing +pytest==8.3.4 # Framework de testing +pytest-django==4.9.0 # Plugin pytest para Django +pytest-cov==6.0.0 # Cobertura de tests +coverage==7.6.9 # Herramienta de cobertura +factory-boy==3.3.1 # Factories para testing + +# Testing automation +tox==4.23.2 # Automatización de testing multi-entorno + +# Pre-commit hooks +pre-commit==4.0.1 # Framework de pre-commit hooks + +# Utilidades de desarrollo +django-extensions==3.2.3 # Extensiones útiles para Django +ipython==8.29.0 # Shell interactivo mejorado +ipdb==0.13.13 # Debugger interactivo diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..9e9b978 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,40 @@ +# Django Core +Django==5.2.6 +djangorestframework==3.15.2 + +# CORS Headers +django-cors-headers==4.6.0 + +# Database - PostgreSQL +psycopg2-binary==2.9.9 + +# Environment Variables +python-decouple==3.8 + +# Autenticación JWT (recomendado para API REST) +djangorestframework-simplejwt==5.3.1 + +# Rate Limiting (protección contra ataques de fuerza bruta) +django-ratelimit==4.1.0 + +# Exportación de datos + django-import-export==4.3.10 + +# Utilidades de desarrollo +# django-extensions==3.2.3 +# ipython==8.29.0 + +# Testing +# pytest==8.3.4 +# pytest-django==4.9.0 + +# Exportación de datos +# django-import-export==4.2.0 +# openpyxl==3.1.5 + +# Generación de PDFs +# reportlab==4.2.5 + +# Servidor de producción +# gunicorn==23.0.0 +# whitenoise==6.8.2 \ No newline at end of file diff --git a/backend/scripts/check_production.py b/backend/scripts/check_production.py new file mode 100644 index 0000000..4a08208 --- /dev/null +++ b/backend/scripts/check_production.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python +""" +Script de validación para verificar configuraciones de producción +Ejecutar: python check_production.py +""" + +import os +import sys +from pathlib import Path + +# Desactivar colores en Windows para evitar problemas de encoding +if sys.platform == 'win32': + GREEN = '' + YELLOW = '' + RED = '' + RESET = '' + BOLD = '' +else: + # Colores para terminal Unix + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + RESET = '\033[0m' + BOLD = '\033[1m' + +def check_env_file(): + """Verificar que existe archivo .env""" + print(f"\n{BOLD}1. Verificando archivo .env...{RESET}") + + if not os.path.exists('.env'): + print(f"{RED}[X] Archivo .env no encontrado{RESET}") + print(f"{YELLOW} Copia .env.example o .env.production.example como .env{RESET}") + return False + + print(f"{GREEN}[OK] Archivo .env encontrado{RESET}") + return True + +def check_env_vars(): + """Verificar variables de entorno críticas""" + print(f"\n{BOLD}2. Verificando variables de entorno...{RESET}") + + from decouple import config + + issues = [] + + # SECRET_KEY + secret_key = config('SECRET_KEY', default='') + if not secret_key or 'django-insecure' in secret_key or 'CHANGE' in secret_key: + print(f"{RED}[X] SECRET_KEY no configurada o insegura{RESET}") + issues.append("Genera una nueva SECRET_KEY segura") + else: + print(f"{GREEN}[OK] SECRET_KEY configurada{RESET}") + + # DEBUG + debug = config('DEBUG', default=True, cast=bool) + if debug: + print(f"{YELLOW}[!] DEBUG=True (OK para desarrollo, cambiar a False en produccion){RESET}") + else: + print(f"{GREEN}[OK] DEBUG=False (configuracion de produccion){RESET}") + + # ALLOWED_HOSTS + allowed_hosts = config('ALLOWED_HOSTS', default='') + if not debug and not allowed_hosts: + print(f"{RED}[X] ALLOWED_HOSTS no configurado{RESET}") + issues.append("Configura ALLOWED_HOSTS con tus dominios de produccion") + else: + print(f"{GREEN}[OK] ALLOWED_HOSTS: {allowed_hosts}{RESET}") + + return len(issues) == 0, issues + +def check_security_settings(): + """Verificar configuraciones de seguridad para producción""" + print(f"\n{BOLD}3. Verificando configuraciones de seguridad...{RESET}") + + from decouple import config + debug = config('DEBUG', default=True, cast=bool) + + if debug: + print(f"{YELLOW}[!] Modo desarrollo - Configuraciones de seguridad no aplicadas{RESET}") + print(f"{YELLOW} Esto es normal en desarrollo local{RESET}") + return True + + # Verificar configuraciones de produccion + ssl_redirect = config('SECURE_SSL_REDIRECT', default=False, cast=bool) + session_secure = config('SESSION_COOKIE_SECURE', default=False, cast=bool) + csrf_secure = config('CSRF_COOKIE_SECURE', default=False, cast=bool) + + all_secure = True + + if not ssl_redirect: + print(f"{YELLOW}[!] SECURE_SSL_REDIRECT no habilitado{RESET}") + all_secure = False + else: + print(f"{GREEN}[OK] SECURE_SSL_REDIRECT habilitado{RESET}") + + if not session_secure: + print(f"{YELLOW}[!] SESSION_COOKIE_SECURE no habilitado{RESET}") + all_secure = False + else: + print(f"{GREEN}[OK] SESSION_COOKIE_SECURE habilitado{RESET}") + + if not csrf_secure: + print(f"{YELLOW}[!] CSRF_COOKIE_SECURE no habilitado{RESET}") + all_secure = False + else: + print(f"{GREEN}[OK] CSRF_COOKIE_SECURE habilitado{RESET}") + + return all_secure + +def check_dependencies(): + """Verificar que las dependencias estén instaladas""" + print(f"\n{BOLD}4. Verificando dependencias...{RESET}") + + try: + import django + print(f"{GREEN}[OK] Django {django.get_version()}{RESET}") + except ImportError: + print(f"{RED}[X] Django no instalado{RESET}") + return False + + try: + import rest_framework + print(f"{GREEN}[OK] Django REST Framework{RESET}") + except ImportError: + print(f"{RED}[X] Django REST Framework no instalado{RESET}") + return False + + try: + import rest_framework_simplejwt + print(f"{GREEN}[OK] Django REST Framework SimpleJWT{RESET}") + except ImportError: + print(f"{RED}[X] Django REST Framework SimpleJWT no instalado{RESET}") + return False + + try: + import corsheaders + print(f"{GREEN}[OK] Django CORS Headers{RESET}") + except ImportError: + print(f"{RED}[X] Django CORS Headers no instalado{RESET}") + return False + + return True + +def main(): + print(f"{BOLD}{'='*60}{RESET}") + print(f"{BOLD} Verificación de Configuración - MAC Attendance{RESET}") + print(f"{BOLD}{'='*60}{RESET}") + + # Cambiar al directorio del script + os.chdir(Path(__file__).parent) + + all_checks_passed = True + + # Ejecutar verificaciones + if not check_env_file(): + all_checks_passed = False + + env_ok, issues = check_env_vars() + if not env_ok: + all_checks_passed = False + + if not check_security_settings(): + all_checks_passed = False + + if not check_dependencies(): + all_checks_passed = False + + # Resumen + print(f"\n{BOLD}{'='*60}{RESET}") + print(f"{BOLD} RESUMEN{RESET}") + print(f"{BOLD}{'='*60}{RESET}") + + if all_checks_passed: + print(f"{GREEN}[OK] Todas las verificaciones pasaron{RESET}") + else: + print(f"{YELLOW}[!] Algunas verificaciones fallaron o necesitan atencion{RESET}") + if issues: + print(f"\n{BOLD}Acciones requeridas:{RESET}") + for issue in issues: + print(f" - {issue}") + + print(f"\n{BOLD}Siguiente paso:{RESET}") + print(f" Ejecuta: python manage.py check --deploy") + print() + +if __name__ == '__main__': + try: + main() + except Exception as e: + print(f"{RED}Error: {e}{RESET}") + sys.exit(1) \ No newline at end of file diff --git a/backend/scripts/create_test_data.py b/backend/scripts/create_test_data.py new file mode 100644 index 0000000..77c4e5a --- /dev/null +++ b/backend/scripts/create_test_data.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python +"""Script para crear datos de prueba para el sistema de asistencia""" +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings') +django.setup() + +from django.contrib.auth.models import User +from authentication.models import UserProfile, Asistente +from events.models import Event +from attendance.models import Attendance, AttendanceStats +from datetime import date, time + +def create_test_data(): + print("[+] Creando datos de prueba...") + + # Obtener o crear un asistente para registrar asistencias + try: + asistente_profile = UserProfile.objects.get(user_type='assistant') + print(f"[OK] Asistente encontrado: {asistente_profile.full_name}") + except UserProfile.DoesNotExist: + print("[ERROR] No hay asistentes en el sistema. Por favor crea uno primero.") + return + + # Crear 5 estudiantes de prueba + estudiantes_data = [ + ('3001001', 'María González López', 90.0), # Cumple + ('3001002', 'Juan Pérez Martínez', 85.0), # Cumple + ('3001003', 'Ana Rodríguez Sánchez', 75.0), # NO cumple + ('3001004', 'Carlos López García', 95.0), # Cumple + ('3001005', 'Laura Martínez Ruiz', 60.0), # NO cumple + ] + + estudiantes = [] + for account, name, percentage in estudiantes_data: + # Verificar si ya existe + if UserProfile.objects.filter(account_number=account).exists(): + print(f"[SKIP] Estudiante {account} ya existe, omitiendo...") + estudiantes.append(UserProfile.objects.get(account_number=account)) + continue + + # Crear usuario Django + user = User.objects.create_user( + username=account, + first_name=name, + password=None + ) + user.set_unusable_password() + user.save() + + # Crear perfil + profile = UserProfile.objects.create( + user=user, + account_number=account, + full_name=name, + user_type='student' + ) + estudiantes.append(profile) + print(f"[OK] Estudiante creado: {name} ({account})") + + # Crear 3 eventos de prueba (en diferentes horarios para evitar conflictos) + eventos_data = [ + ('Conferencia de Inteligencia Artificial', 'Dr. Roberto Silva', time(9, 0), time(11, 0)), + ('Taller de Python Avanzado', 'Ing. Patricia Gómez', time(12, 0), time(14, 0)), + ('Seminario de Ciberseguridad', 'M.C. Fernando Ruiz', time(15, 0), time(17, 0)), + ] + + eventos = [] + for title, speaker, start, end in eventos_data: + # Verificar si ya existe + if Event.objects.filter(title=title).exists(): + print(f"[SKIP] Evento '{title}' ya existe, omitiendo...") + eventos.append(Event.objects.get(title=title)) + continue + + evento = Event.objects.create( + title=title, + speaker=speaker, + date=date(2025, 10, 15), + start_time=start, + end_time=end, + event_type='lecture', + modality='presential', + location='Auditorio Principal', + description=f'Descripción de {title}', + created_by=asistente_profile, + is_active=True + ) + eventos.append(evento) + print(f"[OK] Evento creado: {title}") + + # Crear asistencias simuladas para generar los porcentajes + print("\n[+] Creando estadisticas de asistencia simuladas...") + + for i, (profile, (_, _, target_percentage)) in enumerate(zip(estudiantes, estudiantes_data)): + # Crear o actualizar estadísticas + stats, created = AttendanceStats.objects.get_or_create( + student=profile, + defaults={ + 'total_events': len(eventos), + 'attended_events': 0, + 'attendance_percentage': 0.0 + } + ) + + # Calcular eventos a asistir para lograr el porcentaje deseado + total_eventos = len(eventos) + eventos_a_asistir = int((target_percentage / 100) * total_eventos) + + # Registrar asistencias + for j in range(eventos_a_asistir): + if j < len(eventos): + # Verificar si ya existe la asistencia + if not Attendance.objects.filter(student=profile, event=eventos[j]).exists(): + Attendance.objects.create( + student=profile, + event=eventos[j], + registered_by=asistente_profile, + registration_method='manual', + is_valid=True + ) + + # Actualizar estadísticas + stats.update_stats() + print(f" {profile.full_name}: {stats.attendance_percentage}% de asistencia") + + print("\n[OK] Datos de prueba creados exitosamente!") + print(f"\n[RESUMEN]") + print(f" - Estudiantes: {len(estudiantes)}") + print(f" - Eventos: {len(eventos)}") + print(f" - Porcentaje minimo: 80%") + print(f"\n[INFO] Ahora puedes probar:") + print(f" 1. Ir a http://127.0.0.1:8000/admin/") + print(f" 2. Navegar a 'Estadisticas de asistencia'") + print(f" 3. Seleccionar todos los registros") + print(f" 4. Usar la accion: 'Exportar estudiantes que cumplen requisito para constancia'") + +if __name__ == '__main__': + create_test_data() diff --git a/backend/scripts/create_valid_test_data.py b/backend/scripts/create_valid_test_data.py new file mode 100644 index 0000000..929415e --- /dev/null +++ b/backend/scripts/create_valid_test_data.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +"""Script para crear datos de prueba VÁLIDOS respetando todas las validaciones""" +import os +import django +from datetime import datetime, time, timedelta + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings') +django.setup() + +from django.contrib.auth.models import User +from django.utils import timezone +from authentication.models import UserProfile, Asistente +from events.models import Event +from attendance.models import Attendance, AttendanceStats + +def create_valid_test_data(): + print("[+] Creando datos de prueba VALIDOS...") + + # Obtener o crear un asistente para registrar asistencias + try: + asistente_profile = UserProfile.objects.get(user_type='assistant') + print(f"[OK] Asistente encontrado: {asistente_profile.full_name}") + except UserProfile.DoesNotExist: + print("[ERROR] No hay asistentes en el sistema. Por favor crea uno primero.") + return + + # Verificar estudiantes existentes + estudiantes = UserProfile.objects.filter(user_type='student') + if estudiantes.count() < 3: + print("[+] Creando estudiantes de prueba...") + estudiantes_data = [ + ('3001001', 'María González López'), + ('3001002', 'Juan Pérez Martínez'), + ('3001003', 'Ana Rodríguez Sánchez'), + ('3001004', 'Carlos López García'), + ('3001005', 'Laura Martínez Ruiz'), + ] + + for account, name in estudiantes_data: + if UserProfile.objects.filter(account_number=account).exists(): + print(f"[SKIP] Estudiante {account} ya existe") + continue + + user = User.objects.create_user( + username=account, + first_name=name, + password=None + ) + user.set_unusable_password() + user.save() + + UserProfile.objects.create( + user=user, + account_number=account, + full_name=name, + user_type='student' + ) + print(f"[OK] Estudiante creado: {name} ({account})") + + estudiantes = list(UserProfile.objects.filter(user_type='student')[:5]) + print(f"[OK] {len(estudiantes)} estudiantes disponibles") + + # Obtener hora actual en timezone local + now = timezone.now() + now_local = timezone.localtime(now) + today = now_local.date() + + print(f"\n[INFO] Fecha/Hora actual (local): {now_local.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"[INFO] Timezone: {timezone.get_current_timezone()}") + + # Crear eventos para HOY con horarios fijos que funcionen + # Como estamos en la noche, usamos eventos con margen suficiente + + from datetime import date + tomorrow = (now_local + timedelta(days=1)).date() + + eventos_data = [] + + # Evento 1: Mañana 9:00-11:00 (aún no permite asistencias) + eventos_data.append(( + 'Introducción a la Inteligencia Artificial', + 'Dr. Luis Martínez', + time(9, 0), + time(11, 0), + tomorrow + )) + + # Evento 2: Mañana 13:00-15:00 (aún no permite asistencias) + eventos_data.append(( + 'Análisis de Datos con Python', + 'Ing. Patricia Gómez', + time(13, 15), + time(15, 0), + tomorrow + )) + + # Evento 3: Mañana 16:00-18:00 (aún no permite asistencias) + eventos_data.append(( + 'Taller de Machine Learning', + 'Dra. Ana López', + time(16, 0), + time(18, 0), + tomorrow + )) + + eventos = [] + for title, speaker, start, end, event_date in eventos_data: + # Verificar si ya existe + if Event.objects.filter(title=title, date=event_date).exists(): + print(f"[SKIP] Evento '{title}' ya existe") + eventos.append(Event.objects.get(title=title, date=event_date)) + continue + + evento = Event.objects.create( + title=title, + speaker=speaker, + date=event_date, + start_time=start, + end_time=end, + event_type='lecture', + modality='presential', + location='Auditorio Principal', + description=f'Evento de prueba: {title}', + created_by=asistente_profile, + is_active=True + ) + eventos.append(evento) + print(f"[OK] Evento creado: {title} ({event_date} {start.strftime('%H:%M')}-{end.strftime('%H:%M')})") + + # Intentar crear asistencias (TODAS deberían fallar porque los eventos son mañana) + print("\n[+] Intentando registrar asistencias para eventos de mañana...") + print("[NOTA] TODAS las asistencias deberían fallar porque los eventos son mañana") + + asistencias_creadas = 0 + for i, evento in enumerate(eventos): + estudiante = estudiantes[i] if i < len(estudiantes) else estudiantes[0] + print(f"\n[TEST] Intentando registrar en: {evento.title} ({evento.date} {evento.start_time})") + try: + # Verificar si ya existe + if Attendance.objects.filter(student=estudiante, event=evento).exists(): + print(f" [SKIP] {estudiante.full_name} ya tiene asistencia") + continue + + att = Attendance.objects.create( + student=estudiante, + event=evento, + registered_by=asistente_profile, + registration_method='manual', + is_valid=True + ) + print(f" [ERROR] Se permitió registro (NO DEBERÍA) - Asistencia: {estudiante.full_name}") + asistencias_creadas += 1 + except Exception as e: + error_msg = str(e) + if "No se puede registrar asistencia antes del evento" in error_msg: + print(f" [OK] Validación funcionó correctamente - Evento es mañana") + else: + print(f" [OK] Validación funcionó: {error_msg[:80]}...") + + # Actualizar estadísticas + print("\n[+] Actualizando estadísticas...") + for estudiante in estudiantes: + stats, created = AttendanceStats.objects.get_or_create(student=estudiante) + stats.update_stats() + print(f" {estudiante.full_name}: {stats.attendance_percentage}% asistencia") + + print("\n" + "="*70) + print("[OK] Datos de prueba creados exitosamente!") + print("="*70) + print(f"\n[RESUMEN]") + print(f" - Estudiantes: {len(estudiantes)}") + print(f" - Eventos creados: {len(eventos)}") + print(f" - Asistencias válidas: {asistencias_creadas}") + print(f" - Fecha/Hora: {now_local.strftime('%Y-%m-%d %H:%M:%S')}") + print(f"\n[INFO] Ahora puedes:") + print(f" 1. Ir a http://127.0.0.1:8000/admin/") + print(f" 2. Ver eventos en 'Events' - eventos para mañana {tomorrow}") + print(f" 3. Ver asistencias en 'Attendances' - debería estar vacío") + print(f" 4. Ver estadísticas en 'Attendance stats'") + print(f"\n[NOTA] Las asistencias solo se pueden registrar durante los eventos") + print(f"[NOTA] Mañana {tomorrow} a partir de las 8:50 AM se podrán registrar asistencias") + +if __name__ == '__main__': + create_valid_test_data() diff --git a/backend/scripts/format.sh b/backend/scripts/format.sh new file mode 100644 index 0000000..7988025 --- /dev/null +++ b/backend/scripts/format.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Script para formatear automáticamente el código + +set -e + +echo "==================================" +echo " Formateo Automático de Código" +echo "==================================" +echo "" + +# Colores +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# 1. isort - Ordenar imports +echo -e "${YELLOW}▶ Ordenando imports con isort...${NC}" +isort . +echo -e "${GREEN}✓ Imports ordenados${NC}" +echo "" + +# 2. Black - Formatear código +echo -e "${YELLOW}▶ Formateando código con black...${NC}" +black . +echo -e "${GREEN}✓ Código formateado${NC}" +echo "" + +# 3. autopep8 - Correcciones adicionales de PEP8 (opcional) +echo -e "${YELLOW}▶ Aplicando correcciones adicionales de PEP8...${NC}" +autopep8 --in-place --aggressive --aggressive --recursive . +echo -e "${GREEN}✓ Correcciones PEP8 aplicadas${NC}" +echo "" + +echo "==================================" +echo -e "${GREEN}✓ Formateo completado${NC}" +echo "==================================" +echo "" +echo "Tip: Ejecuta './scripts/lint.sh' para verificar la calidad del código" diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh new file mode 100644 index 0000000..7af19b4 --- /dev/null +++ b/backend/scripts/lint.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Script para ejecutar todas las herramientas de linting y formateo + +set -e # Salir si algún comando falla + +echo "==================================" +echo " Análisis de Calidad de Código" +echo "==================================" +echo "" + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Función para ejecutar comando y capturar resultado +run_check() { + local name=$1 + local cmd=$2 + + echo -e "${YELLOW}▶ Ejecutando $name...${NC}" + if eval "$cmd"; then + echo -e "${GREEN}✓ $name: OK${NC}" + echo "" + return 0 + else + echo -e "${RED}✗ $name: FALLÓ${NC}" + echo "" + return 1 + fi +} + +# Contador de errores +ERRORS=0 + +# 1. Black - Verificar formato +if ! run_check "Black (formato)" "black --check --diff ."; then + echo -e "${YELLOW} Tip: Ejecuta 'black .' para formatear automáticamente${NC}" + ERRORS=$((ERRORS + 1)) +fi + +# 2. isort - Verificar imports +if ! run_check "isort (imports)" "isort --check-only --diff ."; then + echo -e "${YELLOW} Tip: Ejecuta 'isort .' para ordenar imports automáticamente${NC}" + ERRORS=$((ERRORS + 1)) +fi + +# 3. Flake8 - Verificar PEP8 +if ! run_check "Flake8 (PEP8)" "flake8 ."; then + ERRORS=$((ERRORS + 1)) +fi + +# 4. Pylint - Análisis estático +if ! run_check "Pylint (análisis estático)" "pylint --rcfile=pyproject.toml --exit-zero authentication attendance events mac_attendance"; then + echo -e "${YELLOW} Nota: Pylint puede mostrar warnings que no son críticos${NC}" + # No incrementar errores, pylint es solo informativo +fi + +# 5. Bandit - Seguridad +if ! run_check "Bandit (seguridad)" "bandit -r . -c pyproject.toml"; then + ERRORS=$((ERRORS + 1)) +fi + +# 6. MyPy - Type checking (opcional) +echo -e "${YELLOW}▶ Ejecutando MyPy (type checking)...${NC}" +if mypy --config-file=pyproject.toml . 2>/dev/null; then + echo -e "${GREEN}✓ MyPy: OK${NC}" +else + echo -e "${YELLOW}⚠ MyPy: Algunos errores de tipado (no críticos)${NC}" +fi +echo "" + +# Resumen +echo "==================================" +if [ $ERRORS -eq 0 ]; then + echo -e "${GREEN}✓ Todos los checks pasaron correctamente${NC}" + echo "==================================" + exit 0 +else + echo -e "${RED}✗ $ERRORS check(s) fallaron${NC}" + echo "==================================" + exit 1 +fi diff --git a/backend/scripts/test_ratelimit.py b/backend/scripts/test_ratelimit.py new file mode 100644 index 0000000..ecde0f0 --- /dev/null +++ b/backend/scripts/test_ratelimit.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python +""" +Script de prueba para verificar rate limiting +Ejecutar con el servidor de desarrollo corriendo: + python test_ratelimit.py +""" + +import requests +import time +import json + +API_BASE_URL = 'http://127.0.0.1:8000/api' + +def test_login_rate_limit(): + """ + Probar rate limiting en login (límite: 5/min) + """ + print("\n" + "="*60) + print("TEST: Rate Limiting en Login (5 intentos por minuto)") + print("="*60) + + endpoint = f"{API_BASE_URL}/auth/login/" + credentials = { + "account_number": "9999999", # Cuenta que no existe + "password": "wrongpassword" + } + + print(f"\nEnviando 7 solicitudes a {endpoint}") + print("Esperado: Primeras 5 pasan, las siguientes 2 reciben 429\n") + + for i in range(1, 8): + try: + response = requests.post(endpoint, json=credentials, timeout=5) + + if response.status_code == 429: + print(f"[{i}] ❌ 429 TOO MANY REQUESTS - Rate limit alcanzado!") + print(f" Respuesta: {response.json()}") + elif response.status_code == 400: + print(f"[{i}] ✓ 400 BAD REQUEST - Solicitud procesada (credenciales inválidas)") + else: + print(f"[{i}] ✓ {response.status_code} - Solicitud procesada") + + except requests.exceptions.ConnectionError: + print(f"[{i}] ❌ Error: No se pudo conectar al servidor") + print(" Asegúrate de que el servidor esté corriendo:") + print(" cd backend && python manage.py runserver") + break + except Exception as e: + print(f"[{i}] ❌ Error: {e}") + + time.sleep(0.5) # Pequeña pausa entre solicitudes + +def test_check_auth_rate_limit(): + """ + Probar rate limiting en check-auth (límite: 30/min) + """ + print("\n" + "="*60) + print("TEST: Rate Limiting en Check Auth (30 por minuto)") + print("="*60) + + endpoint = f"{API_BASE_URL}/auth/check-auth/" + + print(f"\nEnviando 32 solicitudes a {endpoint}") + print("Esperado: Primeras 30 pasan, las siguientes 2 reciben 429\n") + + success_count = 0 + rate_limited_count = 0 + + for i in range(1, 33): + try: + response = requests.get(endpoint, timeout=5) + + if response.status_code == 429: + rate_limited_count += 1 + if rate_limited_count == 1: # Solo mostrar el primero + print(f"[{i}] ❌ 429 - Rate limit alcanzado!") + elif rate_limited_count <= 3: + print(f"[{i}] ❌ 429") + elif response.status_code == 200: + success_count += 1 + if success_count <= 3 or success_count == 30: # Mostrar algunos + print(f"[{i}] ✓ 200 - OK") + + except requests.exceptions.ConnectionError: + print(f"[{i}] ❌ Error: Servidor no disponible") + break + except Exception as e: + print(f"[{i}] ❌ Error: {e}") + break + + if i % 10 == 0: + print(f" ... {i} solicitudes enviadas ...") + + print(f"\nResultado:") + print(f" - Exitosas: {success_count}") + print(f" - Rate limited: {rate_limited_count}") + +def main(): + print("\n" + "#"*60) + print("# PRUEBAS DE RATE LIMITING - MAC Attendance") + print("#"*60) + print("\nAsegúrate de que el servidor esté corriendo:") + print(" cd backend && python manage.py runserver\n") + + input("Presiona ENTER para comenzar las pruebas...") + + # Probar login rate limit + test_login_rate_limit() + + print("\n\nEsperando 5 segundos antes del siguiente test...") + time.sleep(5) + + # Probar check-auth rate limit + test_check_auth_rate_limit() + + print("\n" + "="*60) + print("PRUEBAS COMPLETADAS") + print("="*60) + print("\nNOTA: Si necesitas desactivar rate limiting para testing:") + print(" 1. Agregar RATELIMIT_ENABLE=False en .env") + print(" 2. O cambiar block=False en los decoradores\n") + +if __name__ == '__main__': + try: + main() + except KeyboardInterrupt: + print("\n\nPrueba interrumpida por el usuario") \ No newline at end of file diff --git a/backend/static/.gitkeep b/backend/static/.gitkeep new file mode 100644 index 0000000..cccd46c --- /dev/null +++ b/backend/static/.gitkeep @@ -0,0 +1 @@ +# Este archivo mantiene la carpeta static en git \ No newline at end of file diff --git a/backend/templates/admin/asistente_reporte.html b/backend/templates/admin/asistente_reporte.html new file mode 100644 index 0000000..169297e --- /dev/null +++ b/backend/templates/admin/asistente_reporte.html @@ -0,0 +1,122 @@ +{% extends "admin/base_site.html" %} +{% load static %} + +{% block title %}{{ title }}{% endblock %} + +{% block content %} +
+

{{ title }}

+ + {% for data in asistentes_data %} +
+

+ 👤 {{ data.asistente.user_profile.full_name }} + (Cuenta: {{ data.asistente.user_profile.account_number }}) +

+ +
+
+
{{ data.total }}
+
Registros Totales
+
+
+
{{ data.total_estudiantes }}
+
Estudiantes Regulares
+
+
+
{{ data.total_externos }}
+
Usuarios Externos
+
+
+ + {% if data.estudiantes %} +

📚 Estudiantes Regulares ({{ data.total_estudiantes }})

+
+ + + + + + + + + + + + {% for reg in data.estudiantes %} + + + + + + + + {% endfor %} + +
Fecha y HoraEstudianteNúmero de CuentaEventoMétodo
{{ reg.timestamp|date:"d/m/Y H:i" }}{{ reg.student.full_name }}{{ reg.student.account_number }}{{ reg.event.title }} + {% if reg.registration_method == 'manual' %} + ✍️ Manual + {% elif reg.registration_method == 'barcode' %} + 📱 Código + {% else %} + {{ reg.registration_method }} + {% endif %} +
+
+ {% endif %} + + {% if data.externos %} +

🌍 Usuarios Externos ({{ data.total_externos }})

+
+ + + + + + + + + + + + {% for reg in data.externos %} + + + + + + + + {% endfor %} + +
Fecha y HoraUsuario ExternoID TemporalInstituciónEvento
{{ reg.timestamp|date:"d/m/Y H:i" }}{{ reg.external_user.full_name }}{{ reg.external_user.temporary_id }}{{ reg.external_user.institution }}{{ reg.event.title }}
+
+ {% endif %} + + {% if not data.estudiantes and not data.externos %} +

+ Este asistente aún no ha registrado ninguna asistencia. +

+ {% endif %} +
+ {% endfor %} + +
+ + + + +
+
+ + +{% endblock %} diff --git a/backend/tox.ini b/backend/tox.ini new file mode 100644 index 0000000..5e0b748 --- /dev/null +++ b/backend/tox.ini @@ -0,0 +1,192 @@ +# Configuración de Tox para testing multi-entorno +# Sistema de Asistencia MAC + +[tox] +# Entornos a ejecutar por defecto +envlist = + py311-django52 + lint + format-check + type-check + security + coverage + +# Requiere Python 3.11+ +minversion = 4.0 +skipsdist = True + +[testenv] +# Configuración base para todos los entornos +setenv = + PYTHONPATH = {toxinidir} + DJANGO_SETTINGS_MODULE = mac_attendance.settings + DEBUG = False + SECRET_KEY = test-secret-key-for-tox + DB_ENGINE = postgresql + DB_NAME = test_mac_attendance + DB_USER = mac_user + DB_PASSWORD = mac_password_2024_secure + DB_HOST = localhost + DB_PORT = 5432 + +passenv = + CI + POSTGRES_* + DB_* + +deps = + -r{toxinidir}/requirements.txt + -r{toxinidir}/requirements-dev.txt + +# Entorno principal: Tests con pytest +[testenv:py311-django52] +description = Run tests with Python 3.11 and Django 5.2 +commands = + pytest {posargs:--cov=. --cov-report=html --cov-report=term-missing} + +[testenv:test] +description = Run tests quickly (alias) +commands = + pytest {posargs} + +[testenv:test-fast] +description = Run tests without coverage (faster) +commands = + pytest {posargs:--no-cov} + +# Linting y formateo +[testenv:lint] +description = Run all linters (flake8, pylint) +commands = + flake8 {posargs:.} + pylint {posargs:authentication attendance events mac_attendance} + +[testenv:format] +description = Format code with black and isort +commands = + black {posargs:.} + isort {posargs:.} + +[testenv:format-check] +description = Check code formatting without modifying +commands = + black --check --diff {posargs:.} + isort --check-only --diff {posargs:.} + +# Type checking +[testenv:type-check] +description = Run mypy type checker +commands = + mypy {posargs:.} + +# Seguridad +[testenv:security] +description = Run security checks +commands = + bandit -r {posargs:authentication attendance events mac_attendance} -ll + safety check --json + +[testenv:security-full] +description = Run comprehensive security audit +commands = + bandit -r . -ll -f json -o bandit-report.json + safety check --full-report + +# Cobertura +[testenv:coverage] +description = Generate coverage reports +commands = + coverage run -m pytest + coverage report -m + coverage html + coverage xml + +[testenv:coverage-report] +description = Show coverage report +commands = + coverage report -m + +# Métricas de código +[testenv:metrics] +description = Generate code metrics +commands = + radon cc {posargs:. --min B} + radon mi {posargs:. --min B} + +[testenv:complexity] +description = Check code complexity +commands = + radon cc . --total-average --show-complexity + +# Documentación +[testenv:docs] +description = Generate documentation (placeholder) +commands = + python -c "print('Documentation generation not configured yet')" + +# Entorno limpio +[testenv:clean] +description = Clean up generated files +allowlist_externals = + rm + find +commands = + find . -type d -name __pycache__ -exec rm -rf {{}} + + find . -type f -name '*.pyc' -delete + find . -type f -name '*.pyo' -delete + rm -rf .pytest_cache .mypy_cache .tox htmlcov .coverage coverage.xml + +# Configuración de herramientas +[flake8] +max-line-length = 120 +exclude = + .git, + __pycache__, + */migrations/*, + */venv/*, + */env/*, + .venv, + .tox, + dist, + build, + staticfiles, + media +ignore = + E203, # whitespace before ':' + E266, # too many leading '#' for block comment + E501, # line too long (handled by black) + W503, # line break before binary operator + W504, # line break after binary operator +max-complexity = 10 + +[pytest] +DJANGO_SETTINGS_MODULE = mac_attendance.settings +python_files = tests.py test_*.py *_tests.py +addopts = + --verbose + --strict-markers + --tb=short +testpaths = tests +markers = + slow: marks tests as slow + integration: marks tests as integration tests + unit: marks tests as unit tests + +[coverage:run] +source = . +omit = + */migrations/* + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */env/* + .venv/* + manage.py + */wsgi.py + */asgi.py + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False diff --git a/create_superuser.sh b/create_superuser.sh new file mode 100644 index 0000000..27aec50 --- /dev/null +++ b/create_superuser.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +# Script para crear un superusuario de Django en el contenedor + +echo "===================================" +echo "Creando superusuario de Django" +echo "===================================" +echo "" + +# Ejecutar el comando createsuperuser en el contenedor backend +docker-compose exec backend python manage.py createsuperuser + +echo "" +echo "===================================" +echo "Superusuario creado exitosamente!" +echo "===================================" +echo "" +echo "Puedes acceder al panel de administración en:" +echo " http://localhost/admin/" +echo "" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..00723af --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,85 @@ +# Docker Compose para Entorno de Desarrollo +# Incluye todas las herramientas de desarrollo y testing + +services: + # PostgreSQL Database (Desarrollo) + db: + image: postgres:15-alpine + environment: + - POSTGRES_DB=mac_attendance + - POSTGRES_USER=mac_user + - POSTGRES_PASSWORD=mac_password_2024_secure + volumes: + - postgres_data_dev:/var/lib/postgresql/data + networks: + - app-network + ports: + - "5432:5432" # Expuesto para acceso desde host + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mac_user -d mac_attendance"] + interval: 10s + timeout: 5s + retries: 5 + + # Backend Django (Desarrollo) + backend: + build: + context: . + dockerfile: docker/Dockerfile.backend.dev + command: > + sh -c "python manage.py migrate && + python manage.py collectstatic --noinput && + python manage.py runserver 0.0.0.0:8000" + volumes: + - ./backend:/app + - static_volume:/app/staticfiles + - media_volume:/app/media + environment: + - DEBUG=True + - SECRET_KEY=django-insecure-dev-key-change-in-production-12345 + - ALLOWED_HOSTS=localhost,127.0.0.1,nginx,backend + - RATELIMIT_ENABLE=False + - SECURE_SSL_REDIRECT=False + - SESSION_COOKIE_SECURE=False + - CSRF_COOKIE_SECURE=False + - CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:80 + - DB_ENGINE=postgresql + - DB_NAME=mac_attendance + - DB_USER=mac_user + - DB_PASSWORD=mac_password_2024_secure + - DB_HOST=db + - DB_PORT=5432 + networks: + - app-network + depends_on: + db: + condition: service_healthy + expose: + - "8000" + stdin_open: true # Para ipdb y debugging interactivo + tty: true + + # Frontend React + Nginx + nginx: + build: + context: . + dockerfile: docker/Dockerfile.frontend + ports: + - "80:80" + volumes: + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf + - static_volume:/app/staticfiles + - media_volume:/app/media + depends_on: + - backend + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + static_volume: + media_volume: + postgres_data_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5cc5020 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +services: + # PostgreSQL Database + db: + image: postgres:15-alpine + environment: + - POSTGRES_DB=mac_attendance + - POSTGRES_USER=mac_user + - POSTGRES_PASSWORD=mac_password_2024_secure + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app-network + ports: + - "5432:5432" # Expuesto para acceso desde host (comentar en producción si no es necesario) + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mac_user -d mac_attendance"] + interval: 10s + timeout: 5s + retries: 5 + + # Backend Django + backend: + build: + context: . + dockerfile: docker/Dockerfile.backend + command: > + sh -c "python manage.py migrate && + python manage.py collectstatic --noinput && + gunicorn --bind 0.0.0.0:8000 --workers 3 --timeout 120 mac_attendance.wsgi:application" + volumes: + - ./backend:/app + - static_volume:/app/staticfiles + - media_volume:/app/media + environment: + - DEBUG=True + - SECRET_KEY=django-insecure-dev-key-change-in-production-12345 + - ALLOWED_HOSTS=localhost,127.0.0.1,nginx,backend + - RATELIMIT_ENABLE=False + - SECURE_SSL_REDIRECT=False + - SESSION_COOKIE_SECURE=False + - CSRF_COOKIE_SECURE=False + - CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:80 + - DB_ENGINE=postgresql + - DB_NAME=mac_attendance + - DB_USER=mac_user + - DB_PASSWORD=mac_password_2024_secure + - DB_HOST=db + - DB_PORT=5432 + networks: + - app-network + depends_on: + db: + condition: service_healthy + expose: + - "8000" + + # Frontend React + Nginx + nginx: + build: + context: . + dockerfile: docker/Dockerfile.frontend + ports: + - "80:80" + volumes: + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf + - static_volume:/app/staticfiles + - media_volume:/app/media + depends_on: + - backend + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + static_volume: + media_volume: + postgres_data: diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..ac569aa --- /dev/null +++ b/docker/Dockerfile.backend @@ -0,0 +1,38 @@ +# Dockerfile para Backend Django +FROM python:3.11-slim + +# Variables de entorno para Python +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Crear directorio de trabajo +WORKDIR /app + +# Copiar requirements y instalar dependencias Python +COPY backend/requirements.txt . +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install gunicorn whitenoise + +# Copiar código del backend +COPY backend/ . + +# Crear directorio para archivos estáticos y media +RUN mkdir -p /app/staticfiles /app/media + +# Colectar archivos estáticos +RUN python manage.py collectstatic --noinput || true + +# Exponer puerto +EXPOSE 8000 + +# Comando para ejecutar con gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "mac_attendance.wsgi:application"] diff --git a/docker/Dockerfile.backend.dev b/docker/Dockerfile.backend.dev new file mode 100644 index 0000000..e6bba27 --- /dev/null +++ b/docker/Dockerfile.backend.dev @@ -0,0 +1,41 @@ +# Dockerfile para Backend Django - Entorno de Desarrollo +FROM python:3.11-slim + +# Variables de entorno para Python +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# Instalar dependencias del sistema +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Crear directorio de trabajo +WORKDIR /app + +# Copiar requirements y instalar dependencias Python +COPY backend/requirements.txt . +COPY backend/requirements-dev.txt . +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install -r requirements-dev.txt && \ + pip install gunicorn whitenoise + +# Copiar código del backend +COPY backend/ . + +# Crear directorio para archivos estáticos y media +RUN mkdir -p /app/staticfiles /app/media /app/logs + +# Crear directorio para reportes de cobertura +RUN mkdir -p /app/htmlcov + +# Exponer puerto +EXPOSE 8000 + +# Comando para ejecutar con Django development server +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 0000000..5ba5d6e --- /dev/null +++ b/docker/Dockerfile.frontend @@ -0,0 +1,27 @@ +# Etapa 1: Build del Frontend +FROM node:20-alpine AS frontend-build + +WORKDIR /app/frontend + +# Copiar archivos de dependencias +COPY frontend/package*.json ./ + +# Instalar dependencias (incluyendo devDependencies para el build) +RUN npm ci + +# Copiar código fuente +COPY frontend/ ./ + +# Construir aplicación para producción +RUN npm run build + +# Etapa 2: Nginx para servir el frontend +FROM nginx:alpine + +# Copiar archivos construidos del frontend +COPY --from=frontend-build /app/frontend/dist /usr/share/nginx/html + +# Exponer puerto +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..0a8e8ba --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,69 @@ +upstream backend { + server backend:8000; +} + +server { + listen 80; + server_name localhost; + client_max_body_size 100M; + + # Logs + access_log /var/log/nginx/access.log; + error_log /var/log/nginx/error.log; + + # Frontend - Servir archivos estáticos de React + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } + + # API Backend - Proxy al backend Django + location /api/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Admin de Django + location /admin/ { + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } + + # Archivos estáticos de Django + location /static/ { + alias /app/staticfiles/; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Archivos media de Django + location /media/ { + alias /app/media/; + expires 30d; + add_header Cache-Control "public"; + } + + # Configuración de gzip + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml text/javascript + application/json application/javascript application/xml+rss + application/rss+xml font/truetype font/opentype + application/vnd.ms-fontobject image/svg+xml; +} diff --git a/docs/ACCESO_ADMIN.md b/docs/ACCESO_ADMIN.md new file mode 100644 index 0000000..50218a9 --- /dev/null +++ b/docs/ACCESO_ADMIN.md @@ -0,0 +1,234 @@ +# Acceso al Panel de Administración de Django + +## Estado Actual + +Los contenedores Docker están corriendo correctamente: +- ✅ **Frontend (Nginx)**: http://localhost +- ✅ **Backend (Django)**: Disponible en http://localhost/api/ +- ✅ **Panel Admin**: http://localhost/admin/ + +## Problema Resuelto + +Se han corregido los siguientes problemas: + +1. **STATIC_ROOT no configurado**: Se agregó `STATIC_ROOT = BASE_DIR / 'staticfiles'` en `settings.py` +2. **MEDIA_ROOT no configurado**: Se agregó `MEDIA_ROOT = BASE_DIR / 'media'` en `settings.py` +3. **Variables de entorno faltantes**: Se agregaron todas las variables necesarias en `docker-compose.yml` +4. **PostgreSQL removido**: Se simplificó usando SQLite para facilitar el desarrollo + +## Crear Superusuario + +Para acceder al panel de administración, primero necesitas crear un superusuario. + +### En Windows (PowerShell): + +```powershell +docker-compose exec backend python manage.py createsuperuser +``` + +### En Linux/Mac: + +```bash +bash create_superuser.sh +``` + +O manualmente: + +```bash +docker-compose exec backend python manage.py createsuperuser +``` + +### Proceso Interactivo: + +El comando te pedirá: +1. **Username**: Ingresa un nombre de usuario (ejemplo: admin) +2. **Email**: Ingresa un email (ejemplo: admin@example.com) +3. **Password**: Ingresa una contraseña segura +4. **Password (again)**: Confirma la contraseña + +Ejemplo: +``` +Username: admin +Email address: admin@example.com +Password: ********** +Password (again): ********** +Superuser created successfully. +``` + +## Acceder al Panel de Administración + +1. Abre tu navegador web +2. Ve a: **http://localhost/admin/** +3. Ingresa tus credenciales (username y password que creaste) +4. ¡Listo! Ya puedes administrar tu aplicación + +## URLs Disponibles + +| Servicio | URL | Descripción | +|----------|-----|-------------| +| Frontend | http://localhost | Aplicación React | +| API Backend | http://localhost/api/ | API REST de Django | +| Admin Django | http://localhost/admin/ | Panel de administración | +| Archivos Estáticos | http://localhost/static/ | CSS, JS, imágenes de Django | +| Archivos Media | http://localhost/media/ | Archivos subidos por usuarios | + +## Verificar Estado de los Contenedores + +```bash +# Ver estado de los contenedores +docker-compose ps + +# Ver logs del backend +docker-compose logs backend + +# Ver logs del nginx +docker-compose logs nginx + +# Seguir logs en tiempo real +docker-compose logs -f +``` + +## Comandos Útiles de Django + +```bash +# Crear migraciones +docker-compose exec backend python manage.py makemigrations + +# Aplicar migraciones +docker-compose exec backend python manage.py migrate + +# Crear superusuario adicional +docker-compose exec backend python manage.py createsuperuser + +# Acceder al shell de Django +docker-compose exec backend python manage.py shell + +# Colectar archivos estáticos +docker-compose exec backend python manage.py collectstatic --noinput + +# Ver rutas disponibles +docker-compose exec backend python manage.py show_urls +``` + +## Solución de Problemas + +### Error 502 Bad Gateway + +Si ves este error, verifica que el backend esté corriendo: + +```bash +docker-compose logs backend +docker-compose restart backend +``` + +### No puedo acceder a /admin/ + +1. Verifica que el backend esté corriendo: + ```bash + docker-compose ps + ``` + +2. Verifica los logs de nginx: + ```bash + docker-compose logs nginx + ``` + +3. Verifica que hayas creado un superusuario + +### Error de permisos + +Si hay errores de permisos en archivos: + +```bash +# En el backend +docker-compose exec backend chmod -R 755 /app/staticfiles +docker-compose exec backend chmod -R 755 /app/media +``` + +### Reiniciar todo desde cero + +Si algo sale mal, puedes reiniciar todo: + +```bash +# Detener y eliminar todo (¡cuidado! elimina la base de datos) +docker-compose down -v + +# Reconstruir +docker-compose build --no-cache + +# Levantar +docker-compose up -d + +# Aplicar migraciones +docker-compose exec backend python manage.py migrate + +# Crear superusuario +docker-compose exec backend python manage.py createsuperuser +``` + +## Configuración de Producción + +Para producción, debes cambiar estas variables en `docker-compose.yml`: + +```yaml +environment: + - DEBUG=False # ⚠️ IMPORTANTE: Cambiar a False en producción + - SECRET_KEY=cambiar-por-una-clave-super-segura-y-aleatoria + - ALLOWED_HOSTS=tu-dominio.com,www.tu-dominio.com + - SECURE_SSL_REDIRECT=True + - SESSION_COOKIE_SECURE=True + - CSRF_COOKIE_SECURE=True +``` + +También deberías considerar usar PostgreSQL en lugar de SQLite para producción. + +## Arquitectura + +``` +┌─────────────────────────────────────────┐ +│ Cliente (Navegador) │ +└────────────────┬────────────────────────┘ + │ HTTP + ▼ +┌─────────────────────────────────────────┐ +│ Nginx (Puerto 80) │ +│ ├─ / → Frontend React (build estático) │ +│ ├─ /api/* → Backend Django (proxy) │ +│ ├─ /admin/* → Django Admin (proxy) │ +│ ├─ /static/* → Archivos estáticos │ +│ └─ /media/* → Archivos media │ +└────────────────┬────────────────────────┘ + │ + ┌─────────┴─────────┐ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│ Frontend │ │ Backend │ +│ (React) │ │ (Django) │ +│ /usr/share/│ │ Puerto: │ +│ nginx/html │ │ 8000 │ +└─────────────┘ └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ SQLite │ + │ (db.sqlite3)│ + └─────────────┘ +``` + +## Seguridad + +- ✅ CORS configurado correctamente +- ✅ CSRF protection habilitado +- ✅ JWT Authentication para API +- ✅ Rate limiting disponible (deshabilitado en desarrollo) +- ⚠️ DEBUG=True (solo para desarrollo) +- ⚠️ Cambiar SECRET_KEY en producción +- ⚠️ Habilitar HTTPS en producción + +## Próximos Pasos + +1. ✅ Contenedores corriendo +2. ⚠️ Crear superusuario (pendiente - ejecuta el comando arriba) +3. ⚠️ Acceder a http://localhost/admin/ +4. ⚠️ Configurar usuarios y permisos +5. ⚠️ Probar las funcionalidades de la aplicación diff --git a/docs/API_DOCUMENTATION.md b/docs/API_DOCUMENTATION.md new file mode 100644 index 0000000..6546f8f --- /dev/null +++ b/docs/API_DOCUMENTATION.md @@ -0,0 +1,701 @@ +# Sistema de Asistencia MAC - Documentación de API + +## Información General + +- **Version**: 1.0.0 +- **Base URL**: `http://localhost/api/` +- **Autenticación**: JWT Bearer Token +- **Formato**: JSON + +## Índice + +- [Inicio Rápido](#inicio-rápido) +- [Autenticación](#autenticación) +- [Endpoints](#endpoints) + - [Authentication](#authentication-endpoints) + - [Events](#events-endpoints) + - [Attendance](#attendance-endpoints) +- [Códigos de Estado](#códigos-de-estado) +- [Ejemplos con cURL](#ejemplos-con-curl) +- [Testing con Postman](#testing-con-postman) + +## Inicio Rápido + +### Acceder a la Documentación + +- **API Root (JSON)**: http://localhost/api/ 🔒 **Requiere Autenticación** +- **Documentación HTML**: http://localhost/api/docs/ 🔒 **Requiere Autenticación** +- **Panel Admin**: http://localhost/admin/ + +> **Nota**: Los endpoints `/api/` y `/api/docs/` ahora requieren autenticación JWT. +> Primero debes hacer login en `/api/auth/login/` para obtener un token de acceso. + +### Flujo Básico + +1. **Login**: Obtener tokens de acceso +2. **Usar Token**: Incluir en header `Authorization` +3. **Refrescar Token**: Cuando expire el access token + +## Autenticación + +La API utiliza **JWT (JSON Web Tokens)** para autenticación. + +### Obtener Token + +```bash +POST /api/auth/login/ +Content-Type: application/json + +{ + "account_number": "3123123" +} +``` + +**Respuesta:** +```json +{ + "message": "Login exitoso", + "user": { + "id": 3, + "username": "3123123", + "profile": { + "account_number": "3123123", + "user_type": "assistant", + "full_name": "pancho" + } + }, + "tokens": { + "access": "eyJhbGci....", + "refresh": "eyJhbGci...." + } +} +``` + +### Usar Token + +Para endpoints que requieren autenticación, incluye el header: + +``` +Authorization: Bearer +``` + +### Refrescar Token + +```bash +POST /api/auth/token/refresh/ +Content-Type: application/json + +{ + "refresh": "" +} +``` + +## Endpoints + +### Authentication Endpoints + +#### 1. Login +``` +POST /api/auth/login/ +``` + +**Descripción**: Iniciar sesión con número de cuenta + +**Auth Required**: ❌ No + +**Body**: +```json +{ + "account_number": "1234567" +} +``` + +**Response 200**: +```json +{ + "message": "Login exitoso", + "user": { ... }, + "tokens": { + "access": "...", + "refresh": "..." + } +} +``` + +**Response 400**: +```json +{ + "account_number": ["El número de cuenta debe tener exactamente 7 dígitos."] +} +``` + +--- + +#### 2. Logout +``` +POST /api/auth/logout/ +``` + +**Descripción**: Cerrar sesión + +**Auth Required**: ✅ Sí + +**Response 200**: +```json +{ + "message": "Logout exitoso" +} +``` + +--- + +#### 3. Get Profile +``` +GET /api/auth/profile/ +``` + +**Descripción**: Obtener perfil del usuario autenticado + +**Auth Required**: ✅ Sí + +**Response 200**: +```json +{ + "id": 3, + "username": "3123123", + "email": "", + "profile": { + "account_number": "3123123", + "user_type": "assistant", + "full_name": "pancho" + }, + "is_staff": false +} +``` + +--- + +#### 4. Check Auth Status +``` +GET /api/auth/check-auth/ +``` + +**Descripción**: Verificar si el usuario está autenticado + +**Auth Required**: ❌ No + +**Response 200**: +```json +{ + "is_authenticated": true, + "user": { ... } +} +``` + +--- + +#### 5. Refresh Token +``` +POST /api/auth/token/refresh/ +``` + +**Descripción**: Refrescar access token usando refresh token + +**Auth Required**: ❌ No + +**Body**: +```json +{ + "refresh": "" +} +``` + +**Response 200**: +```json +{ + "access": "" +} +``` + +--- + +#### 6. System Configuration +``` +GET /api/auth/system-config/ +``` + +**Descripción**: Obtener configuración del sistema + +**Auth Required**: ✅ Sí + +**Response 200**: +```json +{ + "minimum_attendance_percentage": 80.0, + "minutes_before_event": 10, + "minutes_after_start": 25 +} +``` + +--- + +### Events Endpoints + +#### 1. List Events +``` +GET /api/events/ +``` + +**Descripción**: Listar todos los eventos activos + +**Auth Required**: ❌ No + +**Response 200**: +```json +[ + { + "id": 1, + "title": "Inteligencia Artificial", + "description": "Conferencia sobre IA", + "event_type": "conference", + "modality": "presencial", + "speaker": "Dr. García", + "date": "2025-10-12", + "start_time": "10:00:00", + "end_time": "12:00:00", + "location": "Auditorio A", + "max_capacity": 100, + "is_active": true + } +] +``` + +--- + +#### 2. Register External User +``` +POST /api/events/external/register/ +``` + +**Descripción**: Registrar un usuario externo para eventos + +**Auth Required**: ✅ Sí (Solo asistentes) + +**Body**: +```json +{ + "full_name": "Juan Pérez", + "account_number": "9999999" +} +``` + +**Response 201**: +```json +{ + "id": 10, + "full_name": "Juan Pérez", + "account_number": "9999999", + "status": "approved" +} +``` + +--- + +#### 3. Search External Users +``` +GET /api/events/external/search/?account_number=9999999 +``` + +**Descripción**: Buscar usuarios externos + +**Auth Required**: ✅ Sí + +**Query Parameters**: +- `account_number` (string): Número de cuenta a buscar + +**Response 200**: +```json +[ + { + "id": 10, + "full_name": "Juan Pérez", + "account_number": "9999999", + "status": "approved" + } +] +``` + +--- + +#### 4. Approve External User +``` +POST /api/events/external//approve/ +``` + +**Descripción**: Aprobar un usuario externo + +**Auth Required**: ✅ Sí (Solo asistentes) + +**Body**: +```json +{ + "approved": true +} +``` + +**Response 200**: +```json +{ + "message": "Usuario aprobado exitosamente" +} +``` + +--- + +### Attendance Endpoints + +#### 1. Register Attendance +``` +POST /api/attendance/ +``` + +**Descripción**: Registrar asistencia a un evento + +**Auth Required**: ✅ Sí (Solo asistentes) + +**Body**: +```json +{ + "event_id": 1, + "account_number": "3123123", + "registration_method": "manual" +} +``` + +**Response 201**: +```json +{ + "id": 42, + "event": 1, + "timestamp": "2025-10-12T10:30:00Z", + "is_valid": true +} +``` + +--- + +#### 2. Get Student Stats +``` +GET /api/attendance/stats/?account_number=0000111 +``` + +**Descripción**: Obtener estadísticas de asistencia de un estudiante + +**Auth Required**: ✅ Sí + +**Query Parameters**: +- `account_number` (string): Número de cuenta del estudiante + +**Response 200**: +```json +{ + "total_events": 10, + "attended_events": 8, + "attendance_percentage": 80.0, + "meets_minimum": true +} +``` + +--- + +#### 3. Get Recent Attendances +``` +GET /api/attendance/recent/ +``` + +**Descripción**: Obtener asistencias recientes (últimas 50) + +**Auth Required**: ✅ Sí + +**Response 200**: +```json +[ + { + "id": 42, + "student": { + "account_number": "0000111", + "full_name": "María García" + }, + "event": { + "id": 1, + "title": "Inteligencia Artificial" + }, + "timestamp": "2025-10-12T10:30:00Z", + "is_valid": true + } +] +``` + +--- + +#### 4. Get My Attendances +``` +GET /api/attendance/my/ +``` + +**Descripción**: Obtener mis asistencias registradas + +**Auth Required**: ✅ Sí + +**Response 200**: +```json +[ + { + "id": 42, + "event": { + "id": 1, + "title": "Inteligencia Artificial", + "date": "2025-10-12", + "start_time": "10:00:00" + }, + "timestamp": "2025-10-12T10:30:00Z", + "is_valid": true + } +] +``` + +--- + +## Códigos de Estado + +| Código | Descripción | +|--------|-------------| +| 200 | OK - Solicitud exitosa | +| 201 | Created - Recurso creado exitosamente | +| 400 | Bad Request - Datos inválidos | +| 401 | Unauthorized - No autenticado o token inválido | +| 403 | Forbidden - Sin permisos | +| 404 | Not Found - Recurso no encontrado | +| 429 | Too Many Requests - Rate limit excedido | +| 500 | Internal Server Error - Error del servidor | + +## Ejemplos con cURL + +### 1. Login y obtener token + +```bash +curl -X POST http://localhost/api/auth/login/ \ + -H "Content-Type: application/json" \ + -d '{"account_number":"3123123"}' +``` + +### 2. Obtener perfil (con token) + +```bash +TOKEN="your_access_token_here" + +curl -X GET http://localhost/api/auth/profile/ \ + -H "Authorization: Bearer $TOKEN" +``` + +### 3. Listar eventos + +```bash +curl -X GET http://localhost/api/events/ +``` + +### 4. Registrar asistencia + +```bash +TOKEN="your_access_token_here" + +curl -X POST http://localhost/api/attendance/ \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event_id": 1, + "account_number": "0000111", + "registration_method": "manual" + }' +``` + +### 5. Obtener estadísticas + +```bash +TOKEN="your_access_token_here" + +curl -X GET "http://localhost/api/attendance/stats/?account_number=0000111" \ + -H "Authorization: Bearer $TOKEN" +``` + +### 6. Refrescar token + +```bash +REFRESH_TOKEN="your_refresh_token_here" + +curl -X POST http://localhost/api/auth/token/refresh/ \ + -H "Content-Type: application/json" \ + -d "{\"refresh\":\"$REFRESH_TOKEN\"}" +``` + +## Testing con Postman + +### Importar Collection + +1. Descarga la colección: `postman_collection.json` +2. En Postman: File → Import +3. Selecciona el archivo JSON + +### Variables de Entorno + +Crea un entorno con estas variables: + +```json +{ + "base_url": "http://localhost", + "access_token": "", + "refresh_token": "", + "account_number": "3123123" +} +``` + +### Workflow de Testing + +1. **Login**: Ejecuta el request de login +2. **Set Token**: Copia el access_token a la variable de entorno +3. **Test Endpoints**: Ejecuta otros requests usando `{{access_token}}` + +## Rate Limiting + +La API implementa rate limiting para prevenir abuso: + +- **Login**: 5 intentos por minuto por IP +- **Check Auth**: 30 consultas por minuto por IP +- **Token Refresh**: 10 intentos por minuto por IP + +## Errores Comunes + +### 401 Unauthorized + +**Causa**: Token inválido o expirado + +**Solución**: Refrescar el token o hacer login nuevamente + +```bash +# Refrescar token +curl -X POST http://localhost/api/auth/token/refresh/ \ + -H "Content-Type: application/json" \ + -d '{"refresh":""}' +``` + +### 400 Bad Request + +**Causa**: Datos inválidos en el request + +**Solución**: Verificar el formato y campos requeridos + +```json +{ + "account_number": ["El número de cuenta debe tener exactamente 7 dígitos."] +} +``` + +### 429 Too Many Requests + +**Causa**: Rate limit excedido + +**Solución**: Esperar un minuto antes de reintentar + +## Best Practices + +### 1. Manejo de Tokens + +```javascript +// Guardar tokens +localStorage.setItem('access_token', response.tokens.access); +localStorage.setItem('refresh_token', response.tokens.refresh); + +// Incluir en requests +headers: { + 'Authorization': `Bearer ${localStorage.getItem('access_token')}` +} + +// Refrescar cuando expire +if (error.status === 401) { + // Intentar refrescar + const newToken = await refreshToken(); + // Reintentar request original +} +``` + +### 2. Manejo de Errores + +```javascript +try { + const response = await fetch('/api/endpoint/', options); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Request failed'); + } + return await response.json(); +} catch (error) { + console.error('API Error:', error); + // Mostrar mensaje al usuario +} +``` + +### 3. Rate Limiting + +```javascript +// Implementar backoff exponencial +const retryWithBackoff = async (fn, retries = 3) => { + for (let i = 0; i < retries; i++) { + try { + return await fn(); + } catch (error) { + if (error.status === 429 && i < retries - 1) { + await new Promise(r => setTimeout(r, 2 ** i * 1000)); + continue; + } + throw error; + } + } +}; +``` + +## Seguridad + +### Headers de Seguridad + +La API implementa los siguientes headers de seguridad: + +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `X-XSS-Protection: 1; mode=block` + +### CORS + +CORS está configurado para permitir requests desde: +- `http://localhost` +- `http://127.0.0.1` + +### HTTPS + +En producción, **siempre** usar HTTPS para proteger los tokens. + +## Soporte + +- **Documentación**: http://localhost/api/docs/ +- **Admin Panel**: http://localhost/admin/ +- **Issues**: https://github.com/yourusername/mac_attendance/issues + +## Changelog + +### v1.0.0 (2025-10-12) +- ✅ Autenticación JWT +- ✅ Gestión de eventos +- ✅ Registro de asistencias +- ✅ Estadísticas de asistencia +- ✅ Usuarios externos +- ✅ Rate limiting +- ✅ Documentación completa diff --git a/docs/CHANGELOG_FIX.md b/docs/CHANGELOG_FIX.md new file mode 100644 index 0000000..94512c6 --- /dev/null +++ b/docs/CHANGELOG_FIX.md @@ -0,0 +1,207 @@ +# Changelog - Corrección de Errores de Autenticación + +**Fecha**: 2025-10-12 +**Versión**: 1.0.1 + +## Problemas Resueltos + +### 1. Error 403 CSRF en Login desde Navegador + +**Síntoma**: Al intentar hacer login desde el navegador web, el backend retornaba error 403 (Forbidden) con mensaje "Acceso denegado (permisos insuficientes)". + +**Causa Raíz**: Django estaba aplicando validación CSRF a todas las rutas, incluyendo las rutas de API REST que usan JWT para autenticación. El middleware `CsrfViewMiddleware` rechazaba requests sin token CSRF. + +**Solución Implementada**: + +1. **Creado nuevo middleware** `DisableCSRFOnAPIMiddleware` en `backend/mac_attendance/middleware.py` + - Desactiva validación CSRF para todas las rutas que coincidan con `^api/.*$` + - Colocado antes de `CsrfViewMiddleware` en la cadena de middleware + +2. **Actualizado `settings.py`**: + - Agregado `CSRF_EXEMPT_URLS = [r'^api/.*$']` + - Actualizado `CORS_ALLOWED_ORIGINS` para incluir `http://localhost` + - Agregado `CSRF_TRUSTED_ORIGINS` para desarrollo local + - Agregado middleware en posición correcta (línea 45) + +3. **Configuración final de middleware**: +```python +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'mac_attendance.middleware.DisableCSRFOnAPIMiddleware', # ← NUEVO + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'mac_attendance.middleware.AuditMiddleware', +] +``` + +**Resultado**: ✅ Login desde navegador funciona correctamente sin error 403 + +--- + +### 2. Endpoint `/api/` Expuesto Públicamente + +**Síntoma**: Cualquier usuario no autenticado podía acceder a `/api/` y `/api/docs/` para ver información sobre todos los endpoints del sistema. + +**Riesgo de Seguridad**: Exposición de información sensible sobre la estructura de la API. + +**Solución Implementada**: + +1. **Actualizado `backend/mac_attendance/urls.py`**: + - Agregado decorador `@permission_classes([IsAuthenticated])` a función `api_root()` + - Agregado decorador `@permission_classes([IsAuthenticated])` a función `api_docs()` + - Ambos endpoints ahora requieren token JWT válido + +2. **Código modificado**: +```python +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_root(request): + """API root endpoint - Returns comprehensive API information (Protected)""" + # ... + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def api_docs(request): + """API documentation endpoint (Protected)""" + # ... +``` + +**Resultado**: +- ✅ `/api/` sin autenticación → Error 401 "Las credenciales de autenticación no se proveyeron" +- ✅ `/api/` con token válido → Muestra información de la API +- ✅ `/api/docs/` ahora también protegido + +--- + +## Archivos Modificados + +### 1. `backend/mac_attendance/middleware.py` +- **Agregado**: Clase `DisableCSRFOnAPIMiddleware` +- **Líneas**: 103-126 + +### 2. `backend/mac_attendance/settings.py` +- **Modificado**: Lista `MIDDLEWARE` (línea 40-51) +- **Agregado**: `CSRF_EXEMPT_URLS` (línea 154) +- **Modificado**: `CORS_ALLOWED_ORIGINS` (línea 140-143) +- **Agregado**: `CSRF_TRUSTED_ORIGINS` (línea 148-151) + +### 3. `backend/mac_attendance/urls.py` +- **Modificado**: Función `api_root()` - agregado autenticación (línea 23-26) +- **Modificado**: Función `api_docs()` - agregado autenticación (línea 138-140) + +### 4. `API_DOCUMENTATION.md` +- **Actualizado**: Sección "Acceder a la Documentación" con advertencia de autenticación + +--- + +## Testing Realizado + +### Test 1: Login desde cURL (simulando navegador) +```bash +curl -X POST http://localhost/api/auth/login/ \ + -H "Content-Type: application/json" \ + -H "Origin: http://localhost" \ + -d '{"account_number":"3123123"}' +``` +**Resultado**: ✅ Status 200, retorna tokens JWT + +### Test 2: API Root sin autenticación +```bash +curl -X GET http://localhost/api/ +``` +**Resultado**: ✅ Status 401, mensaje de error apropiado + +### Test 3: API Root con autenticación +```bash +curl -X GET http://localhost/api/ \ + -H "Authorization: Bearer " +``` +**Resultado**: ✅ Status 200, retorna información de API + +--- + +## Seguridad Mejorada + +### Antes de la Corrección: +- ❌ Login fallaba con 403 desde navegador +- ❌ `/api/` accesible sin autenticación +- ❌ `/api/docs/` accesible sin autenticación +- ⚠️ Información de endpoints expuesta públicamente + +### Después de la Corrección: +- ✅ Login funciona correctamente desde navegador +- ✅ `/api/` requiere autenticación JWT +- ✅ `/api/docs/` requiere autenticación JWT +- ✅ Información de API protegida +- ✅ CSRF deshabilitado solo en rutas API (usamos JWT) +- ✅ CSRF sigue activo en panel admin (usa sesiones) + +--- + +## Notas Técnicas + +### ¿Por qué deshabilitar CSRF en API? + +Django REST Framework con JWT no necesita protección CSRF porque: +1. **No usa cookies de sesión**: JWT se envía en header `Authorization` +2. **Tokens stateless**: No hay estado de sesión en servidor +3. **CORS protege**: CORS ya previene requests desde orígenes no autorizados +4. **JWT expira**: Tokens tienen tiempo de vida limitado (1 hora) + +### Alternativa No Recomendada +Usar `@csrf_exempt` en cada vista → ❌ Requiere modificar múltiples archivos +**Nuestra solución**: Middleware centralizado → ✅ Un solo punto de configuración + +--- + +## Cómo Probar + +### 1. Desde el navegador (http://localhost) +1. Abrir la aplicación +2. Ingresar número de cuenta: `3123123` +3. Click en "Iniciar Sesión" +4. ✅ Debe iniciar sesión exitosamente + +### 2. Verificar protección de `/api/` +1. Abrir navegador e ir a http://localhost/api/ +2. ✅ Debe mostrar: `{"detail": "Las credenciales de autenticación no se proveyeron."}` + +### 3. Acceder a `/api/` autenticado +1. Hacer login primero +2. Copiar el access_token +3. Usar extensión de navegador o Postman: + - URL: http://localhost/api/ + - Header: `Authorization: Bearer ` +4. ✅ Debe mostrar información completa de la API + +--- + +## Próximos Pasos (Opcional) + +- [ ] Implementar rate limiting específico para login (ya existe global) +- [ ] Agregar logs de accesos exitosos/fallidos a `/api/` +- [ ] Considerar implementar API key para documentación pública +- [ ] Agregar tests automatizados para validación CSRF + +--- + +## Contacto + +Si encuentras algún problema con estas correcciones, revisa: +1. Logs del backend: `docker-compose logs backend` +2. Logs de nginx: `docker-compose logs nginx` +3. Consola del navegador (F12) para ver errores de JavaScript + +--- + +**Revisado y probado**: ✅ +**Contenedores actualizados**: ✅ +**Documentación actualizada**: ✅ diff --git a/docs/COMO_USAR_ESCANER_USB.md b/docs/COMO_USAR_ESCANER_USB.md new file mode 100644 index 0000000..287b7fa --- /dev/null +++ b/docs/COMO_USAR_ESCANER_USB.md @@ -0,0 +1,143 @@ +# 📱 Cómo Usar un Escáner de Código de Barras USB + +## ✅ Qué es un Escáner USB + +Un **escáner de código de barras USB** (también llamado pistola o lector) es un dispositivo físico que se conecta a la computadora por USB y **funciona automáticamente como un teclado**. + +### Tipos de Escáneres USB: +- 🔫 **Pistola/Lector de mano** - Se sostiene y se apunta al código +- 📟 **Lector fijo de mesa** - Se coloca en el escritorio y se pasa el código +- 🖊️ **Lápiz lector** - Tipo pluma, se desliza sobre el código + +## 🔌 Cómo Funciona + +1. **Conectas el escáner USB a la computadora** +2. Windows/Mac/Linux lo reconoce automáticamente como teclado +3. **No necesita drivers ni software especial** +4. Cuando escaneas un código de barras: + - El escáner lee el código + - **Escribe automáticamente los números** como si los escribieras en el teclado + - Presiona ENTER automáticamente (configurable) + +## 📋 Pasos para Usar con el Sistema + +### 1. Conectar el Escáner +``` +1. Conecta el escáner USB a la computadora +2. Espera el sonido de "dispositivo conectado" +3. ¡Listo! Ya funciona +``` + +### 2. Registrar Asistencia +``` +1. Abre el sistema en el navegador +2. Inicia sesión como ASISTENTE +3. Selecciona el EVENTO activo +4. Haz clic en el campo "Número de Cuenta" +5. Escanea el código de barras de la credencial +6. El número se escribe automáticamente +7. Presiona ENTER o clic en "Registrar Asistencia" +``` + +### 3. Flujo Rápido (Recomendado) +``` +1. Selecciona el evento +2. Mantén el cursor en el campo "Número de Cuenta" +3. Escanea código → automáticamente aparece el número +4. ENTER → se registra la asistencia +5. El cursor vuelve al campo +6. Repite: Escanea → ENTER → Escanea → ENTER +``` + +## ⚙️ Configuración del Escáner + +### Activar ENTER Automático +La mayoría de escáneres USB tienen configuración para presionar ENTER automáticamente después de escanear. + +**Para activarlo:** +1. Busca el manual de tu escáner +2. Escanea el código de barras "Add Suffix CR" o "Enable Enter Key" +3. Esto hará que después de escanear presione ENTER automáticamente + +### Configuraciones Comunes: +- ✅ **Suffix CR** - Presiona ENTER después de escanear +- ✅ **Beep on Read** - Sonido al leer correctamente +- ✅ **LED Indicator** - Luz al leer correctamente + +## 🛒 Dónde Comprar + +### Tiendas en Línea: +- **Amazon México** - Desde $200 MXN +- **Mercado Libre** - Desde $150 MXN +- **AliExpress** - Desde $100 MXN (tarda más) + +### Modelos Recomendados: +1. **Honeywell Voyager 1200g** - $800-1200 MXN (Profesional) +2. **Zebra DS2208** - $1500-2000 MXN (Alta calidad) +3. **Genéricos USB** - $200-400 MXN (Económicos, funcionan bien) + +**Búsqueda sugerida:** +- "Lector código barras USB" +- "Pistola escáner USB" +- "Barcode scanner USB" + +## 💡 Ventajas vs Cámara + +| Característica | Escáner USB | Cámara Web | +|---------------|-------------|------------| +| Velocidad | ⚡ Instantáneo | 🐌 Lento (2-3 seg) | +| Precisión | ✅ 99.9% | ❌ 60-70% | +| Distancia | 📏 10-30cm | 📏 5-10cm | +| Iluminación | ☀️ Funciona con poca luz | 💡 Requiere buena luz | +| Costo | 💰 $200-1500 MXN | 💰 $0 (ya tienes cámara) | +| Instalación | 🔌 Plug & Play | 🔧 Requiere código | +| Confiabilidad | ✅ Muy alta | ❌ Media-baja | + +## 🎯 Recomendación Final + +**Para un evento real de asistencia masiva:** +- ✅ **USA ESCÁNER USB** - Rápido, preciso, confiable +- ❌ Evita la cámara - Lento, impreciso, frustrante + +**Inversión mínima:** +- Un escáner genérico de $200-400 MXN funciona perfectamente +- Se paga solo con el tiempo ahorrado en 1 evento + +## 📝 Ejemplo de Uso + +``` +EVENTO: Conferencia MAC - 9 de Octubre 2025 +ASISTENTES: 150 personas + +Con Escáner USB: +- 2 segundos por persona +- Total: 5 minutos para todos +- 0 errores + +Con Cámara: +- 10-15 segundos por persona (intentos fallidos) +- Total: 25-40 minutos +- 10-20% de errores (reintento manual) +``` + +## 🔧 Solución de Problemas + +### El escáner no escribe nada: +1. Verifica que esté conectado (luz LED encendida) +2. Abre un Bloc de Notas y escanea - debe escribir +3. Si no escribe, intenta otro puerto USB +4. Reinicia la computadora + +### Escribe caracteres raros: +1. El escáner está en modo "teclado internacional" +2. Busca código de barras "Set Keyboard Layout to English" +3. Escanéalo para cambiar a modo correcto + +### No presiona ENTER: +1. Busca en el manual el código "Add Suffix CR" +2. Escanea ese código de configuración +3. Ahora presionará ENTER automáticamente + +--- + +**Sistema desarrollado para FES Acatlán - UNAM** diff --git a/docs/DESARROLLO.md b/docs/DESARROLLO.md new file mode 100644 index 0000000..840b400 --- /dev/null +++ b/docs/DESARROLLO.md @@ -0,0 +1,524 @@ +# Guía de Desarrollo - Sistema de Asistencia MAC + +Esta guía describe cómo configurar y usar el entorno de desarrollo con todas las herramientas de calidad de código, testing y análisis. + +## Índice + +1. [Configuración del Entorno](#configuración-del-entorno) +2. [Herramientas de Desarrollo](#herramientas-de-desarrollo) +3. [Testing con Tox](#testing-con-tox) +4. [Acceso a PostgreSQL](#acceso-a-postgresql) +5. [Comandos Útiles](#comandos-útiles) +6. [Flujo de Trabajo](#flujo-de-trabajo) + +--- + +## Configuración del Entorno + +### Requisitos Previos + +- Docker y Docker Compose instalados +- Git +- Puerto 80 (nginx), 5432 (PostgreSQL) disponibles + +### Iniciar Entorno de Desarrollo + +**Opción 1: Usando docker-compose.dev.yml (Recomendado para desarrollo)** + +```bash +# Construir e iniciar todos los servicios +docker-compose -f docker-compose.dev.yml up --build + +# En modo detached (segundo plano) +docker-compose -f docker-compose.dev.yml up -d --build +``` + +**Opción 2: Usando docker-compose.yml (Producción)** + +```bash +docker-compose up --build +``` + +### Diferencias entre Entornos + +| Característica | docker-compose.yml (Producción) | docker-compose.dev.yml (Desarrollo) | +|----------------|--------------------------------|-------------------------------------| +| Servidor | Gunicorn (3 workers) | Django runserver | +| Herramientas dev | ❌ No incluidas | ✅ Tox, pytest, linters, etc. | +| Puerto PostgreSQL | ✅ Expuesto (5432) | ✅ Expuesto (5432) | +| Debugging | ❌ Limitado | ✅ ipdb, logging detallado | +| Volúmenes | Solo static/media | Código completo montado | +| Hot reload | ❌ No | ✅ Sí (Django runserver) | + +--- + +## Herramientas de Desarrollo + +### Instaladas en el Contenedor + +El archivo `backend/requirements-dev.txt` incluye: + +#### **Linting y Formateo** +- **flake8** - Verificador de estilo PEP8 +- **pycodestyle** - Verificador de estilo PEP8 +- **autopep8** - Corrector automático de PEP8 +- **black** - Formateador de código opinionado +- **isort** - Ordenador de imports + +#### **Análisis Estático** +- **pylint** - Analizador de código estático +- **mccabe** - Complejidad ciclomática +- **radon** - Métricas de código + +#### **Type Checking** +- **mypy** - Verificador de tipos estáticos +- **django-stubs** - Type stubs para Django +- **djangorestframework-stubs** - Type stubs para DRF + +#### **Seguridad** +- **bandit** - Verificador de seguridad +- **safety** - Verificador de vulnerabilidades + +#### **Testing** +- **pytest** - Framework de testing +- **pytest-django** - Plugin pytest para Django +- **pytest-cov** - Cobertura de tests +- **coverage** - Herramienta de cobertura +- **factory-boy** - Factories para testing + +#### **Testing Automation** +- **tox** - Automatización de testing multi-entorno + +#### **Utilidades** +- **django-extensions** - Extensiones útiles para Django +- **ipython** - Shell interactivo mejorado +- **ipdb** - Debugger interactivo +- **pre-commit** - Framework de pre-commit hooks + +--- + +## Testing con Tox + +Tox automatiza testing en múltiples entornos. Configurado en `backend/tox.ini`. + +### Entornos Disponibles + +```bash +# Entrar al contenedor backend +docker-compose -f docker-compose.dev.yml exec backend bash + +# Ver todos los entornos disponibles +tox -l +``` + +**Salida esperada:** +``` +py311-django52 +test +test-fast +lint +format +format-check +type-check +security +security-full +coverage +coverage-report +metrics +complexity +docs +clean +``` + +### Ejecutar Tests + +**Tests completos con cobertura:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e py311-django52 +``` + +**Tests rápidos (sin cobertura):** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e test-fast +``` + +**Solo tests unitarios:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e test -- -m unit +``` + +**Solo tests de integración:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e test -- -m integration +``` + +### Linting y Formateo + +**Ejecutar linters (flake8 + pylint):** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e lint +``` + +**Verificar formato (sin modificar):** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e format-check +``` + +**Formatear código automáticamente:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e format +``` + +### Type Checking + +**Verificar tipos con mypy:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e type-check +``` + +### Análisis de Seguridad + +**Escaneo de seguridad básico:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e security +``` + +**Escaneo completo (genera reportes JSON):** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e security-full +``` + +### Cobertura de Código + +**Generar reporte de cobertura:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e coverage +``` + +**Ver reporte en consola:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e coverage-report +``` + +**Reporte HTML** se genera en `backend/htmlcov/index.html` + +### Métricas de Código + +**Complejidad ciclomática y mantenibilidad:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e metrics +``` + +**Solo complejidad (con promedio):** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e complexity +``` + +### Limpieza + +**Eliminar archivos generados:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox -e clean +``` + +### Ejecutar Todos los Entornos + +**Ejecutar todos los chequeos:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox +``` + +⚠️ **Nota:** Esto puede tardar varios minutos. + +--- + +## Acceso a PostgreSQL + +### Desde tu Laptop (Host) + +El puerto 5432 está expuesto en ambos entornos (producción y desarrollo). + +**Credenciales por defecto:** +- **Host:** localhost +- **Puerto:** 5432 +- **Database:** mac_attendance +- **Usuario:** mac_user +- **Password:** mac_password_2024_secure + +#### **Con psql (línea de comandos)** + +```bash +psql -h localhost -p 5432 -U mac_user -d mac_attendance +``` + +#### **Con pgAdmin** + +1. Abrir pgAdmin +2. Crear nueva conexión: + - Name: MAC Attendance + - Host: localhost + - Port: 5432 + - Database: mac_attendance + - Username: mac_user + - Password: mac_password_2024_secure + +#### **Con DBeaver / DataGrip** + +Similar a pgAdmin, usar las credenciales arriba. + +### Desde el Contenedor Backend + +```bash +# Entrar al contenedor backend +docker-compose -f docker-compose.dev.yml exec backend bash + +# Conectarse a PostgreSQL +psql -h db -U mac_user -d mac_attendance +``` + +### Comandos Útiles de PostgreSQL + +**Listar tablas:** +```sql +\dt +``` + +**Describir tabla:** +```sql +\d authentication_student +``` + +**Ver tamaño de base de datos:** +```sql +SELECT pg_size_pretty(pg_database_size('mac_attendance')); +``` + +**Ver conexiones activas:** +```sql +SELECT * FROM pg_stat_activity; +``` + +**Contar registros:** +```sql +SELECT COUNT(*) FROM authentication_student; +SELECT COUNT(*) FROM events_event; +SELECT COUNT(*) FROM attendance_attendance; +``` + +--- + +## Comandos Útiles + +### Gestión de Contenedores + +**Ver logs en tiempo real:** +```bash +docker-compose -f docker-compose.dev.yml logs -f backend +docker-compose -f docker-compose.dev.yml logs -f db +``` + +**Entrar al contenedor backend:** +```bash +docker-compose -f docker-compose.dev.yml exec backend bash +``` + +**Ejecutar comando Django:** +```bash +docker-compose -f docker-compose.dev.yml exec backend python manage.py +``` + +**Crear superusuario:** +```bash +docker-compose -f docker-compose.dev.yml exec backend python manage.py createsuperuser +``` + +**Hacer migraciones:** +```bash +docker-compose -f docker-compose.dev.yml exec backend python manage.py makemigrations +docker-compose -f docker-compose.dev.yml exec backend python manage.py migrate +``` + +**Django shell:** +```bash +docker-compose -f docker-compose.dev.yml exec backend python manage.py shell +``` + +**Django shell_plus (con django-extensions):** +```bash +docker-compose -f docker-compose.dev.yml exec backend python manage.py shell_plus +``` + +### Backup y Restore + +**Backup de base de datos:** +```bash +docker-compose -f docker-compose.dev.yml exec db pg_dump -U mac_user mac_attendance > backup_$(date +%Y%m%d).sql +``` + +**Restaurar backup:** +```bash +cat backup.sql | docker-compose -f docker-compose.dev.yml exec -T db psql -U mac_user mac_attendance +``` + +--- + +## Flujo de Trabajo + +### 1. Desarrollo de Nueva Funcionalidad + +```bash +# 1. Iniciar entorno de desarrollo +docker-compose -f docker-compose.dev.yml up -d + +# 2. Crear rama de feature +git checkout -b feature/nueva-funcionalidad + +# 3. Desarrollar (los cambios se reflejan automáticamente) +# Editar archivos en backend/ o frontend/ + +# 4. Ejecutar tests mientras desarrollas +docker-compose -f docker-compose.dev.yml exec backend tox -e test-fast + +# 5. Verificar formato antes de commit +docker-compose -f docker-compose.dev.yml exec backend tox -e format-check + +# 6. Formatear código si es necesario +docker-compose -f docker-compose.dev.yml exec backend tox -e format + +# 7. Ejecutar linters +docker-compose -f docker-compose.dev.yml exec backend tox -e lint + +# 8. Verificar tipos +docker-compose -f docker-compose.dev.yml exec backend tox -e type-check + +# 9. Verificar seguridad +docker-compose -f docker-compose.dev.yml exec backend tox -e security + +# 10. Ejecutar suite completa antes de commit +docker-compose -f docker-compose.dev.yml exec backend tox +``` + +### 2. Debugging + +**Con ipdb:** + +```python +# En tu código Python +import ipdb; ipdb.set_trace() +``` + +Luego: +```bash +docker-compose -f docker-compose.dev.yml up +# El contenedor se pausará en el breakpoint +``` + +**Ver logs detallados:** +```bash +docker-compose -f docker-compose.dev.yml logs -f backend +``` + +### 3. Testing + +```bash +# Tests rápidos durante desarrollo +docker-compose -f docker-compose.dev.yml exec backend pytest + +# Con cobertura +docker-compose -f docker-compose.dev.yml exec backend pytest --cov=. + +# Solo una app +docker-compose -f docker-compose.dev.yml exec backend pytest authentication/ + +# Solo un archivo +docker-compose -f docker-compose.dev.yml exec backend pytest authentication/tests/test_models.py + +# Solo una función de test +docker-compose -f docker-compose.dev.yml exec backend pytest authentication/tests/test_models.py::test_student_creation +``` + +### 4. Antes de Hacer Commit + +**Checklist:** + +- [ ] Tests pasan: `tox -e test` +- [ ] Código formateado: `tox -e format` +- [ ] Linters limpios: `tox -e lint` +- [ ] Type checking OK: `tox -e type-check` +- [ ] Sin vulnerabilidades: `tox -e security` +- [ ] Cobertura > 80%: `tox -e coverage` + +**Ejecutar todo de una vez:** +```bash +docker-compose -f docker-compose.dev.yml exec backend tox +``` + +### 5. Pre-commit Hooks (Opcional) + +Para automatizar chequeos antes de cada commit: + +```bash +# Dentro del contenedor backend +docker-compose -f docker-compose.dev.yml exec backend bash + +# Instalar hooks +pre-commit install + +# Ejecutar manualmente +pre-commit run --all-files +``` + +--- + +## Solución de Problemas + +### "Database does not exist" + +```bash +docker-compose -f docker-compose.dev.yml down -v +docker-compose -f docker-compose.dev.yml up -d +``` + +### "Port 5432 already in use" + +Cambiar el puerto en `docker-compose.dev.yml`: +```yaml +ports: + - "5433:5432" # Usar puerto 5433 en host +``` + +### "Permission denied" en archivos + +```bash +# Dentro del contenedor +chown -R $(whoami) /app +``` + +### Limpiar todo y empezar de nuevo + +```bash +docker-compose -f docker-compose.dev.yml down -v +docker system prune -a +docker volume prune +docker-compose -f docker-compose.dev.yml up --build +``` + +--- + +## Recursos Adicionales + +- [Documentación de Django](https://docs.djangoproject.com/) +- [Documentación de Django REST Framework](https://www.django-rest-framework.org/) +- [Documentación de Tox](https://tox.wiki/) +- [Documentación de pytest](https://docs.pytest.org/) +- [PostgreSQL Docs](https://www.postgresql.org/docs/) + +--- + +## Fecha de Creación + +**Octubre 15, 2025** + +--- + +**Nota:** Este documento describe el entorno de desarrollo completo con todas las herramientas de calidad de código, testing y análisis integradas. diff --git a/docs/DOCKER_SETUP.md b/docs/DOCKER_SETUP.md new file mode 100644 index 0000000..9b448a3 --- /dev/null +++ b/docs/DOCKER_SETUP.md @@ -0,0 +1,271 @@ +# Configuración Docker con Nginx + +Esta configuración permite ejecutar la aplicación completa (Frontend React + Backend Django) usando Docker con Nginx como servidor web. + +## Arquitectura + +``` +┌─────────────────────────────────────────┐ +│ Nginx (Puerto 80) │ +│ - Sirve Frontend (React build) │ +│ - Proxy reverso para Backend Django │ +└─────────────────────────────────────────┘ + │ │ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ Frontend │ │ Backend │ + │ React │ │ Django │ + │ (Vite) │ │ (Puerto │ + └──────────┘ │ 8000) │ + └──────────┘ + │ + ▼ + ┌──────────┐ + │PostgreSQL│ + │ DB │ + └──────────┘ +``` + +## Archivos Creados + +- `Dockerfile.frontend` - Build del frontend React +- `Dockerfile.backend` - Configuración del backend Django +- `docker-compose.yml` - Orquestación de servicios +- `nginx/nginx.conf` - Configuración de Nginx +- `.dockerignore` - Archivos excluidos del build + +## Requisitos Previos + +- Docker Desktop instalado +- Docker Compose instalado + +## Instrucciones de Uso + +### 1. Configurar Variables de Entorno + +Edita el archivo `docker-compose.yml` y ajusta las variables de entorno del servicio `backend`: + +```yaml +environment: + - SECRET_KEY=tu-secret-key-super-segura-cambiar-en-produccion + - ALLOWED_HOSTS=localhost,127.0.0.1,nginx,tu-dominio.com +``` + +### 2. Construir y Levantar los Contenedores + +```bash +# Construir las imágenes +docker-compose build + +# Levantar todos los servicios +docker-compose up -d + +# Ver logs +docker-compose logs -f +``` + +### 3. Acceder a la Aplicación + +- **Frontend**: http://localhost +- **API Backend**: http://localhost/api/ +- **Admin Django**: http://localhost/admin/ + +### 4. Comandos Útiles + +```bash +# Detener los contenedores +docker-compose down + +# Detener y eliminar volúmenes (¡cuidado, elimina la base de datos!) +docker-compose down -v + +# Reconstruir un servicio específico +docker-compose build backend +docker-compose up -d backend + +# Ver logs de un servicio específico +docker-compose logs -f nginx +docker-compose logs -f backend + +# Ejecutar comandos en el backend +docker-compose exec backend python manage.py createsuperuser +docker-compose exec backend python manage.py migrate + +# Reiniciar un servicio +docker-compose restart backend +``` + +### 5. Gestión de la Base de Datos + +```bash +# Crear migraciones +docker-compose exec backend python manage.py makemigrations + +# Aplicar migraciones +docker-compose exec backend python manage.py migrate + +# Crear superusuario +docker-compose exec backend python manage.py createsuperuser + +# Backup de la base de datos (PostgreSQL) +docker-compose exec db pg_dump -U postgres mac_attendance > backup.sql + +# Restaurar backup +docker-compose exec -T db psql -U postgres mac_attendance < backup.sql +``` + +## Configuración para Producción + +### 1. Usar SQLite en lugar de PostgreSQL + +Si prefieres usar SQLite (más simple pero menos escalable): + +En `docker-compose.yml`: +- Comenta o elimina el servicio `db` +- Comenta la línea `DATABASE_URL` en el servicio `backend` +- Asegúrate de que Django esté configurado para usar SQLite en `settings.py` + +### 2. Variables de Entorno Seguras + +Crea un archivo `.env` en la raíz del proyecto: + +```env +SECRET_KEY=tu-secret-key-muy-segura-y-larga +DEBUG=False +ALLOWED_HOSTS=localhost,tu-dominio.com +DB_NAME=mac_attendance +DB_USER=postgres +DB_PASSWORD=password-super-seguro +DB_HOST=db +DB_PORT=5432 +``` + +Modifica `docker-compose.yml` para usar el archivo `.env`: + +```yaml +services: + backend: + env_file: + - .env +``` + +### 3. HTTPS con Certificados SSL + +Para habilitar HTTPS, necesitas: + +1. Obtener certificados SSL (Let's Encrypt, Certbot, etc.) +2. Modificar `nginx/nginx.conf` para escuchar en el puerto 443 +3. Agregar la configuración SSL + +Ejemplo: + +```nginx +server { + listen 443 ssl http2; + server_name tu-dominio.com; + + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + + # ... resto de la configuración +} + +server { + listen 80; + server_name tu-dominio.com; + return 301 https://$server_name$request_uri; +} +``` + +### 4. Optimizaciones de Producción + +En `Dockerfile.backend`, ajusta workers de Gunicorn según tu servidor: + +```dockerfile +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "--threads", "2", "mac_attendance.wsgi:application"] +``` + +Regla general: `workers = (2 x CPU cores) + 1` + +## Troubleshooting + +### El frontend no carga + +```bash +# Verificar logs del nginx +docker-compose logs nginx + +# Reconstruir el frontend +docker-compose build nginx --no-cache +docker-compose up -d nginx +``` + +### Error de conexión al backend + +```bash +# Verificar que el backend esté corriendo +docker-compose ps + +# Ver logs del backend +docker-compose logs backend + +# Verificar conectividad +docker-compose exec nginx ping backend +``` + +### Problemas con migraciones + +```bash +# Entrar al contenedor del backend +docker-compose exec backend sh + +# Ejecutar migraciones manualmente +python manage.py migrate --run-syncdb +``` + +### Permisos en archivos media/static + +```bash +# Dar permisos al directorio +docker-compose exec backend chmod -R 755 /app/media +docker-compose exec backend chmod -R 755 /app/staticfiles +``` + +## Monitoreo y Logs + +```bash +# Ver uso de recursos +docker stats + +# Ver todos los logs +docker-compose logs --tail=100 + +# Seguir logs en tiempo real +docker-compose logs -f --tail=100 +``` + +## Limpieza + +```bash +# Limpiar contenedores detenidos +docker container prune + +# Limpiar imágenes sin usar +docker image prune -a + +# Limpiar volúmenes sin usar +docker volume prune + +# Limpieza completa (¡cuidado!) +docker system prune -a --volumes +``` + +## Mejoras Futuras + +- [ ] Configurar Redis para caché +- [ ] Agregar Celery para tareas asíncronas +- [ ] Implementar health checks más robustos +- [ ] Configurar logging centralizado +- [ ] Agregar monitoreo con Prometheus/Grafana +- [ ] Implementar CI/CD con GitHub Actions diff --git a/docs/ESTRUCTURA_PROYECTO.md b/docs/ESTRUCTURA_PROYECTO.md new file mode 100644 index 0000000..21ff34c --- /dev/null +++ b/docs/ESTRUCTURA_PROYECTO.md @@ -0,0 +1,156 @@ +# Estructura del Proyecto + +Este documento describe la organización de carpetas y archivos del Sistema de Asistencia MAC. + +## Estructura de Directorios + +``` +pagina-mac-og/ +├── backend/ # Aplicación Django (Backend) +│ ├── attendance/ # App de gestión de asistencias +│ ├── authentication/ # App de autenticación y usuarios +│ ├── events/ # App de gestión de eventos +│ ├── mac_attendance/ # Configuración principal de Django +│ ├── scripts/ # Scripts de utilidad y pruebas +│ ├── static/ # Archivos estáticos (CSS, JS) +│ ├── templates/ # Plantillas HTML +│ ├── db/ # Base de datos SQLite +│ ├── logs/ # Logs de la aplicación +│ ├── media/ # Archivos multimedia subidos +│ └── manage.py # CLI de Django +│ +├── frontend/ # Aplicación React (Frontend) +│ ├── src/ # Código fuente +│ │ ├── components/ # Componentes React +│ │ ├── contexts/ # Contextos (Auth, etc.) +│ │ ├── services/ # Servicios (API calls) +│ │ └── styles/ # Estilos CSS +│ ├── public/ # Archivos públicos +│ └── package.json # Dependencias de Node.js +│ +├── docker/ # Configuración Docker +│ ├── Dockerfile.backend # Dockerfile para Django +│ ├── Dockerfile.frontend # Dockerfile para React + Nginx +│ └── nginx.conf # Configuración de Nginx +│ +├── docs/ # Documentación +│ ├── ACCESO_ADMIN.md # Guía de acceso al admin +│ ├── API_DOCUMENTATION.md # Documentación de API +│ ├── DOCKER_SETUP.md # Configuración de Docker +│ ├── GUIA_COMPARTIR_DOCKER.md # Guía para compartir +│ ├── INSTRUCCIONES_GITHUB.md # Instrucciones de Git +│ ├── ESTRUCTURA_PROYECTO.md # Este archivo +│ └── postman_collection.json # Colección de Postman +│ +├── docker-compose.yml # Orquestación de contenedores +├── .dockerignore # Archivos ignorados por Docker +├── .env.example # Ejemplo de variables de entorno +├── .gitignore # Archivos ignorados por Git +├── install.sh # Script de instalación (Linux/Mac) +├── install.bat # Script de instalación (Windows) +├── create_superuser.sh # Script para crear superusuario +├── README.md # Documentación principal +└── LICENSE # Licencia del proyecto +``` + +## Descripción de Componentes + +### Backend (Django) + +**Aplicaciones Django:** +- **attendance**: Gestión de asistencias y estadísticas +- **authentication**: Sistema de autenticación, usuarios, perfiles +- **events**: Gestión de eventos del MAC +- **mac_attendance**: Configuración principal del proyecto Django + +**Archivos importantes:** +- `manage.py`: CLI de Django para ejecutar comandos +- `requirements.txt`: Dependencias de Python +- `db.sqlite3`: Base de datos SQLite (desarrollo) + +### Frontend (React) + +**Estructura:** +- `src/components/`: Componentes React organizados por rol (admin, student, attendance) +- `src/contexts/`: Contextos de React (AuthContext para autenticación) +- `src/services/`: Servicios para llamadas a la API +- `src/styles/`: Archivos CSS + +### Docker + +**Archivos:** +- `Dockerfile.backend`: Imagen de Docker para el backend Django +- `Dockerfile.frontend`: Imagen de Docker para el frontend React + Nginx +- `nginx.conf`: Configuración del servidor Nginx como proxy reverso + +### Documentación + +Toda la documentación técnica, guías de uso y configuración se encuentra en la carpeta `docs/`. + +## Flujo de Trabajo + +### Desarrollo + +1. **Backend**: Modificar código en `backend/` +2. **Frontend**: Modificar código en `frontend/src/` +3. **Docker**: Los cambios se reflejan automáticamente con volúmenes + +### Despliegue + +1. Construir imágenes: `docker-compose build` +2. Iniciar servicios: `docker-compose up -d` +3. Verificar logs: `docker-compose logs -f` + +## Scripts Útiles + +### Backend (desde `backend/`) +```bash +python manage.py migrate # Aplicar migraciones +python manage.py createsuperuser # Crear superusuario +python manage.py collectstatic # Recopilar archivos estáticos +python scripts/create_test_data.py # Crear datos de prueba +``` + +### Docker +```bash +docker-compose up -d # Iniciar servicios +docker-compose down # Detener servicios +docker-compose logs backend # Ver logs del backend +docker-compose restart backend # Reiniciar backend +``` + +## Variables de Entorno + +Copia `.env.example` a `.env` y configura las variables según tu entorno: + +```bash +cp .env.example .env +# Editar .env con tus valores +``` + +## Puertos + +- **Frontend (Nginx)**: http://localhost (puerto 80) +- **Backend (Django)**: http://localhost:8000 (interno, proxy via Nginx) +- **Admin Django**: http://localhost/admin + +## Notas Importantes + +1. **Base de datos**: SQLite por defecto en desarrollo. Para producción, usar PostgreSQL. +2. **Archivos estáticos**: Servidos por Nginx en producción +3. **CORS**: Configurado para permitir localhost en desarrollo +4. **Logs**: Disponibles en `backend/logs/` y via `docker-compose logs` + +## Convenciones + +- **Backend**: Seguir guía de estilo PEP 8 para Python +- **Frontend**: Seguir guía de estilo de Airbnb para JavaScript/React +- **Commits**: Mensajes descriptivos en español +- **Branches**: `main` para producción, crear feature branches para desarrollo + +## Soporte + +Para más información, consulta: +- [README.md](../README.md) - Guía general del proyecto +- [API_DOCUMENTATION.md](API_DOCUMENTATION.md) - Documentación de la API +- [DOCKER_SETUP.md](DOCKER_SETUP.md) - Configuración detallada de Docker diff --git a/docs/POSTGRESQL_MIGRATION.md b/docs/POSTGRESQL_MIGRATION.md new file mode 100644 index 0000000..93d6c48 --- /dev/null +++ b/docs/POSTGRESQL_MIGRATION.md @@ -0,0 +1,289 @@ +# Migración a PostgreSQL + +## Resumen + +El Sistema de Asistencia MAC ha sido migrado de SQLite a PostgreSQL para mejorar el rendimiento, escalabilidad y prepararlo para producción. + +## ¿Por qué PostgreSQL? + +### Ventajas sobre SQLite + +✅ **Escalabilidad**: Maneja millones de registros sin degradación de rendimiento +✅ **Concurrencia**: Múltiples usuarios simultáneos sin bloqueos +✅ **Rendimiento**: Mejor optimización de queries complejos +✅ **Integridad**: Constraints y validaciones más robustas +✅ **Backups**: Herramientas profesionales de respaldo (pg_dump, pg_basebackup) +✅ **Replicación**: Soporte nativo para alta disponibilidad +✅ **Producción**: Estándar en la industria para aplicaciones web + +## Cambios Realizados + +### 1. Backend - Requirements + +**Archivo:** `backend/requirements.txt` + +```python +# Database - PostgreSQL +psycopg2-binary==2.9.9 +``` + +### 2. Backend - Settings + +**Archivo:** `backend/mac_attendance/settings.py` + +```python +# Database Configuration - PostgreSQL +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': config('DB_NAME', default='mac_attendance'), + 'USER': config('DB_USER', default='mac_user'), + 'PASSWORD': config('DB_PASSWORD', default='mac_password_2024_secure'), + 'HOST': config('DB_HOST', default='db'), + 'PORT': config('DB_PORT', default='5432'), + 'CONN_MAX_AGE': 600, # Conexiones persistentes + 'OPTIONS': { + 'connect_timeout': 10, + } + } +} +``` + +### 3. Docker Compose + +**Archivo:** `docker-compose.yml` + +Se agregó el servicio de PostgreSQL: + +```yaml +services: + # PostgreSQL Database + db: + image: postgres:15-alpine + environment: + - POSTGRES_DB=mac_attendance + - POSTGRES_USER=mac_user + - POSTGRES_PASSWORD=mac_password_2024_secure + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app-network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mac_user -d mac_attendance"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + depends_on: + db: + condition: service_healthy + environment: + - DB_ENGINE=postgresql + - DB_NAME=mac_attendance + - DB_USER=mac_user + - DB_PASSWORD=mac_password_2024_secure + - DB_HOST=db + - DB_PORT=5432 + +volumes: + postgres_data: +``` + +### 4. Variables de Entorno + +**Archivo:** `.env.example` + +```env +# Database - PostgreSQL (Producción) +DB_NAME=mac_attendance +DB_USER=mac_user +DB_PASSWORD=mac_password_2024_secure +DB_HOST=db +DB_PORT=5432 +``` + +## Estado del Sistema + +### Contenedores Activos + +```bash +$ docker-compose ps +NAME STATUS +pagina-mac-og-backend-1 Up (running) +pagina-mac-og-db-1 Up (healthy) +pagina-mac-og-nginx-1 Up (running) +``` + +### Migraciones Aplicadas + +✅ Todas las migraciones de Django aplicadas correctamente +✅ Base de datos creada: `mac_attendance` +✅ Usuario de base de datos: `mac_user` +✅ Tablas creadas: 16 tablas (auth, authentication, events, attendance, admin, sessions) + +## Gestión de PostgreSQL + +### Comandos Útiles + +**Conectarse a PostgreSQL:** +```bash +docker-compose exec db psql -U mac_user -d mac_attendance +``` + +**Listar tablas:** +```sql +\dt +``` + +**Ver tamaño de base de datos:** +```sql +SELECT pg_size_pretty(pg_database_size('mac_attendance')); +``` + +**Backup de base de datos:** +```bash +docker-compose exec db pg_dump -U mac_user mac_attendance > backup.sql +``` + +**Restaurar backup:** +```bash +cat backup.sql | docker-compose exec -T db psql -U mac_user mac_attendance +``` + +**Ver conexiones activas:** +```sql +SELECT * FROM pg_stat_activity; +``` + +## Configuración de Producción + +### 1. Cambiar Credenciales + +**IMPORTANTE:** Antes de desplegar en producción, cambiar: + +```env +DB_USER=tu_usuario_seguro +DB_PASSWORD=contraseña_muy_segura_y_compleja +SECRET_KEY=tu_secret_key_super_segura +``` + +### 2. Backups Automáticos + +Configurar cron job para backups diarios: + +```bash +#!/bin/bash +# /etc/cron.daily/postgres-backup + +BACKUP_DIR="/backups/postgres" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/mac_attendance_$DATE.sql" + +mkdir -p $BACKUP_DIR +docker-compose exec -T db pg_dump -U mac_user mac_attendance > $BACKUP_FILE +gzip $BACKUP_FILE + +# Mantener solo los últimos 30 días +find $BACKUP_DIR -type f -mtime +30 -delete +``` + +### 3. Optimización + +**Configurar índices adicionales (si es necesario):** + +```python +# En los modelos, agregar: +class Meta: + indexes = [ + models.Index(fields=['account_number']), + models.Index(fields=['event', 'student']), + ] +``` + +### 4. Monitoring + +**Ver queries lentas:** +```sql +SELECT query, mean_exec_time +FROM pg_stat_statements +ORDER BY mean_exec_time DESC +LIMIT 10; +``` + +## Capacidad y Límites + +### Capacidad PostgreSQL + +- **Tamaño máximo de base de datos**: Ilimitado +- **Tamaño máximo de tabla**: 32 TB +- **Filas por tabla**: Ilimitadas (prácticamente) +- **Usuarios concurrentes**: Miles + +### Estimaciones para el Sistema MAC + +**Escenario actual:** +- 1,000 estudiantes +- 100 eventos/año +- ~5,000 asistencias/año +- **Tamaño estimado**: < 100 MB/año + +**Escenario a 10 años:** +- **Tamaño estimado**: < 1 GB +- **Performance**: Excelente + +## Rollback (Volver a SQLite) + +Si necesitas volver a SQLite por alguna razón: + +### 1. Cambiar settings.py + +```python +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} +``` + +### 2. Comentar servicio db en docker-compose.yml + +```yaml +# db: +# image: postgres:15-alpine +# ... +``` + +### 3. Reconstruir + +```bash +docker-compose down -v +docker-compose up -d --build +``` + +## Verificación + +### ✅ Checklist Post-Migración + +- [✅] PostgreSQL corriendo y saludable +- [✅] Backend conectado a PostgreSQL +- [✅] Todas las migraciones aplicadas +- [✅] Aplicación accesible en http://localhost +- [✅] Variables de entorno configuradas +- [✅] Documentación actualizada + +## Soporte + +Para más información: +- [Documentación de PostgreSQL](https://www.postgresql.org/docs/) +- [Django con PostgreSQL](https://docs.djangoproject.com/en/stable/ref/databases/#postgresql-notes) +- [psycopg2](https://www.psycopg.org/docs/) + +## Fecha de Migración + +**Octubre 14, 2025** + +--- + +**Nota:** Este documento describe la migración a PostgreSQL para preparar el sistema para producción. Todas las funcionalidades permanecen idénticas, solo cambió el motor de base de datos. diff --git a/docs/postman_collection.json b/docs/postman_collection.json new file mode 100644 index 0000000..b038f90 --- /dev/null +++ b/docs/postman_collection.json @@ -0,0 +1,347 @@ +{ + "info": { + "name": "Sistema MAC - API Collection", + "_postman_id": "mac-attendance-api", + "description": "Colección completa de endpoints para el Sistema de Asistencia MAC", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost", + "type": "string" + }, + { + "key": "access_token", + "value": "", + "type": "string" + }, + { + "key": "refresh_token", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "Authentication", + "item": [ + { + "name": "Login", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const jsonData = pm.response.json();", + " pm.environment.set('access_token', jsonData.tokens.access);", + " pm.environment.set('refresh_token', jsonData.tokens.refresh);", + " pm.test('Login successful', () => {", + " pm.expect(jsonData.message).to.eql('Login exitoso');", + " });", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account_number\": \"3123123\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/auth/login/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "login", ""] + }, + "description": "Iniciar sesión con número de cuenta" + }, + "response": [] + }, + { + "name": "Get Profile", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/auth/profile/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "profile", ""] + }, + "description": "Obtener perfil del usuario autenticado" + }, + "response": [] + }, + { + "name": "Check Auth Status", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/auth/check-auth/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "check-auth", ""] + }, + "description": "Verificar estado de autenticación" + }, + "response": [] + }, + { + "name": "Refresh Token", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " const jsonData = pm.response.json();", + " pm.environment.set('access_token', jsonData.access);", + "}" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"refresh\": \"{{refresh_token}}\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/auth/token/refresh/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "token", "refresh", ""] + }, + "description": "Refrescar access token" + }, + "response": [] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/auth/logout/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "logout", ""] + }, + "description": "Cerrar sesión" + }, + "response": [] + }, + { + "name": "System Config", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/auth/system-config/", + "host": ["{{base_url}}"], + "path": ["api", "auth", "system-config", ""] + }, + "description": "Obtener configuración del sistema" + }, + "response": [] + } + ] + }, + { + "name": "Events", + "item": [ + { + "name": "List Events", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/events/", + "host": ["{{base_url}}"], + "path": ["api", "events", ""] + }, + "description": "Listar todos los eventos activos" + }, + "response": [] + }, + { + "name": "Register External User", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"full_name\": \"Juan Pérez\",\n \"account_number\": \"9999999\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/events/external/register/", + "host": ["{{base_url}}"], + "path": ["api", "events", "external", "register", ""] + }, + "description": "Registrar usuario externo" + }, + "response": [] + }, + { + "name": "Search External Users", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/events/external/search/?account_number=9999999", + "host": ["{{base_url}}"], + "path": ["api", "events", "external", "search", ""], + "query": [ + { + "key": "account_number", + "value": "9999999" + } + ] + }, + "description": "Buscar usuarios externos" + }, + "response": [] + } + ] + }, + { + "name": "Attendance", + "item": [ + { + "name": "Register Attendance", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"event_id\": 1,\n \"account_number\": \"0000111\",\n \"registration_method\": \"manual\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/attendance/", + "host": ["{{base_url}}"], + "path": ["api", "attendance", ""] + }, + "description": "Registrar asistencia a evento" + }, + "response": [] + }, + { + "name": "Get Student Stats", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attendance/stats/?account_number=0000111", + "host": ["{{base_url}}"], + "path": ["api", "attendance", "stats", ""], + "query": [ + { + "key": "account_number", + "value": "0000111" + } + ] + }, + "description": "Obtener estadísticas de asistencia" + }, + "response": [] + }, + { + "name": "Get Recent Attendances", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attendance/recent/", + "host": ["{{base_url}}"], + "path": ["api", "attendance", "recent", ""] + }, + "description": "Obtener asistencias recientes" + }, + "response": [] + }, + { + "name": "Get My Attendances", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{access_token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attendance/my/", + "host": ["{{base_url}}"], + "path": ["api", "attendance", "my", ""] + }, + "description": "Obtener mis asistencias" + }, + "response": [] + } + ] + } + ] +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7059a96 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,12 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..cee1e2c --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0c589ec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..aa16545 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3103 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.12.2", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "vite": "^7.1.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.36.0.tgz", + "integrity": "sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.35", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", + "integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.0.tgz", + "integrity": "sha512-VxDYCDqOaR7NXzAtvRx7G1u54d2kEHopb28YH/pKzY6y0qmogP3gG7CSiWsq9WvDFxOQMpNEyjVAHZFXfH3o/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.0.tgz", + "integrity": "sha512-pqDirm8koABIKvzL59YI9W9DWbRlTX7RWhN+auR8HXJxo89m4mjqbah7nJZjeKNTNYopqL+yGg+0mhCpf3xZtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.0.tgz", + "integrity": "sha512-YCdWlY/8ltN6H78HnMsRHYlPiKvqKagBP1r+D7SSylxX+HnsgXGCmLiV3Y4nSyY9hW8qr8U9LDUx/Lo7M6MfmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.0.tgz", + "integrity": "sha512-z4nw6y1j+OOSGzuVbSWdIp1IUks9qNw4dc7z7lWuWDKojY38VMWBlEN7F9jk5UXOkUcp97vA1N213DF+Lz8BRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.0.tgz", + "integrity": "sha512-Q/dv9Yvyr5rKlK8WQJZVrp5g2SOYeZUs9u/t2f9cQ2E0gJjYB/BWoedXfUT0EcDJefi2zzVfhcOj8drWCzTviw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.0.tgz", + "integrity": "sha512-kdBsLs4Uile/fbjZVvCRcKB4q64R+1mUq0Yd7oU1CMm1Av336ajIFqNFovByipciuUQjBCPMxwJhCgfG2re3rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.0.tgz", + "integrity": "sha512-aL6hRwu0k7MTUESgkg7QHY6CoqPgr6gdQXRJI1/VbFlUMwsSzPGSR7sG5d+MCbYnJmJwThc2ol3nixj1fvI/zQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.0.tgz", + "integrity": "sha512-BTs0M5s1EJejgIBJhCeiFo7GZZ2IXWkFGcyZhxX4+8usnIo5Mti57108vjXFIQmmJaRyDwmV59Tw64Ap1dkwMw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.0.tgz", + "integrity": "sha512-uj672IVOU9m08DBGvoPKPi/J8jlVgjh12C9GmjjBxCTQc3XtVmRkRKyeHSmIKQpvJ7fIm1EJieBUcnGSzDVFyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.0.tgz", + "integrity": "sha512-/+IVbeDMDCtB/HP/wiWsSzduD10SEGzIZX2945KSgZRNi4TSkjHqRJtNTVtVb8IRwhJ65ssI56krlLik+zFWkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.0.tgz", + "integrity": "sha512-U1vVzvSWtSMWKKrGoROPBXMh3Vwn93TA9V35PldokHGqiUbF6erSzox/5qrSMKp6SzakvyjcPiVF8yB1xKr9Pg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.0.tgz", + "integrity": "sha512-X/4WfuBAdQRH8cK3DYl8zC00XEE6aM472W+QCycpQJeLWVnHfkv7RyBFVaTqNUMsTgIX8ihMjCvFF9OUgeABzw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.0.tgz", + "integrity": "sha512-xIRYc58HfWDBZoLmWfWXg2Sq8VCa2iJ32B7mqfWnkx5mekekl0tMe7FHpY8I72RXEcUkaWawRvl3qA55og+cwQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.0.tgz", + "integrity": "sha512-mbsoUey05WJIOz8U1WzNdf+6UMYGwE3fZZnQqsM22FZ3wh1N887HT6jAOjXs6CNEK3Ntu2OBsyQDXfIjouI4dw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.0.tgz", + "integrity": "sha512-qP6aP970bucEi5KKKR4AuPFd8aTx9EF6BvutvYxmZuWLJHmnq4LvBfp0U+yFDMGwJ+AIJEH5sIP+SNypauMWzg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.0.tgz", + "integrity": "sha512-nmSVN+F2i1yKZ7rJNKO3G7ZzmxJgoQBQZ/6c4MuS553Grmr7WqR7LLDcYG53Z2m9409z3JLt4sCOhLdbKQ3HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.0.tgz", + "integrity": "sha512-2d0qRo33G6TfQVjaMR71P+yJVGODrt5V6+T0BDYH4EMfGgdC/2HWDVjSSFw888GSzAZUwuska3+zxNUCDco6rQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.0.tgz", + "integrity": "sha512-A1JalX4MOaFAAyGgpO7XP5khquv/7xKzLIyLmhNrbiCxWpMlnsTYr8dnsWM7sEeotNmxvSOEL7F65j0HXFcFsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.0.tgz", + "integrity": "sha512-YQugafP/rH0eOOHGjmNgDURrpYHrIX0yuojOI8bwCyXwxC9ZdTd3vYkmddPX0oHONLXu9Rb1dDmT0VNpjkzGGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.0.tgz", + "integrity": "sha512-zYdUYhi3Qe2fndujBqL5FjAFzvNeLxtIqfzNEVKD1I7C37/chv1VxhscWSQHTNfjPCrBFQMnynwA3kpZpZ8w4A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.0.tgz", + "integrity": "sha512-fGk03kQylNaCOQ96HDMeT7E2n91EqvCDd3RwvT5k+xNdFCeMGnj5b5hEgTGrQuyidqSsD3zJDQ21QIaxXqTBJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.0.tgz", + "integrity": "sha512-6iKDCVSIUQ8jPMoIV0OytRKniaYyy5EbY/RRydmLW8ZR3cEBhxbWl5ro0rkUNe0ef6sScvhbY79HrjRm8i3vDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz", + "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.3.tgz", + "integrity": "sha512-PFVHhosKkofGH0Yzrw1BipSedTH68BFF8ZWy1kfUpCtJcouXXY0+racG8sExw7hw0HoX36813ga5o3LTWZ4FUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.35", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", + "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.26.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz", + "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.3", + "caniuse-lite": "^1.0.30001741", + "electron-to-chromium": "^1.5.218", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001743", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz", + "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.222", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.222.tgz", + "integrity": "sha512-gA7psSwSwQRE60CEoLz6JBCQPIxNeuzB2nL8vE03GK/OHxlvykbLyeiumQy1iH5C2f3YbRAZpGCMT12a/9ih9w==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.36.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.36.0.tgz", + "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.36.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.21", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.21.tgz", + "integrity": "sha512-MWDWTtNC4voTcWDxXbdmBNe8b/TxfxRFUL6hXgKWJjN9c1AagYEmpiFWBWzDw+5H3SulWUe1pJKTnoSdmk88UA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.21", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", + "integrity": "sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.52.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.0.tgz", + "integrity": "sha512-+IuescNkTJQgX7AkIDtITipZdIGcWF0pnVvZTWStiazUmcGA2ag8dfg0urest2XlXUi9kuhfQ+qmdc5Stc3z7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.0", + "@rollup/rollup-android-arm64": "4.52.0", + "@rollup/rollup-darwin-arm64": "4.52.0", + "@rollup/rollup-darwin-x64": "4.52.0", + "@rollup/rollup-freebsd-arm64": "4.52.0", + "@rollup/rollup-freebsd-x64": "4.52.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.0", + "@rollup/rollup-linux-arm-musleabihf": "4.52.0", + "@rollup/rollup-linux-arm64-gnu": "4.52.0", + "@rollup/rollup-linux-arm64-musl": "4.52.0", + "@rollup/rollup-linux-loong64-gnu": "4.52.0", + "@rollup/rollup-linux-ppc64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-gnu": "4.52.0", + "@rollup/rollup-linux-riscv64-musl": "4.52.0", + "@rollup/rollup-linux-s390x-gnu": "4.52.0", + "@rollup/rollup-linux-x64-gnu": "4.52.0", + "@rollup/rollup-linux-x64-musl": "4.52.0", + "@rollup/rollup-openharmony-arm64": "4.52.0", + "@rollup/rollup-win32-arm64-msvc": "4.52.0", + "@rollup/rollup-win32-ia32-msvc": "4.52.0", + "@rollup/rollup-win32-x64-gnu": "4.52.0", + "@rollup/rollup-win32-x64-msvc": "4.52.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", + "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e82bc18 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.12.2", + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.4.0", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/images/escudo.png b/frontend/public/images/escudo.png new file mode 100644 index 0000000..7934d6a Binary files /dev/null and b/frontend/public/images/escudo.png differ diff --git a/frontend/public/images/logo.png b/frontend/public/images/logo.png new file mode 100644 index 0000000..674c359 Binary files /dev/null and b/frontend/public/images/logo.png differ diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..59e3e8c --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,50 @@ +/* App Component Styles */ + +#root { + width: 100%; + min-height: 100vh; +} + +/* Loading State */ +.app-loading { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background-color: #1e3a8a; +} + +.app-loading-content { + color: white; + text-align: center; +} + +.app-loading-spinner { + width: 3rem; + height: 3rem; + border: 2px solid white; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +.app-loading-text { + font-size: clamp(0.875rem, 2vw, 1rem); + margin: 0; +} + +/* Responsive Design */ +@media (max-width: 767px) { + .app-loading-spinner { + width: 2.5rem; + height: 2.5rem; + } +} + +@media (min-width: 768px) { + .app-loading-spinner { + width: 3.5rem; + height: 3.5rem; + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..5f2ebcf --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react' +import './App.css' +import { AuthProvider, useAuth } from './contexts/AuthContext' +import LandingPage from './components/auth/LandingPage' +import Login from './components/auth/Login' +import Dashboard from './components/common/Dashboard' + +const MacAttendanceApp = () => { + const { user, loading } = useAuth() + const [showLogin, setShowLogin] = useState(false) + + // Resetear showLogin cuando el usuario cierra sesión + useEffect(() => { + if (!user) { + setShowLogin(false) + } + }, [user]) + + if (loading) { + return ( +
+
+
+

Cargando sistema...

+
+
+ ) + } + + if (!user) { + return showLogin ? : setShowLogin(true)} /> + } + + return +} + +function App() { + return ( + + + + ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/components/admin/AdminPanel.jsx b/frontend/src/components/admin/AdminPanel.jsx new file mode 100644 index 0000000..6b87790 --- /dev/null +++ b/frontend/src/components/admin/AdminPanel.jsx @@ -0,0 +1,336 @@ +import { useState, useEffect } from 'react' +import { apiRequest } from '../../services/api' +import ExternalUsersPanel from './ExternalUsersPanel' + +const AdminPanel = () => { + const [activeTab, setActiveTab] = useState('events') + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreateForm, setShowCreateForm] = useState(false) + const [newEvent, setNewEvent] = useState({ + title: '', + description: '', + event_type: 'conference', + modality: 'presencial', + speaker: '', + date: '', + start_time: '', + end_time: '', + location: '', + max_capacity: 100, + meeting_link: '' + }) + + useEffect(() => { + fetchEvents() + }, []) + + const fetchEvents = async () => { + try { + const response = await apiRequest('/events/') + setEvents(response.results || response) + } catch (error) { + console.error('Error fetching events:', error) + } finally { + setLoading(false) + } + } + + const handleCreateEvent = async (e) => { + e.preventDefault() + try { + await apiRequest('/events/', { + method: 'POST', + body: newEvent + }) + alert('Evento creado exitosamente') + setShowCreateForm(false) + setNewEvent({ + title: '', + description: '', + event_type: 'conference', + modality: 'presencial', + speaker: '', + date: '', + start_time: '', + end_time: '', + location: '', + max_capacity: 100, + meeting_link: '' + }) + fetchEvents() + } catch (error) { + alert('Error al crear evento: ' + (error.response?.data?.error || 'Error desconocido')) + } + } + + if (loading) return
Cargando...
+ + return ( +
+

+ Panel de Administración +

+ + {/* Tabs */} +
+ + +
+ + {/* Panel de Eventos */} + {activeTab === 'events' && ( +
+
+ +
+ + {showCreateForm && ( +
+

Crear Nuevo Evento

+
+
+ + setNewEvent({ ...newEvent, title: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', borderRadius: '0.375rem', border: '1px solid #d1d5db' }} + /> +
+
+ +