From f428b9b9e0c39b7957ed3e17b61d12d65ba77934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valeria=20L=C3=B3pez?= Date: Tue, 11 Nov 2025 05:33:49 -0600 Subject: [PATCH] archivo .env.production.example y eliminado de archivos temporales --- .env.development.example | 49 ++++ .env.production.example | 70 +++++ .gitignore | 6 + backend/ESTADISTICAS_README.md | 282 +++++++++++++++++++ backend/attendance/__init__.py | 2 + backend/attendance/admin.py | 284 +++++++++++++++----- backend/attendance/apps.py | 7 + backend/attendance/models.py | 4 +- backend/attendance/signals.py | 64 +++++ backend/check_excel_sheets.py | 45 ---- backend/corregir_asistencias.py | 236 ++++++++++++++++ backend/crear_reporte_asistencia.py | 203 ++++++++++++++ backend/verificar_correccion_asistencias.py | 126 +++++++++ 13 files changed, 1267 insertions(+), 111 deletions(-) create mode 100644 .env.development.example create mode 100644 .env.production.example create mode 100644 backend/ESTADISTICAS_README.md create mode 100644 backend/attendance/signals.py delete mode 100644 backend/check_excel_sheets.py create mode 100644 backend/corregir_asistencias.py create mode 100644 backend/crear_reporte_asistencia.py create mode 100644 backend/verificar_correccion_asistencias.py diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..4c9e516 --- /dev/null +++ b/.env.development.example @@ -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 diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..aa52670 --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitignore b/.gitignore index 8b6ebfd..3d7484f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 # ============================================== diff --git a/backend/ESTADISTICAS_README.md b/backend/ESTADISTICAS_README.md new file mode 100644 index 0000000..19f7053 --- /dev/null +++ b/backend/ESTADISTICAS_README.md @@ -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 diff --git a/backend/attendance/__init__.py b/backend/attendance/__init__.py index e69de29..2772f84 100644 --- a/backend/attendance/__init__.py +++ b/backend/attendance/__init__.py @@ -0,0 +1,2 @@ +# App de Asistencia - Configuracion +default_app_config = 'attendance.apps.AttendanceConfig' diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py index ced151b..3b40834 100644 --- a/backend/attendance/admin.py +++ b/backend/attendance/admin.py @@ -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 diff --git a/backend/attendance/apps.py b/backend/attendance/apps.py index ba31b45..9d8be55 100644 --- a/backend/attendance/apps.py +++ b/backend/attendance/apps.py @@ -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 diff --git a/backend/attendance/models.py b/backend/attendance/models.py index 966123c..5f32c0f 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -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""" diff --git a/backend/attendance/signals.py b/backend/attendance/signals.py new file mode 100644 index 0000000..853c33a --- /dev/null +++ b/backend/attendance/signals.py @@ -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.") diff --git a/backend/check_excel_sheets.py b/backend/check_excel_sheets.py deleted file mode 100644 index 1cb7c25..0000000 --- a/backend/check_excel_sheets.py +++ /dev/null @@ -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() diff --git a/backend/corregir_asistencias.py b/backend/corregir_asistencias.py new file mode 100644 index 0000000..7e6e358 --- /dev/null +++ b/backend/corregir_asistencias.py @@ -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() diff --git a/backend/crear_reporte_asistencia.py b/backend/crear_reporte_asistencia.py new file mode 100644 index 0000000..b20a1ad --- /dev/null +++ b/backend/crear_reporte_asistencia.py @@ -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.") diff --git a/backend/verificar_correccion_asistencias.py b/backend/verificar_correccion_asistencias.py new file mode 100644 index 0000000..01101cb --- /dev/null +++ b/backend/verificar_correccion_asistencias.py @@ -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")