archivo .env.production.example y eliminado de archivos temporales

This commit is contained in:
Valeria López
2025-11-11 05:33:49 -06:00
parent 5a17516399
commit f428b9b9e0
13 changed files with 1267 additions and 111 deletions
+49
View File
@@ -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
+70
View File
@@ -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
+6
View File
@@ -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
# ==============================================
+282
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
# App de Asistencia - Configuracion
default_app_config = 'attendance.apps.AttendanceConfig'
+219 -65
View File
@@ -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
+7
View File
@@ -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
+3 -1
View File
@@ -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"""
+64
View File
@@ -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.")
-45
View File
@@ -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()
+236
View File
@@ -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()
+203
View File
@@ -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.")
+126
View File
@@ -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")