Files
pagasis/backend/attendance/models.py
T
2025-10-20 16:14:08 -06:00

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}%"