forked from val-lop20/Pagina-de-Asistencia-MAC
Primer commit - Sistema de asistencia MAC
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)$
|
||||
@@ -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/)
|
||||
@@ -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"
|
||||
@@ -0,0 +1,169 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from import_export import resources, fields
|
||||
from import_export.admin import ExportMixin
|
||||
from .models import Attendance, AttendanceStats
|
||||
|
||||
|
||||
class AttendanceStatsResource(resources.ModelResource):
|
||||
"""Recurso para exportar estadísticas de asistencia"""
|
||||
account_number = fields.Field()
|
||||
full_name = fields.Field()
|
||||
cumple_requisito = fields.Field()
|
||||
|
||||
class Meta:
|
||||
model = AttendanceStats
|
||||
fields = ('account_number', 'full_name', 'attended_events', 'total_events',
|
||||
'attendance_percentage', 'cumple_requisito')
|
||||
export_order = fields
|
||||
|
||||
def dehydrate_account_number(self, stats):
|
||||
"""Obtener número de cuenta del estudiante"""
|
||||
return stats.student.account_number
|
||||
|
||||
def dehydrate_full_name(self, stats):
|
||||
"""Obtener nombre completo del estudiante"""
|
||||
return stats.student.full_name
|
||||
|
||||
def dehydrate_cumple_requisito(self, stats):
|
||||
"""Verificar si cumple el requisito mínimo"""
|
||||
return 'SÍ' if stats.meets_minimum_requirement() else 'NO'
|
||||
|
||||
@admin.register(Attendance)
|
||||
class AttendanceAdmin(admin.ModelAdmin):
|
||||
list_display = ['attendee_name', 'attendee_identifier', 'event', 'timestamp', 'registration_method', 'get_registered_by', 'is_valid']
|
||||
list_filter = ['registration_method', 'registered_by', 'event__date', 'is_valid', 'event']
|
||||
search_fields = ['student__full_name', 'student__account_number', 'external_user__full_name',
|
||||
'external_user__temporary_id', 'event__title', 'registered_by__full_name',
|
||||
'registered_by__account_number']
|
||||
ordering = ['-timestamp']
|
||||
readonly_fields = ['timestamp', 'attendee_name', 'attendee_identifier']
|
||||
date_hierarchy = 'timestamp'
|
||||
|
||||
fieldsets = (
|
||||
('Información del Asistente', {
|
||||
'fields': ('student', 'external_user', 'attendee_name', 'attendee_identifier')
|
||||
}),
|
||||
('Información del Evento', {
|
||||
'fields': ('event',)
|
||||
}),
|
||||
('Registro', {
|
||||
'fields': ('registered_by', 'registration_method', 'timestamp', 'is_valid', 'notes')
|
||||
}),
|
||||
)
|
||||
|
||||
def get_registered_by(self, obj):
|
||||
"""Muestra el asistente que registró con formato mejorado"""
|
||||
if obj.registered_by:
|
||||
return format_html(
|
||||
'<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
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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}%"
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AuthenticationConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'authentication'
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
+22
@@ -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),
|
||||
]
|
||||
+32
@@ -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',),
|
||||
),
|
||||
]
|
||||
+54
@@ -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),
|
||||
]
|
||||
+24
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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
|
||||
})
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'events'
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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'
|
||||
]
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <your_access_token></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')),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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;"]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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**: ✅
|
||||
@@ -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**
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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?
|
||||
@@ -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
Reference in New Issue
Block a user