Files
pagasis/backend/authentication/admin.py
T
2025-10-20 14:22:42 -06:00

624 lines
24 KiB
Python

from django.contrib import admin
from django.contrib.auth.models import User, Group
from django import forms
from django.utils.html import format_html
from django.http import HttpResponse
from import_export import resources, fields
from import_export.admin import ImportExportModelAdmin
from import_export.widgets import ForeignKeyWidget
from .models import UserProfile, Asistente, ExternalUser, SystemConfiguration, Student, AssistantProfile
from .audit import AuditLog
# Ocultar modelos de Django que no se usan
admin.site.unregister(User)
admin.site.unregister(Group)
# ===== RECURSOS PARA IMPORT/EXPORT =====
class StudentResource(resources.ModelResource):
"""Recurso para importar/exportar SOLO estudiantes"""
class Meta:
model = UserProfile
fields = ('account_number', 'full_name')
export_order = ('account_number', 'full_name')
import_id_fields = ['account_number']
skip_unchanged = True
report_skipped = True
def before_import_row(self, row, **kwargs):
"""Validar y limpiar datos antes de importar"""
# Limpiar espacios
row['account_number'] = str(row.get('account_number', '')).strip()
row['full_name'] = str(row.get('full_name', '')).strip()
# Asignar automáticamente user_type como student
row['user_type'] = 'student'
def before_save_instance(self, instance, row, using_transactions, dry_run, **kwargs):
"""Crear usuario de Django si no existe y asegurar que sea estudiante"""
# Asegurar que sea estudiante
instance.user_type = 'student'
# Crear usuario de Django si no existe
if not instance.user_id:
# Buscar si ya existe el usuario
try:
user = User.objects.get(username=instance.account_number)
except User.DoesNotExist:
if not dry_run:
# Solo crear en la base de datos si no es dry_run
user = User.objects.create_user(
username=instance.account_number,
first_name=instance.full_name,
password=None
)
user.set_unusable_password()
user.save()
else:
# En dry_run, dejar user como None (ahora permitido)
user = None
if user:
instance.user = user
class AssistantResource(resources.ModelResource):
"""Recurso para importar/exportar SOLO asistentes"""
class Meta:
model = UserProfile
fields = ('account_number', 'full_name')
export_order = ('account_number', 'full_name')
import_id_fields = ['account_number']
skip_unchanged = True
report_skipped = True
def before_import_row(self, row, **kwargs):
"""Validar y limpiar datos antes de importar"""
# Limpiar espacios
row['account_number'] = str(row.get('account_number', '')).strip()
row['full_name'] = str(row.get('full_name', '')).strip()
# Asignar automáticamente user_type como assistant
row['user_type'] = 'assistant'
def before_save_instance(self, instance, row, using_transactions, dry_run, **kwargs):
"""Crear usuario de Django si no existe y asegurar que sea asistente"""
# Asegurar que sea asistente
instance.user_type = 'assistant'
# Crear usuario de Django si no existe
if not instance.user_id:
# Buscar si ya existe el usuario
try:
user = User.objects.get(username=instance.account_number)
except User.DoesNotExist:
if not dry_run:
# Solo crear en la base de datos si no es dry_run
user = User.objects.create_user(
username=instance.account_number,
first_name=instance.full_name,
password=None
)
user.set_unusable_password()
user.save()
else:
# En dry_run, dejar user como None (ahora permitido)
user = None
if user:
instance.user = user
def after_save_instance(self, instance, using_transactions, dry_run):
"""Crear automáticamente el registro de Asistente después de guardar"""
if not dry_run:
# Crear automáticamente el registro de Asistente si no existe
from .models import Asistente
Asistente.objects.get_or_create(
user_profile=instance,
defaults={'can_manage_events': True}
)
class UserProfileResource(resources.ModelResource):
"""Recurso para importar/exportar UserProfile (LEGACY - no usar)"""
class Meta:
model = UserProfile
fields = ('account_number', 'full_name', 'user_type')
import_id_fields = ['account_number']
skip_unchanged = True
report_skipped = True
def before_import_row(self, row, **kwargs):
"""Validar y limpiar datos antes de importar"""
# Limpiar espacios
row['account_number'] = str(row.get('account_number', '')).strip()
row['full_name'] = str(row.get('full_name', '')).strip()
row['user_type'] = str(row.get('user_type', '')).strip().lower()
def after_save_instance(self, instance, using_transactions, dry_run):
"""Crear usuario de Django si no existe"""
if not dry_run and not instance.user_id:
user = User.objects.create_user(
username=instance.account_number,
first_name=instance.full_name,
password=None
)
user.set_unusable_password()
user.save()
instance.user = user
instance.save()
class ExternalUserResource(resources.ModelResource):
"""Recurso para exportar usuarios externos"""
approved_by_name = fields.Field()
class Meta:
model = ExternalUser
fields = ('account_number', 'full_name', 'status', 'approved_by_name', 'created_at', 'rejection_reason')
export_order = fields
def dehydrate_approved_by_name(self, external_user):
"""Obtener nombre del aprobador"""
return external_user.approved_by.full_name if external_user.approved_by else '-'
class UserProfileForm(forms.ModelForm):
"""Formulario personalizado para crear UserProfile sin contraseña"""
class Meta:
model = UserProfile
fields = ['account_number', 'full_name', 'user_type']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Hacer el campo user_type obligatorio
self.fields['user_type'].required = True
self.fields['user_type'].empty_label = None # Quitar opción vacía del dropdown
def save(self, commit=True):
profile = super().save(commit=False)
# Si el perfil no tiene usuario asociado, crear uno automáticamente
if not profile.user_id:
# Crear usuario con username = número de cuenta, sin contraseña
user = User.objects.create_user(
username=profile.account_number,
first_name=profile.full_name,
password=None # Sin contraseña
)
# Desactivar contraseña usable
user.set_unusable_password()
user.save()
profile.user = user
if commit:
profile.save()
return profile
class StudentAdmin(ImportExportModelAdmin):
"""Admin SOLO para estudiantes"""
resource_class = StudentResource
list_display = ['account_number', 'full_name']
search_fields = ['account_number', 'full_name']
actions = ['export_selected_students']
fieldsets = (
('Información del Estudiante', {
'fields': ('account_number', 'full_name'),
'description': '📚 Importa el Excel con solo 2 columnas: account_number y full_name. El sistema automáticamente los creará como estudiantes.'
}),
)
def get_queryset(self, request):
"""Mostrar SOLO estudiantes"""
qs = super().get_queryset(request)
return qs.filter(user_type='student')
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 export_selected_students(self, request, queryset):
"""Acción para exportar estudiantes seleccionados"""
resource = StudentResource()
dataset = resource.export(queryset)
from import_export.formats.base_formats import XLSX
xlsx_format = XLSX()
export_data = xlsx_format.export_data(dataset)
response = HttpResponse(
export_data,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = 'attachment; filename="estudiantes.xlsx"'
self.message_user(request, f'Se exportaron {queryset.count()} estudiantes.')
return response
export_selected_students.short_description = "📊 Exportar estudiantes seleccionados"
def save_model(self, request, obj, form, change):
"""Asegurar que siempre sea estudiante y crear usuario si no existe"""
obj.user_type = 'student'
# Si no tiene usuario, crear uno
if not obj.user_id:
user = User.objects.create_user(
username=obj.account_number,
first_name=obj.full_name,
password=None
)
user.set_unusable_password()
user.save()
obj.user = user
super().save_model(request, obj, form, change)
class AssistantProfileAdmin(ImportExportModelAdmin):
"""Admin SOLO para asistentes"""
resource_class = AssistantResource
list_display = ['account_number', 'full_name']
search_fields = ['account_number', 'full_name']
actions = ['export_selected_assistants']
fieldsets = (
('Información del Asistente', {
'fields': ('account_number', 'full_name'),
'description': '👨‍🏫 Importa el Excel con solo 2 columnas: account_number y full_name. El sistema automáticamente los creará como asistentes.'
}),
)
def get_queryset(self, request):
"""Mostrar SOLO asistentes"""
qs = super().get_queryset(request)
return qs.filter(user_type='assistant')
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 export_selected_assistants(self, request, queryset):
"""Acción para exportar asistentes seleccionados"""
resource = AssistantResource()
dataset = resource.export(queryset)
from import_export.formats.base_formats import XLSX
xlsx_format = XLSX()
export_data = xlsx_format.export_data(dataset)
response = HttpResponse(
export_data,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = 'attachment; filename="asistentes.xlsx"'
self.message_user(request, f'Se exportaron {queryset.count()} asistentes.')
return response
export_selected_assistants.short_description = "📊 Exportar asistentes seleccionados"
def save_model(self, request, obj, form, change):
"""Asegurar que siempre sea asistente y crear usuario si no existe"""
obj.user_type = 'assistant'
# Si no tiene usuario, crear uno
if not obj.user_id:
user = User.objects.create_user(
username=obj.account_number,
first_name=obj.full_name,
password=None
)
user.set_unusable_password()
user.save()
obj.user = user
super().save_model(request, obj, form, change)
# Crear automáticamente el registro de Asistente si no existe
Asistente.objects.get_or_create(
user_profile=obj,
defaults={'can_manage_events': True}
)
# Registrar los admins separados con proxy models
admin.site.register(Student, StudentAdmin)
admin.site.register(AssistantProfile, AssistantProfileAdmin)
# UserProfile está oculto del admin - se usan Student y AssistantProfile en su lugar
@admin.register(Asistente)
class AsistenteAdmin(admin.ModelAdmin):
list_display = ['get_asistente_info', 'get_numero_cuenta', 'get_registros_realizados', 'ver_alumnos_registrados', 'can_manage_events']
list_filter = ['can_manage_events']
search_fields = ['user_profile__full_name', 'user_profile__account_number']
readonly_fields = ['user_profile', 'get_registros_realizados', 'get_ultimos_registros']
actions = ['ver_reporte_registros']
fieldsets = (
('Asistente', {
'fields': ('user_profile',),
'description': '📋 Los permisos de asistente se crean automáticamente cuando se registra un nuevo asistente.'
}),
('Permisos y Configuración', {
'fields': ('can_manage_events',)
}),
('Estadísticas de Registros', {
'fields': ('get_registros_realizados', 'get_ultimos_registros'),
'classes': ('collapse',)
}),
)
def has_add_permission(self, request):
"""Ocultar botón de agregar - se crean automáticamente"""
return False
def has_delete_permission(self, request, obj=None):
"""No permitir eliminar permisos de asistente"""
return False
def get_asistente_info(self, obj):
"""Mostrar nombre del asistente"""
return obj.user_profile.full_name
get_asistente_info.short_description = 'Asistente'
def get_numero_cuenta(self, obj):
"""Mostrar número de cuenta"""
return obj.user_profile.account_number
get_numero_cuenta.short_description = 'Número de Cuenta'
def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Filtrar el campo user_profile para mostrar solo asistentes"""
if db_field.name == "user_profile":
kwargs["queryset"] = UserProfile.objects.filter(user_type='assistant')
return super().formfield_for_foreignkey(db_field, request, **kwargs)
def get_registros_realizados(self, obj):
"""Muestra el total de registros realizados por este asistente"""
from attendance.models import Attendance
count = Attendance.objects.filter(registered_by=obj.user_profile).count()
return f"📊 {count} registros"
get_registros_realizados.short_description = 'Total de registros'
def get_ultimos_registros(self, obj):
"""Muestra los últimos 5 registros realizados por este asistente"""
from django.utils.html import format_html
from attendance.models import Attendance
registros = Attendance.objects.filter(
registered_by=obj.user_profile
).select_related('student', 'external_user', 'event').order_by('-timestamp')[:5]
if not registros:
return "Sin registros"
html = '<table style="width:100%; border-collapse: collapse;">'
html += '<tr style="background-color: #f0f0f0;"><th>Fecha</th><th>Asistente</th><th>Evento</th></tr>'
for reg in registros:
attendee = reg.student.full_name if reg.student else reg.external_user.full_name
html += f'''
<tr style="border-bottom: 1px solid #ddd;">
<td>{reg.timestamp.strftime("%d/%m/%Y %H:%M")}</td>
<td>{attendee}</td>
<td>{reg.event.title[:30]}...</td>
</tr>
'''
html += '</table>'
return format_html(html)
get_ultimos_registros.short_description = 'Últimos 5 registros'
def ver_alumnos_registrados(self, obj):
"""Botón para ver todos los alumnos registrados por este asistente"""
from django.utils.html import format_html
from django.urls import reverse
url = reverse('admin:attendance_attendance_changelist') + f'?registered_by__id__exact={obj.user_profile.id}'
return format_html(
'<a class="button" href="{}" style="background-color: #417690; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">📋 Ver Alumnos</a>',
url
)
ver_alumnos_registrados.short_description = 'Alumnos Registrados'
def ver_reporte_registros(self, request, queryset):
"""Acción para ver reporte detallado de registros de asistentes seleccionados"""
from django.shortcuts import render
from attendance.models import Attendance
from django.db.models import Count, Q
asistentes_data = []
for asistente in queryset:
registros = Attendance.objects.filter(registered_by=asistente.user_profile)
estudiantes = registros.filter(student__isnull=False).select_related('student', 'event').order_by('-timestamp')
externos = registros.filter(external_user__isnull=False).select_related('external_user', 'event').order_by('-timestamp')
asistentes_data.append({
'asistente': asistente,
'total': registros.count(),
'estudiantes': estudiantes,
'externos': externos,
'total_estudiantes': estudiantes.count(),
'total_externos': externos.count(),
})
context = {
'asistentes_data': asistentes_data,
'title': 'Reporte de Registros por Asistente',
}
return render(request, 'admin/asistente_reporte.html', context)
ver_reporte_registros.short_description = "📊 Ver reporte detallado de registros"
@admin.register(ExternalUser)
class ExternalUserAdmin(ImportExportModelAdmin):
resource_class = ExternalUserResource
list_display = ['account_number', 'full_name', 'get_status', 'get_approved_by', 'created_at']
list_filter = ['status', 'created_at', 'approved_by']
search_fields = ['full_name', 'account_number', 'approved_by__full_name']
ordering = ['-created_at']
readonly_fields = ['created_at', 'processed_at']
date_hierarchy = 'created_at'
actions = ['export_selected_external_users']
fieldsets = (
('Información Personal', {
'fields': ('account_number', 'full_name')
}),
('Estado de Aprobación', {
'fields': ('status', 'approved_by', 'processed_at', 'rejection_reason', 'created_at')
}),
)
def get_export_formats(self):
"""Solo exportar (no importar usuarios externos)"""
from import_export.formats.base_formats import XLSX, CSV
return [XLSX, CSV]
def has_import_permission(self, request):
"""No permitir importar usuarios externos (se crean desde la app)"""
return False
def export_selected_external_users(self, request, queryset):
"""Acción para exportar usuarios externos seleccionados"""
resource = ExternalUserResource()
dataset = resource.export(queryset)
from import_export.formats.base_formats import XLSX
xlsx_format = XLSX()
export_data = xlsx_format.export_data(dataset)
response = HttpResponse(
export_data,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = 'attachment; filename="usuarios_externos.xlsx"'
self.message_user(request, f'Se exportaron {queryset.count()} usuarios externos.')
return response
export_selected_external_users.short_description = "📊 Exportar usuarios externos seleccionados"
def get_status(self, obj):
"""Muestra el estado con iconos y colores"""
status_icons = {
'pending': ('⏳', '#FFA500', 'Pendiente'),
'approved': ('✅', '#28a745', 'Aprobado'),
'rejected': ('❌', '#dc3545', 'Rechazado')
}
icon, color, text = status_icons.get(obj.status, ('?', '#666', obj.status))
return format_html(
'<span style="color: {}; font-weight: bold;">{} {}</span>',
color, icon, text
)
get_status.short_description = 'Estado'
def get_approved_by(self, obj):
"""Muestra quién aprobó/rechazó al usuario"""
if obj.approved_by:
color = '#28a745' if obj.status == 'approved' else '#dc3545'
action = 'Aprobado por' if obj.status == 'approved' else 'Rechazado por'
return format_html(
'<span style="color: {};">👤 {}</span><br><small>{}</small>',
color,
obj.approved_by.full_name,
action
)
return '-'
get_approved_by.short_description = 'Procesado por'
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ['timestamp', 'category', 'severity', 'action', 'username', 'ip_address', 'success', 'message']
list_filter = ['category', 'severity', 'action', 'success', 'timestamp']
search_fields = ['username', 'ip_address', 'message', 'path']
readonly_fields = ['timestamp', 'category', 'severity', 'action', 'user', 'username',
'ip_address', 'user_agent', 'path', 'method', 'message',
'details', 'success', 'status_code']
date_hierarchy = 'timestamp'
def has_add_permission(self, request):
# No permitir crear logs manualmente desde el admin
return False
def has_change_permission(self, request, obj=None):
# No permitir editar logs (son inmutables)
return False
def has_delete_permission(self, request, obj=None):
# Solo superusuarios pueden eliminar logs
return request.user.is_superuser
@admin.register(SystemConfiguration)
class SystemConfigurationAdmin(admin.ModelAdmin):
list_display = ['get_config_name', 'minimum_attendance_percentage', 'minutes_before_event', 'minutes_after_start', 'updated_at', 'updated_by']
readonly_fields = ['updated_at', 'updated_by']
fieldsets = (
('Configuración de Asistencia', {
'fields': ('minimum_attendance_percentage',),
'description': '⚙️ Este porcentaje se aplica a TODOS los estudiantes del sistema. Define el porcentaje mínimo de asistencia requerido para obtener la constancia.'
}),
('Configuración de Tiempos de Registro', {
'fields': ('minutes_before_event', 'minutes_after_start'),
'description': '''
⏱️ <strong>Tiempos de registro de asistencia (aplica a TODAS las ponencias):</strong><br><br>
• <strong>Minutos antes del evento:</strong> Tiempo permitido para comenzar a registrar asistencia antes del inicio programado.<br>
Ejemplo: Si el evento inicia a las 10:00 AM y configuras 10 minutos, se podrá registrar desde las 9:50 AM.<br><br>
• <strong>Minutos después del inicio:</strong> Tiempo límite después del inicio para registrar asistencia.<br>
Ejemplo: Si el evento inicia a las 10:00 AM y configuras 25 minutos, el límite será a las 10:25 AM.<br><br>
<em>Estos valores se aplican automáticamente a todos los eventos del sistema.</em>
'''
}),
('Información de Auditoría', {
'fields': ('updated_at', 'updated_by'),
'classes': ('collapse',)
}),
)
def get_config_name(self, obj):
return "Configuración Global del Sistema"
get_config_name.short_description = 'Configuración'
def has_add_permission(self, request):
# Solo permitir una configuración
return not SystemConfiguration.objects.exists()
def has_delete_permission(self, request, obj=None):
# No permitir eliminar la configuración
return False
def save_model(self, request, obj, form, change):
# Registrar quién actualizó la configuración
try:
obj.updated_by = request.user.profile
except:
pass
super().save_model(request, obj, form, change)