forked from val-lop20/Pagina-de-Asistencia-MAC
294 lines
11 KiB
Python
294 lines
11 KiB
Python
from django.db import models
|
|
from django.core.exceptions import ValidationError
|
|
from django.utils import timezone
|
|
from datetime import datetime, time as dt_time, timedelta
|
|
from authentication.models import UserProfile, ExternalUser
|
|
from events.models import Event
|
|
|
|
class Attendance(models.Model):
|
|
REGISTRATION_METHODS = (
|
|
('manual', 'Registro Manual'),
|
|
('barcode', 'Código de Barras'),
|
|
('external', 'Usuario Externo'),
|
|
)
|
|
|
|
# Puede ser estudiante regular o externo
|
|
student = models.ForeignKey(
|
|
UserProfile,
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
limit_choices_to={'user_type': 'student'},
|
|
verbose_name="Estudiante"
|
|
)
|
|
external_user = models.ForeignKey(
|
|
ExternalUser,
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
verbose_name="Usuario Externo"
|
|
)
|
|
event = models.ForeignKey(
|
|
Event,
|
|
on_delete=models.CASCADE,
|
|
verbose_name="Evento"
|
|
)
|
|
timestamp = models.DateTimeField(
|
|
auto_now_add=True,
|
|
verbose_name="Hora de registro"
|
|
)
|
|
registered_by = models.ForeignKey(
|
|
UserProfile,
|
|
on_delete=models.CASCADE,
|
|
related_name='registered_attendances',
|
|
limit_choices_to={'user_type': 'assistant'},
|
|
verbose_name="Registrado por"
|
|
)
|
|
registration_method = models.CharField(
|
|
max_length=10,
|
|
choices=REGISTRATION_METHODS,
|
|
default='manual',
|
|
verbose_name="Método de registro"
|
|
)
|
|
notes = models.TextField(
|
|
blank=True,
|
|
null=True,
|
|
verbose_name="Notas adicionales"
|
|
)
|
|
is_valid = models.BooleanField(
|
|
default=True,
|
|
verbose_name="Asistencia válida"
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Asistencia"
|
|
verbose_name_plural = "Asistencias"
|
|
ordering = ['-timestamp']
|
|
|
|
def clean(self):
|
|
# Debe tener estudiante O usuario externo, pero no ambos
|
|
if not self.student and not self.external_user:
|
|
raise ValidationError("Debe especificar un estudiante o usuario externo.")
|
|
|
|
if self.student and self.external_user:
|
|
raise ValidationError("No puede tener ambos: estudiante y usuario externo.")
|
|
|
|
# Validar que el registrador sea un asistente
|
|
if self.registered_by.user_type != 'assistant':
|
|
raise ValidationError("Solo los asistentes pueden registrar asistencias.")
|
|
|
|
# Validar que el evento esté en curso (entre start_time y end_time)
|
|
from authentication.models import SystemConfiguration
|
|
config = SystemConfiguration.get_config()
|
|
|
|
now = timezone.now()
|
|
event_date = self.event.date
|
|
event_start = datetime.combine(event_date, self.event.start_time)
|
|
event_end = datetime.combine(event_date, self.event.end_time)
|
|
|
|
# Hacer timezone-aware si es necesario
|
|
if timezone.is_naive(event_start):
|
|
event_start = timezone.make_aware(event_start)
|
|
if timezone.is_naive(event_end):
|
|
event_end = timezone.make_aware(event_end)
|
|
|
|
# Usar configuración global del sistema
|
|
registration_start = event_start - timedelta(minutes=config.minutes_before_event)
|
|
registration_end = event_start + timedelta(minutes=config.minutes_after_start)
|
|
|
|
if now < registration_start:
|
|
raise ValidationError(
|
|
f"No se puede registrar asistencia antes del evento. "
|
|
f"El evento inicia el {event_date.strftime('%d/%m/%Y')} a las {self.event.start_time.strftime('%H:%M')}. "
|
|
f"Puedes registrar desde {config.minutes_before_event} minutos antes."
|
|
)
|
|
|
|
if now > registration_end:
|
|
raise ValidationError(
|
|
f"No se puede registrar asistencia después del tiempo límite. "
|
|
f"El evento inició el {event_date.strftime('%d/%m/%Y')} a las {self.event.start_time.strftime('%H:%M')}. "
|
|
f"El tiempo límite de registro es {config.minutes_after_start} minutos después del inicio."
|
|
)
|
|
|
|
# Validar que no haya duplicados
|
|
if self.student:
|
|
existing = Attendance.objects.filter(
|
|
student=self.student,
|
|
event=self.event,
|
|
is_valid=True
|
|
)
|
|
if self.pk:
|
|
existing = existing.exclude(pk=self.pk)
|
|
if existing.exists():
|
|
raise ValidationError("Este estudiante ya tiene asistencia registrada para este evento.")
|
|
|
|
if self.external_user:
|
|
existing = Attendance.objects.filter(
|
|
external_user=self.external_user,
|
|
event=self.event,
|
|
is_valid=True
|
|
)
|
|
if self.pk:
|
|
existing = existing.exclude(pk=self.pk)
|
|
if existing.exists():
|
|
raise ValidationError("Este usuario externo ya tiene asistencia registrada para este evento.")
|
|
|
|
# Validar eventos simultáneos (solo para estudiantes regulares)
|
|
if self.student:
|
|
overlapping_events = Event.objects.filter(
|
|
date=self.event.date,
|
|
start_time__lt=self.event.end_time,
|
|
end_time__gt=self.event.start_time,
|
|
is_active=True
|
|
).exclude(id=self.event.id)
|
|
|
|
existing_attendance = Attendance.objects.filter(
|
|
student=self.student,
|
|
event__in=overlapping_events,
|
|
is_valid=True
|
|
).exists()
|
|
|
|
if existing_attendance:
|
|
raise ValidationError(
|
|
"El estudiante ya tiene asistencia registrada en un evento simultáneo."
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
# 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()
|
|
|
|
def update_student_stats(self):
|
|
"""Actualizar las estadísticas de asistencia del estudiante"""
|
|
stats, created = AttendanceStats.objects.get_or_create(
|
|
student=self.student,
|
|
defaults={
|
|
'total_events': 0,
|
|
'attended_events': 0,
|
|
'attendance_percentage': 0.0
|
|
}
|
|
)
|
|
stats.update_stats()
|
|
|
|
@property
|
|
def attendee_name(self):
|
|
"""Nombre del asistente (estudiante o externo)"""
|
|
if self.student:
|
|
return self.student.full_name
|
|
elif self.external_user:
|
|
return self.external_user.full_name
|
|
return "Desconocido"
|
|
|
|
@property
|
|
def attendee_identifier(self):
|
|
"""Identificador del asistente"""
|
|
if self.student:
|
|
return self.student.account_number
|
|
elif self.external_user:
|
|
return self.external_user.account_number
|
|
return "N/A"
|
|
|
|
def __str__(self):
|
|
return f"{self.attendee_name} - {self.event.title}"
|
|
|
|
class AttendanceStats(models.Model):
|
|
student = models.OneToOneField(
|
|
UserProfile,
|
|
on_delete=models.CASCADE,
|
|
limit_choices_to={'user_type': 'student'},
|
|
verbose_name="Estudiante"
|
|
)
|
|
total_events = models.IntegerField(
|
|
default=0,
|
|
verbose_name="Total de eventos"
|
|
)
|
|
attended_events = models.IntegerField(
|
|
default=0,
|
|
verbose_name="Eventos asistidos"
|
|
)
|
|
attendance_percentage = models.FloatField(
|
|
default=0.0,
|
|
verbose_name="Porcentaje de asistencia"
|
|
)
|
|
last_updated = models.DateTimeField(
|
|
auto_now=True,
|
|
verbose_name="Última actualización"
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Estadísticas de asistencia"
|
|
verbose_name_plural = "Estadísticas de asistencia"
|
|
|
|
def update_stats(self):
|
|
"""Actualizar las estadísticas de asistencia"""
|
|
from events.models import Event # Importar aquí para evitar circular imports
|
|
|
|
# Obtener todos los eventos activos
|
|
all_events = Event.objects.filter(is_active=True).order_by('date', 'start_time')
|
|
|
|
# Agrupar eventos por bloques de horario (misma fecha y horarios que se solapan)
|
|
event_slots = {} # {(fecha, hora_inicio, hora_fin): [lista de eventos]}
|
|
|
|
for event in all_events:
|
|
# Crear una clave única para el bloque horario
|
|
slot_key = (event.date, event.start_time, event.end_time)
|
|
|
|
# Buscar si hay un slot existente que se solape con este evento
|
|
found_slot = False
|
|
for existing_slot in list(event_slots.keys()):
|
|
existing_date, existing_start, existing_end = existing_slot
|
|
|
|
# Verificar si es el mismo día y hay solapamiento de horarios
|
|
if event.date == existing_date:
|
|
# Hay solapamiento si el inicio de uno es menor al fin del otro
|
|
if (event.start_time < existing_end and event.end_time > existing_start):
|
|
event_slots[existing_slot].append(event)
|
|
found_slot = True
|
|
break
|
|
|
|
# Si no encontramos un slot existente, crear uno nuevo
|
|
if not found_slot:
|
|
event_slots[slot_key] = [event]
|
|
|
|
# El total de "bloques" es la cantidad de slots únicos
|
|
total_slots = len(event_slots)
|
|
|
|
# Obtener asistencias del estudiante
|
|
student_attendances = Attendance.objects.filter(
|
|
student=self.student,
|
|
is_valid=True
|
|
).values_list('event_id', flat=True)
|
|
|
|
# Contar cuántos bloques tiene asistencia
|
|
attended_slots = 0
|
|
for slot_events in event_slots.values():
|
|
# Si asistió a al menos uno de los eventos del bloque, cuenta
|
|
event_ids = [e.id for e in slot_events]
|
|
if any(event_id in student_attendances for event_id in event_ids):
|
|
attended_slots += 1
|
|
|
|
self.total_events = total_slots
|
|
self.attended_events = attended_slots
|
|
|
|
# Calcular porcentaje
|
|
if total_slots > 0:
|
|
self.attendance_percentage = round((attended_slots / total_slots) * 100, 2)
|
|
else:
|
|
self.attendance_percentage = 0.0
|
|
|
|
self.save()
|
|
|
|
def meets_minimum_requirement(self):
|
|
"""Verifica si cumple con el requisito mínimo de asistencia global"""
|
|
from authentication.models import SystemConfiguration
|
|
config = SystemConfiguration.get_config()
|
|
return self.attendance_percentage >= config.minimum_attendance_percentage
|
|
|
|
def __str__(self):
|
|
return f"Stats: {self.student.full_name} - {self.attendance_percentage}%" |