Agrgar SSL

This commit is contained in:
Valeria López
2025-10-21 04:14:58 -06:00
parent 5008ebf5b4
commit 9d0d0b6f56
28 changed files with 71565 additions and 721 deletions
+16
View File
@@ -76,10 +76,23 @@ Desktop.ini
# ==============================================
docker-compose.override.yml
# ==============================================
# SSL/TLS Certificados
# ==============================================
docker/ssl/
*.pem
*.key
*.crt
*.csr
*.p12
*.pfx
# ==============================================
# Archivos Sensibles y Credenciales
# ==============================================
.env
.env.development
.env.production
backend/.env
.env.local
.env.development.local
@@ -105,6 +118,9 @@ media/
backup/
backups/
# EXCEPCIÓN: Permitir backup compartido para colaboradores
!backend/fixtures/db_full_backup.sql
# ==============================================
# Archivos Temporales
# ==============================================
+140 -42
View File
@@ -27,62 +27,95 @@ Sistema de gestión de asistencia para ponencias y eventos académicos de Matem
## 🛠️ Instalación
### 🌐 URLs de Acceso
**Modo Desarrollo (HTTP - Recomendado para local):**
- Aplicación: http://localhost
- Admin: http://localhost/admin
**Modo Producción (HTTPS - Con advertencia de certificados):**
- Aplicación: https://localhost
- Admin: https://localhost/admin
📖 **Guía completa de acceso:** Ver [ACCESO_AL_SISTEMA.md](ACCESO_AL_SISTEMA.md)
---
### Opción 1: Con Docker (Recomendado)
#### Producción
#### Desarrollo Local (HTTP sin SSL - Recomendado)
```bash
# Clonar el repositorio
git clone <url-del-repositorio>
cd pagina-mac-og
cd Pagina-de-Asistencia-MAC
# Copiar variables de entorno
cp .env.example .env
# Copiar variables de entorno de desarrollo
cp .env.development.example .env.development
# IMPORTANTE: Editar .env y cambiar las credenciales para producción
# Cambiar: SECRET_KEY, DB_PASSWORD, etc.
# (Opcional) Personalizar tu .env.development
# nano .env.development
# Usar configuración de desarrollo (HTTP sin SSL)
docker-compose -f docker-compose.dev.yml up --build -d
# Ver logs
docker-compose -f docker-compose.dev.yml logs -f
# Acceder a la aplicación
# http://localhost (puerto 80 - sin advertencias de seguridad)
```
El sistema estará disponible en `http://localhost` con:
-**Puerto 80** (HTTP estándar - sin advertencias de seguridad)
-**Debug mode** activado
-**Hot reload** para desarrollo
-**Base de datos PostgreSQL** expuesta en puerto 5432
-**Rate limiting** desactivado
#### Producción (HTTPS con SSL)
```bash
# Copiar variables de entorno de producción
cp .env.production.example .env.production
# IMPORTANTE: Editar .env.production y cambiar las credenciales
nano .env.production
# - Generar SECRET_KEY segura: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
# - Cambiar DB_PASSWORD a una contraseña segura
# - Verificar ALLOWED_HOSTS (tu dominio o IP)
# - Verificar CORS_ALLOWED_ORIGINS (solo HTTPS)
# Construir e iniciar contenedores
docker-compose up --build -d
# Ver logs
docker-compose logs -f
# Acceder a la aplicación
# http://localhost
# https://localhost (puerto 443 - aparecerá advertencia de certificados)
```
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)
El sistema estará disponible en `https://localhost` con:
- 🔒 **Puerto 443** (HTTPS con SSL/TLS)
- 🔒 **Certificados autofirmados** (advertencia "No seguro" en navegador - es normal)
- 🔒 **Rate limiting** activado
- 🔒 **Configuración de producción**
- 🔒 **Base de datos PostgreSQL** NO expuesta (solo red interna)
#### Desarrollo
⚠️ **Advertencia de Certificados:**
El navegador mostrará "No es seguro" porque usas certificados autofirmados. Ver [ACCESO_AL_SISTEMA.md](ACCESO_AL_SISTEMA.md) para instrucciones sobre cómo aceptar el certificado en cada navegador
⚠️ **Nota sobre cambios en .env**:
Si modificas el archivo `.env.development` o `.env.production` mientras los contenedores están corriendo, **DEBES reiniciarlos** para que los cambios surtan efecto:
```bash
# Usar configuración de desarrollo
docker-compose -f docker-compose.dev.yml up --build -d
# Desarrollo
docker-compose -f docker-compose.dev.yml restart
# 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
# Producción
docker-compose restart
```
**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)
@@ -109,7 +142,7 @@ pip install django-import-export openpyxl tablib
4. Configurar variables de entorno:
```bash
cp .env.example .env
cp .env.development.example .env
# Editar .env con tus configuraciones
```
@@ -145,6 +178,44 @@ npm run dev
El frontend estará disponible en `http://localhost:5173`
## 🔄 Compartir Base de Datos con Colaboradores
¿Quieres que tus colaboradores trabajen con los mismos datos que tú? El proyecto incluye scripts para compartir fácilmente la base de datos completa.
### Para crear un backup y compartirlo:
**Windows (PowerShell):**
```powershell
.\create_backup.ps1
```
**Linux/Mac:**
```bash
chmod +x create_backup.sh
./create_backup.sh
```
Esto creará un archivo `backend/fixtures/db_full_backup.sql` que puedes:
1. Subir a GitHub (ya está configurado en .gitignore para permitirlo)
2. Compartir por Google Drive, Dropbox, etc.
### Para restaurar el backup compartido:
**Windows (PowerShell):**
```powershell
.\restore_database.ps1
```
**Linux/Mac:**
```bash
chmod +x restore_database.sh
./restore_database.sh
```
📖 **Documentación completa:** Ver [COMPARTIR_BASE_DE_DATOS.md](COMPARTIR_BASE_DE_DATOS.md)
---
## 📥 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.
@@ -282,18 +353,37 @@ Sigue las instrucciones para crear:
## 🔧 Configuración Adicional
### Variables de Entorno (.env)
### Variables de Entorno
El proyecto usa archivos `.env` separados para desarrollo y producción:
**Desarrollo** (`.env.development`):
```env
SECRET_KEY=tu-clave-secreta
DEBUG=True
ALLOWED_HOSTS=localhost,127.0.0.1
CORS_ALLOWED_ORIGINS=http://localhost:5173
SECRET_KEY=django-insecure-dev-key-12345
ALLOWED_HOSTS=localhost,127.0.0.1,nginx,backend
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1
```
**Producción** (`.env.production`):
```env
DEBUG=False
SECRET_KEY=<generar-clave-segura>
ALLOWED_HOSTS=132.248.80.77,tudominio.com
CORS_ALLOWED_ORIGINS=https://132.248.80.77,https://tudominio.com
```
📖 Ver `USO_ENV_FILES.md` para documentación completa sobre variables de entorno.
⚠️ **IMPORTANTE**:
- Los archivos `.env.development` y `.env.production` NO se suben a Git
- Usa `.env.development.example` y `.env.production.example` como plantillas
- Si modificas un archivo `.env` con contenedores corriendo, reinícialos: `docker-compose restart`
### CORS
El backend está configurado para aceptar peticiones desde:
- `http://localhost:5173` (desarrollo)
- `http://127.0.0.1:5173` (desarrollo)
- **Desarrollo**: `http://localhost`, `http://127.0.0.1` (configurado en `.env.development`)
- **Producción**: `https://132.248.80.77` (configurado en `.env.production` - solo HTTPS)
## 🔒 Seguridad
@@ -383,12 +473,20 @@ Ver `docs/POSTGRESQL_MIGRATION.md` para más detalles.
## 📚 Documentación
### Configuración y Despliegue
- `USO_ENV_FILES.md` - **Guía completa de variables de entorno (.env)**
- `DEPLOYMENT_PRODUCTION.md` - Despliegue en servidor de producción (132.248.80.77)
- `CAMBIOS_SEGURIDAD_PUERTOS.md` - Configuración de puertos y seguridad
### Desarrollo
- `docs/DESARROLLO.md` - Guía completa de desarrollo
- `docs/ESTRUCTURA_PROYECTO.md` - Estructura del proyecto
- `docs/POSTGRESQL_MIGRATION.md` - Migración a PostgreSQL
### 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
- `docs/POSTGRESQL_MIGRATION.md` - Migración a PostgreSQL
- `docs/ESTRUCTURA_PROYECTO.md` - Estructura del proyecto
## 🚧 Mejoras Futuras
+3 -4
View File
@@ -35,16 +35,15 @@ class AttendanceStatsResource(resources.ModelResource):
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']
search_fields = ['student__full_name', 'student__account_number', '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')
'fields': ('student', 'attendee_name', 'attendee_identifier')
}),
('Información del Evento', {
'fields': ('event',)
+23 -57
View File
@@ -2,32 +2,22 @@ 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 authentication.models import UserProfile
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
# Estudiante que asiste
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,
@@ -66,12 +56,9 @@ class Attendance(models.Model):
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 haya un estudiante
if not self.student:
raise ValidationError("Debe especificar un estudiante.")
# Validar que el registrador sea un asistente
if self.registered_by.user_type != 'assistant':
@@ -111,29 +98,17 @@ class Attendance(models.Model):
)
# 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)
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.")
# Validar eventos simultáneos
if self.student:
overlapping_events = Event.objects.filter(
date=self.event.date,
@@ -160,9 +135,8 @@ class Attendance(models.Model):
self.clean()
super().save(*args, **kwargs)
# Actualizar estadísticas si es estudiante regular
if self.student:
self.update_student_stats()
# Actualizar estadísticas del estudiante
self.update_student_stats()
def update_student_stats(self):
"""Actualizar las estadísticas de asistencia del estudiante"""
@@ -178,21 +152,13 @@ class Attendance(models.Model):
@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"
"""Nombre del asistente"""
return self.student.full_name if self.student else "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"
return self.student.account_number if self.student else "N/A"
def __str__(self):
return f"{self.attendee_name} - {self.event.title}"
-2
View File
@@ -6,6 +6,4 @@ urlpatterns = [
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'),
]
+13 -178
View File
@@ -4,7 +4,7 @@ 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 authentication.models import UserProfile
from events.models import Event
from .models import Attendance, AttendanceStats
@@ -48,62 +48,37 @@ def register_attendance(request):
'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
# Buscar estudiante
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
return Response({
'error': f'Estudiante con número de cuenta {account_number} no encontrado'
}, status=status.HTTP_404_NOT_FOUND)
# 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)
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)
# Crear asistencia
try:
attendance = Attendance.objects.create(
student=student_profile,
external_user=external_user,
event=event,
registered_by=assistant_profile,
registered_by=registrar_profile,
registration_method='manual'
)
return Response({
'message': f'Asistencia registrada para {attendee_name}',
'message': f'Asistencia registrada para {student_profile.full_name}',
'attendance_id': attendance.id,
'event': event.title,
'registered_by': assistant_profile.full_name,
'attendee_type': 'student' if student_profile else 'external'
'registered_by': registrar_profile.full_name
}, status=status.HTTP_201_CREATED)
except Exception as e:
@@ -244,144 +219,4 @@ def get_my_attendances(request):
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)
# Funciones de usuarios externos eliminadas - Todos los usuarios son ahora estudiantes regulares
+470
View File
@@ -0,0 +1,470 @@
#!/usr/bin/env python
"""
Script para cargar datos iniciales desde archivos Excel a la base de datos.
Este script importa:
- Eventos desde Conferencias MAC Agenda Completa.xlsx
- Estudiantes desde Student.xlsx
- Perfiles de Asistentes desde AssistantProfile.xlsx
- Estadísticas de Asistencia desde AttendanceStats.xlsx
"""
import os
import sys
import django
from pathlib import Path
# Configurar Django
BASE_DIR = Path(__file__).resolve().parent
sys.path.append(str(BASE_DIR))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings')
django.setup()
import openpyxl
from datetime import datetime, time
from django.contrib.auth.models import User
from authentication.models import Student, AssistantProfile, Asistente, SystemConfiguration
from events.models import Event
from attendance.models import Attendance, AttendanceStats
from django.utils import timezone
def import_events(file_path):
"""Importar eventos desde Conferencias MAC Agenda Completa.xlsx"""
print(f"\n📅 Importando eventos desde {file_path}...")
wb = openpyxl.load_workbook(file_path)
ws = wb.active
events_created = 0
events_updated = 0
# Saltar el encabezado (primera fila)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]: # Si no hay título, saltar
continue
title = row[0]
speaker = row[1] if len(row) > 1 and row[1] else ""
date_str = row[2] if len(row) > 2 and row[2] else None
start_time_str = row[3] if len(row) > 3 and row[3] else None
end_time_str = row[4] if len(row) > 4 and row[4] else None
event_type = row[5] if len(row) > 5 and row[5] else "Conferencia"
modality = row[6] if len(row) > 6 and row[6] else "Presencial"
location = row[7] if len(row) > 7 and row[7] else ""
description = row[8] if len(row) > 8 and row[8] else ""
is_active = bool(row[9]) if len(row) > 9 and row[9] else True
if not date_str or not start_time_str:
print(f" ⚠️ Saltando evento sin fecha u hora: {title}")
continue
# Convertir fecha
if isinstance(date_str, datetime):
event_date = date_str.date()
elif isinstance(date_str, str):
try:
event_date = datetime.strptime(date_str, '%Y-%m-%d').date()
except:
try:
event_date = datetime.strptime(date_str, '%d/%m/%Y').date()
except:
print(f" ⚠️ Formato de fecha inválido para {title}: {date_str}")
continue
else:
event_date = date_str
# Convertir hora de inicio
if isinstance(start_time_str, time):
start_time = start_time_str
elif isinstance(start_time_str, str):
try:
start_time = datetime.strptime(start_time_str, '%H:%M:%S').time()
except:
try:
start_time = datetime.strptime(start_time_str, '%H:%M').time()
except:
print(f" ⚠️ Formato de hora inválido para {title}: {start_time_str}")
continue
else:
start_time = start_time_str
# Convertir hora de fin
end_time = None
if end_time_str:
if isinstance(end_time_str, time):
end_time = end_time_str
elif isinstance(end_time_str, str):
try:
end_time = datetime.strptime(end_time_str, '%H:%M:%S').time()
except:
try:
end_time = datetime.strptime(end_time_str, '%H:%M').time()
except:
pass
# Crear o actualizar evento (sin validaciones para permitir fechas pasadas)
try:
event = Event.objects.get(title=title, date=event_date)
# Actualizar usando update() para evitar validaciones
Event.objects.filter(id=event.id).update(
description=description,
location=location,
start_time=start_time,
end_time=end_time,
is_active=is_active
)
created = False
except Event.DoesNotExist:
# Crear sin validaciones usando el método directo de la base
event = Event(
title=title,
date=event_date,
description=description,
location=location,
start_time=start_time,
end_time=end_time,
is_active=is_active
)
# Guardar sin llamar a clean() para evitar validación de fechas pasadas
super(Event, event).save(force_insert=True)
created = True
if created:
events_created += 1
print(f" ✅ Evento creado: {title} - {event_date} {start_time}")
else:
events_updated += 1
print(f"\n✅ Eventos importados: {events_created} creados, {events_updated} actualizados")
return events_created + events_updated
def import_students(file_path):
"""Importar estudiantes desde Student.xlsx"""
print(f"\n👨‍🎓 Importando estudiantes desde {file_path}...")
wb = openpyxl.load_workbook(file_path)
ws = wb.active
students_created = 0
students_updated = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]: # Si no hay número de cuenta, saltar
continue
account_number = str(row[0]).strip()
full_name = row[1] if len(row) > 1 and row[1] else "Sin nombre"
student, created = Student.objects.update_or_create(
account_number=account_number,
defaults={
'full_name': full_name,
'user_type': 'student'
}
)
if created:
students_created += 1
print(f" ✅ Estudiante creado: {account_number} - {full_name}")
else:
students_updated += 1
print(f"\n✅ Estudiantes importados: {students_created} creados, {students_updated} actualizados")
return students_created + students_updated
def import_assistant_profiles(file_path):
"""Importar perfiles de asistentes desde AssistantProfile.xlsx"""
print(f"\n👔 Importando perfiles de asistentes desde {file_path}...")
from authentication.models import UserProfile
wb = openpyxl.load_workbook(file_path)
ws = wb.active
profiles_created = 0
profiles_updated = 0
profiles_skipped = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]: # Si no hay número de cuenta, saltar
continue
account_number = str(row[0]).strip()
full_name = row[1] if len(row) > 1 and row[1] else "Sin nombre"
# Verificar si ya existe un UserProfile con este account_number
existing_profile = UserProfile.objects.filter(account_number=account_number).first()
if existing_profile:
# Si existe y es estudiante, actualizar a asistente
if existing_profile.user_type == 'student':
print(f" ⚠️ Saltando {account_number} - ya existe como estudiante")
profiles_skipped += 1
continue
else:
# Si ya es asistente, actualizar
existing_profile.full_name = full_name
existing_profile.save()
profiles_updated += 1
else:
# No existe, crear nuevo
profile = AssistantProfile.objects.create(
account_number=account_number,
full_name=full_name,
user_type='assistant'
)
profiles_created += 1
print(f" ✅ Perfil de asistente creado: {account_number} - {full_name}")
print(f"\n✅ Perfiles de asistentes importados: {profiles_created} creados, {profiles_updated} actualizados, {profiles_skipped} saltados")
return profiles_created + profiles_updated
def assign_permissions_to_assistant():
"""Asignar permisos al asistente para registrar asistencias"""
print(f"\n🔑 Asignando permisos a asistentes...")
from authentication.models import UserProfile
# Obtener solo los UserProfile que son asistentes
assistants = UserProfile.objects.filter(user_type='assistant')
permissions_created = 0
for assistant_profile in assistants:
# Crear permiso de asistente (Asistente model)
asistente, created = Asistente.objects.get_or_create(
user_profile=assistant_profile,
defaults={
'can_manage_events': True
}
)
if created:
permissions_created += 1
print(f" ✅ Permisos asignados a: {assistant_profile.full_name}")
print(f"\n✅ Permisos asignados: {permissions_created} asistentes")
return permissions_created
def import_attendance_stats(file_path):
"""Importar estadísticas de asistencia desde AttendanceStats.xlsx"""
print(f"\n📊 Importando estadísticas de asistencia desde {file_path}...")
from authentication.models import UserProfile
wb = openpyxl.load_workbook(file_path)
ws = wb.active
stats_created = 0
attendances_created = 0
# Obtener eventos principales
event1 = Event.objects.filter(title__icontains="Transformación Digital").first()
event2 = Event.objects.filter(title__icontains="Finanzas").first()
if not event1:
print(" ⚠️ Evento 'Transformación Digital' no encontrado")
if not event2:
print(" ⚠️ Evento 'Finanzas' no encontrado")
# Obtener el asistente para registered_by
assistant = UserProfile.objects.filter(user_type='assistant').first()
if not assistant:
print(" ⚠️ No hay asistentes en el sistema. Creando asistente por defecto...")
assistant = UserProfile.objects.create(
account_number='99999999',
full_name='Sistema - Importación Automática',
user_type='assistant'
)
print(f" ✅ Asistente creado: {assistant.full_name}")
for row in ws.iter_rows(min_row=2, values_only=True):
if not row[0]: # Si no hay número de cuenta, saltar
continue
account_number = str(row[0]).strip()
full_name = row[1] if len(row) > 1 else ""
attended_events = int(row[2]) if len(row) > 2 and row[2] else 0
total_events = int(row[3]) if len(row) > 3 and row[3] else 18
attendance_percentage = float(row[4]) if len(row) > 4 and row[4] else 0.0
try:
student = Student.objects.get(account_number=account_number)
except Student.DoesNotExist:
# Si el estudiante no existe, crearlo
student = Student.objects.create(
account_number=account_number,
full_name=full_name,
user_type='student'
)
print(f" ️ Estudiante creado desde stats: {account_number}")
# Crear estadísticas
stats, created = AttendanceStats.objects.update_or_create(
student=student,
defaults={
'total_events': total_events,
'attended_events': attended_events,
'attendance_percentage': attendance_percentage
}
)
if created:
stats_created += 1
# Crear asistencias según el número total
if attended_events == 2:
# Asistió a ambas conferencias
if event1:
try:
attendance = Attendance.objects.get(student=student, event=event1)
att_created = False
except Attendance.DoesNotExist:
attendance = Attendance(
student=student,
event=event1,
timestamp=timezone.now(),
is_valid=True,
registered_by=assistant
)
# Guardar sin validaciones
super(Attendance, attendance).save(force_insert=True)
att_created = True
attendances_created += 1
if event2:
try:
attendance = Attendance.objects.get(student=student, event=event2)
att_created = False
except Attendance.DoesNotExist:
attendance = Attendance(
student=student,
event=event2,
timestamp=timezone.now(),
is_valid=True,
registered_by=assistant
)
# Guardar sin validaciones
super(Attendance, attendance).save(force_insert=True)
att_created = True
attendances_created += 1
elif attended_events == 1:
# Asistió solo a "Transformación Digital"
if event1:
try:
attendance = Attendance.objects.get(student=student, event=event1)
att_created = False
except Attendance.DoesNotExist:
attendance = Attendance(
student=student,
event=event1,
timestamp=timezone.now(),
is_valid=True,
registered_by=assistant
)
# Guardar sin validaciones
super(Attendance, attendance).save(force_insert=True)
att_created = True
attendances_created += 1
print(f"\n✅ Estadísticas importadas: {stats_created} creadas")
print(f"✅ Asistencias creadas: {attendances_created}")
return stats_created
def configure_system_settings():
"""Configurar settings globales del sistema"""
print(f"\n⚙️ Configurando sistema global...")
config, created = SystemConfiguration.objects.get_or_create(
id=1,
defaults={
'minimum_attendance_percentage': 80.0,
'minutes_before_event': 10, # 10 minutos antes
'minutes_after_start': 25, # 25 minutos después
}
)
if created:
print(f" ✅ Configuración creada: 80% asistencia, 10min antes, 25min después")
else:
config.minimum_attendance_percentage = 80.0
config.minutes_before_event = 10
config.minutes_after_start = 25
config.save()
print(f" 🔄 Configuración actualizada")
return config
def main():
"""Función principal"""
print("="*60)
print("🚀 CARGA DE DATOS INICIALES - SISTEMA DE ASISTENCIAS MAC")
print("="*60)
# Rutas a los archivos Excel
base_path = Path(__file__).resolve().parent
events_file = base_path / "Conferencias MAC Agenda Completa (1) (1).xlsx"
students_file = base_path / "Student-2025-10-21.xlsx"
assistants_file = base_path / "AssistantProfile-2025-10-21.xlsx"
stats_file = base_path / "AttendanceStats-2025-10-21.xlsx"
# Verificar que existan los archivos
files_ok = True
for file_path, name in [
(events_file, "Eventos"),
(students_file, "Estudiantes"),
(assistants_file, "Asistentes"),
(stats_file, "Estadísticas")
]:
if not file_path.exists():
print(f"❌ Archivo no encontrado: {name} - {file_path}")
files_ok = False
if not files_ok:
print("\n⚠️ Algunos archivos no se encontraron. Abortando.")
return
# Importar datos
try:
# 1. Configurar sistema
configure_system_settings()
# 2. Importar eventos
import_events(events_file)
# 3. Importar estudiantes
import_students(students_file)
# 4. Importar asistentes
import_assistant_profiles(assistants_file)
# 5. Asignar permisos a asistentes
assign_permissions_to_assistant()
# 6. Importar estadísticas y asistencias
import_attendance_stats(stats_file)
print("\n" + "="*60)
print("✅ IMPORTACIÓN COMPLETADA EXITOSAMENTE")
print("="*60)
# Mostrar resumen
print(f"\n📊 Resumen de datos en la base de datos:")
print(f" - Eventos: {Event.objects.count()}")
print(f" - Estudiantes: {Student.objects.count()}")
print(f" - Asistentes: {AssistantProfile.objects.count()}")
print(f" - Permisos de asistentes: {Asistente.objects.count()}")
print(f" - Asistencias: {Attendance.objects.count()}")
print(f" - Estadísticas: {AttendanceStats.objects.count()}")
except Exception as e:
print(f"\n❌ Error durante la importación: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
main()
-126
View File
@@ -1,126 +0,0 @@
"""
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
@@ -19,7 +19,19 @@ SECRET_KEY = config('SECRET_KEY')
DEBUG = config('DEBUG', default=False, cast=bool)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1').split(',')
# ALLOWED_HOSTS - En producción NO debe incluir localhost
# Solo se permite localhost si DEBUG=True
if DEBUG:
# Desarrollo: permitir localhost
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='localhost,127.0.0.1').split(',')
else:
# Producción: NO permitir localhost, solo dominios reales
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
if not ALLOWED_HOSTS or ALLOWED_HOSTS == ['']:
raise ValueError(
"⚠️ ERROR: ALLOWED_HOSTS debe configurarse en producción (DEBUG=False). "
"Ejemplo: ALLOWED_HOSTS=tudominio.com,www.tudominio.com"
)
INSTALLED_APPS = [
'django.contrib.admin',
@@ -42,12 +54,12 @@ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'mac_attendance.middleware.DisableCSRFOnAPIMiddleware', # Exentar API de CSRF (usamos JWT)
'authentication.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
'authentication.middleware.AuditMiddleware', # Auditoría de seguridad
]
# Rate Limiting Configuration
@@ -146,20 +158,39 @@ if not DEBUG:
# 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 settings - NO permitir localhost en producción
if DEBUG:
# Desarrollo: permitir localhost
CORS_ALLOWED_ORIGINS = config(
'CORS_ALLOWED_ORIGINS',
default='http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1'
).split(',')
CSRF_TRUSTED_ORIGINS = config(
'CSRF_TRUSTED_ORIGINS',
default='http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1'
).split(',')
else:
# Producción: NO permitir localhost, solo HTTPS con dominio real
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='').split(',')
CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', default='').split(',')
# Validar que se hayan configurado orígenes en producción
if not CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGINS == ['']:
raise ValueError(
"⚠️ ERROR: CORS_ALLOWED_ORIGINS debe configurarse en producción. "
"Ejemplo: CORS_ALLOWED_ORIGINS=https://tudominio.com,https://www.tudominio.com"
)
# Validar que TODOS los orígenes usen HTTPS
for origin in CORS_ALLOWED_ORIGINS:
if not origin.startswith('https://'):
raise ValueError(
f"⚠️ ERROR: En producción, todos los orígenes deben usar HTTPS. "
f"Origen inválido: {origin}"
)
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/.*$']
@@ -0,0 +1,28 @@
"""
Settings module for mac_attendance project.
Este módulo carga automáticamente la configuración correcta basándose
en la variable de entorno DJANGO_SETTINGS_MODULE.
Estructura:
- base.py: Configuración común para todos los entornos
- local.py: Configuración para desarrollo local
- production.py: Configuración para producción
Uso:
- Desarrollo: DJANGO_SETTINGS_MODULE=mac_attendance.settings.local
- Producción: DJANGO_SETTINGS_MODULE=mac_attendance.settings.production
"""
import os
# Determinar qué configuración cargar basándose en la variable de entorno
DJANGO_ENV = os.environ.get('DJANGO_ENV', 'production')
if DJANGO_ENV == 'local':
from .local import * # noqa
elif DJANGO_ENV == 'production':
from .production import * # noqa
else:
# Por defecto, usar producción (más seguro)
from .production import * # noqa
+160
View File
@@ -0,0 +1,160 @@
"""
Django settings for mac_attendance project - BASE configuration.
Configuración compartida entre todos los entornos (desarrollo y producción).
NO incluye configuraciones específicas de entorno como DEBUG, ALLOWED_HOSTS, etc.
"""
from pathlib import Path
from datetime import timedelta
from decouple import config
import os
# Build paths inside the project
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config('SECRET_KEY')
# Application definition
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',
'authentication.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',
'authentication.middleware.AuditMiddleware', # Auditoría de seguridad
]
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,
}
}
}
# Password validation
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',
},
]
# Internationalization
LANGUAGE_CODE = 'es-mx'
TIME_ZONE = 'America/Mexico_City'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# CORS settings
CORS_ALLOW_CREDENTIALS = True
# 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
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',
}
# Crear directorio de logs si no existe
LOGS_DIR = BASE_DIR / 'logs'
if not os.path.exists(LOGS_DIR):
os.makedirs(LOGS_DIR)
+102
View File
@@ -0,0 +1,102 @@
"""
Django settings for mac_attendance project - LOCAL/DEVELOPMENT configuration.
Configuración específica para entorno de desarrollo local.
Hereda de base.py y sobreescribe/agrega configuraciones de desarrollo.
"""
from .base import * # noqa
from decouple import config
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
# Allowed hosts en desarrollo
ALLOWED_HOSTS = config(
'ALLOWED_HOSTS',
default='localhost,127.0.0.1,nginx,backend'
).split(',')
# CORS Settings - Desarrollo (permitir localhost)
CORS_ALLOWED_ORIGINS = config(
'CORS_ALLOWED_ORIGINS',
default='http://localhost,http://127.0.0.1,http://localhost:80,https://localhost,https://127.0.0.1'
).split(',')
CSRF_TRUSTED_ORIGINS = config(
'CSRF_TRUSTED_ORIGINS',
default='http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1'
).split(',')
# Security Settings - Relajadas para desarrollo
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
# Rate Limiting - Desactivado en desarrollo
RATELIMIT_ENABLE = False
RATELIMIT_USE_CACHE = 'default'
# Cache Configuration - LocMem para desarrollo
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'ratelimit-cache',
}
}
# Logging Configuration - Solo consola en desarrollo
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',
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'DEBUG',
},
'django.security': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': False,
},
'authentication.audit': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'django_ratelimit': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': False,
},
},
}
# Email backend - Console para desarrollo (imprime en consola)
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
# Mostrar toolbar de debug si está instalada (opcional)
# if 'debug_toolbar' not in INSTALLED_APPS:
# INSTALLED_APPS += ['debug_toolbar']
# MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware'] + MIDDLEWARE
# INTERNAL_IPS = ['127.0.0.1']
@@ -0,0 +1,156 @@
"""
Django settings for mac_attendance project - PRODUCTION configuration.
Configuración específica para entorno de producción.
Hereda de base.py y sobreescribe/agrega configuraciones de producción.
"""
from .base import * # noqa
from decouple import config
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
# Allowed hosts - PRODUCCIÓN (NO permitir localhost)
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
if not ALLOWED_HOSTS or ALLOWED_HOSTS == ['']:
raise ValueError(
"⚠️ ERROR: ALLOWED_HOSTS debe configurarse en producción (DEBUG=False). "
"Ejemplo: ALLOWED_HOSTS=132.248.80.77,tudominio.com,www.tudominio.com"
)
# CORS Settings - PRODUCCIÓN (solo HTTPS, NO localhost)
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='').split(',')
CSRF_TRUSTED_ORIGINS = config('CSRF_TRUSTED_ORIGINS', default='').split(',')
# Validar que se hayan configurado orígenes en producción
if not CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGINS == ['']:
raise ValueError(
"⚠️ ERROR: CORS_ALLOWED_ORIGINS debe configurarse en producción. "
"Ejemplo: CORS_ALLOWED_ORIGINS=https://132.248.80.77,https://tudominio.com"
)
# Validar que TODOS los orígenes usen HTTPS
for origin in CORS_ALLOWED_ORIGINS:
if not origin.startswith('https://'):
raise ValueError(
f"⚠️ ERROR: En producción, todos los orígenes deben usar HTTPS. "
f"Origen inválido: {origin}"
)
# 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')
# Rate Limiting - ACTIVADO en producción
RATELIMIT_ENABLE = config('RATELIMIT_ENABLE', default=True, cast=bool)
RATELIMIT_USE_CACHE = 'default'
# Cache Configuration - Puede usar Redis/Memcached en producción
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'ratelimit-cache',
}
}
# TODO: Para mejor rendimiento en producción, considerar Redis:
# CACHES = {
# 'default': {
# 'BACKEND': 'django_redis.cache.RedisCache',
# 'LOCATION': config('REDIS_URL', default='redis://127.0.0.1:6379/1'),
# 'OPTIONS': {
# 'CLIENT_CLASS': 'django_redis.client.DefaultClient',
# }
# }
# }
# Logging Configuration - Archivos en producción
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', # noqa
'formatter': 'verbose',
},
'security_file': {
'class': 'logging.FileHandler',
'filename': BASE_DIR / 'logs' / 'security.log', # noqa
'formatter': 'audit',
},
'audit_file': {
'class': 'logging.FileHandler',
'filename': BASE_DIR / 'logs' / 'audit.log', # noqa
'formatter': 'audit',
},
},
'loggers': {
'django': {
'handlers': ['console', 'file'],
'level': 'INFO',
},
'django.security': {
'handlers': ['console', 'security_file'],
'level': 'WARNING',
'propagate': False,
},
'authentication.audit': {
'handlers': ['console', 'audit_file'],
'level': 'INFO',
'propagate': False,
},
'django_ratelimit': {
'handlers': ['console', 'security_file'],
'level': 'WARNING',
'propagate': False,
},
},
}
# Email backend - Real en producción (requiere configuración SMTP)
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_HOST = config('EMAIL_HOST', default='smtp.gmail.com')
# EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
# EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
# EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
# EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
# DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='noreply@mac-fes.unam.mx')
# Admins - Para recibir emails de errores 500
# ADMINS = [
# ('Admin MAC', 'admin@mac-fes.unam.mx'),
# ]
# MANAGERS = ADMINS
+1 -247
View File
@@ -16,253 +16,7 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import path, include
from django.http import JsonResponse
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def api_root(request):
"""API root endpoint - Returns comprehensive API information (Protected)"""
return JsonResponse({
'name': 'Sistema de Asistencia MAC - API',
'version': '1.0.0',
'description': 'API REST para el Sistema de Asistencia MAC - FES Acatlán UNAM',
'documentation': {
'main': '/api/docs/',
'swagger': '/api/swagger/',
'redoc': '/api/redoc/',
},
'endpoints': {
'authentication': {
'login': {
'url': '/api/auth/login/',
'method': 'POST',
'description': 'Iniciar sesión con número de cuenta',
'auth_required': False,
},
'logout': {
'url': '/api/auth/logout/',
'method': 'POST',
'description': 'Cerrar sesión',
'auth_required': True,
},
'profile': {
'url': '/api/auth/profile/',
'method': 'GET',
'description': 'Obtener perfil de usuario',
'auth_required': True,
},
'check_auth': {
'url': '/api/auth/check-auth/',
'method': 'GET',
'description': 'Verificar estado de autenticación',
'auth_required': False,
},
'token_refresh': {
'url': '/api/auth/token/refresh/',
'method': 'POST',
'description': 'Refrescar access token',
'auth_required': False,
},
'system_config': {
'url': '/api/auth/system-config/',
'method': 'GET',
'description': 'Obtener configuración del sistema',
'auth_required': True,
},
},
'events': {
'list': {
'url': '/api/events/',
'method': 'GET',
'description': 'Listar todos los eventos',
'auth_required': False,
},
'register_external': {
'url': '/api/events/external/register/',
'method': 'POST',
'description': 'Registrar usuario externo',
'auth_required': True,
},
'search_external': {
'url': '/api/events/external/search/',
'method': 'GET',
'description': 'Buscar usuarios externos',
'auth_required': True,
},
'approve_external': {
'url': '/api/events/external/<user_id>/approve/',
'method': 'POST',
'description': 'Aprobar usuario externo',
'auth_required': True,
},
},
'attendance': {
'register': {
'url': '/api/attendance/',
'method': 'POST',
'description': 'Registrar asistencia a evento',
'auth_required': True,
},
'stats': {
'url': '/api/attendance/stats/',
'method': 'GET',
'description': 'Obtener estadísticas de asistencia',
'auth_required': True,
},
'recent': {
'url': '/api/attendance/recent/',
'method': 'GET',
'description': 'Obtener asistencias recientes',
'auth_required': True,
},
'my_attendances': {
'url': '/api/attendance/my/',
'method': 'GET',
'description': 'Obtener mis asistencias',
'auth_required': True,
},
},
},
'authentication': {
'type': 'JWT Bearer Token',
'header': 'Authorization: Bearer <token>',
'token_lifetime': '1 hour',
'refresh_token_lifetime': '7 days',
},
'status': 'operational',
'server_time': request.build_absolute_uri(),
})
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def api_docs(request):
"""API documentation endpoint (Protected)"""
from django.http import HttpResponse
html_content = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - Sistema MAC</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px;
border-radius: 10px;
margin-bottom: 30px;
}
.endpoint {
background: white;
padding: 20px;
margin-bottom: 15px;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.method {
display: inline-block;
padding: 4px 12px;
border-radius: 4px;
font-weight: bold;
font-size: 12px;
margin-right: 10px;
}
.GET { background: #28a745; color: white; }
.POST { background: #007bff; color: white; }
.PUT { background: #ffc107; color: black; }
.DELETE { background: #dc3545; color: white; }
code {
background: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.auth-badge {
display: inline-block;
padding: 4px 10px;
background: #ff6b6b;
color: white;
border-radius: 4px;
font-size: 11px;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="header">
<h1>📚 API Documentation</h1>
<p>Sistema de Asistencia MAC - FES Acatlán UNAM</p>
<p><strong>Version:</strong> 1.0.0 | <strong>Base URL:</strong> /api/</p>
</div>
<div class="endpoint">
<h2>🔐 Authentication Endpoints</h2>
<h3><span class="method POST">POST</span> /api/auth/login/</h3>
<p>Iniciar sesión con número de cuenta</p>
<pre><code>{
"account_number": "1234567"
}</code></pre>
<h3><span class="method GET">GET</span> /api/auth/profile/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener perfil del usuario autenticado</p>
<h3><span class="method POST">POST</span> /api/auth/token/refresh/</h3>
<p>Refrescar access token</p>
</div>
<div class="endpoint">
<h2>📅 Events Endpoints</h2>
<h3><span class="method GET">GET</span> /api/events/</h3>
<p>Listar todos los eventos activos</p>
<h3><span class="method POST">POST</span> /api/events/external/register/ <span class="auth-badge">AUTH</span></h3>
<p>Registrar un usuario externo para eventos</p>
</div>
<div class="endpoint">
<h2>✅ Attendance Endpoints</h2>
<h3><span class="method POST">POST</span> /api/attendance/ <span class="auth-badge">AUTH</span></h3>
<p>Registrar asistencia a un evento</p>
<h3><span class="method GET">GET</span> /api/attendance/stats/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener estadísticas de asistencia del usuario</p>
<h3><span class="method GET">GET</span> /api/attendance/my/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener mis asistencias registradas</p>
</div>
<div class="endpoint">
<h2>🔑 Authentication</h2>
<p>La API utiliza <strong>JWT (JSON Web Tokens)</strong> para autenticación.</p>
<p>Para endpoints que requieren autenticación, incluye el header:</p>
<pre><code>Authorization: Bearer &lt;your_access_token&gt;</code></pre>
</div>
<div class="endpoint">
<h2>📖 Quick Links</h2>
<ul>
<li><a href="/api/">API Root (JSON)</a></li>
<li><a href="/admin/">Admin Panel</a></li>
<li><a href="/">Frontend Application</a></li>
</ul>
</div>
</body>
</html>
"""
return HttpResponse(html_content)
from .views import api_root, api_docs
urlpatterns = [
path('', api_root, name='api_root'),
+130
View File
@@ -0,0 +1,130 @@
"""
Vistas del proyecto principal MAC Attendance
"""
from django.http import JsonResponse
from django.shortcuts import render
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)"""
return render(request, 'api_docs.html')
+1
View File
@@ -30,6 +30,7 @@ django-ratelimit==4.1.0
# Exportación de datos
openpyxl==3.1.5
pandas==2.2.0
# Generación de PDFs
# reportlab==4.2.5
+183
View File
@@ -0,0 +1,183 @@
<!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": "12345678"
}</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>
<h3><span class="method POST">POST</span> /api/auth/logout/</h3>
<p>Cerrar sesión del usuario</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 GET">GET</span> /api/events/{id}/</h3>
<p>Obtener detalles de un evento específico</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 (solo asistentes)</p>
<pre><code>{
"event_id": 1,
"account_number": "12345678"
}</code></pre>
<h3><span class="method GET">GET</span> /api/attendance/stats/?account_number=12345678 <span class="auth-badge">AUTH</span></h3>
<p>Obtener estadísticas de asistencia de un estudiante</p>
<h3><span class="method GET">GET</span> /api/attendance/my/?account_number=12345678 <span class="auth-badge">AUTH</span></h3>
<p>Obtener lista de asistencias del estudiante</p>
<h3><span class="method GET">GET</span> /api/attendance/recent/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener asistencias recientes (solo asistentes)</p>
</div>
<div class="endpoint">
<h2>🔑 Authentication</h2>
<p>La API utiliza <strong>JWT (JSON Web Tokens)</strong> para autenticación.</p>
<p>Para endpoints que requieren autenticación, incluye el header:</p>
<pre><code>Authorization: Bearer &lt;your_access_token&gt;</code></pre>
<h3>Flujo de autenticación:</h3>
<ol>
<li>Envía tu número de cuenta a <code>/api/auth/login/</code></li>
<li>Recibe <code>access_token</code> y <code>refresh_token</code></li>
<li>Usa el <code>access_token</code> en el header de las peticiones</li>
<li>Cuando expire (1 hora), usa el <code>refresh_token</code> en <code>/api/auth/token/refresh/</code></li>
</ol>
</div>
<div class="endpoint">
<h2>⚙️ System Configuration</h2>
<h3><span class="method GET">GET</span> /api/auth/system-config/ <span class="auth-badge">AUTH</span></h3>
<p>Obtener configuración del sistema (% mínimo, tiempos de registro, etc.)</p>
</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>
<div class="endpoint">
<h2>️ Response Codes</h2>
<table style="width: 100%; border-collapse: collapse;">
<tr style="background: #f8f9fa;">
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Code</th>
<th style="padding: 10px; text-align: left; border-bottom: 2px solid #dee2e6;">Descripción</th>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>200</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Petición exitosa</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>201</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Recurso creado exitosamente</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>400</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Petición inválida (error en datos)</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>401</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">No autenticado</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>403</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Sin permisos</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>404</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Recurso no encontrado</td>
</tr>
<tr>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;"><code>500</code></td>
<td style="padding: 10px; border-bottom: 1px solid #dee2e6;">Error del servidor</td>
</tr>
</table>
</div>
</body>
</html>
+50
View File
@@ -0,0 +1,50 @@
# Script para crear un backup de la base de datos en Windows
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "Creación de Backup de Base de Datos MAC" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
# Verificar que los contenedores estén corriendo
$dbStatus = docker-compose ps db 2>$null
if ($dbStatus -notmatch "Up") {
Write-Host "❌ Error: El contenedor de base de datos no está corriendo" -ForegroundColor Red
Write-Host "Ejecuta: docker-compose up -d" -ForegroundColor Yellow
exit 1
}
Write-Host "📦 Creando backup de la base de datos..." -ForegroundColor Yellow
Write-Host ""
# Crear el directorio de fixtures si no existe
if (-Not (Test-Path "backend\fixtures")) {
New-Item -ItemType Directory -Path "backend\fixtures" -Force | Out-Null
}
# Crear el backup
docker-compose exec -T db pg_dump -U mac_user mac_attendance > backend\fixtures\db_full_backup.sql
if ($LASTEXITCODE -eq 0) {
# Obtener el tamaño del archivo
$size = (Get-Item "backend\fixtures\db_full_backup.sql").Length / 1MB
$sizeFormatted = "{0:N2} MB" -f $size
Write-Host "✅ ¡Backup creado exitosamente!" -ForegroundColor Green
Write-Host ""
Write-Host "📄 Archivo: backend\fixtures\db_full_backup.sql" -ForegroundColor White
Write-Host "📊 Tamaño: $sizeFormatted" -ForegroundColor White
Write-Host ""
Write-Host "Ahora puedes compartir este backup con tus colaboradores:" -ForegroundColor Cyan
Write-Host " 1. Sube el archivo a GitHub:" -ForegroundColor White
Write-Host " git add backend/fixtures/db_full_backup.sql" -ForegroundColor Gray
Write-Host " git commit -m `"Actualizar backup de base de datos`"" -ForegroundColor Gray
Write-Host " git push origin main" -ForegroundColor Gray
Write-Host ""
Write-Host " 2. O compártelo por otro medio (Google Drive, Dropbox, etc.)" -ForegroundColor White
Write-Host ""
} else {
Write-Host "❌ Error al crear el backup" -ForegroundColor Red
exit 1
}
Write-Host "==========================================" -ForegroundColor Cyan
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# Script para crear un backup de la base de datos
echo "=========================================="
echo "Creación de Backup de Base de Datos MAC"
echo "=========================================="
echo ""
# Verificar que los contenedores estén corriendo
if ! docker-compose ps | grep -q "db.*Up"; then
echo "❌ Error: El contenedor de base de datos no está corriendo"
echo "Ejecuta: docker-compose up -d"
exit 1
fi
echo "📦 Creando backup de la base de datos..."
echo ""
# Crear el directorio de fixtures si no existe
mkdir -p backend/fixtures
# Crear el backup
docker-compose exec -T db pg_dump -U mac_user mac_attendance > backend/fixtures/db_full_backup.sql
if [ $? -eq 0 ]; then
# Obtener el tamaño del archivo
SIZE=$(du -h backend/fixtures/db_full_backup.sql | cut -f1)
echo "✅ ¡Backup creado exitosamente!"
echo ""
echo "📄 Archivo: backend/fixtures/db_full_backup.sql"
echo "📊 Tamaño: $SIZE"
echo ""
echo "Ahora puedes compartir este backup con tus colaboradores:"
echo " 1. Sube el archivo a GitHub:"
echo " git add backend/fixtures/db_full_backup.sql"
echo " git commit -m \"Actualizar backup de base de datos\""
echo " git push origin main"
echo ""
echo " 2. O compártelo por otro medio (Google Drive, Dropbox, etc.)"
echo ""
else
echo "❌ Error al crear el backup"
exit 1
fi
echo "=========================================="
File diff suppressed because it is too large Load Diff
+7 -22
View File
@@ -5,10 +5,8 @@ services:
# PostgreSQL Database (Desarrollo)
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=mac_attendance
- POSTGRES_USER=mac_user
- POSTGRES_PASSWORD=mac_password_2024_secure
env_file:
- .env.development
volumes:
- postgres_data_dev:/var/lib/postgresql/data
networks:
@@ -34,21 +32,8 @@ services:
- ./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
env_file:
- .env.development
networks:
- app-network
depends_on:
@@ -59,15 +44,15 @@ services:
stdin_open: true # Para ipdb y debugging interactivo
tty: true
# Frontend React + Nginx
# Frontend React + Nginx (HTTP sin SSL para desarrollo local)
nginx:
build:
context: .
dockerfile: docker/Dockerfile.frontend
ports:
- "80:80"
- "80:80" # HTTP para desarrollo (sin SSL)
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
- ./docker/nginx.dev.conf:/etc/nginx/conf.d/default.conf # Configuración sin SSL
- static_volume:/app/staticfiles
- media_volume:/app/media
depends_on:
+10 -24
View File
@@ -2,16 +2,13 @@ services:
# PostgreSQL Database
db:
image: postgres:15-alpine
environment:
- POSTGRES_DB=mac_attendance
- POSTGRES_USER=mac_user
- POSTGRES_PASSWORD=mac_password_2024_secure
env_file:
- .env.production
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)
# NO exponer puertos al host - solo accesible internamente
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mac_user -d mac_attendance"]
interval: 10s
@@ -26,26 +23,13 @@ services:
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"
gunicorn --bind 0.0.0.0:8000 --workers 3 --timeout 300 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
env_file:
- .env.production
networks:
- app-network
depends_on:
@@ -60,9 +44,10 @@ services:
context: .
dockerfile: docker/Dockerfile.frontend
ports:
- "80:80"
- "443:443" # HTTPS (SSL/TLS) - ÚNICO puerto expuesto al host
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf
- ./docker/ssl:/etc/nginx/ssl:ro # Certificados SSL (solo lectura)
- static_volume:/app/staticfiles
- media_volume:/app/media
depends_on:
@@ -72,7 +57,8 @@ services:
networks:
app-network:
driver: bridge
# Red interna para comunicación entre contenedores
# Solo nginx expone puerto 443 al host
volumes:
static_volume:
+2 -2
View File
@@ -28,8 +28,8 @@ 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
# Nota: collectstatic se ejecuta en docker-compose command, no aquí
# porque en build-time no tenemos acceso al archivo .env
# Exponer puerto
EXPOSE 8000
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Script para generar certificados SSL autofirmados para desarrollo
# Crear directorio para certificados
mkdir -p ssl
# Generar certificado autofirmado
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout ssl/key.pem \
-out ssl/cert.pem \
-subj "/C=MX/ST=Estado/L=Ciudad/O=MAC/OU=IT/CN=localhost"
echo "✅ Certificados SSL generados en docker/ssl/"
echo " - Certificado: docker/ssl/cert.pem"
echo " - Clave privada: docker/ssl/key.pem"
echo ""
echo "⚠️ IMPORTANTE: Estos son certificados autofirmados para DESARROLLO"
echo " Para PRODUCCIÓN, usa certificados de Let's Encrypt o una CA confiable"
+43 -3
View File
@@ -2,11 +2,44 @@ upstream backend {
server backend:8000;
}
# Servidor HTTPS (SSL/TLS)
# NOTA: Solo se expone puerto 443. No hay redirección HTTP->HTTPS
# Los usuarios DEBEN acceder directamente vía HTTPS
server {
listen 80;
listen 443 ssl http2;
server_name localhost;
client_max_body_size 100M;
# Certificados SSL
# NOTA: Debes generar estos certificados o usar Let's Encrypt
# Para desarrollo/pruebas locales, usa certificados autofirmados
# Para producción, usa certificados reales de Let's Encrypt o una CA
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# Configuración SSL moderna y segura
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
# HSTS (HTTP Strict Transport Security)
# Obliga al navegador a usar HTTPS por 1 año
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Seguridad adicional
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Sesión SSL
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
# Logs
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
@@ -24,7 +57,8 @@ server {
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_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Ssl on;
proxy_redirect off;
# Timeouts
@@ -39,8 +73,14 @@ server {
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_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Ssl on;
proxy_redirect off;
# Timeouts extendidos para importaciones/exportaciones de archivos grandes
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Archivos estáticos de Django
+75
View File
@@ -0,0 +1,75 @@
upstream backend {
server backend:8000;
}
# Servidor HTTP (sin SSL) para desarrollo local
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 http;
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 http;
proxy_redirect off;
# Timeouts extendidos para importaciones/exportaciones de archivos grandes
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# 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;
}
+59
View File
@@ -0,0 +1,59 @@
# Script para restaurar la base de datos compartida en Windows
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "Restauración de Base de Datos MAC" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host ""
# Verificar que exista el archivo de backup
if (-Not (Test-Path "backend\fixtures\db_full_backup.sql")) {
Write-Host "❌ Error: No se encontró el archivo de backup en backend\fixtures\db_full_backup.sql" -ForegroundColor Red
Write-Host "Por favor, asegúrate de tener el archivo de backup." -ForegroundColor Red
exit 1
}
Write-Host "📋 Archivo de backup encontrado" -ForegroundColor Green
Write-Host ""
# Preguntar confirmación
Write-Host "⚠️ ADVERTENCIA: Este proceso eliminará todos los datos actuales de la base de datos." -ForegroundColor Yellow
$confirmacion = Read-Host "¿Estás seguro de que deseas continuar? (si/no)"
if ($confirmacion -ne "si") {
Write-Host "❌ Operación cancelada." -ForegroundColor Red
exit 0
}
Write-Host ""
Write-Host "🔄 Deteniendo contenedores..." -ForegroundColor Yellow
docker-compose down
Write-Host ""
Write-Host "🗑️ Eliminando volumen de base de datos anterior..." -ForegroundColor Yellow
docker volume rm pagina-de-asistencia-mac_postgres_data 2>$null
Write-Host ""
Write-Host "🚀 Iniciando contenedor de base de datos..." -ForegroundColor Yellow
docker-compose up -d db
Write-Host ""
Write-Host "⏳ Esperando a que la base de datos esté lista..." -ForegroundColor Yellow
Start-Sleep -Seconds 10
Write-Host ""
Write-Host "📥 Restaurando backup..." -ForegroundColor Yellow
Get-Content backend\fixtures\db_full_backup.sql | docker-compose exec -T db psql -U mac_user -d mac_attendance
Write-Host ""
Write-Host "🚀 Iniciando todos los servicios..." -ForegroundColor Yellow
docker-compose up -d
Write-Host ""
Write-Host "✅ ¡Base de datos restaurada exitosamente!" -ForegroundColor Green
Write-Host ""
Write-Host "Puedes iniciar sesión con:" -ForegroundColor Cyan
Write-Host " - Superusuario: admin / admin123" -ForegroundColor White
Write-Host " - Asistente: 11111111" -ForegroundColor White
Write-Host " - Estudiante: Cualquier número de cuenta de 8 dígitos" -ForegroundColor White
Write-Host ""
Write-Host "==========================================" -ForegroundColor Cyan
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# Script para restaurar la base de datos compartida
echo "=========================================="
echo "Restauración de Base de Datos MAC"
echo "=========================================="
echo ""
# Verificar que exista el archivo de backup
if [ ! -f "backend/fixtures/db_full_backup.sql" ]; then
echo "❌ Error: No se encontró el archivo de backup en backend/fixtures/db_full_backup.sql"
echo "Por favor, asegúrate de tener el archivo de backup."
exit 1
fi
echo "📋 Archivo de backup encontrado"
echo ""
# Preguntar confirmación
echo "⚠️ ADVERTENCIA: Este proceso eliminará todos los datos actuales de la base de datos."
read -p "¿Estás seguro de que deseas continuar? (si/no): " confirmacion
if [ "$confirmacion" != "si" ]; then
echo "❌ Operación cancelada."
exit 0
fi
echo ""
echo "🔄 Deteniendo contenedores..."
docker-compose down
echo ""
echo "🗑️ Eliminando volumen de base de datos anterior..."
docker volume rm pagina-de-asistencia-mac_postgres_data 2>/dev/null || true
echo ""
echo "🚀 Iniciando contenedor de base de datos..."
docker-compose up -d db
echo ""
echo "⏳ Esperando a que la base de datos esté lista..."
sleep 10
echo ""
echo "📥 Restaurando backup..."
docker-compose exec -T db psql -U mac_user -d mac_attendance < backend/fixtures/db_full_backup.sql
echo ""
echo "🚀 Iniciando todos los servicios..."
docker-compose up -d
echo ""
echo "✅ ¡Base de datos restaurada exitosamente!"
echo ""
echo "Puedes iniciar sesión con:"
echo " - Superusuario: admin / admin123"
echo " - Asistente: 11111111"
echo " - Estudiante: Cualquier número de cuenta de 8 dígitos"
echo ""
echo "=========================================="