From 2bedabe231a0ad6e28dfb60df4424e1497d7267e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valeria=20L=C3=B3pez?= Date: Wed, 22 Oct 2025 00:58:44 -0600 Subject: [PATCH] =?UTF-8?q?Importaci=C3=B3n=20y=20Exportaci=C3=B3n=20de=20?= =?UTF-8?q?asistencias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPORTACION_ASISTENCIAS.md | 174 ++++++++++++++++++++++++++++++++++++ backend/attendance/admin.py | 136 ++++++++++++++++++++++++++-- 2 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 IMPORTACION_ASISTENCIAS.md diff --git a/IMPORTACION_ASISTENCIAS.md b/IMPORTACION_ASISTENCIAS.md new file mode 100644 index 0000000..a88368d --- /dev/null +++ b/IMPORTACION_ASISTENCIAS.md @@ -0,0 +1,174 @@ +# 📥 Guía de Importación/Exportación de Asistencias + +## 📋 Descripción General + +El sistema permite importar y exportar asistencias y estadísticas desde el admin de Django usando archivos Excel (.xlsx) o CSV. + +## ✨ Características + +### 1. **Asistencias (Attendance)** + +#### Importar Asistencias +- ✅ Botón "Importar" disponible en `/admin/attendance/attendance/` +- ✅ Formatos soportados: Excel (.xlsx), CSV (.csv) +- ✅ **Permite importar asistencias pasadas** (fuera de ventana de tiempo) +- ✅ Valida duplicados y eventos simultáneos automáticamente +- ✅ Auto-crea permisos de Asistente si no existen + +#### Exportar Asistencias +- ✅ Botón "Exportar" disponible en la lista +- ✅ Formatos: Excel (.xlsx), CSV (.csv) + +### 2. **Estadísticas de Asistencia (AttendanceStats)** + +#### Importar Estadísticas +- ✅ Botón "Importar" disponible en `/admin/attendance/attendancestats/` +- ✅ Permite actualizar estadísticas masivamente + +#### Exportar Estadísticas +- ✅ Botón "Exportar" para todas las estadísticas +- ✅ Acción "📊 Exportar estadísticas seleccionadas" +- ✅ Acción "📊 Exportar estudiantes que cumplen requisito para constancia" +- ✅ Acción "🔄 Actualizar estadísticas seleccionadas" + +--- + +## 📊 Formato del Archivo Excel para Importar Asistencias + +### Columnas Requeridas: + +| Columna | Descripción | Ejemplo | Obligatorio | +|---------|-------------|---------|-------------| +| `account_number` | Número de cuenta del estudiante (8 dígitos) | 32114471 | ✅ Sí | +| `student_name` | Nombre completo del estudiante | Lopez Martinez Valeria | ❌ No (solo referencia) | +| `event_title` | Título exacto del evento | Matemáticas Aplicadas a Medicina... | ✅ Sí | +| `registered_by_account` | Cuenta del asistente que registra | 11111111 | ❌ No (default: 11111111) | +| `timestamp` | Fecha y hora del registro | 2025-10-21 11:30:00 | ❌ No (default: ahora) | +| `registration_method` | Método de registro | manual | ❌ No (default: manual) | +| `notes` | Notas adicionales | Importado desde Excel | ❌ No | +| `is_valid` | ¿Es válida la asistencia? | True | ❌ No (default: True) | + +### Ejemplo de Archivo Excel: + +``` +account_number | student_name | event_title | registered_by_account +32114471 | Lopez Martinez Valeria | Matemáticas Aplicadas a Medicina... | 11111111 +31904405 | Lopez Martinez Angel | La presión fiscal como oportunidad... | 11111111 +42510571 | Almaraz Ramirez Rafael | Tecnologías IIoT al servicio de la Industria | 11111111 +``` + +--- + +## ⚙️ Comportamiento del Sistema + +### 🔓 skip_validation=True + +Cuando se importan asistencias, el sistema usa `skip_validation=True` que: + +✅ **PERMITE:** +- Importar asistencias fuera de la ventana de tiempo del evento +- Importar asistencias de eventos pasados +- Procesar importaciones masivas históricas + +✅ **MANTIENE VALIDACIÓN DE:** +- ❌ **Duplicados**: No permite crear dos asistencias del mismo estudiante al mismo evento +- ❌ **Eventos simultáneos**: No permite asistencia a dos eventos que ocurren al mismo tiempo + +### 🔐 Permisos de Asistente + +El sistema **automáticamente**: +1. Busca el asistente por `registered_by_account` +2. Si no existe, usa el asistente por defecto (11111111) +3. Verifica si tiene permisos en la tabla `Asistente` +4. Si no tiene permisos, los crea automáticamente con `can_manage_events=True` + +--- + +## 🚀 Cómo Usar + +### Importar Asistencias desde Admin + +1. Ve a `/admin/attendance/attendance/` +2. Click en botón **"IMPORTAR"** (arriba a la derecha) +3. Selecciona tu archivo Excel o CSV +4. El sistema muestra una **vista previa** de los cambios +5. Revisa los errores si los hay +6. Click en **"Confirmar importación"** +7. ✅ Listo! Las asistencias se importaron + +### Exportar Asistencias + +1. Ve a `/admin/attendance/attendance/` +2. Click en botón **"EXPORTAR"** +3. Selecciona formato (Excel o CSV) +4. Se descarga el archivo automáticamente + +### Exportar Estudiantes con Constancia + +1. Ve a `/admin/attendance/attendancestats/` +2. Selecciona estudiantes (o déjalos todos) +3. En "Acción": selecciona **"📊 Exportar estudiantes que cumplen requisito para constancia"** +4. Click en **"Ir"** +5. Se descarga Excel con estudiantes que cumplen el % mínimo + +--- + +## ⚠️ Errores Comunes y Soluciones + +### Error: "Estudiante con cuenta XXXXX no encontrado" +**Causa:** El número de cuenta no existe en la base de datos +**Solución:** Verifica que el estudiante esté registrado en el sistema + +### Error: "Evento 'XXXXX' no encontrado" +**Causa:** El título del evento no coincide exactamente +**Solución:** Copia el título exacto desde `/admin/events/event/` + +### Error: "Este estudiante ya tiene asistencia registrada para este evento" +**Causa:** Ya existe una asistencia para ese estudiante en ese evento +**Solución:** Es un duplicado, omítelo o elimina el existente primero + +### Error: "El estudiante ya tiene asistencia registrada en un evento simultáneo" +**Causa:** El estudiante tiene asistencia en otro evento que ocurre al mismo tiempo +**Solución:** El estudiante no puede estar en dos lugares a la vez. Elige uno solo. + +--- + +## 🔄 Actualizar Estadísticas después de Importar + +Las estadísticas se actualizan **automáticamente** después de cada importación. + +Si necesitas actualizar manualmente: +1. Ve a `/admin/attendance/attendancestats/` +2. Selecciona los estudiantes +3. Acción: **"🔄 Actualizar estadísticas seleccionadas"** +4. Click en **"Ir"** + +--- + +## 📝 Notas Importantes + +1. **Solo superusuarios** pueden importar asistencias +2. El sistema **normaliza automáticamente** números de cuenta (elimina espacios, guiones) +3. Los **certificados SSL** pueden variar entre máquinas (regenerar con `./docker/generate_ssl_certs.sh`) +4. La **base de datos NO se sincroniza con Git**, solo el código +5. Para sincronizar datos entre máquinas, usa **backup/restore de PostgreSQL** + +--- + +## 🛠️ Desarrollo + +### Archivos Modificados: +- `backend/attendance/admin.py` - Admin con ImportExportMixin +- `backend/attendance/models.py` - Modelo con skip_validation + +### Dependencias: +- `django-import-export` - Manejo de importación/exportación +- `openpyxl` - Lectura de archivos Excel +- `pandas` - Procesamiento de datos (opcional) + +--- + +## 📚 Referencias + +- Django Import-Export: https://django-import-export.readthedocs.io/ +- Admin de Django: https://docs.djangoproject.com/en/5.0/ref/contrib/admin/ diff --git a/backend/attendance/admin.py b/backend/attendance/admin.py index 6944cae..ced151b 100644 --- a/backend/attendance/admin.py +++ b/backend/attendance/admin.py @@ -2,9 +2,103 @@ from django.contrib import admin from django.utils.html import format_html 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 ExportMixin +from import_export.admin import ImportExportMixin from .models import Attendance, AttendanceStats +from django.core.exceptions import ValidationError +import pandas as pd +from datetime import datetime + + +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') + + 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 + import_id_fields = [] # No usar ID para importación + skip_unchanged = True + + def dehydrate_student_account_number(self, attendance): + """Obtener número de cuenta del estudiante""" + return attendance.student.account_number + + def dehydrate_student_name(self, attendance): + """Obtener nombre del estudiante""" + return attendance.student.full_name + + def dehydrate_event_title(self, attendance): + """Obtener título del evento""" + return attendance.event.title + + def dehydrate_registered_by_account(self, attendance): + """Obtener cuenta del asistente que registró""" + 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 + + # Normalizar número de cuenta del estudiante + account_number = str(row.get('account_number', '')).strip() + account_number = account_number.replace(' ', '').replace('-', '')[:8] + + # 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): + """ + Guardar la instancia usando skip_validation=True para permitir + importaciones de asistencias pasadas (fuera de ventana de tiempo) + """ + if not dry_run: + # 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) class AttendanceStatsResource(resources.ModelResource): @@ -32,7 +126,8 @@ class AttendanceStatsResource(resources.ModelResource): return 'SÍ' if stats.meets_minimum_requirement() else 'NO' @admin.register(Attendance) -class AttendanceAdmin(admin.ModelAdmin): +class AttendanceAdmin(ImportExportMixin, admin.ModelAdmin): + resource_class = AttendanceResource list_display = ['attendee_name', 'attendee_identifier', 'event', 'timestamp', 'registration_method', 'get_registered_by', 'is_valid'] list_filter = ['registration_method', 'registered_by', 'event__date', 'is_valid', 'event'] search_fields = ['student__full_name', 'student__account_number', 'event__title', @@ -64,9 +159,19 @@ class AttendanceAdmin(admin.ModelAdmin): return '-' get_registered_by.short_description = 'Registrado por' + def get_import_formats(self): + """Formatos permitidos para importar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + + def get_export_formats(self): + """Formatos permitidos para exportar""" + from import_export.formats.base_formats import XLSX, CSV + return [XLSX, CSV] + def has_add_permission(self, request): - # Prevenir creación manual desde admin (debe hacerse desde la API) - return False + # Permitir importación pero no creación manual individual + return request.user.is_superuser def has_change_permission(self, request, obj=None): # Solo superusuarios pueden editar asistencias (para correcciones excepcionales) @@ -77,13 +182,13 @@ class AttendanceAdmin(admin.ModelAdmin): return request.user.is_superuser @admin.register(AttendanceStats) -class AttendanceStatsAdmin(ExportMixin, admin.ModelAdmin): +class AttendanceStatsAdmin(ImportExportMixin, admin.ModelAdmin): resource_class = AttendanceStatsResource list_display = ['student', 'attended_events', 'total_events', 'attendance_percentage', 'get_cumple_requisito'] ordering = ['-attendance_percentage'] list_filter = ['attendance_percentage'] search_fields = ['student__account_number', 'student__full_name'] - actions = ['export_selected_stats', 'export_students_with_certificate'] + actions = ['export_selected_stats', 'export_students_with_certificate', 'update_all_stats'] def get_cumple_requisito(self, obj): """Mostrar si cumple el requisito mínimo""" @@ -150,6 +255,25 @@ class AttendanceStatsAdmin(ExportMixin, admin.ModelAdmin): export_students_with_certificate.short_description = "📊 Exportar estudiantes que cumplen requisito para constancia" + def update_all_stats(self, request, queryset): + """Acción para actualizar estadísticas de los estudiantes seleccionados""" + count = 0 + for stats in queryset: + stats.update_stats() + count += 1 + + self.message_user( + request, + f'Se actualizaron las estadísticas de {count} estudiantes.' + ) + + 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