Primer commit - Sistema de asistencia MAC

This commit is contained in:
Valeria López
2025-10-15 23:45:32 -06:00
commit a683957af6
126 changed files with 17298 additions and 0 deletions
+74
View File
@@ -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
+58
View File
@@ -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
+115
View File
@@ -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/
+512
View File
@@ -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 <URL-DEL-REPOSITORIO> .
# 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=<GENERAR-CLAVE-ALEATORIA-AQUÍ>
# 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=<CONTRASEÑA-SEGURA-AQUÍ> # 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=<MISMA-CONTRASEÑA-DEL-ENV> # 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
+21
View File
@@ -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.
+429
View File
@@ -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 <url-del-repositorio>
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
+31
View File
@@ -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
+12
View File
@@ -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
+61
View File
@@ -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
+88
View File
@@ -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)
)$
+482
View File
@@ -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/)
+107
View File
@@ -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"
View File
+169
View File
@@ -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 '' 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(
'<span style="color: #0066cc;">👤 {}</span><br><small>Cuenta: {}</small>',
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(
'<span style="color: {}; font-weight: bold;">{}</span>',
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
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AttendanceConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'attendance'
@@ -0,0 +1 @@
# Management package
@@ -0,0 +1 @@
# Commands package
@@ -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'
)
)
@@ -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',
},
),
]
@@ -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'),
),
]
@@ -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'),
),
]
+291
View File
@@ -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}%"
+11
View File
@@ -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'),
]
+387
View File
@@ -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)
View File
+607
View File
@@ -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 = '<table style="width:100%; border-collapse: collapse;">'
html += '<tr style="background-color: #f0f0f0;"><th>Fecha</th><th>Asistente</th><th>Evento</th></tr>'
for reg in registros:
attendee = reg.student.full_name if reg.student else reg.external_user.full_name
html += f'''
<tr style="border-bottom: 1px solid #ddd;">
<td>{reg.timestamp.strftime("%d/%m/%Y %H:%M")}</td>
<td>{attendee}</td>
<td>{reg.event.title[:30]}...</td>
</tr>
'''
html += '</table>'
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(
'<a class="button" href="{}" style="background-color: #417690; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">📋 Ver Alumnos</a>',
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(
'<span style="color: {}; font-weight: bold;">{} {}</span>',
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(
'<span style="color: {};">👤 {}</span><br><small>{}</small>',
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': '''
⏱️ <strong>Tiempos de registro de asistencia (aplica a TODAS las ponencias):</strong><br><br>
• <strong>Minutos antes del evento:</strong> Tiempo permitido para comenzar a registrar asistencia antes del inicio programado.<br>
Ejemplo: Si el evento inicia a las 10:00 AM y configuras 10 minutos, se podrá registrar desde las 9:50 AM.<br><br>
• <strong>Minutos después del inicio:</strong> Tiempo límite después del inicio para registrar asistencia.<br>
Ejemplo: Si el evento inicia a las 10:00 AM y configuras 25 minutos, el límite será a las 10:25 AM.<br><br>
<em>Estos valores se aplican automáticamente a todos los eventos del sistema.</em>
'''
}),
('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)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'authentication'
+222
View File
@@ -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
)
@@ -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')),
],
),
]
@@ -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')],
},
),
]
@@ -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'),
),
]
@@ -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',
),
]
@@ -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',
),
]
@@ -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'],
},
),
]
@@ -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),
]
@@ -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',
},
),
]
@@ -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',),
),
]
@@ -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'),
),
]
@@ -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 = [
]
@@ -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),
]
@@ -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'),
),
]
+217
View File
@@ -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)
+64
View File
@@ -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
+12
View File
@@ -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'),
]
+136
View File
@@ -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
})
View File
+67
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'events'
+66
View File
@@ -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'],
},
),
]
@@ -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'),
),
]
@@ -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',
),
]
@@ -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'),
),
]
@@ -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',
),
]
@@ -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'),
),
]
@@ -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',
),
]
+136
View File
@@ -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}"
+20
View File
@@ -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'
]
+9
View File
@@ -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/<int:user_id>/approve/', views.approve_external_user, name='approve_external'),
]
+167
View File
@@ -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)
View File
+16
View File
@@ -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()
+26
View File
@@ -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
+126
View File
@@ -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
+269
View File
@@ -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)
+275
View File
@@ -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/<user_id>/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>',
'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 = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - Sistema MAC</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
border-radius: 10px;
margin-bottom: 30px;
}
.endpoint {
background: white;
padding: 20px;
margin-bottom: 15px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.method {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
margin-right: 10px;
}
.GET { background: #28a745; color: white; }
.POST { background: #007bff; color: white; }
.PUT { background: #ffc107; color: black; }
.DELETE { background: #dc3545; color: white; }
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.auth-badge {
display: inline-block;
padding: 4px 10px;
background: #ff6b6b;
color: white;
border-radius: 4px;
font-size: 11px;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="header">
<h1>📚 API Documentation</h1>
<p>Sistema de Asistencia MAC - FES Acatlán UNAM</p>
<p><strong>Version:</strong> 1.0.0 | <strong>Base URL:</strong> /api/</p>
</div>
<div class="endpoint">
<h2>🔐 Authentication Endpoints</h2>
<h3><span class="method POST">POST</span> /api/auth/login/</h3>
<p>Iniciar sesión con número de cuenta</p>
<pre><code>{
"account_number": "1234567"
}</code></pre>
<h3><span class="method GET">GET</span> /api/auth/profile/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener perfil del usuario autenticado</p>
<h3><span class="method POST">POST</span> /api/auth/token/refresh/</h3>
<p>Refrescar access token</p>
</div>
<div class="endpoint">
<h2>📅 Events Endpoints</h2>
<h3><span class="method GET">GET</span> /api/events/</h3>
<p>Listar todos los eventos activos</p>
<h3><span class="method POST">POST</span> /api/events/external/register/ <span class="auth-badge">AUTH</span></h3>
<p>Registrar un usuario externo para eventos</p>
</div>
<div class="endpoint">
<h2>✅ Attendance Endpoints</h2>
<h3><span class="method POST">POST</span> /api/attendance/ <span class="auth-badge">AUTH</span></h3>
<p>Registrar asistencia a un evento</p>
<h3><span class="method GET">GET</span> /api/attendance/stats/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener estadísticas de asistencia del usuario</p>
<h3><span class="method GET">GET</span> /api/attendance/my/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener mis asistencias registradas</p>
</div>
<div class="endpoint">
<h2>🔑 Authentication</h2>
<p>La API utiliza <strong>JWT (JSON Web Tokens)</strong> para autenticación.</p>
<p>Para endpoints que requieren autenticación, incluye el header:</p>
<pre><code>Authorization: Bearer &lt;your_access_token&gt;</code></pre>
</div>
<div class="endpoint">
<h2>📖 Quick Links</h2>
<ul>
<li><a href="/api/">API Root (JSON)</a></li>
<li><a href="/admin/">Admin Panel</a></li>
<li><a href="/">Frontend Application</a></li>
</ul>
</div>
</body>
</html>
"""
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')),
]
+16
View File
@@ -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()
+22
View File
@@ -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()
+145
View File
@@ -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"
+40
View File
@@ -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
+40
View File
@@ -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
+191
View File
@@ -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)
+140
View File
@@ -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()
+184
View File
@@ -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()
+38
View File
@@ -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"
+84
View File
@@ -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
+127
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
# Este archivo mantiene la carpeta static en git
@@ -0,0 +1,122 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div id="content-main">
<h1>{{ title }}</h1>
{% for data in asistentes_data %}
<div style="background-color: white; padding: 20px; margin-bottom: 30px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<h2 style="color: #417690; border-bottom: 2px solid #417690; padding-bottom: 10px;">
👤 {{ data.asistente.user_profile.full_name }}
<small style="color: #666; font-size: 0.8em;">(Cuenta: {{ data.asistente.user_profile.account_number }})</small>
</h2>
<div style="display: flex; gap: 20px; margin: 20px 0;">
<div style="background-color: #e8f4f8; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<div style="font-size: 2em; color: #417690; font-weight: bold;">{{ data.total }}</div>
<div style="color: #666;">Registros Totales</div>
</div>
<div style="background-color: #e8f4e8; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<div style="font-size: 2em; color: #28a745; font-weight: bold;">{{ data.total_estudiantes }}</div>
<div style="color: #666;">Estudiantes Regulares</div>
</div>
<div style="background-color: #fff4e8; padding: 15px; border-radius: 5px; flex: 1; text-align: center;">
<div style="font-size: 2em; color: #ff9800; font-weight: bold;">{{ data.total_externos }}</div>
<div style="color: #666;">Usuarios Externos</div>
</div>
</div>
{% if data.estudiantes %}
<h3 style="color: #28a745; margin-top: 30px;">📚 Estudiantes Regulares ({{ data.total_estudiantes }})</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 12px; text-align: left;">Fecha y Hora</th>
<th style="padding: 12px; text-align: left;">Estudiante</th>
<th style="padding: 12px; text-align: left;">Número de Cuenta</th>
<th style="padding: 12px; text-align: left;">Evento</th>
<th style="padding: 12px; text-align: left;">Método</th>
</tr>
</thead>
<tbody>
{% for reg in data.estudiantes %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">{{ reg.timestamp|date:"d/m/Y H:i" }}</td>
<td style="padding: 10px;">{{ reg.student.full_name }}</td>
<td style="padding: 10px;"><code>{{ reg.student.account_number }}</code></td>
<td style="padding: 10px;">{{ reg.event.title }}</td>
<td style="padding: 10px;">
{% if reg.registration_method == 'manual' %}
<span style="background-color: #e3f2fd; color: #1976d2; padding: 3px 8px; border-radius: 3px;">✍️ Manual</span>
{% elif reg.registration_method == 'barcode' %}
<span style="background-color: #f3e5f5; color: #7b1fa2; padding: 3px 8px; border-radius: 3px;">📱 Código</span>
{% else %}
<span style="background-color: #e8f5e9; color: #388e3c; padding: 3px 8px; border-radius: 3px;">{{ reg.registration_method }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if data.externos %}
<h3 style="color: #ff9800; margin-top: 30px;">🌍 Usuarios Externos ({{ data.total_externos }})</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; margin-top: 10px;">
<thead>
<tr style="background-color: #f8f9fa; border-bottom: 2px solid #dee2e6;">
<th style="padding: 12px; text-align: left;">Fecha y Hora</th>
<th style="padding: 12px; text-align: left;">Usuario Externo</th>
<th style="padding: 12px; text-align: left;">ID Temporal</th>
<th style="padding: 12px; text-align: left;">Institución</th>
<th style="padding: 12px; text-align: left;">Evento</th>
</tr>
</thead>
<tbody>
{% for reg in data.externos %}
<tr style="border-bottom: 1px solid #dee2e6;">
<td style="padding: 10px;">{{ reg.timestamp|date:"d/m/Y H:i" }}</td>
<td style="padding: 10px;">{{ reg.external_user.full_name }}</td>
<td style="padding: 10px;"><code>{{ reg.external_user.temporary_id }}</code></td>
<td style="padding: 10px;">{{ reg.external_user.institution }}</td>
<td style="padding: 10px;">{{ reg.event.title }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if not data.estudiantes and not data.externos %}
<p style="text-align: center; color: #999; padding: 40px; font-style: italic;">
Este asistente aún no ha registrado ninguna asistencia.
</p>
{% endif %}
</div>
{% endfor %}
<div style="margin-top: 30px; padding: 15px; background-color: #f8f9fa; border-radius: 5px;">
<a href="{% url 'admin:authentication_asistente_changelist' %}" style="text-decoration: none;">
<button style="background-color: #6c757d; color: white; padding: 10px 20px; border: none; border-radius: 3px; cursor: pointer;">
← Volver a Lista de Asistentes
</button>
</a>
<button onclick="window.print()" style="background-color: #28a745; color: white; padding: 10px 20px; border: none; border-radius: 3px; cursor: pointer; margin-left: 10px;">
🖨️ Imprimir Reporte
</button>
</div>
</div>
<style>
@media print {
.breadcrumbs, #header, #footer, button { display: none !important; }
body { background: white; }
}
</style>
{% endblock %}
+192
View File
@@ -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
+20
View File
@@ -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 ""
+85
View File
@@ -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:
+80
View File
@@ -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:
+38
View File
@@ -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"]
+41
View File
@@ -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"]
+27
View File
@@ -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;"]
+69
View File
@@ -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;
}
+234
View File
@@ -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
+701
View File
@@ -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 <your_access_token>
```
### Refrescar Token
```bash
POST /api/auth/token/refresh/
Content-Type: application/json
{
"refresh": "<your_refresh_token>"
}
```
## 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": "<refresh_token>"
}
```
**Response 200**:
```json
{
"access": "<new_access_token>"
}
```
---
#### 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/<user_id>/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":"<refresh_token>"}'
```
### 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
+207
View File
@@ -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 <token>"
```
**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 <access_token>`
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**: ✅
+143
View File
@@ -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**
+524
View File
@@ -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 <comando>
```
**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.
+271
View File
@@ -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
+156
View File
@@ -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
+289
View File
@@ -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.
+347
View File
@@ -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": []
}
]
}
]
}
+24
View File
@@ -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?
+12
View File
@@ -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.

Some files were not shown because too many files have changed in this diff Show More