diff --git a/backend/attendance/management/commands/import_attendance.py b/backend/attendance/management/commands/import_attendance.py new file mode 100644 index 0000000..1bf37f6 --- /dev/null +++ b/backend/attendance/management/commands/import_attendance.py @@ -0,0 +1,282 @@ +""" +Comando para importar asistencias desde un archivo Excel. + +Formato esperado del Excel (asistencias.xlsx): +- Hoja: "Asistencias" +- Columnas: + 1. numero_cuenta (str): Número de cuenta del asistente (8 dígitos) + 2. nombre_completo (str): Nombre completo del asistente + 3. titulo_evento (str): Título del evento (debe existir) + 4. fecha_evento (str): Fecha del evento en formato YYYY-MM-DD o DD/MM/YYYY + 5. numero_cuenta_asistente (str): Número de cuenta del asistente que registra (8 dígitos) + 6. metodo_registro (str, opcional): manual, barcode, external (default: manual) + 7. notas (str, opcional): Notas adicionales + +Uso: + python manage.py import_attendance ruta/al/archivo.xlsx +""" + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from attendance.models import Attendance +from events.models import Event +from authentication.models import UserProfile, ExternalUser +import openpyxl +from datetime import datetime + + +class Command(BaseCommand): + help = 'Importa asistencias desde un archivo Excel' + + def add_arguments(self, parser): + parser.add_argument('excel_file', type=str, help='Ruta al archivo Excel') + parser.add_argument( + '--sheet', + type=str, + default='Asistencias', + help='Nombre de la hoja de Excel (default: Asistencias)' + ) + parser.add_argument( + '--registrador', + type=str, + required=True, + help='Número de cuenta del asistente que realiza la importación' + ) + parser.add_argument( + '--skip-validation', + action='store_true', + help='Omitir validación de horarios (para importar asistencias históricas)' + ) + parser.add_argument( + '--create-external', + action='store_true', + help='Crear automáticamente usuarios externos si no existen' + ) + + def handle(self, *args, **options): + excel_file = options['excel_file'] + sheet_name = options['sheet'] + registrador_cuenta = options['registrador'] + skip_validation = options['skip_validation'] + create_external = options['create_external'] + + # Validar que el registrador exista y sea asistente + try: + registrador = UserProfile.objects.get( + account_number=registrador_cuenta, + user_type='assistant' + ) + except UserProfile.DoesNotExist: + raise CommandError( + f'No se encontró un asistente con número de cuenta: {registrador_cuenta}' + ) + + try: + workbook = openpyxl.load_workbook(excel_file) + + if sheet_name not in workbook.sheetnames: + raise CommandError(f'La hoja "{sheet_name}" no existe en el archivo') + + sheet = workbook[sheet_name] + + # Validar encabezados + expected_headers = [ + 'numero_cuenta', 'nombre_completo', 'titulo_evento', + 'fecha_evento', 'metodo_registro', 'notas' + ] + + headers = [cell.value.lower().strip() if cell.value else '' + for cell in sheet[1]] + + if not all(h in headers for h in expected_headers[:4]): + self.stdout.write( + self.style.WARNING( + f'Encabezados esperados: {", ".join(expected_headers[:4])}' + ) + ) + raise CommandError( + 'El archivo debe tener los encabezados correctos en la primera fila' + ) + + created_count = 0 + error_count = 0 + external_created = 0 + + # Procesar filas (comenzando desde la fila 2) + for row_idx, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + try: + # Extraer datos + numero_cuenta = str(row[0]).strip() if row[0] else None + nombre_completo = row[1] + titulo_evento = row[2] + fecha_evento_str = row[3] + metodo_registro = row[4] if len(row) > 4 and row[4] else 'manual' + notas = row[5] if len(row) > 5 else None + + # Validar datos requeridos + if not all([numero_cuenta, nombre_completo, titulo_evento, fecha_evento_str]): + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Datos incompletos, omitiendo...' + ) + ) + error_count += 1 + continue + + # Parsear fecha del evento + if isinstance(fecha_evento_str, datetime): + fecha_evento = fecha_evento_str.date() + else: + try: + fecha_evento = datetime.strptime(str(fecha_evento_str), '%Y-%m-%d').date() + except ValueError: + try: + fecha_evento = datetime.strptime(str(fecha_evento_str), '%d/%m/%Y').date() + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Formato de fecha inválido ({fecha_evento_str})' + ) + ) + error_count += 1 + continue + + # Buscar el evento + try: + event = Event.objects.get( + title=titulo_evento, + date=fecha_evento + ) + except Event.DoesNotExist: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Evento "{titulo_evento}" del {fecha_evento} no encontrado' + ) + ) + error_count += 1 + continue + except Event.MultipleObjectsReturned: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Múltiples eventos con título "{titulo_evento}" del {fecha_evento}"' + ) + ) + error_count += 1 + continue + + # Buscar al estudiante o usuario externo + student = None + external_user = None + + # Primero buscar en estudiantes regulares + try: + student = UserProfile.objects.get( + account_number=numero_cuenta, + user_type='student' + ) + except UserProfile.DoesNotExist: + # Buscar en usuarios externos + try: + external_user = ExternalUser.objects.get( + account_number=numero_cuenta + ) + except ExternalUser.DoesNotExist: + # Si create_external está activo, crear usuario externo + if create_external: + external_user = ExternalUser.objects.create( + account_number=numero_cuenta, + full_name=nombre_completo, + status='approved', + approved_by=registrador + ) + external_created += 1 + self.stdout.write( + self.style.SUCCESS( + f' ↳ Usuario externo creado: {nombre_completo} ({numero_cuenta})' + ) + ) + else: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Usuario {numero_cuenta} no encontrado (usa --create-external para crear)' + ) + ) + error_count += 1 + continue + + # Verificar si ya existe la asistencia + if student: + existing = Attendance.objects.filter( + student=student, + event=event, + is_valid=True + ).exists() + else: + existing = Attendance.objects.filter( + external_user=external_user, + event=event, + is_valid=True + ).exists() + + if existing: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Asistencia ya existe para {nombre_completo} en "{titulo_evento}"' + ) + ) + error_count += 1 + continue + + # Crear asistencia + if skip_validation: + # Crear directamente sin validación + attendance = Attendance( + student=student, + external_user=external_user, + event=event, + registered_by=registrador, + registration_method=metodo_registro, + notes=notas, + is_valid=True + ) + # Guardar sin validación + super(Attendance, attendance).save() + else: + attendance = Attendance.objects.create( + student=student, + external_user=external_user, + event=event, + registered_by=registrador, + registration_method=metodo_registro, + notes=notas, + is_valid=True + ) + + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f'✓ Fila {row_idx}: Asistencia de "{nombre_completo}" a "{titulo_evento}" registrada' + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f'✗ Fila {row_idx}: Error - {str(e)}' + ) + ) + error_count += 1 + + # Resumen + self.stdout.write('\n' + '='*60) + self.stdout.write(self.style.SUCCESS(f'Asistencias creadas: {created_count}')) + if external_created > 0: + self.stdout.write(self.style.SUCCESS(f'Usuarios externos creados: {external_created}')) + if error_count > 0: + self.stdout.write(self.style.WARNING(f'Errores: {error_count}')) + self.stdout.write('='*60) + + except FileNotFoundError: + raise CommandError(f'Archivo no encontrado: {excel_file}') + except Exception as e: + raise CommandError(f'Error al procesar el archivo: {str(e)}') diff --git a/backend/attendance/models.py b/backend/attendance/models.py index 45902ce..feef9ad 100644 --- a/backend/attendance/models.py +++ b/backend/attendance/models.py @@ -154,9 +154,12 @@ class Attendance(models.Model): ) def save(self, *args, **kwargs): - self.clean() + # Permitir omitir validación para importaciones históricas + skip_validation = kwargs.pop('skip_validation', False) + if not skip_validation: + self.clean() super().save(*args, **kwargs) - + # Actualizar estadísticas si es estudiante regular if self.student: self.update_student_stats() diff --git a/backend/authentication/migrations/0014_alter_externaluser_account_number_and_more.py b/backend/authentication/migrations/0014_alter_externaluser_account_number_and_more.py new file mode 100644 index 0000000..3a13107 --- /dev/null +++ b/backend/authentication/migrations/0014_alter_externaluser_account_number_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.6 on 2025-10-20 21:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0013_alter_userprofile_user'), + ] + + operations = [ + migrations.AlterField( + model_name='externaluser', + name='account_number', + field=models.CharField(max_length=8, unique=True, verbose_name='Número de cuenta'), + ), + migrations.AlterField( + model_name='userprofile', + name='account_number', + field=models.CharField(max_length=8, unique=True, validators=[django.core.validators.RegexValidator(message='El número de cuenta debe tener exactamente 8 dígitos.', regex='^\\d{8}$')], verbose_name='Número de cuenta'), + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index bc27805..78108f1 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -12,11 +12,11 @@ class UserProfile(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True) account_number = models.CharField( - max_length=7, + max_length=8, unique=True, validators=[RegexValidator( - regex=r'^\d{7}$', - message='El número de cuenta debe tener exactamente 7 dígitos.' + regex=r'^\d{8}$', + message='El número de cuenta debe tener exactamente 8 dígitos.' )], verbose_name="Número de cuenta" ) @@ -68,7 +68,7 @@ class ExternalUser(models.Model): full_name = models.CharField(max_length=200, verbose_name="Nombre completo") account_number = models.CharField( - max_length=7, + max_length=8, unique=True, verbose_name="Número de cuenta" ) diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 5c3d702..19c0a0a 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -20,9 +20,9 @@ class LoginSerializer(serializers.Serializer): account_number = serializers.CharField(max_length=20) def validate_account_number(self, value): - # Validar formato para números de cuenta (7 dígitos) - if not re.match(r'^\d{7}$', value): - raise serializers.ValidationError('El número de cuenta debe tener exactamente 7 dígitos.') + # Validar formato para números de cuenta (8 dígitos) + if not re.match(r'^\d{8}$', value): + raise serializers.ValidationError('El número de cuenta debe tener exactamente 8 dígitos.') return value def validate(self, data): diff --git a/backend/events/management/__init__.py b/backend/events/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/management/commands/__init__.py b/backend/events/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/management/commands/import_events.py b/backend/events/management/commands/import_events.py new file mode 100644 index 0000000..8dc3ce7 --- /dev/null +++ b/backend/events/management/commands/import_events.py @@ -0,0 +1,221 @@ +""" +Comando para importar eventos desde un archivo Excel. + +Formato esperado del Excel (eventos.xlsx): +- Hoja: "Eventos" +- Columnas: + 1. titulo (str): Título de la ponencia + 2. descripcion (str): Descripción del evento + 3. tipo (str): conference, workshop, panel, seminar + 4. modalidad (str): presencial, online, hybrid + 5. ponente (str): Nombre del ponente + 6. fecha (str): Formato YYYY-MM-DD o DD/MM/YYYY + 7. hora_inicio (str): Formato HH:MM + 8. hora_fin (str): Formato HH:MM + 9. ubicacion (str): Ubicación o plataforma + 10. capacidad (int): Capacidad máxima + 11. enlace_reunion (str, opcional): URL para eventos online/hybrid + 12. id_reunion (str, opcional): ID de la reunión + +Uso: + python manage.py import_events ruta/al/archivo.xlsx +""" + +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from events.models import Event +import openpyxl +from datetime import datetime + + +class Command(BaseCommand): + help = 'Importa eventos desde un archivo Excel' + + def add_arguments(self, parser): + parser.add_argument('excel_file', type=str, help='Ruta al archivo Excel') + parser.add_argument( + '--sheet', + type=str, + default='Eventos', + help='Nombre de la hoja de Excel (default: Eventos)' + ) + parser.add_argument( + '--skip-validation', + action='store_true', + help='Omitir validación de fechas pasadas (para importar eventos históricos)' + ) + + def handle(self, *args, **options): + excel_file = options['excel_file'] + sheet_name = options['sheet'] + skip_validation = options['skip_validation'] + + try: + workbook = openpyxl.load_workbook(excel_file) + + if sheet_name not in workbook.sheetnames: + raise CommandError(f'La hoja "{sheet_name}" no existe en el archivo') + + sheet = workbook[sheet_name] + + # Validar encabezados + expected_headers = [ + 'titulo', 'descripcion', 'tipo', 'modalidad', 'ponente', + 'fecha', 'hora_inicio', 'hora_fin', 'ubicacion', 'capacidad', + 'enlace_reunion', 'id_reunion' + ] + + headers = [cell.value.lower().strip() if cell.value else '' + for cell in sheet[1]] + + if not all(h in headers for h in expected_headers[:10]): + self.stdout.write( + self.style.WARNING( + f'Encabezados esperados: {", ".join(expected_headers[:10])}' + ) + ) + raise CommandError( + 'El archivo debe tener los encabezados correctos en la primera fila' + ) + + created_count = 0 + error_count = 0 + + # Procesar filas (comenzando desde la fila 2) + for row_idx, row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2): + try: + # Extraer datos + titulo = row[0] + descripcion = row[1] + tipo = row[2] + modalidad = row[3] + ponente = row[4] + fecha_str = row[5] + hora_inicio_str = row[6] + hora_fin_str = row[7] + ubicacion = row[8] + capacidad = row[9] + enlace_reunion = row[10] if len(row) > 10 else None + id_reunion = row[11] if len(row) > 11 else None + + # Validar datos requeridos + if not all([titulo, descripcion, tipo, modalidad, ponente, + fecha_str, hora_inicio_str, hora_fin_str, ubicacion]): + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Datos incompletos, omitiendo...' + ) + ) + error_count += 1 + continue + + # Parsear fecha + if isinstance(fecha_str, datetime): + fecha = fecha_str.date() + else: + try: + fecha = datetime.strptime(str(fecha_str), '%Y-%m-%d').date() + except ValueError: + try: + fecha = datetime.strptime(str(fecha_str), '%d/%m/%Y').date() + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Formato de fecha inválido ({fecha_str})' + ) + ) + error_count += 1 + continue + + # Parsear horas + if isinstance(hora_inicio_str, datetime): + hora_inicio = hora_inicio_str.time() + else: + try: + hora_inicio = datetime.strptime(str(hora_inicio_str), '%H:%M').time() + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Formato de hora de inicio inválido ({hora_inicio_str})' + ) + ) + error_count += 1 + continue + + if isinstance(hora_fin_str, datetime): + hora_fin = hora_fin_str.time() + else: + try: + hora_fin = datetime.strptime(str(hora_fin_str), '%H:%M').time() + except ValueError: + self.stdout.write( + self.style.WARNING( + f'Fila {row_idx}: Formato de hora de fin inválido ({hora_fin_str})' + ) + ) + error_count += 1 + continue + + # Crear evento (sin validación si skip_validation está activo) + if skip_validation: + # Crear directamente sin validación usando el método save del modelo base + event = Event( + title=titulo, + description=descripcion, + event_type=tipo, + modality=modalidad, + speaker=ponente, + date=fecha, + start_time=hora_inicio, + end_time=hora_fin, + location=ubicacion, + max_capacity=int(capacidad) if capacidad else 100, + meeting_link=enlace_reunion if enlace_reunion else None, + meeting_id=id_reunion if id_reunion else None, + is_active=True + ) + # Guardar sin llamar a clean() - usar skip_validation + event.save(skip_validation=True) + else: + event = Event.objects.create( + title=titulo, + description=descripcion, + event_type=tipo, + modality=modalidad, + speaker=ponente, + date=fecha, + start_time=hora_inicio, + end_time=hora_fin, + location=ubicacion, + max_capacity=int(capacidad) if capacidad else 100, + meeting_link=enlace_reunion if enlace_reunion else None, + meeting_id=id_reunion if id_reunion else None, + is_active=True + ) + + created_count += 1 + self.stdout.write( + self.style.SUCCESS( + f'✓ Fila {row_idx}: Evento "{titulo}" creado exitosamente' + ) + ) + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f'✗ Fila {row_idx}: Error - {str(e)}' + ) + ) + error_count += 1 + + # Resumen + self.stdout.write('\n' + '='*60) + self.stdout.write(self.style.SUCCESS(f'Eventos creados: {created_count}')) + if error_count > 0: + self.stdout.write(self.style.WARNING(f'Errores: {error_count}')) + self.stdout.write('='*60) + + except FileNotFoundError: + raise CommandError(f'Archivo no encontrado: {excel_file}') + except Exception as e: + raise CommandError(f'Error al procesar el archivo: {str(e)}') diff --git a/backend/events/models.py b/backend/events/models.py index 4469361..ad90deb 100644 --- a/backend/events/models.py +++ b/backend/events/models.py @@ -104,7 +104,10 @@ class Event(models.Model): raise ValidationError("Los eventos en línea o híbridos requieren un enlace de reunión.") def save(self, *args, **kwargs): - self.clean() + # Permitir omitir validación para importaciones históricas + skip_validation = kwargs.pop('skip_validation', False) + if not skip_validation: + self.clean() super().save(*args, **kwargs) @property diff --git a/backend/events/views.py b/backend/events/views.py index f7d1fc7..6de0db8 100644 --- a/backend/events/views.py +++ b/backend/events/views.py @@ -56,9 +56,9 @@ def register_external_user(request): if not account_number or not full_name: return Response({'error': 'Número de cuenta y nombre completo son requeridos'}, status=400) - # Validar formato de número de cuenta (7 dígitos) - if not re.match(r'^\d{7}$', account_number): - return Response({'error': 'El número de cuenta debe tener exactamente 7 dígitos'}, status=400) + # Validar formato de número de cuenta (8 dígitos) + if not re.match(r'^\d{8}$', account_number): + return Response({'error': 'El número de cuenta debe tener exactamente 8 dígitos'}, status=400) # Verificar que no exista en usuarios regulares from authentication.models import UserProfile diff --git a/docs/IMPORTACION_DATOS.md b/docs/IMPORTACION_DATOS.md new file mode 100644 index 0000000..12cfbfc --- /dev/null +++ b/docs/IMPORTACION_DATOS.md @@ -0,0 +1,271 @@ +# Guía de Importación de Datos desde Excel + +Este documento describe cómo importar eventos y asistencias históricas desde archivos Excel al sistema. + +## Requisitos Previos + +1. Tener el contenedor Docker del backend ejecutándose +2. Tener los archivos Excel preparados con el formato correcto +3. Conocer el número de cuenta de un asistente autorizado para realizar la importación + +## 1. Importación de Eventos + +### Formato del Archivo Excel (eventos.xlsx) + +El archivo debe tener una hoja llamada **"Eventos"** con las siguientes columnas en la primera fila (encabezados): + +| Columna | Tipo | Requerido | Descripción | Ejemplo | +|---------|------|-----------|-------------|---------| +| titulo | Texto | ✓ | Título de la ponencia | "Introducción a Python" | +| descripcion | Texto | ✓ | Descripción del evento | "Taller introductorio sobre Python para principiantes" | +| tipo | Texto | ✓ | Tipo de evento | conference, workshop, panel, seminar | +| modalidad | Texto | ✓ | Modalidad del evento | presencial, online, hybrid | +| ponente | Texto | ✓ | Nombre del ponente | "Dr. Juan Pérez" | +| fecha | Fecha/Texto | ✓ | Fecha del evento | 2024-03-15 o 15/03/2024 | +| hora_inicio | Hora/Texto | ✓ | Hora de inicio | 10:00 | +| hora_fin | Hora/Texto | ✓ | Hora de finalización | 12:00 | +| ubicacion | Texto | ✓ | Ubicación o plataforma | "Aula A-101" o "Zoom" | +| capacidad | Número | ✓ | Capacidad máxima | 100 | +| enlace_reunion | URL | - | Enlace de reunión (para eventos online/hybrid) | https://zoom.us/j/123456 | +| id_reunion | Texto | - | ID de reunión | "123 456 789" | + +### Ejemplo de Archivo Excel - Eventos + +``` +| titulo | descripcion | tipo | modalidad | ponente | fecha | hora_inicio | hora_fin | ubicacion | capacidad | enlace_reunion | id_reunion | +|--------------------------|----------------------------------|------------|-------------|------------------|------------|-------------|----------|-----------|-----------|----------------|------------| +| Introducción a Python | Taller básico de Python | workshop | presencial | Dr. Juan Pérez | 2024-03-15 | 10:00 | 12:00 | Aula A-101| 50 | | | +| Desarrollo Web Moderno | Conferencia sobre React y Django | conference | online | Ing. María López | 2024-03-16 | 14:00 | 16:00 | Zoom | 100 | https://zoom... | 123456789 | +``` + +### Comando de Importación de Eventos + +```bash +# Ingresar al contenedor Docker +docker-compose exec backend bash + +# Importar eventos (con validación de fechas) +python manage.py import_events /path/to/eventos.xlsx + +# Importar eventos históricos (sin validación de fechas pasadas) +python manage.py import_events /path/to/eventos.xlsx --skip-validation + +# Especificar nombre de hoja diferente +python manage.py import_events /path/to/eventos.xlsx --sheet "MisEventos" +``` + +### Copiar Archivo al Contenedor + +Si tu archivo está en tu computadora local, primero cópialo al contenedor: + +```bash +# Desde tu terminal local (fuera del contenedor) +docker cp eventos.xlsx pagina-de-asistencia-mac-backend-1:/app/ + +# Luego importa desde dentro del contenedor +docker-compose exec backend python manage.py import_events /app/eventos.xlsx --skip-validation +``` + +--- + +## 2. Importación de Asistencias + +### Formato del Archivo Excel (asistencias.xlsx) + +El archivo debe tener una hoja llamada **"Asistencias"** con las siguientes columnas en la primera fila (encabezados): + +| Columna | Tipo | Requerido | Descripción | Ejemplo | +|---------|------|-----------|-------------|---------| +| numero_cuenta | Texto | ✓ | Número de cuenta del asistente (8 dígitos) | "12345678" | +| nombre_completo | Texto | ✓ | Nombre completo del asistente | "Ana García Rodríguez" | +| titulo_evento | Texto | ✓ | Título exacto del evento (debe existir) | "Introducción a Python" | +| fecha_evento | Fecha/Texto | ✓ | Fecha del evento | 2024-03-15 o 15/03/2024 | +| metodo_registro | Texto | - | Método de registro | manual, barcode, external (default: manual) | +| notas | Texto | - | Notas adicionales | "Llegó tarde" | + +### Ejemplo de Archivo Excel - Asistencias + +``` +| numero_cuenta | nombre_completo | titulo_evento | fecha_evento | metodo_registro | notas | +|--------------|----------------------|--------------------------|--------------|-----------------|-------| +| 12345678 | Ana García Rodríguez | Introducción a Python | 2024-03-15 | manual | | +| 87654321 | Carlos Ruiz Martínez | Introducción a Python | 2024-03-15 | barcode | | +| 11223344 | Luis Fernández Pérez | Desarrollo Web Moderno | 2024-03-16 | external | | +``` + +### Comando de Importación de Asistencias + +```bash +# Ingresar al contenedor Docker +docker-compose exec backend bash + +# Importar asistencias (REQUIERE número de cuenta del asistente que importa) +python manage.py import_attendance /path/to/asistencias.xlsx --registrador 12345678 + +# Importar asistencias históricas (sin validación de horarios) +python manage.py import_attendance /path/to/asistencias.xlsx --registrador 12345678 --skip-validation + +# Crear automáticamente usuarios externos si no existen +python manage.py import_attendance /path/to/asistencias.xlsx --registrador 12345678 --skip-validation --create-external + +# Especificar nombre de hoja diferente +python manage.py import_attendance /path/to/asistencias.xlsx --registrador 12345678 --sheet "MisAsistencias" --skip-validation +``` + +### Copiar Archivo al Contenedor + +```bash +# Desde tu terminal local (fuera del contenedor) +docker cp asistencias.xlsx pagina-de-asistencia-mac-backend-1:/app/ + +# Luego importa desde dentro del contenedor +docker-compose exec backend python manage.py import_attendance /app/asistencias.xlsx --registrador 12345678 --skip-validation --create-external +``` + +--- + +## Flujo Completo de Importación + +### Paso 1: Preparar los archivos Excel + +1. Crear `eventos.xlsx` con la hoja "Eventos" y los datos de las conferencias +2. Crear `asistencias.xlsx` con la hoja "Asistencias" y los registros de asistencia + +### Paso 2: Copiar archivos al contenedor Docker + +```bash +docker cp eventos.xlsx pagina-de-asistencia-mac-backend-1:/app/ +docker cp asistencias.xlsx pagina-de-asistencia-mac-backend-1:/app/ +``` + +### Paso 3: Importar eventos primero + +```bash +docker-compose exec backend python manage.py import_events /app/eventos.xlsx --skip-validation +``` + +### Paso 4: Importar asistencias + +```bash +# Reemplaza 12345678 con el número de cuenta de un asistente autorizado +docker-compose exec backend python manage.py import_attendance /app/asistencias.xlsx --registrador 12345678 --skip-validation --create-external +``` + +### Paso 5: Verificar la importación + +Puedes verificar en: +- Admin de Django: http://localhost/admin/ +- API de eventos: http://localhost/api/events/ +- API de asistencias: http://localhost/api/attendance/ + +--- + +## Notas Importantes + +### Para Eventos Históricos + +- **IMPORTANTE**: Usa `--skip-validation` para eventos que ya pasaron, de lo contrario el sistema rechazará eventos con fechas pasadas. +- Los eventos en línea o híbridos requieren un `enlace_reunion` + +### Para Asistencias Históricas + +- **IMPORTANTE**: Usa `--skip-validation` para asistencias de eventos pasados. +- Los eventos deben existir ANTES de importar asistencias (importa eventos primero). +- El `titulo_evento` y `fecha_evento` deben coincidir exactamente con un evento existente. +- El número de cuenta del asistente debe ser de 8 dígitos. + +### Usuarios Externos + +- Si un número de cuenta no existe como estudiante regular, el comando buscará en usuarios externos. +- Usa `--create-external` para crear automáticamente usuarios externos que no existan. +- Los usuarios externos creados automáticamente serán marcados como "aprobados". + +### Números de Cuenta + +- Todos los números de cuenta deben tener **8 dígitos**. +- Si tus datos antiguos tienen 7 dígitos, agrégales un 0 al inicio en Excel (ejemplo: 1234567 → 01234567). + +### Formato de Fechas y Horas + +Formatos aceptados: +- **Fechas**: `YYYY-MM-DD` (2024-03-15) o `DD/MM/YYYY` (15/03/2024) +- **Horas**: `HH:MM` (10:00, 14:30) + +### Tipos de Evento + +Valores válidos para la columna `tipo`: +- `conference` - Conferencia +- `workshop` - Taller +- `panel` - Mesa Redonda +- `seminar` - Seminario + +### Modalidades + +Valores válidos para la columna `modalidad`: +- `presencial` - Presencial +- `online` - En línea +- `hybrid` - Híbrido + +### Métodos de Registro + +Valores válidos para la columna `metodo_registro`: +- `manual` - Registro Manual (default) +- `barcode` - Código de Barras +- `external` - Usuario Externo + +--- + +## Solución de Problemas + +### Error: "Evento no encontrado" +- Verifica que el `titulo_evento` y `fecha_evento` coincidan exactamente con un evento existente. +- Importa los eventos antes de las asistencias. + +### Error: "No se encontró un asistente" +- Verifica que el número de cuenta del `--registrador` sea válido y pertenezca a un asistente. + +### Error: "Usuario no encontrado" +- Usa `--create-external` para crear automáticamente usuarios externos. +- O crea manualmente los usuarios/estudiantes antes de importar. + +### Error: "Formato de fecha inválido" +- Verifica que las fechas estén en formato `YYYY-MM-DD` o `DD/MM/YYYY`. +- En Excel, formatea las columnas de fecha como "Texto" para evitar conversiones automáticas. + +### Error: "Datos incompletos" +- Verifica que todas las columnas requeridas (marcadas con ✓) tengan valores. + +--- + +## Ejemplo Completo + +Aquí hay un ejemplo completo con dos eventos y sus asistencias: + +### eventos.xlsx +``` +titulo | descripcion | tipo | modalidad | ponente | fecha | hora_inicio | hora_fin | ubicacion | capacidad +Introducción a Python | Taller básico de Python | workshop | presencial | Dr. Juan Pérez | 2024-03-15 | 10:00 | 12:00 | Aula A-101| 50 +Desarrollo Web Moderno | Conferencia sobre React y Django | conference | presencial | Ing. María López | 2024-03-16 | 14:00 | 16:00 | Aula B-202| 100 +``` + +### asistencias.xlsx +``` +numero_cuenta | nombre_completo | titulo_evento | fecha_evento +12345678 | Ana García Rodríguez | Introducción a Python | 2024-03-15 +87654321 | Carlos Ruiz Martínez | Introducción a Python | 2024-03-15 +11223344 | Luis Fernández Pérez | Desarrollo Web Moderno | 2024-03-16 +12345678 | Ana García Rodríguez | Desarrollo Web Moderno | 2024-03-16 +``` + +### Comandos +```bash +# Copiar archivos +docker cp eventos.xlsx pagina-de-asistencia-mac-backend-1:/app/ +docker cp asistencias.xlsx pagina-de-asistencia-mac-backend-1:/app/ + +# Importar eventos +docker-compose exec backend python manage.py import_events /app/eventos.xlsx --skip-validation + +# Importar asistencias (asumiendo que 12345678 es un asistente) +docker-compose exec backend python manage.py import_attendance /app/asistencias.xlsx --registrador 12345678 --skip-validation --create-external +``` diff --git a/frontend/src/components/attendance/AttendancePanel.jsx b/frontend/src/components/attendance/AttendancePanel.jsx index eaf0b2f..81b150a 100644 --- a/frontend/src/components/attendance/AttendancePanel.jsx +++ b/frontend/src/components/attendance/AttendancePanel.jsx @@ -39,9 +39,9 @@ const AttendancePanel = () => { } }, [events]) - // Auto-registrar cuando se complete un número de cuenta de 7 dígitos + // Auto-registrar cuando se complete un número de cuenta de 8 dígitos useEffect(() => { - if (studentAccount.length === 7 && selectedEvent) { + if (studentAccount.length === 8 && selectedEvent) { registerAttendance() } }, [studentAccount]) @@ -161,8 +161,8 @@ const AttendancePanel = () => { return } - if (!/^\d{7}$/.test(externalUser.account_number)) { - setMessage('El número de cuenta debe tener exactamente 7 dígitos') + if (!/^\d{8}$/.test(externalUser.account_number)) { + setMessage('El número de cuenta debe tener exactamente 8 dígitos') setMessageType('error') return } @@ -292,18 +292,18 @@ const AttendancePanel = () => {
- ✓ El registro es automático al completar 7 dígitos + ✓ El registro es automático al completar 8 dígitos
Usa el escáner USB o escribe manualmente @@ -460,17 +460,17 @@ const AttendancePanel = () => {