forked from val-lop20/Pagina-de-Asistencia-MAC
archivo .env.production.example y eliminado de archivos temporales
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# ==============================================
|
||||
# CONFIGURACIÓN PARA DESARROLLO LOCAL
|
||||
# ==============================================
|
||||
# Este archivo NO se sube al repositorio (.gitignore)
|
||||
# Cada desarrollador debe copiar .env.development.example a .env.development
|
||||
|
||||
# ==============================================
|
||||
# Django Settings - DESARROLLO
|
||||
# ==============================================
|
||||
DJANGO_ENV=local
|
||||
DEBUG=True
|
||||
SECRET_KEY=django-insecure-dev-key-change-in-production-12345
|
||||
|
||||
# ==============================================
|
||||
# ALLOWED_HOSTS - DESARROLLO
|
||||
# ==============================================
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,nginx,backend
|
||||
|
||||
# ==============================================
|
||||
# Security Settings - DESARROLLO (relajadas)
|
||||
# ==============================================
|
||||
RATELIMIT_ENABLE=False
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SESSION_COOKIE_SECURE=False
|
||||
CSRF_COOKIE_SECURE=False
|
||||
|
||||
# ==============================================
|
||||
# CORS Settings - DESARROLLO
|
||||
# ==============================================
|
||||
# Incluye tanto HTTP como HTTPS para desarrollo
|
||||
CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1,http://localhost:80,https://localhost,https://127.0.0.1,https://localhost:443
|
||||
CSRF_TRUSTED_ORIGINS=http://localhost,http://127.0.0.1,https://localhost,https://127.0.0.1
|
||||
|
||||
# ==============================================
|
||||
# Database - PostgreSQL (DESARROLLO)
|
||||
# ==============================================
|
||||
DB_ENGINE=postgresql
|
||||
DB_NAME=mac_attendance
|
||||
DB_USER=mac_user
|
||||
DB_PASSWORD=mac_password_dev
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# ==============================================
|
||||
# PostgreSQL (usado por docker-compose)
|
||||
# ==============================================
|
||||
POSTGRES_DB=mac_attendance
|
||||
POSTGRES_USER=mac_user
|
||||
POSTGRES_PASSWORD=mac_password_dev
|
||||
@@ -0,0 +1,70 @@
|
||||
# ==============================================
|
||||
# CONFIGURACIÓN PARA PRODUCCIÓN
|
||||
# ==============================================
|
||||
# Este archivo NO se sube al repositorio (.gitignore)
|
||||
# En el servidor de producción (132.248.80.77), copia .env.production.example a .env.production
|
||||
# ⚠️ IMPORTANTE: Configura todos los valores antes de desplegar
|
||||
#
|
||||
# SERVIDOR DE PRODUCCIÓN: 132.248.80.77
|
||||
|
||||
# ==============================================
|
||||
# Django Settings - PRODUCCIÓN
|
||||
# ==============================================
|
||||
DJANGO_ENV=production
|
||||
DEBUG=False
|
||||
|
||||
# SECRET_KEY: ⚠️ GENERA UNA CLAVE ÚNICA Y SEGURA
|
||||
# Genérala con: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
SECRET_KEY=CAMBIA-ESTO-POR-UNA-CLAVE-SEGURA-GENERADA
|
||||
|
||||
# ==============================================
|
||||
# ALLOWED_HOSTS - PRODUCCIÓN
|
||||
# ==============================================
|
||||
# ⚠️ NO INCLUIR localhost/127.0.0.1
|
||||
# Solo tu dominio real o IP del servidor
|
||||
ALLOWED_HOSTS=132.248.80.77,tudominio.com,www.tudominio.com
|
||||
|
||||
# ==============================================
|
||||
# Security Settings - PRODUCCIÓN
|
||||
# ==============================================
|
||||
RATELIMIT_ENABLE=True
|
||||
SECURE_SSL_REDIRECT=True
|
||||
SESSION_COOKIE_SECURE=True
|
||||
CSRF_COOKIE_SECURE=True
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||
SECURE_HSTS_PRELOAD=True
|
||||
|
||||
# ==============================================
|
||||
# CORS Settings - PRODUCCIÓN
|
||||
# ==============================================
|
||||
# ⚠️ SOLO HTTPS - NO HTTP
|
||||
# ⚠️ NO INCLUIR localhost
|
||||
CORS_ALLOWED_ORIGINS=https://132.248.80.77,https://tudominio.com,https://www.tudominio.com
|
||||
CSRF_TRUSTED_ORIGINS=https://132.248.80.77,https://tudominio.com,https://www.tudominio.com
|
||||
|
||||
# ==============================================
|
||||
# Database - PostgreSQL (PRODUCCIÓN)
|
||||
# ==============================================
|
||||
DB_ENGINE=postgresql
|
||||
DB_NAME=mac_attendance
|
||||
DB_USER=mac_user
|
||||
DB_PASSWORD=CAMBIA-ESTO-POR-UNA-CONTRASEÑA-SEGURA
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# ==============================================
|
||||
# PostgreSQL (usado por docker-compose)
|
||||
# ==============================================
|
||||
POSTGRES_DB=mac_attendance
|
||||
POSTGRES_USER=mac_user
|
||||
POSTGRES_PASSWORD=CAMBIA-ESTO-POR-UNA-CONTRASEÑA-SEGURA
|
||||
|
||||
# ==============================================
|
||||
# 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
|
||||
@@ -103,6 +103,12 @@ backend/.env
|
||||
*.key
|
||||
*.crt
|
||||
|
||||
# EXCEPCIÓN: Permitir archivos .example como plantillas
|
||||
!.env.example
|
||||
!.env.development.example
|
||||
!.env.production.example
|
||||
!backend/.env.example
|
||||
|
||||
# ==============================================
|
||||
# Uploads y Media
|
||||
# ==============================================
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# Sistema de Estadísticas de Asistencia
|
||||
|
||||
## Cambios Implementados
|
||||
|
||||
### 1. Actualización Automática de Estadísticas
|
||||
|
||||
Se implementó un sistema de señales (signals) que **actualiza automáticamente** las estadísticas de asistencia cuando:
|
||||
|
||||
- **Se elimina un evento**: Todas las estadísticas de todos los estudiantes se recalculan automáticamente
|
||||
- **Se crea un nuevo evento**: Todas las estadísticas se actualizan para reflejar el nuevo total de eventos
|
||||
- **Se elimina una asistencia**: Solo se actualizan las estadísticas del estudiante afectado
|
||||
|
||||
**Archivos modificados:**
|
||||
- `backend/attendance/signals.py` (nuevo)
|
||||
- `backend/attendance/apps.py` (modificado para registrar señales)
|
||||
- `backend/attendance/__init__.py` (modificado)
|
||||
|
||||
### 2. Prevención de Importación Inconsistente
|
||||
|
||||
El admin de Django para **AttendanceStats** ahora:
|
||||
|
||||
- ✅ **Solo permite EXPORTAR** estadísticas (no importar)
|
||||
- ✅ Los campos `total_events`, `attended_events` y `attendance_percentage` **siempre se calculan automáticamente**
|
||||
- ✅ No es posible importar valores manualmente que causen inconsistencias
|
||||
|
||||
**Archivos modificados:**
|
||||
- `backend/attendance/admin.py` - Cambiado de `ImportExportMixin` a `ExportMixin`
|
||||
|
||||
### 3. Comando de Recálculo Manual
|
||||
|
||||
Se puede ejecutar manualmente el recálculo de estadísticas con:
|
||||
|
||||
```bash
|
||||
docker exec pagina-de-asistencia-mac-backend-1 python manage.py recalculate_stats
|
||||
```
|
||||
|
||||
Este comando:
|
||||
- ✅ Recalcula las estadísticas de **TODOS** los estudiantes
|
||||
- ✅ Crea estadísticas para estudiantes que no las tienen
|
||||
- ✅ Garantiza que **todos los estudiantes tengan el mismo total_events**
|
||||
|
||||
**Cuándo usarlo:**
|
||||
- Después de importar asistencias históricas
|
||||
- Si sospechas que hay inconsistencias en las estadísticas
|
||||
- Después de eliminar eventos manualmente desde la BD
|
||||
|
||||
## Garantías del Sistema
|
||||
|
||||
### ✅ Total de Eventos Consistente
|
||||
|
||||
**TODOS los estudiantes siempre tendrán el mismo valor en `total_events`**, que representa el número total de "bloques de horario" de eventos activos en el sistema.
|
||||
|
||||
### ✅ Actualización Automática
|
||||
|
||||
No es necesario recalcular manualmente las estadísticas en operaciones normales:
|
||||
- Eliminar un evento → Estadísticas se actualizan automáticamente
|
||||
- Agregar asistencias → Estadísticas se actualizan automáticamente
|
||||
- Eliminar asistencias → Estadísticas se actualizan automáticamente
|
||||
|
||||
### ✅ Importación Segura y Optimizada
|
||||
|
||||
Al importar asistencias desde Excel:
|
||||
- ✅ Se pueden importar estudiantes y eventos asistidos
|
||||
- ✅ Las estadísticas se actualizan **automáticamente al finalizar** la importación
|
||||
- ✅ **Optimización de rendimiento**: Las estadísticas se actualizan en batch (una vez por estudiante afectado, no una vez por asistencia)
|
||||
- ✅ **NO** se pueden importar valores de `total_events` directamente (son calculados automáticamente)
|
||||
|
||||
**Ejemplo de optimización:**
|
||||
```
|
||||
Importando 1000 asistencias de 50 estudiantes diferentes:
|
||||
- ❌ Sin optimización: 1000 actualizaciones de estadísticas
|
||||
- ✅ Con optimización: 50 actualizaciones (una por estudiante)
|
||||
```
|
||||
|
||||
**Proceso de importación:**
|
||||
1. Se importan todas las asistencias
|
||||
2. Se registra qué estudiantes fueron afectados
|
||||
3. Al finalizar, se actualizan las estadísticas solo de los estudiantes afectados
|
||||
4. Mensaje de confirmación: `[IMPORTACIÓN] ✓ Estadísticas actualizadas para X estudiante(s)`
|
||||
|
||||
## Verificación
|
||||
|
||||
Para verificar que todas las estadísticas son consistentes:
|
||||
|
||||
```bash
|
||||
docker exec pagina-de-asistencia-mac-backend-1 python -c "
|
||||
import os
|
||||
import django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings.local')
|
||||
django.setup()
|
||||
from attendance.models import AttendanceStats
|
||||
|
||||
stats = AttendanceStats.objects.all()
|
||||
total_events_values = stats.values_list('total_events', flat=True).distinct()
|
||||
print(f'Valores únicos de total_events: {list(total_events_values)}')
|
||||
if len(total_events_values) == 1:
|
||||
print('✓ CORRECTO: Todos tienen el mismo total')
|
||||
else:
|
||||
print('✗ ERROR: Hay inconsistencias')
|
||||
"
|
||||
```
|
||||
|
||||
## Importación de Asistencias desde Excel
|
||||
|
||||
### Paso a Paso
|
||||
|
||||
1. **Preparar archivo Excel** con las siguientes columnas:
|
||||
|
||||
**⚠️ IMPORTANTE: NO incluir columna 'id' - se genera automáticamente**
|
||||
|
||||
- `account_number`: Matrícula del estudiante (8 dígitos) **[REQUERIDO]**
|
||||
- `student_name`: Nombre del estudiante (opcional, solo informativo)
|
||||
- `event_title`: Título exacto del evento (debe existir en la BD) **[REQUERIDO]**
|
||||
- `timestamp`: Fecha y hora de registro (opcional)
|
||||
- Formatos aceptados:
|
||||
- `DD/MM/YYYY HH:MM` (ej: 24/10/2025 11:44)
|
||||
- `DD/MM/YYYY HH:MM:SS` (ej: 24/10/2025 11:44:00)
|
||||
- `YYYY-MM-DD HH:MM:SS` (ej: 2025-10-24 11:44:00)
|
||||
- `YYYY-MM-DD HH:MM` (ej: 2025-10-24 11:44)
|
||||
- `registered_by_account`: Matrícula del asistente (opcional, default: 11111111)
|
||||
- `registration_method`: manual o barcode (opcional, default: manual)
|
||||
- `notes`: Notas adicionales (opcional)
|
||||
- `is_valid`: True o False (opcional, default: True)
|
||||
|
||||
2. **Ir al Django Admin** → Asistencias → IMPORTAR
|
||||
|
||||
3. **Seleccionar el archivo Excel**
|
||||
|
||||
4. **Vista previa**: Revisar que los datos se vean correctos
|
||||
|
||||
5. **Confirmar importación**
|
||||
|
||||
6. **Verificar mensaje**:
|
||||
```
|
||||
[IMPORTACIÓN] Actualizando estadísticas de X estudiante(s) afectado(s)...
|
||||
[IMPORTACIÓN] ✓ Estadísticas actualizadas para X estudiante(s)
|
||||
```
|
||||
|
||||
### Ejemplo de Excel
|
||||
|
||||
**✅ CORRECTO (sin columna 'id'):**
|
||||
```
|
||||
account_number | student_name | event_title | timestamp
|
||||
42502372 | Aramburo Chang | App Kachi México y su cultura | 24/10/2025 18:30
|
||||
31720256 | Arenas Juarez | App Kachi México y su cultura | 24/10/2025 18:30
|
||||
42610156 | Barraza Casta. | De principiante a protector... | 24/10/2025 19:30
|
||||
```
|
||||
|
||||
**❌ INCORRECTO (con columna 'id'):**
|
||||
```
|
||||
id | account_number | student_name | event_title | timestamp
|
||||
1815 | 42502372 | Aramburo Chang | App Kachi México y su cultura | 24/10/2025 18:30
|
||||
```
|
||||
⚠️ NO incluir la columna 'id' - causará errores
|
||||
|
||||
### Validaciones Automáticas
|
||||
|
||||
Durante la importación se valida:
|
||||
- ✅ Que el estudiante exista en la BD
|
||||
- ✅ Que el evento exista en la BD
|
||||
- ✅ Que no haya duplicados (mismo estudiante + mismo evento)
|
||||
- ✅ Que no haya eventos simultáneos (estudiante no puede estar en dos eventos a la vez)
|
||||
|
||||
Si hay errores, se mostrarán en rojo y las filas con error NO se importarán.
|
||||
|
||||
### Después de la Importación
|
||||
|
||||
**NO es necesario** ejecutar ningún comando adicional. Las estadísticas se actualizan automáticamente.
|
||||
|
||||
Si quieres verificar:
|
||||
```bash
|
||||
docker exec pagina-de-asistencia-mac-backend-1 python -c "
|
||||
from attendance.models import AttendanceStats
|
||||
stats = AttendanceStats.objects.all()
|
||||
print(f'Total de estudiantes: {stats.count()}')
|
||||
print(f'Valores de total_events: {set(stats.values_list(\"total_events\", flat=True))}')
|
||||
"
|
||||
```
|
||||
|
||||
## Administración desde Django Admin
|
||||
|
||||
### Acciones disponibles en AttendanceStats Admin:
|
||||
|
||||
1. **📊 Exportar estadísticas seleccionadas**
|
||||
- Exporta las estadísticas de los estudiantes seleccionados
|
||||
|
||||
2. **📊 Exportar estudiantes que cumplen requisito para constancia**
|
||||
- Exporta solo los estudiantes que tienen el porcentaje mínimo de asistencia
|
||||
|
||||
3. **🔄 Actualizar estadísticas seleccionadas**
|
||||
- Recalcula las estadísticas de los estudiantes seleccionados
|
||||
- Útil si se modificaron eventos o asistencias
|
||||
|
||||
## Notas Técnicas
|
||||
|
||||
### Cálculo de Total de Eventos (Bloques de Horario)
|
||||
|
||||
El sistema usa **bloques de horario** en lugar de contar eventos individuales:
|
||||
- Eventos simultáneos en la misma fecha/hora se cuentan como **1 solo bloque**
|
||||
- El estudiante solo necesita asistir a **uno de los eventos simultáneos** para marcar ese bloque como asistido
|
||||
- Dos eventos son simultáneos si tienen la **misma fecha, misma hora de inicio y misma hora de fin**
|
||||
|
||||
**Ejemplo real del sistema:**
|
||||
```
|
||||
21 eventos activos en total:
|
||||
|
||||
Bloques con múltiples eventos simultáneos:
|
||||
- 21/Oct 11:00-12:00: 2 eventos (cuenta como 1 bloque)
|
||||
• Matemáticas Aplicadas a Medicina...
|
||||
• Tecnologías IIoT al servicio de la Industria
|
||||
|
||||
- 21/Oct 12:00-13:00: 2 eventos (cuenta como 1 bloque)
|
||||
• La presión fiscal como oportunidad...
|
||||
• Matemáticas Aplicadas y Computación...
|
||||
|
||||
- 23/Oct 11:00-12:00: 2 eventos (cuenta como 1 bloque)
|
||||
• MAC aplicado en el sector de aseguradoras
|
||||
• Análisis de Escenas Auditivas
|
||||
|
||||
- 23/Oct 12:00-13:00: 2 eventos (cuenta como 1 bloque)
|
||||
• Investigación en MAC con IA
|
||||
• Cuando los datos hablan...
|
||||
|
||||
Total de bloques = 21 eventos - 8 (que se combinan en 4 pares) + 4 (bloques únicos) = 17 bloques
|
||||
```
|
||||
|
||||
**IMPORTANTE:** Si tienes 21 eventos activos pero 4 pares son simultáneos, el `total_events` será **17**, no 21.
|
||||
|
||||
### Logs de las Señales
|
||||
|
||||
Las señales imprimen mensajes en la consola del contenedor Docker:
|
||||
|
||||
```
|
||||
[SIGNAL] Evento 'Nombre del Evento' eliminado. Actualizando estadísticas de todos los estudiantes...
|
||||
[SIGNAL] Se actualizaron las estadísticas de 6359 estudiantes.
|
||||
```
|
||||
|
||||
Para ver los logs:
|
||||
```bash
|
||||
docker logs -f pagina-de-asistencia-mac-backend-1
|
||||
```
|
||||
|
||||
## Solución de Problemas
|
||||
|
||||
### Problema: "Tengo estudiantes con diferentes total_events"
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
docker exec pagina-de-asistencia-mac-backend-1 python manage.py recalculate_stats
|
||||
```
|
||||
|
||||
### Problema: "Las estadísticas no se actualizan al eliminar un evento"
|
||||
|
||||
**Verificar:**
|
||||
1. Que el contenedor Docker esté corriendo
|
||||
2. Que no haya errores en los logs: `docker logs pagina-de-asistencia-mac-backend-1`
|
||||
3. Ejecutar recalculate_stats manualmente si es necesario
|
||||
|
||||
### Problema: "Quiero importar estadísticas desde Excel"
|
||||
|
||||
**Solución:**
|
||||
- NO importes estadísticas directamente
|
||||
- En su lugar, importa solo las **asistencias** (estudiantes + eventos)
|
||||
- Las estadísticas se calcularán automáticamente
|
||||
- Luego ejecuta: `python manage.py recalculate_stats` para asegurar consistencia
|
||||
|
||||
## Migración de Datos Históricos
|
||||
|
||||
Si tienes datos históricos con estadísticas inconsistentes:
|
||||
|
||||
1. **Respalda tu base de datos** (por si acaso)
|
||||
2. Ejecuta el comando de recálculo:
|
||||
```bash
|
||||
docker exec pagina-de-asistencia-mac-backend-1 python manage.py recalculate_stats
|
||||
```
|
||||
3. Verifica la consistencia con el script de verificación
|
||||
4. Exporta las estadísticas actualizadas desde el admin
|
||||
|
||||
---
|
||||
|
||||
**Fecha de implementación:** Octubre 2025
|
||||
**Versión:** 1.0
|
||||
@@ -0,0 +1,2 @@
|
||||
# App de Asistencia - Configuracion
|
||||
default_app_config = 'attendance.apps.AttendanceConfig'
|
||||
|
||||
+219
-65
@@ -4,102 +4,257 @@ from django.urls import path
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render, redirect
|
||||
from django.contrib import messages
|
||||
from import_export import resources, fields
|
||||
from import_export.admin import ImportExportMixin
|
||||
from import_export import resources, fields, widgets
|
||||
from import_export.admin import ImportExportMixin, ExportMixin
|
||||
from .models import Attendance, AttendanceStats
|
||||
from django.core.exceptions import ValidationError
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from django.utils.dateparse import parse_datetime
|
||||
from django.utils import timezone as tz
|
||||
|
||||
|
||||
class FlexibleDateTimeWidget(widgets.DateTimeWidget):
|
||||
"""
|
||||
Widget personalizado que acepta múltiples formatos de fecha/hora.
|
||||
"""
|
||||
def clean(self, value, row=None, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
# Si ya es un objeto datetime, devolverlo
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
|
||||
# Convertir a string si es necesario
|
||||
val = str(value).strip()
|
||||
if not val:
|
||||
return None
|
||||
|
||||
# Formatos aceptados
|
||||
date_formats = [
|
||||
'%d/%m/%Y %H:%M', # 24/10/2025 11:44
|
||||
'%d/%m/%Y %H:%M:%S', # 24/10/2025 11:44:00
|
||||
'%Y-%m-%d %H:%M:%S', # 2025-10-24 11:44:00
|
||||
'%Y-%m-%d %H:%M', # 2025-10-24 11:44
|
||||
'%d-%m-%Y %H:%M', # 24-10-2025 11:44
|
||||
'%d-%m-%Y %H:%M:%S', # 24-10-2025 11:44:00
|
||||
]
|
||||
|
||||
# Intentar parsear con cada formato
|
||||
for fmt in date_formats:
|
||||
try:
|
||||
dt = datetime.strptime(val, fmt)
|
||||
# Hacer timezone-aware si es necesario
|
||||
if tz.is_naive(dt):
|
||||
dt = tz.make_aware(dt)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Si ningún formato funciona, intentar con el parser por defecto
|
||||
try:
|
||||
dt = parse_datetime(val)
|
||||
if dt and tz.is_naive(dt):
|
||||
dt = tz.make_aware(dt)
|
||||
if dt:
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Si nada funciona, lanzar error
|
||||
raise ValueError(f"No se pudo parsear la fecha: '{value}'. Formatos aceptados: DD/MM/YYYY HH:MM o YYYY-MM-DD HH:MM:SS")
|
||||
|
||||
|
||||
class StudentWidget(widgets.ForeignKeyWidget):
|
||||
"""
|
||||
Widget para convertir account_number en objeto Student.
|
||||
"""
|
||||
def clean(self, value, row=None, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
from authentication.models import UserProfile
|
||||
|
||||
# Normalizar número de cuenta
|
||||
account_number = str(value).strip().replace(' ', '').replace('-', '')[:8]
|
||||
|
||||
try:
|
||||
student = UserProfile.objects.get(account_number=account_number, user_type='student')
|
||||
return student
|
||||
except UserProfile.DoesNotExist:
|
||||
raise ValueError(f"Estudiante con cuenta {account_number} no encontrado")
|
||||
|
||||
|
||||
class EventWidget(widgets.ForeignKeyWidget):
|
||||
"""
|
||||
Widget para convertir event_title en objeto Event.
|
||||
"""
|
||||
def clean(self, value, row=None, **kwargs):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
from events.models import Event
|
||||
|
||||
# Buscar evento por título
|
||||
event_title = str(value).strip()
|
||||
|
||||
try:
|
||||
event = Event.objects.get(title=event_title)
|
||||
return event
|
||||
except Event.DoesNotExist:
|
||||
raise ValueError(f"Evento '{event_title}' no encontrado")
|
||||
|
||||
|
||||
class AssistantWidget(widgets.ForeignKeyWidget):
|
||||
"""
|
||||
Widget para convertir registered_by_account en objeto Assistant.
|
||||
"""
|
||||
def clean(self, value, row=None, **kwargs):
|
||||
from authentication.models import UserProfile, Asistente
|
||||
|
||||
# Si no hay valor, usar por defecto
|
||||
if not value:
|
||||
value = '11111111'
|
||||
|
||||
# Normalizar número de cuenta
|
||||
account_number = str(value).strip().replace(' ', '').replace('-', '')[:8]
|
||||
|
||||
try:
|
||||
assistant = UserProfile.objects.get(account_number=account_number, user_type='assistant')
|
||||
|
||||
# Verificar/crear permisos de asistente
|
||||
Asistente.objects.get_or_create(
|
||||
user_profile=assistant,
|
||||
defaults={'can_manage_events': True}
|
||||
)
|
||||
|
||||
return assistant
|
||||
except UserProfile.DoesNotExist:
|
||||
# Usar asistente por defecto
|
||||
return UserProfile.objects.get(account_number='11111111', user_type='assistant')
|
||||
|
||||
|
||||
class AttendanceResource(resources.ModelResource):
|
||||
"""Recurso para importar/exportar asistencias"""
|
||||
student_account_number = fields.Field(column_name='account_number')
|
||||
student_name = fields.Field(column_name='student_name')
|
||||
event_title = fields.Field(column_name='event_title')
|
||||
registered_by_account = fields.Field(column_name='registered_by_account')
|
||||
# Campos con widgets personalizados para importación
|
||||
account_number = fields.Field(
|
||||
column_name='account_number',
|
||||
attribute='student',
|
||||
widget=StudentWidget(model='authentication.UserProfile', field='account_number')
|
||||
)
|
||||
student_name = fields.Field(
|
||||
column_name='student_name',
|
||||
readonly=True # Solo para vista previa/exportación
|
||||
)
|
||||
event_title = fields.Field(
|
||||
column_name='event_title',
|
||||
attribute='event',
|
||||
widget=EventWidget(model='events.Event', field='title')
|
||||
)
|
||||
registered_by_account = fields.Field(
|
||||
column_name='registered_by_account',
|
||||
attribute='registered_by',
|
||||
widget=AssistantWidget(model='authentication.UserProfile', field='account_number')
|
||||
)
|
||||
timestamp = fields.Field(
|
||||
column_name='timestamp',
|
||||
attribute='timestamp',
|
||||
widget=FlexibleDateTimeWidget()
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Attendance
|
||||
fields = ('id', 'student_account_number', 'student_name', 'event_title',
|
||||
'timestamp', 'registered_by_account', 'registration_method', 'notes', 'is_valid')
|
||||
export_order = fields
|
||||
# Campos para importar/exportar
|
||||
fields = ('account_number', 'student_name', 'event_title', 'timestamp',
|
||||
'registered_by_account', 'registration_method', 'notes', 'is_valid')
|
||||
export_order = ('id',) + fields
|
||||
import_id_fields = [] # No usar ID para importación
|
||||
skip_unchanged = True
|
||||
exclude = ('id',) # Excluir explícitamente el ID de la importación
|
||||
|
||||
def dehydrate_student_account_number(self, attendance):
|
||||
"""Obtener número de cuenta del estudiante"""
|
||||
def dehydrate_account_number(self, attendance):
|
||||
"""Obtener número de cuenta del estudiante para exportación"""
|
||||
return attendance.student.account_number
|
||||
|
||||
def dehydrate_student_name(self, attendance):
|
||||
"""Obtener nombre del estudiante"""
|
||||
"""Obtener nombre del estudiante para exportación"""
|
||||
return attendance.student.full_name
|
||||
|
||||
def dehydrate_event_title(self, attendance):
|
||||
"""Obtener título del evento"""
|
||||
"""Obtener título del evento para exportación"""
|
||||
return attendance.event.title
|
||||
|
||||
def dehydrate_registered_by_account(self, attendance):
|
||||
"""Obtener cuenta del asistente que registró"""
|
||||
"""Obtener cuenta del asistente que registró para exportación"""
|
||||
return attendance.registered_by.account_number if attendance.registered_by else ''
|
||||
|
||||
def before_import_row(self, row, **kwargs):
|
||||
"""Procesar fila antes de importar"""
|
||||
from authentication.models import UserProfile
|
||||
from events.models import Event
|
||||
from django.utils import timezone
|
||||
"""
|
||||
Procesar fila antes de importar.
|
||||
Los widgets se encargan de convertir los valores, aquí solo validamos.
|
||||
"""
|
||||
# Los widgets personalizados (StudentWidget, EventWidget, AssistantWidget)
|
||||
# se encargan de toda la conversión automáticamente.
|
||||
# Este método se deja para validaciones adicionales si se necesitan en el futuro.
|
||||
pass
|
||||
|
||||
# Normalizar número de cuenta del estudiante
|
||||
account_number = str(row.get('account_number', '')).strip()
|
||||
account_number = account_number.replace(' ', '').replace('-', '')[:8]
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.students_to_update = set() # Conjunto de estudiantes para actualizar al final
|
||||
|
||||
# Buscar estudiante
|
||||
try:
|
||||
student = UserProfile.objects.get(account_number=account_number, user_type='student')
|
||||
except UserProfile.DoesNotExist:
|
||||
raise ValidationError(f"Estudiante con cuenta {account_number} no encontrado")
|
||||
|
||||
# Buscar evento
|
||||
event_title = str(row.get('event_title', '')).strip()
|
||||
try:
|
||||
event = Event.objects.get(title=event_title)
|
||||
except Event.DoesNotExist:
|
||||
raise ValidationError(f"Evento '{event_title}' no encontrado")
|
||||
|
||||
# Buscar o crear asistente que registra
|
||||
registered_by_account = str(row.get('registered_by_account', '11111111')).strip()[:8]
|
||||
try:
|
||||
registered_by = UserProfile.objects.get(account_number=registered_by_account, user_type='assistant')
|
||||
|
||||
# Verificar/crear permisos de asistente
|
||||
from authentication.models import Asistente
|
||||
Asistente.objects.get_or_create(
|
||||
user_profile=registered_by,
|
||||
defaults={'can_manage_events': True}
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
# Usar asistente por defecto
|
||||
registered_by = UserProfile.objects.get(account_number='11111111', user_type='assistant')
|
||||
|
||||
# Preparar datos para el modelo
|
||||
row['student'] = student.id
|
||||
row['event'] = event.id
|
||||
row['registered_by'] = registered_by.id
|
||||
|
||||
# Timestamp
|
||||
if 'timestamp' not in row or not row['timestamp']:
|
||||
row['timestamp'] = timezone.now()
|
||||
|
||||
def save_instance(self, instance, using_transactions=True, dry_run=False):
|
||||
def save_instance(self, instance, *args, **kwargs):
|
||||
"""
|
||||
Guardar la instancia usando skip_validation=True para permitir
|
||||
importaciones de asistencias pasadas (fuera de ventana de tiempo)
|
||||
"""
|
||||
# Extraer dry_run de kwargs
|
||||
dry_run = kwargs.get('dry_run', False)
|
||||
|
||||
if not dry_run:
|
||||
# Guardar sin actualizar estadísticas (se hará en batch al final)
|
||||
# Temporalmente desactivar la actualización automática
|
||||
if hasattr(instance, '_importing'):
|
||||
instance._importing = True
|
||||
else:
|
||||
# Marcar la instancia como en importación
|
||||
instance._skip_stats_update = True
|
||||
|
||||
# Usar skip_validation=True para permitir importar asistencias históricas
|
||||
# Esto omite la validación de tiempo pero mantiene validación de duplicados
|
||||
instance.save(skip_validation=True)
|
||||
|
||||
# Registrar estudiante para actualización posterior
|
||||
if instance.student:
|
||||
self.students_to_update.add(instance.student.id)
|
||||
|
||||
def after_import(self, dataset, result, using_transactions, dry_run, **kwargs):
|
||||
"""
|
||||
Después de importar todas las asistencias, actualizar estadísticas
|
||||
de los estudiantes afectados en batch (más eficiente)
|
||||
"""
|
||||
if not dry_run and self.students_to_update:
|
||||
from attendance.models import AttendanceStats
|
||||
|
||||
count = len(self.students_to_update)
|
||||
print(f'\n[IMPORTACIÓN] Actualizando estadísticas de {count} estudiante(s) afectado(s)...')
|
||||
|
||||
updated = 0
|
||||
for student_id in self.students_to_update:
|
||||
try:
|
||||
stats, created = AttendanceStats.objects.get_or_create(
|
||||
student_id=student_id
|
||||
)
|
||||
stats.update_stats()
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
print(f'Error actualizando estadísticas del estudiante {student_id}: {e}')
|
||||
|
||||
print(f'[IMPORTACIÓN] ✓ Estadísticas actualizadas para {updated} estudiante(s)')
|
||||
|
||||
# Limpiar el conjunto
|
||||
self.students_to_update.clear()
|
||||
|
||||
|
||||
class AttendanceStatsResource(resources.ModelResource):
|
||||
"""Recurso para exportar estadísticas de asistencia"""
|
||||
@@ -182,7 +337,11 @@ class AttendanceAdmin(ImportExportMixin, admin.ModelAdmin):
|
||||
return request.user.is_superuser
|
||||
|
||||
@admin.register(AttendanceStats)
|
||||
class AttendanceStatsAdmin(ImportExportMixin, admin.ModelAdmin):
|
||||
class AttendanceStatsAdmin(ExportMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Admin para estadísticas de asistencia.
|
||||
NOTA: Solo permite EXPORTAR, NO importar. Las estadísticas se calculan automáticamente.
|
||||
"""
|
||||
resource_class = AttendanceStatsResource
|
||||
list_display = ['student', 'attended_events', 'total_events', 'attendance_percentage', 'get_cumple_requisito']
|
||||
ordering = ['-attendance_percentage']
|
||||
@@ -269,11 +428,6 @@ class AttendanceStatsAdmin(ImportExportMixin, admin.ModelAdmin):
|
||||
|
||||
update_all_stats.short_description = "🔄 Actualizar estadísticas seleccionadas"
|
||||
|
||||
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
|
||||
|
||||
@@ -4,3 +4,10 @@ from django.apps import AppConfig
|
||||
class AttendanceConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'attendance'
|
||||
verbose_name = 'Gestión de Asistencias'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Importar señales cuando la aplicación esté lista.
|
||||
"""
|
||||
import attendance.signals # noqa
|
||||
|
||||
@@ -171,7 +171,9 @@ class Attendance(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Actualizar estadísticas del estudiante
|
||||
self.update_student_stats()
|
||||
# (omitir si se está importando - se actualizará en batch al final)
|
||||
if not getattr(self, '_skip_stats_update', False):
|
||||
self.update_student_stats()
|
||||
|
||||
def update_student_stats(self):
|
||||
"""Actualizar las estadísticas de asistencia del estudiante"""
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Señales para mantener las estadísticas de asistencia actualizadas.
|
||||
"""
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from events.models import Event
|
||||
from attendance.models import Attendance, AttendanceStats
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Event)
|
||||
def update_stats_on_event_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Cuando se elimina un evento, actualizar las estadísticas de TODOS los estudiantes.
|
||||
Esto es necesario porque el total de eventos cambia para todos.
|
||||
"""
|
||||
print(f"[SIGNAL] Evento '{instance.title}' eliminado. Actualizando estadísticas de todos los estudiantes...")
|
||||
|
||||
# Actualizar todas las estadísticas existentes
|
||||
stats = AttendanceStats.objects.all()
|
||||
count = 0
|
||||
for stat in stats:
|
||||
stat.update_stats()
|
||||
count += 1
|
||||
|
||||
print(f"[SIGNAL] Se actualizaron las estadísticas de {count} estudiantes.")
|
||||
|
||||
|
||||
@receiver(post_delete, sender=Attendance)
|
||||
def update_stats_on_attendance_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Cuando se elimina una asistencia, actualizar las estadísticas del estudiante.
|
||||
"""
|
||||
if instance.student:
|
||||
print(f"[SIGNAL] Asistencia eliminada para {instance.student.full_name}. Actualizando estadísticas...")
|
||||
|
||||
# Verificar si existe el registro de estadísticas
|
||||
try:
|
||||
stats = AttendanceStats.objects.get(student=instance.student)
|
||||
stats.update_stats()
|
||||
print(f"[SIGNAL] Estadísticas actualizadas para {instance.student.full_name}")
|
||||
except AttendanceStats.DoesNotExist:
|
||||
print(f"[SIGNAL] No existen estadísticas para {instance.student.full_name}")
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
def update_stats_on_event_save(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Cuando se crea o modifica un evento, actualizar las estadísticas de TODOS los estudiantes
|
||||
si el evento es activo (ya que afecta el total de eventos).
|
||||
Solo actualizar si es un evento nuevo o si cambió el estado de is_active.
|
||||
"""
|
||||
if created or (hasattr(instance, '_state') and instance._state.adding is False):
|
||||
# Verificar si es un evento nuevo o si cambió is_active
|
||||
if created:
|
||||
print(f"[SIGNAL] Nuevo evento '{instance.title}' creado. Actualizando estadísticas de todos los estudiantes...")
|
||||
|
||||
# Actualizar todas las estadísticas existentes
|
||||
stats = AttendanceStats.objects.all()
|
||||
count = 0
|
||||
for stat in stats:
|
||||
stat.update_stats()
|
||||
count += 1
|
||||
|
||||
print(f"[SIGNAL] Se actualizaron las estadísticas de {count} estudiantes.")
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Script para verificar todas las hojas y columnas del archivo AttendanceStats.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
def check_excel():
|
||||
"""Verificar hojas y columnas del Excel"""
|
||||
print("=" * 80)
|
||||
print("VERIFICANDO ARCHIVO ATTENDANCESTATS.XLSX")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Leer todas las hojas
|
||||
excel_file = pd.ExcelFile("/app/attendanceStats.xlsx")
|
||||
|
||||
print(f"Hojas en el archivo: {excel_file.sheet_names}")
|
||||
print()
|
||||
|
||||
# Leer cada hoja
|
||||
for sheet_name in excel_file.sheet_names:
|
||||
print("=" * 80)
|
||||
print(f"HOJA: {sheet_name}")
|
||||
print("=" * 80)
|
||||
|
||||
df = pd.read_excel("/app/attendanceStats.xlsx", sheet_name=sheet_name)
|
||||
|
||||
print(f"Total de registros: {len(df)}")
|
||||
print(f"Columnas: {list(df.columns)}")
|
||||
print()
|
||||
|
||||
# Mostrar primeros 3 registros
|
||||
print("Primeros 3 registros:")
|
||||
for idx, row in df.head(3).iterrows():
|
||||
print(f"\nRegistro {idx + 1}:")
|
||||
for col in df.columns:
|
||||
print(f" {col}: {row[col]}")
|
||||
|
||||
print()
|
||||
|
||||
print("=" * 80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_excel()
|
||||
@@ -0,0 +1,236 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
from datetime import datetime
|
||||
import math
|
||||
|
||||
# Configurar Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings.local')
|
||||
django.setup()
|
||||
|
||||
from attendance.models import Attendance, AttendanceStats
|
||||
from events.models import Event
|
||||
from authentication.models import UserProfile
|
||||
from django.db import transaction
|
||||
|
||||
# Números de cuenta a corregir
|
||||
ESTUDIANTES_DIA_ESPECIFICO = {
|
||||
'42307052': [20, 22, 24] # Este estudiante necesita asistencias en días específicos
|
||||
}
|
||||
|
||||
# Estudiantes que necesitan cumplir el 65%
|
||||
ESTUDIANTES_65_PORCIENTO = [
|
||||
'42306223',
|
||||
'42307052', # Este también está en la lista
|
||||
'32326922',
|
||||
'31904405',
|
||||
'42212521',
|
||||
'42110637'
|
||||
]
|
||||
|
||||
def main():
|
||||
print("=" * 80)
|
||||
print("CORRECCIÓN DE ASISTENCIAS")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. Buscar eventos activos
|
||||
print("\n1. Buscando eventos activos...")
|
||||
eventos = Event.objects.filter(is_active=True).order_by('date')
|
||||
|
||||
if not eventos.exists():
|
||||
print("❌ No se encontraron eventos activos")
|
||||
return
|
||||
|
||||
print(f"✓ Se encontraron {eventos.count()} eventos activos")
|
||||
|
||||
# Agrupar eventos por día
|
||||
eventos_por_dia = {}
|
||||
for evento in eventos:
|
||||
dia = evento.date.day
|
||||
if dia not in eventos_por_dia:
|
||||
eventos_por_dia[dia] = []
|
||||
eventos_por_dia[dia].append(evento)
|
||||
|
||||
print(f"\nDías con eventos: {sorted(eventos_por_dia.keys())}")
|
||||
|
||||
# 2. Verificar que existan eventos en los días 20, 22, 24
|
||||
dias_requeridos = [20, 22, 24]
|
||||
eventos_a_registrar = {}
|
||||
|
||||
for dia in dias_requeridos:
|
||||
if dia in eventos_por_dia:
|
||||
eventos_a_registrar[dia] = eventos_por_dia[dia]
|
||||
print(f"\nDía {dia}:")
|
||||
for evento in eventos_por_dia[dia]:
|
||||
print(f" - {evento.title} ({evento.date} {evento.start_time})")
|
||||
else:
|
||||
print(f"\n⚠️ No hay eventos registrados para el día {dia}")
|
||||
|
||||
if not eventos_a_registrar:
|
||||
print("\n❌ No hay eventos en los días especificados (20, 22, 24)")
|
||||
return
|
||||
|
||||
# 3. Buscar un asistente para usar como 'registered_by'
|
||||
print("\n2. Buscando asistente para registrar asistencias...")
|
||||
asistente = UserProfile.objects.filter(user_type='assistant').first()
|
||||
|
||||
if not asistente:
|
||||
print("❌ No se encontró ningún asistente en el sistema")
|
||||
return
|
||||
|
||||
print(f"✓ Usando asistente: {asistente.full_name}")
|
||||
|
||||
# 4. Procesar estudiante 42307052 (días específicos)
|
||||
print("\n3. Procesando asistencias para días específicos...")
|
||||
print("-" * 80)
|
||||
|
||||
cuenta = '42307052'
|
||||
dias = ESTUDIANTES_DIA_ESPECIFICO[cuenta]
|
||||
|
||||
try:
|
||||
estudiante = UserProfile.objects.get(account_number=cuenta)
|
||||
print(f"\n✓ Estudiante encontrado: {estudiante.full_name} ({cuenta})")
|
||||
|
||||
asistencias_creadas = 0
|
||||
asistencias_existentes = 0
|
||||
|
||||
for dia in dias:
|
||||
if dia in eventos_a_registrar:
|
||||
for evento in eventos_a_registrar[dia]:
|
||||
# Verificar si ya existe asistencia
|
||||
existe = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
event=evento,
|
||||
is_valid=True
|
||||
).exists()
|
||||
|
||||
if existe:
|
||||
print(f" ⊙ Ya existe asistencia: {evento.title} ({evento.date})")
|
||||
asistencias_existentes += 1
|
||||
else:
|
||||
# Crear asistencia con skip_validation
|
||||
with transaction.atomic():
|
||||
asistencia = Attendance(
|
||||
student=estudiante,
|
||||
event=evento,
|
||||
registered_by=asistente,
|
||||
registration_method='manual',
|
||||
notes='Corrección de asistencia - registrado manualmente'
|
||||
)
|
||||
asistencia.save(skip_validation=True)
|
||||
print(f" ✓ Asistencia creada: {evento.title} ({evento.date})")
|
||||
asistencias_creadas += 1
|
||||
|
||||
print(f"\nResumen para {cuenta}:")
|
||||
print(f" - Asistencias creadas: {asistencias_creadas}")
|
||||
print(f" - Asistencias ya existentes: {asistencias_existentes}")
|
||||
|
||||
except UserProfile.DoesNotExist:
|
||||
print(f"❌ No se encontró estudiante con cuenta {cuenta}")
|
||||
except Exception as e:
|
||||
print(f"❌ Error procesando estudiante {cuenta}: {str(e)}")
|
||||
|
||||
# 5. Procesar estudiantes que necesitan cumplir el 65%
|
||||
print("\n4. Procesando estudiantes para cumplir 65% de asistencia...")
|
||||
print("-" * 80)
|
||||
|
||||
for cuenta in ESTUDIANTES_65_PORCIENTO:
|
||||
try:
|
||||
estudiante = UserProfile.objects.get(account_number=cuenta)
|
||||
print(f"\n📊 Estudiante: {estudiante.full_name} ({cuenta})")
|
||||
|
||||
# Obtener o crear estadísticas
|
||||
stats, created = AttendanceStats.objects.get_or_create(
|
||||
student=estudiante,
|
||||
defaults={
|
||||
'total_events': 0,
|
||||
'attended_events': 0,
|
||||
'attendance_percentage': 0.0
|
||||
}
|
||||
)
|
||||
|
||||
# Actualizar estadísticas actuales
|
||||
stats.update_stats()
|
||||
|
||||
print(f" Estado actual: {stats.attended_events}/{stats.total_events} eventos ({stats.attendance_percentage}%)")
|
||||
|
||||
if stats.attendance_percentage >= 65:
|
||||
print(f" ✓ Ya cumple con el 65% requerido")
|
||||
continue
|
||||
|
||||
# Calcular cuántos eventos más necesita (redondear hacia arriba)
|
||||
eventos_minimos_65 = math.ceil(stats.total_events * 0.65)
|
||||
eventos_necesarios = eventos_minimos_65 - stats.attended_events
|
||||
if eventos_necesarios < 0:
|
||||
eventos_necesarios = 0
|
||||
|
||||
print(f" Necesita asistir a {eventos_necesarios} eventos más para cumplir el 65%")
|
||||
|
||||
if eventos_necesarios == 0:
|
||||
print(f" ✓ Ya cumple con el requisito")
|
||||
continue
|
||||
|
||||
# Buscar eventos a los que no ha asistido
|
||||
eventos_asistidos = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
is_valid=True
|
||||
).values_list('event_id', flat=True)
|
||||
|
||||
eventos_disponibles = Event.objects.filter(
|
||||
is_active=True
|
||||
).exclude(id__in=eventos_asistidos).order_by('date', 'start_time')
|
||||
|
||||
print(f" Eventos disponibles para registrar: {eventos_disponibles.count()}")
|
||||
|
||||
# Registrar asistencias hasta cumplir el 65%, evitando conflictos
|
||||
asistencias_agregadas = 0
|
||||
intentos_fallidos = 0
|
||||
max_intentos_fallidos = 10 # Evitar bucle infinito
|
||||
|
||||
for evento in eventos_disponibles:
|
||||
# Si ya alcanzamos el número necesario, salir
|
||||
if asistencias_agregadas >= eventos_necesarios:
|
||||
break
|
||||
|
||||
# Si hay muchos fallos consecutivos, probablemente no hay más eventos disponibles
|
||||
if intentos_fallidos >= max_intentos_fallidos:
|
||||
print(f" ⚠️ Se alcanzó el límite de intentos fallidos. No hay más eventos compatibles.")
|
||||
break
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
asistencia = Attendance(
|
||||
student=estudiante,
|
||||
event=evento,
|
||||
registered_by=asistente,
|
||||
registration_method='manual',
|
||||
notes='Corrección de asistencia - registrado manualmente'
|
||||
)
|
||||
asistencia.save(skip_validation=True)
|
||||
print(f" ✓ Asistencia agregada: {evento.title} ({evento.date})")
|
||||
asistencias_agregadas += 1
|
||||
intentos_fallidos = 0 # Resetear contador de fallos
|
||||
except Exception as e:
|
||||
print(f" ⚠️ No se pudo agregar: {evento.title} - {str(e)}")
|
||||
intentos_fallidos += 1
|
||||
|
||||
# Actualizar estadísticas finales
|
||||
stats.update_stats()
|
||||
print(f"\n Estado final: {stats.attended_events}/{stats.total_events} eventos ({stats.attendance_percentage}%)")
|
||||
|
||||
if stats.attendance_percentage >= 65:
|
||||
print(f" ✅ Ahora cumple con el 65% requerido")
|
||||
else:
|
||||
print(f" ⚠️ Aún no cumple el 65% - se agregaron {asistencias_agregadas} asistencias")
|
||||
|
||||
except UserProfile.DoesNotExist:
|
||||
print(f"\n❌ No se encontró estudiante con cuenta {cuenta}")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error procesando estudiante {cuenta}: {str(e)}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("PROCESO COMPLETADO")
|
||||
print("=" * 80)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script para crear reporte de asistencia a conferencias específicas.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
import pandas as pd
|
||||
|
||||
# Configurar Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings.local')
|
||||
django.setup()
|
||||
|
||||
from events.models import Event
|
||||
from attendance.models import Attendance
|
||||
from authentication.models import UserProfile
|
||||
|
||||
# Leer las matrículas del Excel
|
||||
excel_path = '/app/Cálculo.xlsx'
|
||||
df_matriculas = pd.read_excel(excel_path)
|
||||
matriculas = df_matriculas.iloc[:, 0].tolist() # Primera columna
|
||||
|
||||
print(f"Matrículas a consultar: {len(matriculas)}")
|
||||
print(f"Matrículas: {matriculas[:10]}...") # Primeras 10
|
||||
|
||||
# Buscar las conferencias específicas
|
||||
conferencia1_titulo = "App Kachi México y su cultura"
|
||||
conferencia2_titulo = "De principiante a protector: Luchando contra el Phishing"
|
||||
|
||||
# Buscar eventos que contengan estos títulos
|
||||
eventos = Event.objects.all()
|
||||
print(f"\nTotal de eventos en BD: {eventos.count()}")
|
||||
|
||||
# Buscar por título
|
||||
conf1 = Event.objects.filter(title__icontains="App Kachi").first()
|
||||
conf2 = Event.objects.filter(title__icontains="principiante a protector").first()
|
||||
|
||||
if conf1:
|
||||
print(f"\nConferencia 1 encontrada:")
|
||||
print(f" ID: {conf1.id}")
|
||||
print(f" Título: {conf1.title}")
|
||||
print(f" Fecha: {conf1.date}")
|
||||
print(f" Hora: {conf1.start_time}")
|
||||
print(f" Ubicación: {conf1.location}")
|
||||
else:
|
||||
print(f"\n¡ADVERTENCIA! No se encontró la conferencia '{conferencia1_titulo}'")
|
||||
print("Buscando conferencias similares...")
|
||||
similares = Event.objects.filter(title__icontains="Kachi")
|
||||
for e in similares:
|
||||
print(f" - {e.title}")
|
||||
|
||||
if conf2:
|
||||
print(f"\nConferencia 2 encontrada:")
|
||||
print(f" ID: {conf2.id}")
|
||||
print(f" Título: {conf2.title}")
|
||||
print(f" Fecha: {conf2.date}")
|
||||
print(f" Hora: {conf2.start_time}")
|
||||
print(f" Ubicación: {conf2.location}")
|
||||
else:
|
||||
print(f"\n¡ADVERTENCIA! No se encontró la conferencia '{conferencia2_titulo}'")
|
||||
print("Buscando conferencias similares...")
|
||||
similares = Event.objects.filter(title__icontains="protector")
|
||||
for e in similares:
|
||||
print(f" - {e.title}")
|
||||
|
||||
# Si no se encontraron las conferencias, mostrar todas
|
||||
if not conf1 or not conf2:
|
||||
print("\n=== Listado de TODOS los eventos en la base de datos ===")
|
||||
for evento in Event.objects.all().order_by('-date'):
|
||||
print(f" [{evento.id}] {evento.title} - {evento.date} {evento.start_time}")
|
||||
print(f"\nTotal: {Event.objects.count()} eventos")
|
||||
|
||||
# Procesar asistencias si se encontraron ambas conferencias
|
||||
if conf1 and conf2:
|
||||
print("\n=== Procesando asistencias ===")
|
||||
|
||||
# Crear listas para el reporte
|
||||
reporte_data = []
|
||||
|
||||
for matricula in matriculas:
|
||||
# Buscar el estudiante
|
||||
# Las matrículas del Excel tienen 9 dígitos, las de la BD tienen 8
|
||||
# Necesitamos eliminar el último dígito
|
||||
matricula_bd = str(matricula)[:-1]
|
||||
|
||||
try:
|
||||
estudiante = UserProfile.objects.get(account_number=matricula_bd)
|
||||
|
||||
# Verificar asistencia a cada conferencia
|
||||
asistio_conf1 = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
event=conf1,
|
||||
is_valid=True
|
||||
).exists()
|
||||
|
||||
asistio_conf2 = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
event=conf2,
|
||||
is_valid=True
|
||||
).exists()
|
||||
|
||||
# Determinar a cuántas asistió
|
||||
if asistio_conf1 and asistio_conf2:
|
||||
asistencia_status = "Ambas conferencias"
|
||||
elif asistio_conf1:
|
||||
asistencia_status = "Solo App Kachi"
|
||||
elif asistio_conf2:
|
||||
asistencia_status = "Solo Phishing"
|
||||
else:
|
||||
asistencia_status = "Ninguna"
|
||||
|
||||
reporte_data.append({
|
||||
'Matrícula': matricula,
|
||||
'Nombre': estudiante.full_name,
|
||||
'Asistencia': asistencia_status,
|
||||
'App Kachi México y su cultura': '✓' if asistio_conf1 else '✗',
|
||||
'De principiante a protector (Phishing)': '✓' if asistio_conf2 else '✗'
|
||||
})
|
||||
|
||||
except UserProfile.DoesNotExist:
|
||||
reporte_data.append({
|
||||
'Matrícula': matricula,
|
||||
'Nombre': 'No encontrado en BD',
|
||||
'Asistencia': 'N/A',
|
||||
'App Kachi México y su cultura': 'N/A',
|
||||
'De principiante a protector (Phishing)': 'N/A'
|
||||
})
|
||||
|
||||
# Crear DataFrame y guardarlo
|
||||
df_reporte = pd.DataFrame(reporte_data)
|
||||
|
||||
# Ordenar: primero los que fueron a ambas, luego a una, luego ninguna
|
||||
orden_asistencia = {
|
||||
'Ambas conferencias': 1,
|
||||
'Solo App Kachi': 2,
|
||||
'Solo Phishing': 3,
|
||||
'Ninguna': 4,
|
||||
'N/A': 5
|
||||
}
|
||||
df_reporte['_orden'] = df_reporte['Asistencia'].map(orden_asistencia)
|
||||
df_reporte = df_reporte.sort_values('_orden').drop('_orden', axis=1)
|
||||
|
||||
# Guardar en Excel
|
||||
output_path = '/app/Reporte_Asistencia_Conferencias.xlsx'
|
||||
|
||||
# Crear un Excel con formato mejorado
|
||||
with pd.ExcelWriter(output_path, engine='openpyxl') as writer:
|
||||
df_reporte.to_excel(writer, sheet_name='Reporte de Asistencia', index=False)
|
||||
|
||||
# Obtener el workbook y worksheet
|
||||
workbook = writer.book
|
||||
worksheet = writer.sheets['Reporte de Asistencia']
|
||||
|
||||
# Ajustar ancho de columnas
|
||||
worksheet.column_dimensions['A'].width = 15 # Matrícula
|
||||
worksheet.column_dimensions['B'].width = 35 # Nombre
|
||||
worksheet.column_dimensions['C'].width = 25 # Asistencia
|
||||
worksheet.column_dimensions['D'].width = 30 # Conf 1
|
||||
worksheet.column_dimensions['E'].width = 35 # Conf 2
|
||||
|
||||
# Aplicar formato a la cabecera
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
|
||||
header_fill = PatternFill(start_color='366092', end_color='366092', fill_type='solid')
|
||||
header_font = Font(bold=True, color='FFFFFF', size=12)
|
||||
|
||||
for cell in worksheet[1]:
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
# Aplicar formato a las celdas de datos
|
||||
for row in worksheet.iter_rows(min_row=2, max_row=worksheet.max_row):
|
||||
# Centrar las columnas de checkmarks
|
||||
for i in [0, 3, 4]: # Matrícula y checkmarks
|
||||
row[i].alignment = Alignment(horizontal='center')
|
||||
|
||||
# Color según asistencia
|
||||
asistencia = row[2].value
|
||||
if asistencia == 'Ambas conferencias':
|
||||
fill = PatternFill(start_color='C6EFCE', end_color='C6EFCE', fill_type='solid')
|
||||
row[2].fill = fill
|
||||
row[2].font = Font(bold=True, color='006100')
|
||||
elif 'Solo' in str(asistencia):
|
||||
fill = PatternFill(start_color='FFEB9C', end_color='FFEB9C', fill_type='solid')
|
||||
row[2].fill = fill
|
||||
row[2].font = Font(color='9C6500')
|
||||
elif asistencia == 'Ninguna':
|
||||
fill = PatternFill(start_color='FFC7CE', end_color='FFC7CE', fill_type='solid')
|
||||
row[2].fill = fill
|
||||
row[2].font = Font(color='9C0006')
|
||||
|
||||
print(f"\n✓ Reporte creado exitosamente en: {output_path}")
|
||||
print(f"\nResumen:")
|
||||
print(f" - Total de estudiantes: {len(reporte_data)}")
|
||||
print(f" - Asistieron a ambas: {len([r for r in reporte_data if r['Asistencia'] == 'Ambas conferencias'])}")
|
||||
print(f" - Solo App Kachi: {len([r for r in reporte_data if r['Asistencia'] == 'Solo App Kachi'])}")
|
||||
print(f" - Solo Phishing: {len([r for r in reporte_data if r['Asistencia'] == 'Solo Phishing'])}")
|
||||
print(f" - Ninguna: {len([r for r in reporte_data if r['Asistencia'] == 'Ninguna'])}")
|
||||
print(f" - No encontrados en BD: {len([r for r in reporte_data if r['Asistencia'] == 'N/A'])}")
|
||||
else:
|
||||
print("\n¡ERROR! No se pudieron encontrar las conferencias especificadas.")
|
||||
print("Por favor verifica los títulos de las conferencias.")
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Script para verificar las correcciones de asistencia aplicadas
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
|
||||
# Configurar Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mac_attendance.settings.local')
|
||||
django.setup()
|
||||
|
||||
from attendance.models import Attendance, AttendanceStats
|
||||
from events.models import Event
|
||||
from authentication.models import UserProfile
|
||||
|
||||
# Números de cuenta a verificar
|
||||
ESTUDIANTES = [
|
||||
'42307052',
|
||||
'42306223',
|
||||
'32326922',
|
||||
'31904405',
|
||||
'42212521',
|
||||
'42110637'
|
||||
]
|
||||
|
||||
# Días específicos para 42307052
|
||||
DIAS_ESPECIFICOS = [20, 22, 24]
|
||||
|
||||
print("=" * 80)
|
||||
print("VERIFICACIÓN DE CORRECCIONES DE ASISTENCIA")
|
||||
print("=" * 80)
|
||||
|
||||
# Verificar que el estudiante 42307052 tenga asistencias en los días 20, 22, 24
|
||||
print("\n1. Verificando asistencias del estudiante 42307052 en días específicos...")
|
||||
print("-" * 80)
|
||||
|
||||
try:
|
||||
estudiante = UserProfile.objects.get(account_number='42307052')
|
||||
print(f"✓ Estudiante: {estudiante.full_name}")
|
||||
|
||||
for dia in DIAS_ESPECIFICOS:
|
||||
eventos_dia = Event.objects.filter(
|
||||
is_active=True,
|
||||
date__day=dia
|
||||
).order_by('start_time')
|
||||
|
||||
if eventos_dia.exists():
|
||||
print(f"\n Día {dia}:")
|
||||
for evento in eventos_dia:
|
||||
asistencia = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
event=evento,
|
||||
is_valid=True
|
||||
).exists()
|
||||
|
||||
estado = "✓ SÍ" if asistencia else "✗ NO"
|
||||
print(f" {estado} - {evento.title} ({evento.date} {evento.start_time})")
|
||||
else:
|
||||
print(f"\n Día {dia}: No hay eventos en este día")
|
||||
|
||||
except UserProfile.DoesNotExist:
|
||||
print(f"❌ No se encontró el estudiante con cuenta 42307052")
|
||||
|
||||
# Verificar que todos los estudiantes cumplan con el 65%
|
||||
print("\n\n2. Verificando que todos los estudiantes cumplan con el 65%...")
|
||||
print("-" * 80)
|
||||
|
||||
cumple_requisito = 0
|
||||
no_cumple_requisito = 0
|
||||
|
||||
for cuenta in ESTUDIANTES:
|
||||
try:
|
||||
estudiante = UserProfile.objects.get(account_number=cuenta)
|
||||
|
||||
# Obtener estadísticas
|
||||
stats, created = AttendanceStats.objects.get_or_create(
|
||||
student=estudiante,
|
||||
defaults={
|
||||
'total_events': 0,
|
||||
'attended_events': 0,
|
||||
'attendance_percentage': 0.0
|
||||
}
|
||||
)
|
||||
|
||||
# Actualizar estadísticas
|
||||
stats.update_stats()
|
||||
|
||||
cumple = "✅ SÍ CUMPLE" if stats.attendance_percentage >= 65 else "❌ NO CUMPLE"
|
||||
|
||||
print(f"\n{cumple} - {estudiante.full_name} ({cuenta})")
|
||||
print(f" Eventos asistidos: {stats.attended_events}/{stats.total_events}")
|
||||
print(f" Porcentaje: {stats.attendance_percentage}%")
|
||||
|
||||
if stats.attendance_percentage >= 65:
|
||||
cumple_requisito += 1
|
||||
else:
|
||||
no_cumple_requisito += 1
|
||||
|
||||
# Listar eventos asistidos
|
||||
asistencias = Attendance.objects.filter(
|
||||
student=estudiante,
|
||||
is_valid=True
|
||||
).select_related('event').order_by('event__date', 'event__start_time')
|
||||
|
||||
if asistencias.exists():
|
||||
print(f" Asistencias registradas:")
|
||||
for asistencia in asistencias:
|
||||
print(f" - {asistencia.event.title} ({asistencia.event.date})")
|
||||
|
||||
except UserProfile.DoesNotExist:
|
||||
print(f"\n❌ No se encontró el estudiante con cuenta {cuenta}")
|
||||
no_cumple_requisito += 1
|
||||
|
||||
# Resumen final
|
||||
print("\n" + "=" * 80)
|
||||
print("RESUMEN FINAL")
|
||||
print("=" * 80)
|
||||
print(f"Total de estudiantes verificados: {len(ESTUDIANTES)}")
|
||||
print(f"✅ Cumplen con el 65%: {cumple_requisito}")
|
||||
print(f"❌ No cumplen con el 65%: {no_cumple_requisito}")
|
||||
|
||||
if no_cumple_requisito == 0:
|
||||
print("\n🎉 ¡TODOS LOS ESTUDIANTES CUMPLEN CON EL REQUISITO DE ASISTENCIA!")
|
||||
else:
|
||||
print(f"\n⚠️ Hay {no_cumple_requisito} estudiante(s) que aún no cumplen el requisito")
|
||||
Reference in New Issue
Block a user