diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..2b17e2c3 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,109 @@ +# CODEOWNERS para proyecto IACT +# Referencia: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners + +# Por defecto, el equipo de backend revisa todos los cambios +* @2-Coatl/backend-team + +# Configuracion y CI/CD +/.github/ @2-Coatl/devops-team +/scripts/ @2-Coatl/devops-team +/Vagrantfile @2-Coatl/devops-team +/.devcontainer/ @2-Coatl/devops-team + +# Backend - Django +/api/ @2-Coatl/backend-team + +# Modelos y Migraciones - Requiere revision especial +/api/callcentersite/callcentersite/apps/*/models.py @2-Coatl/backend-team @2-Coatl/dba-team +/api/callcentersite/callcentersite/apps/*/migrations/ @2-Coatl/backend-team @2-Coatl/dba-team + +# Sistema de Permisos - Seguridad critica +/api/callcentersite/callcentersite/apps/permissions/ @2-Coatl/backend-team @2-Coatl/security-team +/docs/backend/permisos/ @2-Coatl/backend-team @2-Coatl/security-team +/docs/adr/ADR-012*.md @2-Coatl/backend-team @2-Coatl/security-team + +# Modulos Operativos (Prioridad 3) +/api/callcentersite/callcentersite/apps/llamadas/ @2-Coatl/backend-team @2-Coatl/operations-team +/api/callcentersite/callcentersite/apps/tickets/ @2-Coatl/backend-team @2-Coatl/support-team +/api/callcentersite/callcentersite/apps/clientes/ @2-Coatl/backend-team @2-Coatl/operations-team +/api/callcentersite/callcentersite/apps/metricas/ @2-Coatl/backend-team @2-Coatl/analytics-team +/api/callcentersite/callcentersite/apps/reportes/ @2-Coatl/backend-team @2-Coatl/analytics-team +/api/callcentersite/callcentersite/apps/alertas/ @2-Coatl/backend-team @2-Coatl/operations-team + +# Modulos de Gestion (Prioridad 4) +/api/callcentersite/callcentersite/apps/equipos/ @2-Coatl/backend-team @2-Coatl/management-team +/api/callcentersite/callcentersite/apps/horarios/ @2-Coatl/backend-team @2-Coatl/management-team +/api/callcentersite/callcentersite/apps/evaluaciones/ @2-Coatl/backend-team @2-Coatl/quality-team +/api/callcentersite/callcentersite/apps/audit/ @2-Coatl/backend-team @2-Coatl/security-team + +# Modulos Financieros (Prioridad 5) - Revision critica +/api/callcentersite/callcentersite/apps/pagos/ @2-Coatl/backend-team @2-Coatl/finance-team @2-Coatl/security-team +/api/callcentersite/callcentersite/apps/facturas/ @2-Coatl/backend-team @2-Coatl/finance-team +/api/callcentersite/callcentersite/apps/cobranza/ @2-Coatl/backend-team @2-Coatl/finance-team + +# Modulos Estrategicos (Prioridad 6) - Solo directores +/api/callcentersite/callcentersite/apps/presupuestos/ @2-Coatl/backend-team @2-Coatl/executive-team +/api/callcentersite/callcentersite/apps/politicas/ @2-Coatl/backend-team @2-Coatl/executive-team + +# Frontend - React +/ui/ @2-Coatl/frontend-team + +# Componentes compartidos +/ui/src/components/ @2-Coatl/frontend-team +/ui/src/state/ @2-Coatl/frontend-team + +# Modulos frontend por funcionalidad +/ui/src/modules/permissions/ @2-Coatl/frontend-team @2-Coatl/security-team +/ui/src/modules/llamadas/ @2-Coatl/frontend-team @2-Coatl/operations-team +/ui/src/modules/tickets/ @2-Coatl/frontend-team @2-Coatl/support-team +/ui/src/modules/clientes/ @2-Coatl/frontend-team @2-Coatl/operations-team +/ui/src/modules/metricas/ @2-Coatl/frontend-team @2-Coatl/analytics-team +/ui/src/modules/reportes/ @2-Coatl/frontend-team @2-Coatl/analytics-team + +# Mock data para desarrollo +/ui/src/mocks/ @2-Coatl/frontend-team @2-Coatl/qa-team + +# Documentacion +/docs/ @2-Coatl/documentation-team + +# ADRs - Decisiones arquitectonicas +/docs/adr/ @2-Coatl/backend-team @2-Coatl/frontend-team @2-Coatl/architecture-team + +# Documentacion backend +/docs/backend/ @2-Coatl/backend-team @2-Coatl/documentation-team + +# Documentacion frontend +/docs/frontend/ @2-Coatl/frontend-team @2-Coatl/documentation-team + +# Guias operativas +/docs/guias/ @2-Coatl/devops-team @2-Coatl/documentation-team + +# Metricas DORA +/dora_metrics/ @2-Coatl/devops-team +/docs/backend/devops/metricas-dora.md @2-Coatl/devops-team + +# Tests - QA debe revisar +/api/**/tests/ @2-Coatl/backend-team @2-Coatl/qa-team +/ui/**/*.test.* @2-Coatl/frontend-team @2-Coatl/qa-team +/ui/**/*.spec.* @2-Coatl/frontend-team @2-Coatl/qa-team + +# Scripts de validacion - DevOps + Security +/scripts/check_no_emojis.py @2-Coatl/devops-team +/scripts/validate_critical_restrictions.sh @2-Coatl/devops-team @2-Coatl/security-team + +# Configuracion sensible - Multiples equipos +/api/callcentersite/callcentersite/settings/ @2-Coatl/backend-team @2-Coatl/devops-team @2-Coatl/security-team + +# Base de datos - DBA debe aprobar +/api/callcentersite/callcentersite/database_router.py @2-Coatl/backend-team @2-Coatl/dba-team + +# IVR Legacy - Protegido, solo lectura +/api/callcentersite/callcentersite/apps/ivr_legacy/ @2-Coatl/backend-team @2-Coatl/dba-team @2-Coatl/legacy-team + +# README y archivos raiz +/README.md @2-Coatl/documentation-team +/CONTRIBUTING.md @2-Coatl/documentation-team +/LICENSE @2-Coatl/legal-team + +# Este archivo +/CODEOWNERS @2-Coatl/architecture-team @2-Coatl/devops-team diff --git a/api/callcentersite/callcentersite/apps/alertas/__init__.py b/api/callcentersite/callcentersite/apps/alertas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/apps.py b/api/callcentersite/callcentersite/apps/alertas/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/migrations/__init__.py b/api/callcentersite/callcentersite/apps/alertas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/models.py b/api/callcentersite/callcentersite/apps/alertas/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/serializers.py b/api/callcentersite/callcentersite/apps/alertas/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/tests/__init__.py b/api/callcentersite/callcentersite/apps/alertas/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/tests/test_models.py b/api/callcentersite/callcentersite/apps/alertas/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/urls.py b/api/callcentersite/callcentersite/apps/alertas/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/alertas/views.py b/api/callcentersite/callcentersite/apps/alertas/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/__init__.py b/api/callcentersite/callcentersite/apps/clientes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/apps.py b/api/callcentersite/callcentersite/apps/clientes/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/migrations/__init__.py b/api/callcentersite/callcentersite/apps/clientes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/models.py b/api/callcentersite/callcentersite/apps/clientes/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/serializers.py b/api/callcentersite/callcentersite/apps/clientes/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/tests/__init__.py b/api/callcentersite/callcentersite/apps/clientes/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/tests/test_models.py b/api/callcentersite/callcentersite/apps/clientes/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/urls.py b/api/callcentersite/callcentersite/apps/clientes/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/clientes/views.py b/api/callcentersite/callcentersite/apps/clientes/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/__init__.py b/api/callcentersite/callcentersite/apps/cobranza/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/apps.py b/api/callcentersite/callcentersite/apps/cobranza/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/migrations/__init__.py b/api/callcentersite/callcentersite/apps/cobranza/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/models.py b/api/callcentersite/callcentersite/apps/cobranza/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/serializers.py b/api/callcentersite/callcentersite/apps/cobranza/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/tests/__init__.py b/api/callcentersite/callcentersite/apps/cobranza/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/tests/test_models.py b/api/callcentersite/callcentersite/apps/cobranza/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/urls.py b/api/callcentersite/callcentersite/apps/cobranza/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/cobranza/views.py b/api/callcentersite/callcentersite/apps/cobranza/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/__init__.py b/api/callcentersite/callcentersite/apps/equipos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/apps.py b/api/callcentersite/callcentersite/apps/equipos/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/migrations/__init__.py b/api/callcentersite/callcentersite/apps/equipos/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/models.py b/api/callcentersite/callcentersite/apps/equipos/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/serializers.py b/api/callcentersite/callcentersite/apps/equipos/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/tests/__init__.py b/api/callcentersite/callcentersite/apps/equipos/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/tests/test_models.py b/api/callcentersite/callcentersite/apps/equipos/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/urls.py b/api/callcentersite/callcentersite/apps/equipos/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/equipos/views.py b/api/callcentersite/callcentersite/apps/equipos/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/__init__.py b/api/callcentersite/callcentersite/apps/evaluaciones/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/apps.py b/api/callcentersite/callcentersite/apps/evaluaciones/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/migrations/__init__.py b/api/callcentersite/callcentersite/apps/evaluaciones/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/models.py b/api/callcentersite/callcentersite/apps/evaluaciones/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/serializers.py b/api/callcentersite/callcentersite/apps/evaluaciones/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/tests/__init__.py b/api/callcentersite/callcentersite/apps/evaluaciones/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/tests/test_models.py b/api/callcentersite/callcentersite/apps/evaluaciones/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/urls.py b/api/callcentersite/callcentersite/apps/evaluaciones/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/evaluaciones/views.py b/api/callcentersite/callcentersite/apps/evaluaciones/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/__init__.py b/api/callcentersite/callcentersite/apps/facturas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/apps.py b/api/callcentersite/callcentersite/apps/facturas/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/migrations/__init__.py b/api/callcentersite/callcentersite/apps/facturas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/models.py b/api/callcentersite/callcentersite/apps/facturas/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/serializers.py b/api/callcentersite/callcentersite/apps/facturas/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/tests/__init__.py b/api/callcentersite/callcentersite/apps/facturas/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/tests/test_models.py b/api/callcentersite/callcentersite/apps/facturas/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/urls.py b/api/callcentersite/callcentersite/apps/facturas/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/facturas/views.py b/api/callcentersite/callcentersite/apps/facturas/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/__init__.py b/api/callcentersite/callcentersite/apps/horarios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/apps.py b/api/callcentersite/callcentersite/apps/horarios/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/migrations/__init__.py b/api/callcentersite/callcentersite/apps/horarios/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/models.py b/api/callcentersite/callcentersite/apps/horarios/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/serializers.py b/api/callcentersite/callcentersite/apps/horarios/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/tests/__init__.py b/api/callcentersite/callcentersite/apps/horarios/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/tests/test_models.py b/api/callcentersite/callcentersite/apps/horarios/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/urls.py b/api/callcentersite/callcentersite/apps/horarios/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/horarios/views.py b/api/callcentersite/callcentersite/apps/horarios/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/llamadas/__init__.py b/api/callcentersite/callcentersite/apps/llamadas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/llamadas/apps.py b/api/callcentersite/callcentersite/apps/llamadas/apps.py new file mode 100644 index 00000000..74040146 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/apps.py @@ -0,0 +1,11 @@ +"""Django app configuration para Llamadas.""" + +from django.apps import AppConfig + + +class LlamadasConfig(AppConfig): + """Configuracion de la app Llamadas.""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'callcentersite.apps.llamadas' + verbose_name = 'Llamadas' diff --git a/api/callcentersite/callcentersite/apps/llamadas/migrations/__init__.py b/api/callcentersite/callcentersite/apps/llamadas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/llamadas/models.py b/api/callcentersite/callcentersite/apps/llamadas/models.py new file mode 100644 index 00000000..3a1941ce --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/models.py @@ -0,0 +1,147 @@ +""" +Modelos para gestión de Llamadas. + +Sistema de Permisos Granular - Prioridad 3: Módulo Operativo Llamadas +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone +import uuid + + +User = get_user_model() + + +class EstadoLlamada(models.Model): + """Estados posibles de una llamada.""" + + codigo = models.CharField(max_length=50, unique=True, help_text='Codigo unico del estado') + nombre = models.CharField(max_length=100, help_text='Nombre del estado') + descripcion = models.TextField(blank=True, help_text='Descripcion del estado') + es_final = models.BooleanField(default=False, help_text='Si es un estado final') + activo = models.BooleanField(default=True, help_text='Si esta activo') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'llamadas_estados' + verbose_name = 'Estado de Llamada' + verbose_name_plural = 'Estados de Llamadas' + ordering = ['nombre'] + + def __str__(self): + return self.nombre + + +class TipoLlamada(models.Model): + """Tipos de llamadas.""" + + codigo = models.CharField(max_length=50, unique=True, help_text='Codigo unico del tipo') + nombre = models.CharField(max_length=100, help_text='Nombre del tipo') + descripcion = models.TextField(blank=True, help_text='Descripcion del tipo') + activo = models.BooleanField(default=True, help_text='Si esta activo') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'llamadas_tipos' + verbose_name = 'Tipo de Llamada' + verbose_name_plural = 'Tipos de Llamadas' + ordering = ['nombre'] + + def __str__(self): + return self.nombre + + +class Llamada(models.Model): + """Registro de llamadas telefónicas.""" + + codigo = models.CharField(max_length=50, unique=True, editable=False, help_text='Codigo unico generado') + numero_telefono = models.CharField(max_length=20, help_text='Numero telefonico') + tipo = models.ForeignKey(TipoLlamada, on_delete=models.PROTECT, related_name='llamadas') + estado = models.ForeignKey(EstadoLlamada, on_delete=models.PROTECT, related_name='llamadas') + agente = models.ForeignKey(User, on_delete=models.PROTECT, related_name='llamadas_atendidas') + + # Informacion del cliente + cliente_nombre = models.CharField(max_length=200, blank=True, null=True) + cliente_email = models.EmailField(blank=True, null=True) + cliente_id = models.IntegerField(blank=True, null=True, help_text='ID del cliente si existe') + + # Fechas y tiempos + fecha_inicio = models.DateTimeField(default=timezone.now) + fecha_fin = models.DateTimeField(blank=True, null=True) + + # Metadata + metadata = models.JSONField(default=dict, blank=True, help_text='Datos adicionales JSON') + notas = models.TextField(blank=True, help_text='Notas de la llamada') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'llamadas' + verbose_name = 'Llamada' + verbose_name_plural = 'Llamadas' + ordering = ['-fecha_inicio'] + indexes = [ + models.Index(fields=['numero_telefono']), + models.Index(fields=['agente', 'fecha_inicio']), + models.Index(fields=['estado']), + models.Index(fields=['fecha_inicio']), + ] + + def save(self, *args, **kwargs): + """Generar codigo unico al crear.""" + if not self.codigo: + self.codigo = f"CALL-{uuid.uuid4().hex[:12].upper()}" + super().save(*args, **kwargs) + + def calcular_duracion(self): + """Calcular duracion de la llamada en segundos.""" + if self.fecha_fin: + delta = self.fecha_fin - self.fecha_inicio + return int(delta.total_seconds()) + return None + + def __str__(self): + return f"{self.codigo} - {self.numero_telefono}" + + +class LlamadaTranscripcion(models.Model): + """Transcripción de llamadas.""" + + llamada = models.ForeignKey(Llamada, on_delete=models.CASCADE, related_name='transcripciones') + texto = models.TextField(help_text='Texto transcrito') + timestamp_inicio = models.IntegerField(help_text='Segundo de inicio en la grabacion') + timestamp_fin = models.IntegerField(help_text='Segundo de fin en la grabacion') + hablante = models.CharField(max_length=50, help_text='Identificador del hablante (agente/cliente)') + confianza = models.FloatField(blank=True, null=True, help_text='Nivel de confianza de la transcripcion') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'llamadas_transcripciones' + verbose_name = 'Transcripcion de Llamada' + verbose_name_plural = 'Transcripciones de Llamadas' + ordering = ['llamada', 'timestamp_inicio'] + + def __str__(self): + return f"Transcripcion {self.llamada.codigo} - {self.hablante}" + + +class LlamadaGrabacion(models.Model): + """Grabaciones de llamadas.""" + + llamada = models.OneToOneField(Llamada, on_delete=models.CASCADE, related_name='grabacion') + archivo_url = models.URLField(max_length=500, help_text='URL del archivo de grabacion') + formato = models.CharField(max_length=10, help_text='Formato del audio (mp3, wav, etc)') + duracion_segundos = models.IntegerField(help_text='Duracion en segundos') + tamano_bytes = models.BigIntegerField(blank=True, null=True, help_text='Tamano del archivo en bytes') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'llamadas_grabaciones' + verbose_name = 'Grabacion de Llamada' + verbose_name_plural = 'Grabaciones de Llamadas' + + def __str__(self): + return f"Grabacion {self.llamada.codigo}" diff --git a/api/callcentersite/callcentersite/apps/llamadas/serializers.py b/api/callcentersite/callcentersite/apps/llamadas/serializers.py new file mode 100644 index 00000000..58d8f3b0 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/serializers.py @@ -0,0 +1,80 @@ +""" +Serializers para Llamadas. + +Sistema de Permisos Granular - Prioridad 3: Módulo Operativo Llamadas +""" + +from rest_framework import serializers +from callcentersite.apps.llamadas.models import ( + EstadoLlamada, + TipoLlamada, + Llamada, + LlamadaTranscripcion, + LlamadaGrabacion, +) + + +class EstadoLlamadaSerializer(serializers.ModelSerializer): + """Serializer para EstadoLlamada.""" + + class Meta: + model = EstadoLlamada + fields = ['id', 'codigo', 'nombre', 'descripcion', 'es_final', 'activo', 'created_at'] + read_only_fields = ['id', 'created_at'] + + +class TipoLlamadaSerializer(serializers.ModelSerializer): + """Serializer para TipoLlamada.""" + + class Meta: + model = TipoLlamada + fields = ['id', 'codigo', 'nombre', 'descripcion', 'activo', 'created_at'] + read_only_fields = ['id', 'created_at'] + + +class LlamadaSerializer(serializers.ModelSerializer): + """Serializer para Llamada.""" + + duracion = serializers.SerializerMethodField() + agente_username = serializers.CharField(source='agente.username', read_only=True) + estado_nombre = serializers.CharField(source='estado.nombre', read_only=True) + tipo_nombre = serializers.CharField(source='tipo.nombre', read_only=True) + + class Meta: + model = Llamada + fields = [ + 'id', 'codigo', 'numero_telefono', 'tipo', 'estado', 'agente', + 'cliente_nombre', 'cliente_email', 'cliente_id', + 'fecha_inicio', 'fecha_fin', 'metadata', 'notas', + 'created_at', 'updated_at', + 'duracion', 'agente_username', 'estado_nombre', 'tipo_nombre' + ] + read_only_fields = ['id', 'codigo', 'created_at', 'updated_at'] + + def get_duracion(self, obj): + """Obtener duracion calculada.""" + return obj.calcular_duracion() + + +class LlamadaTranscripcionSerializer(serializers.ModelSerializer): + """Serializer para LlamadaTranscripcion.""" + + class Meta: + model = LlamadaTranscripcion + fields = [ + 'id', 'llamada', 'texto', 'timestamp_inicio', 'timestamp_fin', + 'hablante', 'confianza', 'created_at' + ] + read_only_fields = ['id', 'created_at'] + + +class LlamadaGrabacionSerializer(serializers.ModelSerializer): + """Serializer para LlamadaGrabacion.""" + + class Meta: + model = LlamadaGrabacion + fields = [ + 'id', 'llamada', 'archivo_url', 'formato', 'duracion_segundos', + 'tamano_bytes', 'created_at' + ] + read_only_fields = ['id', 'created_at'] diff --git a/api/callcentersite/callcentersite/apps/llamadas/tests/__init__.py b/api/callcentersite/callcentersite/apps/llamadas/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/llamadas/tests/test_models.py b/api/callcentersite/callcentersite/apps/llamadas/tests/test_models.py new file mode 100644 index 00000000..07c03d53 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/tests/test_models.py @@ -0,0 +1,299 @@ +""" +Tests para modelos de Llamadas. + +Sistema de Permisos Granular - Prioridad 3: Módulo Operativo Llamadas +TDD: Tests escritos ANTES de implementar models.py +""" + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from datetime import timedelta + +from callcentersite.apps.llamadas.models import ( + Llamada, + EstadoLlamada, + TipoLlamada, + LlamadaTranscripcion, + LlamadaGrabacion, +) + + +User = get_user_model() + + +class EstadoLlamadaModelTestCase(TestCase): + """Tests para modelo EstadoLlamada.""" + + def test_crear_estado_llamada(self): + """Crear estado de llamada exitosamente.""" + estado = EstadoLlamada.objects.create( + codigo='EN_CURSO', + nombre='En Curso', + descripcion='Llamada en progreso', + es_final=False + ) + + self.assertEqual(estado.codigo, 'EN_CURSO') + self.assertEqual(estado.nombre, 'En Curso') + self.assertFalse(estado.es_final) + self.assertTrue(estado.activo) + + def test_codigo_estado_debe_ser_unico(self): + """Codigo de estado debe ser unico.""" + EstadoLlamada.objects.create(codigo='COMPLETADA', nombre='Completada') + + with self.assertRaises(Exception): + EstadoLlamada.objects.create(codigo='COMPLETADA', nombre='Otro') + + +class TipoLlamadaModelTestCase(TestCase): + """Tests para modelo TipoLlamada.""" + + def test_crear_tipo_llamada(self): + """Crear tipo de llamada exitosamente.""" + tipo = TipoLlamada.objects.create( + codigo='ENTRANTE', + nombre='Llamada Entrante', + descripcion='Cliente llama al call center' + ) + + self.assertEqual(tipo.codigo, 'ENTRANTE') + self.assertTrue(tipo.activo) + + +class LlamadaModelTestCase(TestCase): + """Tests para modelo Llamada.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.agente = User.objects.create_user( + username='agente1', + email='agente1@test.com', + password='test123' + ) + + self.estado_en_curso = EstadoLlamada.objects.create( + codigo='EN_CURSO', + nombre='En Curso', + es_final=False + ) + + self.estado_completada = EstadoLlamada.objects.create( + codigo='COMPLETADA', + nombre='Completada', + es_final=True + ) + + self.tipo_entrante = TipoLlamada.objects.create( + codigo='ENTRANTE', + nombre='Entrante' + ) + + def test_crear_llamada_minima(self): + """Crear llamada con campos minimos.""" + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente + ) + + self.assertIsNotNone(llamada.id) + self.assertEqual(llamada.numero_telefono, '+521234567890') + self.assertEqual(llamada.agente, self.agente) + self.assertIsNotNone(llamada.fecha_inicio) + self.assertIsNone(llamada.fecha_fin) + + def test_llamada_genera_codigo_unico(self): + """Llamada genera codigo unico automaticamente.""" + llamada1 = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente + ) + + llamada2 = Llamada.objects.create( + numero_telefono='+529876543210', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente + ) + + self.assertIsNotNone(llamada1.codigo) + self.assertIsNotNone(llamada2.codigo) + self.assertNotEqual(llamada1.codigo, llamada2.codigo) + + def test_calcular_duracion_llamada(self): + """Calcular duracion de llamada correctamente.""" + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente, + fecha_inicio=timezone.now() + ) + + # Simular fin de llamada 5 minutos despues + llamada.fecha_fin = llamada.fecha_inicio + timedelta(minutes=5) + llamada.save() + + duracion = llamada.calcular_duracion() + self.assertEqual(duracion, 300) # 5 minutos = 300 segundos + + def test_llamada_sin_fecha_fin_duracion_none(self): + """Llamada sin fecha_fin retorna None en duracion.""" + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente + ) + + duracion = llamada.calcular_duracion() + self.assertIsNone(duracion) + + def test_llamada_con_cliente_asociado(self): + """Llamada puede tener cliente asociado.""" + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente, + cliente_nombre='Juan Perez', + cliente_email='juan@example.com' + ) + + self.assertEqual(llamada.cliente_nombre, 'Juan Perez') + self.assertEqual(llamada.cliente_email, 'juan@example.com') + + def test_llamada_con_metadata(self): + """Llamada puede almacenar metadata JSON.""" + metadata = { + 'motivo': 'consulta', + 'producto': 'tarjeta_credito', + 'prioridad': 'alta' + } + + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente, + metadata=metadata + ) + + self.assertEqual(llamada.metadata['motivo'], 'consulta') + self.assertEqual(llamada.metadata['prioridad'], 'alta') + + def test_cambiar_estado_llamada(self): + """Cambiar estado de llamada.""" + llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=self.tipo_entrante, + estado=self.estado_en_curso, + agente=self.agente + ) + + llamada.estado = self.estado_completada + llamada.save() + + self.assertEqual(llamada.estado.codigo, 'COMPLETADA') + self.assertTrue(llamada.estado.es_final) + + +class LlamadaTranscripcionModelTestCase(TestCase): + """Tests para modelo LlamadaTranscripcion.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.agente = User.objects.create_user( + username='agente1', + email='agente1@test.com', + password='test123' + ) + + estado = EstadoLlamada.objects.create(codigo='EN_CURSO', nombre='En Curso') + tipo = TipoLlamada.objects.create(codigo='ENTRANTE', nombre='Entrante') + + self.llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=tipo, + estado=estado, + agente=self.agente + ) + + def test_crear_transcripcion(self): + """Crear transcripcion de llamada.""" + transcripcion = LlamadaTranscripcion.objects.create( + llamada=self.llamada, + texto='Cliente: Hola, necesito ayuda con mi cuenta', + timestamp_inicio=0, + timestamp_fin=5, + hablante='cliente' + ) + + self.assertEqual(transcripcion.llamada, self.llamada) + self.assertEqual(transcripcion.hablante, 'cliente') + self.assertIn('ayuda', transcripcion.texto) + + def test_transcripcion_con_confianza(self): + """Transcripcion puede tener nivel de confianza.""" + transcripcion = LlamadaTranscripcion.objects.create( + llamada=self.llamada, + texto='Texto transcrito', + timestamp_inicio=0, + timestamp_fin=3, + hablante='agente', + confianza=0.95 + ) + + self.assertEqual(transcripcion.confianza, 0.95) + + +class LlamadaGrabacionModelTestCase(TestCase): + """Tests para modelo LlamadaGrabacion.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.agente = User.objects.create_user( + username='agente1', + email='agente1@test.com', + password='test123' + ) + + estado = EstadoLlamada.objects.create(codigo='EN_CURSO', nombre='En Curso') + tipo = TipoLlamada.objects.create(codigo='ENTRANTE', nombre='Entrante') + + self.llamada = Llamada.objects.create( + numero_telefono='+521234567890', + tipo=tipo, + estado=estado, + agente=self.agente + ) + + def test_crear_grabacion(self): + """Crear grabacion de llamada.""" + grabacion = LlamadaGrabacion.objects.create( + llamada=self.llamada, + archivo_url='https://storage.example.com/calls/123.mp3', + formato='mp3', + duracion_segundos=300 + ) + + self.assertEqual(grabacion.llamada, self.llamada) + self.assertEqual(grabacion.formato, 'mp3') + self.assertEqual(grabacion.duracion_segundos, 300) + + def test_grabacion_tamano_archivo(self): + """Grabacion almacena tamano de archivo.""" + grabacion = LlamadaGrabacion.objects.create( + llamada=self.llamada, + archivo_url='https://storage.example.com/calls/123.mp3', + formato='mp3', + duracion_segundos=300, + tamano_bytes=2048000 # ~2MB + ) + + self.assertEqual(grabacion.tamano_bytes, 2048000) diff --git a/api/callcentersite/callcentersite/apps/llamadas/urls.py b/api/callcentersite/callcentersite/apps/llamadas/urls.py new file mode 100644 index 00000000..f1836bdf --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/urls.py @@ -0,0 +1,23 @@ +""" +URLs para Llamadas. + +Sistema de Permisos Granular - Prioridad 3: Módulo Operativo Llamadas +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from callcentersite.apps.llamadas import views + + +router = DefaultRouter() +router.register(r'estados', views.EstadoLlamadaViewSet, basename='estado-llamada') +router.register(r'tipos', views.TipoLlamadaViewSet, basename='tipo-llamada') +router.register(r'llamadas', views.LlamadaViewSet, basename='llamada') +router.register(r'transcripciones', views.LlamadaTranscripcionViewSet, basename='llamada-transcripcion') +router.register(r'grabaciones', views.LlamadaGrabacionViewSet, basename='llamada-grabacion') + + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/api/callcentersite/callcentersite/apps/llamadas/views.py b/api/callcentersite/callcentersite/apps/llamadas/views.py new file mode 100644 index 00000000..c80dcb67 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/llamadas/views.py @@ -0,0 +1,142 @@ +""" +Views para Llamadas. + +Sistema de Permisos Granular - Prioridad 3: Módulo Operativo Llamadas +""" + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter + +from callcentersite.apps.llamadas.models import ( + EstadoLlamada, + TipoLlamada, + Llamada, + LlamadaTranscripcion, + LlamadaGrabacion, +) +from callcentersite.apps.llamadas.serializers import ( + EstadoLlamadaSerializer, + TipoLlamadaSerializer, + LlamadaSerializer, + LlamadaTranscripcionSerializer, + LlamadaGrabacionSerializer, +) +from callcentersite.apps.permissions.middleware import verificar_permiso + + +class EstadoLlamadaViewSet(viewsets.ModelViewSet): + """ViewSet para EstadoLlamada.""" + + queryset = EstadoLlamada.objects.all() + serializer_class = EstadoLlamadaSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['activo', 'es_final'] + search_fields = ['codigo', 'nombre'] + + +class TipoLlamadaViewSet(viewsets.ModelViewSet): + """ViewSet para TipoLlamada.""" + + queryset = TipoLlamada.objects.all() + serializer_class = TipoLlamadaSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter] + filterset_fields = ['activo'] + search_fields = ['codigo', 'nombre'] + + +class LlamadaViewSet(viewsets.ModelViewSet): + """ + ViewSet para Llamadas. + + Requiere permisos: + - ver: sistema.operaciones.llamadas.ver + - realizar: sistema.operaciones.llamadas.realizar + """ + + queryset = Llamada.objects.select_related('tipo', 'estado', 'agente').all() + serializer_class = LlamadaSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['estado', 'tipo', 'agente', 'cliente_id'] + search_fields = ['codigo', 'numero_telefono', 'cliente_nombre'] + ordering_fields = ['fecha_inicio', 'fecha_fin'] + ordering = ['-fecha_inicio'] + + @verificar_permiso("sistema.operaciones.llamadas.ver") + def list(self, request, *args, **kwargs): + """Listar llamadas requiere permiso ver.""" + return super().list(request, *args, **kwargs) + + @verificar_permiso("sistema.operaciones.llamadas.ver") + def retrieve(self, request, *args, **kwargs): + """Ver detalle requiere permiso ver.""" + return super().retrieve(request, *args, **kwargs) + + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def create(self, request, *args, **kwargs): + """Crear llamada requiere permiso realizar.""" + return super().create(request, *args, **kwargs) + + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def update(self, request, *args, **kwargs): + """Actualizar llamada requiere permiso realizar.""" + return super().update(request, *args, **kwargs) + + @action(detail=True, methods=['post']) + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def finalizar(self, request, pk=None): + """Finalizar llamada.""" + from django.utils import timezone + + llamada = self.get_object() + + if llamada.fecha_fin: + return Response( + {'error': 'Llamada ya finalizada'}, + status=status.HTTP_400_BAD_REQUEST + ) + + estado_completada = EstadoLlamada.objects.filter( + codigo='COMPLETADA', + es_final=True + ).first() + + if not estado_completada: + return Response( + {'error': 'Estado COMPLETADA no existe'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + llamada.fecha_fin = timezone.now() + llamada.estado = estado_completada + llamada.save() + + serializer = self.get_serializer(llamada) + return Response(serializer.data) + + +class LlamadaTranscripcionViewSet(viewsets.ModelViewSet): + """ViewSet para LlamadaTranscripcion.""" + + queryset = LlamadaTranscripcion.objects.select_related('llamada').all() + serializer_class = LlamadaTranscripcionSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['llamada', 'hablante'] + ordering = ['timestamp_inicio'] + + +class LlamadaGrabacionViewSet(viewsets.ModelViewSet): + """ViewSet para LlamadaGrabacion.""" + + queryset = LlamadaGrabacion.objects.select_related('llamada').all() + serializer_class = LlamadaGrabacionSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_fields = ['llamada', 'formato'] diff --git a/api/callcentersite/callcentersite/apps/metricas/__init__.py b/api/callcentersite/callcentersite/apps/metricas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/apps.py b/api/callcentersite/callcentersite/apps/metricas/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/migrations/__init__.py b/api/callcentersite/callcentersite/apps/metricas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/models.py b/api/callcentersite/callcentersite/apps/metricas/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/serializers.py b/api/callcentersite/callcentersite/apps/metricas/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/tests/__init__.py b/api/callcentersite/callcentersite/apps/metricas/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/tests/test_models.py b/api/callcentersite/callcentersite/apps/metricas/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/urls.py b/api/callcentersite/callcentersite/apps/metricas/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/metricas/views.py b/api/callcentersite/callcentersite/apps/metricas/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/__init__.py b/api/callcentersite/callcentersite/apps/pagos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/apps.py b/api/callcentersite/callcentersite/apps/pagos/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/migrations/__init__.py b/api/callcentersite/callcentersite/apps/pagos/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/models.py b/api/callcentersite/callcentersite/apps/pagos/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/serializers.py b/api/callcentersite/callcentersite/apps/pagos/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/tests/__init__.py b/api/callcentersite/callcentersite/apps/pagos/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/tests/test_models.py b/api/callcentersite/callcentersite/apps/pagos/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/urls.py b/api/callcentersite/callcentersite/apps/pagos/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/pagos/views.py b/api/callcentersite/callcentersite/apps/pagos/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/__init__.py b/api/callcentersite/callcentersite/apps/permissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/admin.py b/api/callcentersite/callcentersite/apps/permissions/admin.py new file mode 100644 index 00000000..b628b8ae --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/admin.py @@ -0,0 +1,364 @@ +""" +Admin para Sistema de Permisos Granular. + +Registra todos los modelos en Django Admin para gestion. +""" + +from django.contrib import admin +from django.utils.html import format_html + +from .models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, + AuditoriaPermiso +) + + +@admin.register(Funcion) +class FuncionAdmin(admin.ModelAdmin): + """Admin para Funcion.""" + + list_display = [ + 'nombre', + 'nombre_completo', + 'dominio', + 'categoria', + 'orden_menu', + 'activa_badge', + 'created_at' + ] + list_filter = ['dominio', 'categoria', 'activa'] + search_fields = ['nombre', 'nombre_completo', 'descripcion'] + readonly_fields = ['created_at', 'updated_at'] + ordering = ['orden_menu', 'nombre'] + + fieldsets = [ + ('Informacion Basica', { + 'fields': ('nombre', 'nombre_completo', 'descripcion') + }), + ('Clasificacion', { + 'fields': ('dominio', 'categoria', 'icono') + }), + ('Configuracion', { + 'fields': ('orden_menu', 'activa') + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ] + + def activa_badge(self, obj): + """Badge para campo activa.""" + if obj.activa: + return format_html('ACTIVA') + return format_html('INACTIVA') + activa_badge.short_description = 'Estado' + + +@admin.register(Capacidad) +class CapacidadAdmin(admin.ModelAdmin): + """Admin para Capacidad.""" + + list_display = [ + 'nombre_completo', + 'accion', + 'recurso', + 'dominio', + 'nivel_sensibilidad_badge', + 'requiere_auditoria_badge', + 'activa_badge' + ] + list_filter = ['dominio', 'nivel_sensibilidad', 'requiere_auditoria', 'activa'] + search_fields = ['nombre_completo', 'descripcion', 'accion', 'recurso'] + readonly_fields = ['created_at'] + ordering = ['nombre_completo'] + + fieldsets = [ + ('Identificacion', { + 'fields': ('nombre_completo', 'descripcion') + }), + ('Componentes', { + 'fields': ('accion', 'recurso', 'dominio') + }), + ('Seguridad', { + 'fields': ('nivel_sensibilidad', 'requiere_auditoria') + }), + ('Estado', { + 'fields': ('activa', 'created_at') + }), + ] + + def nivel_sensibilidad_badge(self, obj): + """Badge para nivel de sensibilidad.""" + colors = { + 'bajo': 'green', + 'normal': 'blue', + 'alto': 'orange', + 'critico': 'red' + } + color = colors.get(obj.nivel_sensibilidad, 'gray') + return format_html( + '{}', + color, + obj.get_nivel_sensibilidad_display() + ) + nivel_sensibilidad_badge.short_description = 'Sensibilidad' + + def requiere_auditoria_badge(self, obj): + """Badge para requiere_auditoria.""" + if obj.requiere_auditoria: + return format_html('SI') + return format_html('NO') + requiere_auditoria_badge.short_description = 'Auditoria' + + def activa_badge(self, obj): + """Badge para activa.""" + if obj.activa: + return format_html('SI') + return format_html('NO') + activa_badge.short_description = 'Activa' + + +@admin.register(FuncionCapacidad) +class FuncionCapacidadAdmin(admin.ModelAdmin): + """Admin para FuncionCapacidad.""" + + list_display = [ + 'funcion', + 'capacidad', + 'requerida_badge', + 'visible_en_ui_badge', + 'created_at' + ] + list_filter = ['requerida', 'visible_en_ui'] + search_fields = ['funcion__nombre', 'capacidad__nombre_completo'] + autocomplete_fields = ['funcion', 'capacidad'] + readonly_fields = ['created_at'] + + def requerida_badge(self, obj): + """Badge para requerida.""" + if obj.requerida: + return format_html('OBLIGATORIA') + return format_html('OPCIONAL') + requerida_badge.short_description = 'Requerida' + + def visible_en_ui_badge(self, obj): + """Badge para visible_en_ui.""" + if obj.visible_en_ui: + return format_html('SI') + return format_html('NO') + visible_en_ui_badge.short_description = 'Visible UI' + + +@admin.register(GrupoPermisos) +class GrupoPermisosAdmin(admin.ModelAdmin): + """Admin para GrupoPermisos.""" + + list_display = [ + 'nombre_display', + 'codigo', + 'tipo_acceso', + 'num_capacidades', + 'num_usuarios', + 'activo_badge', + 'created_at' + ] + list_filter = ['tipo_acceso', 'activo'] + search_fields = ['codigo', 'nombre_display', 'descripcion'] + readonly_fields = ['created_at', 'updated_at'] + ordering = ['nombre_display'] + + fieldsets = [ + ('Informacion Basica', { + 'fields': ('codigo', 'nombre_display', 'descripcion') + }), + ('Clasificacion', { + 'fields': ('tipo_acceso',) + }), + ('Estado', { + 'fields': ('activo', 'created_at', 'updated_at') + }), + ] + + def num_capacidades(self, obj): + """Numero de capacidades del grupo.""" + return obj.capacidades.count() + num_capacidades.short_description = 'Capacidades' + + def num_usuarios(self, obj): + """Numero de usuarios asignados al grupo.""" + return obj.usuarios.filter(activo=True).count() + num_usuarios.short_description = 'Usuarios' + + def activo_badge(self, obj): + """Badge para activo.""" + if obj.activo: + return format_html('ACTIVO') + return format_html('INACTIVO') + activo_badge.short_description = 'Estado' + + +@admin.register(GrupoCapacidad) +class GrupoCapacidadAdmin(admin.ModelAdmin): + """Admin para GrupoCapacidad.""" + + list_display = ['grupo', 'capacidad', 'created_at'] + list_filter = ['grupo'] + search_fields = ['grupo__codigo', 'capacidad__nombre_completo'] + autocomplete_fields = ['grupo', 'capacidad'] + readonly_fields = ['created_at'] + + +@admin.register(UsuarioGrupo) +class UsuarioGrupoAdmin(admin.ModelAdmin): + """Admin para UsuarioGrupo.""" + + list_display = [ + 'usuario', + 'grupo', + 'fecha_asignacion', + 'expiracion_badge', + 'asignado_por', + 'activo_badge' + ] + list_filter = ['activo', 'grupo'] + search_fields = ['usuario__username', 'grupo__codigo'] + autocomplete_fields = ['usuario', 'grupo', 'asignado_por'] + readonly_fields = ['fecha_asignacion'] + date_hierarchy = 'fecha_asignacion' + + fieldsets = [ + ('Asignacion', { + 'fields': ('usuario', 'grupo') + }), + ('Fechas', { + 'fields': ('fecha_asignacion', 'fecha_expiracion') + }), + ('Auditoria', { + 'fields': ('asignado_por', 'activo') + }), + ] + + def expiracion_badge(self, obj): + """Badge para fecha_expiracion.""" + if obj.fecha_expiracion is None: + return format_html('PERMANENTE') + if obj.is_expired(): + return format_html( + 'EXPIRADO: {}', + obj.fecha_expiracion.strftime('%Y-%m-%d') + ) + return format_html( + 'Expira: {}', + obj.fecha_expiracion.strftime('%Y-%m-%d') + ) + expiracion_badge.short_description = 'Expiracion' + + def activo_badge(self, obj): + """Badge para activo.""" + if obj.activo and not obj.is_expired(): + return format_html('ACTIVO') + return format_html('INACTIVO') + activo_badge.short_description = 'Estado' + + +@admin.register(PermisoExcepcional) +class PermisoExcepcionalAdmin(admin.ModelAdmin): + """Admin para PermisoExcepcional.""" + + list_display = [ + 'usuario', + 'capacidad', + 'tipo_badge', + 'fecha_inicio', + 'fecha_fin', + 'autorizado_por', + 'estado_badge' + ] + list_filter = ['tipo', 'activo'] + search_fields = ['usuario__username', 'capacidad__nombre_completo', 'motivo'] + autocomplete_fields = ['usuario', 'capacidad', 'autorizado_por'] + readonly_fields = ['created_at'] + date_hierarchy = 'fecha_inicio' + + fieldsets = [ + ('Permiso', { + 'fields': ('usuario', 'capacidad', 'tipo') + }), + ('Periodo', { + 'fields': ('fecha_inicio', 'fecha_fin') + }), + ('Justificacion', { + 'fields': ('motivo', 'autorizado_por') + }), + ('Estado', { + 'fields': ('activo', 'created_at') + }), + ] + + def tipo_badge(self, obj): + """Badge para tipo.""" + if obj.tipo == 'conceder': + return format_html('CONCEDER') + return format_html('REVOCAR') + tipo_badge.short_description = 'Tipo' + + def estado_badge(self, obj): + """Badge para estado actual.""" + if obj.is_active_now(): + return format_html('ACTIVO AHORA') + return format_html('INACTIVO') + estado_badge.short_description = 'Estado' + + +@admin.register(AuditoriaPermiso) +class AuditoriaPermisoAdmin(admin.ModelAdmin): + """Admin para AuditoriaPermiso.""" + + list_display = [ + 'timestamp', + 'usuario', + 'capacidad', + 'accion_badge', + 'recurso_accedido', + 'ip_address' + ] + list_filter = ['accion_realizada', 'timestamp'] + search_fields = ['usuario__username', 'capacidad', 'recurso_accedido', 'ip_address'] + readonly_fields = ['timestamp', 'metadata'] + date_hierarchy = 'timestamp' + ordering = ['-timestamp'] + + fieldsets = [ + ('Acceso', { + 'fields': ('usuario', 'capacidad', 'accion_realizada', 'recurso_accedido') + }), + ('Contexto', { + 'fields': ('ip_address', 'user_agent') + }), + ('Metadata', { + 'fields': ('metadata', 'timestamp'), + 'classes': ('collapse',) + }), + ] + + def has_add_permission(self, request): + """No permitir agregar registros de auditoria manualmente.""" + return False + + def has_delete_permission(self, request, obj=None): + """No permitir eliminar registros de auditoria.""" + return False + + def accion_badge(self, obj): + """Badge para accion_realizada.""" + if obj.accion_realizada == 'acceso_concedido': + return format_html('CONCEDIDO') + return format_html('DENEGADO') + accion_badge.short_description = 'Accion' diff --git a/api/callcentersite/callcentersite/apps/permissions/apps.py b/api/callcentersite/callcentersite/apps/permissions/apps.py new file mode 100644 index 00000000..f8a045f7 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/apps.py @@ -0,0 +1,25 @@ +""" +Configuracion de la app permissions. + +Sistema de Permisos Granular - Prioridad 1 +""" + +from django.apps import AppConfig + + +class PermissionsConfig(AppConfig): + """Configuracion de la app permissions.""" + + default_auto_field = 'django.db.models.BigAutoField' + name = 'callcentersite.apps.permissions' + verbose_name = 'Sistema de Permisos Granular' + + def ready(self): + """ + Ejecutado cuando la app esta lista. + + Aqui se pueden registrar signals, cargar datos iniciales, etc. + """ + # Importar signals si existen + # from . import signals # noqa: F401 + pass diff --git a/api/callcentersite/callcentersite/apps/permissions/management/__init__.py b/api/callcentersite/callcentersite/apps/permissions/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/management/commands/__init__.py b/api/callcentersite/callcentersite/apps/permissions/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/management/commands/seed_permissions.py b/api/callcentersite/callcentersite/apps/permissions/management/commands/seed_permissions.py new file mode 100644 index 00000000..354dd0fa --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/management/commands/seed_permissions.py @@ -0,0 +1,432 @@ +""" +Management command para poblar permisos iniciales. + +Sistema de Permisos Granular - Prioridad 1 +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md + +Uso: + python manage.py seed_permissions + python manage.py seed_permissions --reset # Elimina datos existentes +""" + +from django.core.management.base import BaseCommand +from django.db import transaction + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, +) + + +class Command(BaseCommand): + """Comando para poblar datos iniciales de permisos.""" + + help = 'Pobla funciones, capacidades y grupos de permisos iniciales' + + def add_arguments(self, parser): + """Agregar argumentos al comando.""" + parser.add_argument( + '--reset', + action='store_true', + help='Elimina datos existentes antes de poblar' + ) + + def handle(self, *args, **options): + """Ejecutar comando.""" + if options['reset']: + self.stdout.write(self.style.WARNING('Eliminando datos existentes...')) + self._reset_data() + + self.stdout.write(self.style.SUCCESS('Poblando datos iniciales...')) + + with transaction.atomic(): + self._crear_funciones() + self._crear_capacidades() + self._vincular_funciones_capacidades() + self._crear_grupos_permisos() + self._vincular_grupos_capacidades() + + self.stdout.write(self.style.SUCCESS('Datos iniciales poblados exitosamente')) + + def _reset_data(self): + """Elimina datos existentes.""" + GrupoCapacidad.objects.all().delete() + FuncionCapacidad.objects.all().delete() + GrupoPermisos.objects.all().delete() + Capacidad.objects.all().delete() + Funcion.objects.all().delete() + self.stdout.write(self.style.SUCCESS('Datos eliminados')) + + def _crear_funciones(self): + """Crea funciones del sistema.""" + funciones = [ + # Dominio: vistas + { + 'nombre': 'dashboards', + 'nombre_completo': 'sistema.vistas.dashboards', + 'descripcion': 'Visualizacion de dashboards y paneles de control', + 'dominio': 'vistas', + 'categoria': 'vistas', + 'icono': 'dashboard', + 'orden_menu': 10 + }, + + # Dominio: operaciones + { + 'nombre': 'llamadas', + 'nombre_completo': 'sistema.operaciones.llamadas', + 'descripcion': 'Gestion de llamadas telefonicas', + 'dominio': 'operaciones', + 'categoria': 'operaciones', + 'icono': 'phone', + 'orden_menu': 20 + }, + { + 'nombre': 'tickets', + 'nombre_completo': 'sistema.operaciones.tickets', + 'descripcion': 'Gestion de tickets de soporte', + 'dominio': 'operaciones', + 'categoria': 'operaciones', + 'icono': 'ticket', + 'orden_menu': 30 + }, + { + 'nombre': 'clientes', + 'nombre_completo': 'sistema.operaciones.clientes', + 'descripcion': 'Gestion de informacion de clientes', + 'dominio': 'operaciones', + 'categoria': 'operaciones', + 'icono': 'people', + 'orden_menu': 40 + }, + + # Dominio: administracion + { + 'nombre': 'usuarios', + 'nombre_completo': 'sistema.administracion.usuarios', + 'descripcion': 'Gestion de usuarios del sistema', + 'dominio': 'administracion', + 'categoria': 'administracion', + 'icono': 'person', + 'orden_menu': 50 + }, + + # Dominio: analisis + { + 'nombre': 'metricas', + 'nombre_completo': 'sistema.analisis.metricas', + 'descripcion': 'Visualizacion de metricas y KPIs', + 'dominio': 'analisis', + 'categoria': 'analisis', + 'icono': 'chart', + 'orden_menu': 60 + }, + { + 'nombre': 'reportes', + 'nombre_completo': 'sistema.analisis.reportes', + 'descripcion': 'Generacion de reportes', + 'dominio': 'analisis', + 'categoria': 'analisis', + 'icono': 'report', + 'orden_menu': 70 + }, + + # Dominio: supervision + { + 'nombre': 'equipos', + 'nombre_completo': 'sistema.supervision.equipos', + 'descripcion': 'Gestion de equipos de trabajo', + 'dominio': 'supervision', + 'categoria': 'supervision', + 'icono': 'group', + 'orden_menu': 80 + }, + { + 'nombre': 'horarios', + 'nombre_completo': 'sistema.supervision.horarios', + 'descripcion': 'Gestion de horarios y turnos', + 'dominio': 'supervision', + 'categoria': 'supervision', + 'icono': 'schedule', + 'orden_menu': 90 + }, + + # Dominio: finanzas + { + 'nombre': 'pagos', + 'nombre_completo': 'sistema.finanzas.pagos', + 'descripcion': 'Gestion de pagos y aprobaciones', + 'dominio': 'finanzas', + 'categoria': 'finanzas', + 'icono': 'payment', + 'orden_menu': 100 + }, + + # Dominio: tecnico + { + 'nombre': 'configuracion', + 'nombre_completo': 'sistema.tecnico.configuracion', + 'descripcion': 'Configuracion del sistema', + 'dominio': 'tecnico', + 'categoria': 'tecnico', + 'icono': 'settings', + 'orden_menu': 110 + }, + ] + + for func_data in funciones: + funcion, created = Funcion.objects.get_or_create( + nombre_completo=func_data['nombre_completo'], + defaults=func_data + ) + if created: + self.stdout.write(f' Funcion creada: {funcion.nombre_completo}') + + def _crear_capacidades(self): + """Crea capacidades atomicas.""" + capacidades = [ + # Dashboards + {'nombre_completo': 'sistema.vistas.dashboards.ver', 'accion': 'ver', 'recurso': 'dashboards', 'dominio': 'vistas', 'nivel_sensibilidad': 'bajo'}, + + # Llamadas + {'nombre_completo': 'sistema.operaciones.llamadas.ver', 'accion': 'ver', 'recurso': 'llamadas', 'dominio': 'operaciones', 'nivel_sensibilidad': 'bajo'}, + {'nombre_completo': 'sistema.operaciones.llamadas.realizar', 'accion': 'realizar', 'recurso': 'llamadas', 'dominio': 'operaciones', 'nivel_sensibilidad': 'normal'}, + + # Tickets + {'nombre_completo': 'sistema.operaciones.tickets.ver', 'accion': 'ver', 'recurso': 'tickets', 'dominio': 'operaciones', 'nivel_sensibilidad': 'bajo'}, + {'nombre_completo': 'sistema.operaciones.tickets.crear', 'accion': 'crear', 'recurso': 'tickets', 'dominio': 'operaciones', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.operaciones.tickets.editar', 'accion': 'editar', 'recurso': 'tickets', 'dominio': 'operaciones', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.operaciones.tickets.eliminar', 'accion': 'eliminar', 'recurso': 'tickets', 'dominio': 'operaciones', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + + # Clientes + {'nombre_completo': 'sistema.operaciones.clientes.ver', 'accion': 'ver', 'recurso': 'clientes', 'dominio': 'operaciones', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.operaciones.clientes.editar', 'accion': 'editar', 'recurso': 'clientes', 'dominio': 'operaciones', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + + # Usuarios + {'nombre_completo': 'sistema.administracion.usuarios.ver', 'accion': 'ver', 'recurso': 'usuarios', 'dominio': 'administracion', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.administracion.usuarios.crear', 'accion': 'crear', 'recurso': 'usuarios', 'dominio': 'administracion', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + {'nombre_completo': 'sistema.administracion.usuarios.editar', 'accion': 'editar', 'recurso': 'usuarios', 'dominio': 'administracion', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + {'nombre_completo': 'sistema.administracion.usuarios.eliminar', 'accion': 'eliminar', 'recurso': 'usuarios', 'dominio': 'administracion', 'nivel_sensibilidad': 'critico', 'requiere_auditoria': True}, + + # Metricas + {'nombre_completo': 'sistema.analisis.metricas.ver', 'accion': 'ver', 'recurso': 'metricas', 'dominio': 'analisis', 'nivel_sensibilidad': 'bajo'}, + + # Reportes + {'nombre_completo': 'sistema.analisis.reportes.ver', 'accion': 'ver', 'recurso': 'reportes', 'dominio': 'analisis', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.analisis.reportes.generar', 'accion': 'generar', 'recurso': 'reportes', 'dominio': 'analisis', 'nivel_sensibilidad': 'normal'}, + + # Equipos + {'nombre_completo': 'sistema.supervision.equipos.ver', 'accion': 'ver', 'recurso': 'equipos', 'dominio': 'supervision', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.supervision.equipos.crear', 'accion': 'crear', 'recurso': 'equipos', 'dominio': 'supervision', 'nivel_sensibilidad': 'alto'}, + {'nombre_completo': 'sistema.supervision.equipos.editar', 'accion': 'editar', 'recurso': 'equipos', 'dominio': 'supervision', 'nivel_sensibilidad': 'alto'}, + {'nombre_completo': 'sistema.supervision.equipos.asignar_miembros', 'accion': 'asignar_miembros', 'recurso': 'equipos', 'dominio': 'supervision', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + + # Horarios + {'nombre_completo': 'sistema.supervision.horarios.ver', 'accion': 'ver', 'recurso': 'horarios', 'dominio': 'supervision', 'nivel_sensibilidad': 'bajo'}, + {'nombre_completo': 'sistema.supervision.horarios.crear', 'accion': 'crear', 'recurso': 'horarios', 'dominio': 'supervision', 'nivel_sensibilidad': 'normal'}, + {'nombre_completo': 'sistema.supervision.horarios.editar', 'accion': 'editar', 'recurso': 'horarios', 'dominio': 'supervision', 'nivel_sensibilidad': 'alto'}, + {'nombre_completo': 'sistema.supervision.horarios.aprobar', 'accion': 'aprobar', 'recurso': 'horarios', 'dominio': 'supervision', 'nivel_sensibilidad': 'alto', 'requiere_auditoria': True}, + + # Pagos + {'nombre_completo': 'sistema.finanzas.pagos.ver', 'accion': 'ver', 'recurso': 'pagos', 'dominio': 'finanzas', 'nivel_sensibilidad': 'alto'}, + {'nombre_completo': 'sistema.finanzas.pagos.aprobar', 'accion': 'aprobar', 'recurso': 'pagos', 'dominio': 'finanzas', 'nivel_sensibilidad': 'critico', 'requiere_auditoria': True}, + + # Configuracion + {'nombre_completo': 'sistema.tecnico.configuracion.ver', 'accion': 'ver', 'recurso': 'configuracion', 'dominio': 'tecnico', 'nivel_sensibilidad': 'alto'}, + {'nombre_completo': 'sistema.tecnico.configuracion.editar', 'accion': 'editar', 'recurso': 'configuracion', 'dominio': 'tecnico', 'nivel_sensibilidad': 'critico', 'requiere_auditoria': True}, + ] + + for cap_data in capacidades: + # Agregar descripcion automatica + cap_data['descripcion'] = f"{cap_data['accion'].capitalize()} {cap_data['recurso']}" + + capacidad, created = Capacidad.objects.get_or_create( + nombre_completo=cap_data['nombre_completo'], + defaults=cap_data + ) + if created: + self.stdout.write(f' Capacidad creada: {capacidad.nombre_completo}') + + def _vincular_funciones_capacidades(self): + """Vincula capacidades a funciones.""" + vinculaciones = [ + ('sistema.vistas.dashboards', 'sistema.vistas.dashboards.ver', True), + + ('sistema.operaciones.llamadas', 'sistema.operaciones.llamadas.ver', True), + ('sistema.operaciones.llamadas', 'sistema.operaciones.llamadas.realizar', False), + + ('sistema.operaciones.tickets', 'sistema.operaciones.tickets.ver', True), + ('sistema.operaciones.tickets', 'sistema.operaciones.tickets.crear', False), + ('sistema.operaciones.tickets', 'sistema.operaciones.tickets.editar', False), + ('sistema.operaciones.tickets', 'sistema.operaciones.tickets.eliminar', False), + + ('sistema.operaciones.clientes', 'sistema.operaciones.clientes.ver', True), + ('sistema.operaciones.clientes', 'sistema.operaciones.clientes.editar', False), + + ('sistema.administracion.usuarios', 'sistema.administracion.usuarios.ver', True), + ('sistema.administracion.usuarios', 'sistema.administracion.usuarios.crear', False), + ('sistema.administracion.usuarios', 'sistema.administracion.usuarios.editar', False), + ('sistema.administracion.usuarios', 'sistema.administracion.usuarios.eliminar', False), + + ('sistema.analisis.metricas', 'sistema.analisis.metricas.ver', True), + + ('sistema.analisis.reportes', 'sistema.analisis.reportes.ver', True), + ('sistema.analisis.reportes', 'sistema.analisis.reportes.generar', False), + + ('sistema.supervision.equipos', 'sistema.supervision.equipos.ver', True), + ('sistema.supervision.equipos', 'sistema.supervision.equipos.crear', False), + ('sistema.supervision.equipos', 'sistema.supervision.equipos.editar', False), + ('sistema.supervision.equipos', 'sistema.supervision.equipos.asignar_miembros', False), + + ('sistema.supervision.horarios', 'sistema.supervision.horarios.ver', True), + ('sistema.supervision.horarios', 'sistema.supervision.horarios.crear', False), + ('sistema.supervision.horarios', 'sistema.supervision.horarios.editar', False), + ('sistema.supervision.horarios', 'sistema.supervision.horarios.aprobar', False), + + ('sistema.finanzas.pagos', 'sistema.finanzas.pagos.ver', True), + ('sistema.finanzas.pagos', 'sistema.finanzas.pagos.aprobar', False), + + ('sistema.tecnico.configuracion', 'sistema.tecnico.configuracion.ver', True), + ('sistema.tecnico.configuracion', 'sistema.tecnico.configuracion.editar', False), + ] + + for funcion_nc, capacidad_nc, requerida in vinculaciones: + try: + funcion = Funcion.objects.get(nombre_completo=funcion_nc) + capacidad = Capacidad.objects.get(nombre_completo=capacidad_nc) + + vinc, created = FuncionCapacidad.objects.get_or_create( + funcion=funcion, + capacidad=capacidad, + defaults={'requerida': requerida} + ) + if created: + self.stdout.write(f' Vinculacion creada: {funcion_nc} -> {capacidad_nc}') + except (Funcion.DoesNotExist, Capacidad.DoesNotExist) as e: + self.stdout.write(self.style.ERROR(f' Error vinculando: {e}')) + + def _crear_grupos_permisos(self): + """Crea grupos funcionales de permisos.""" + grupos = [ + { + 'codigo': 'atencion_cliente', + 'nombre_display': 'Atencion al Cliente', + 'descripcion': 'Grupo para agentes de atencion al cliente. Pueden realizar llamadas, gestionar tickets y consultar clientes.', + 'tipo_acceso': 'operativo' + }, + { + 'codigo': 'visualizacion_metricas', + 'nombre_display': 'Visualizacion de Metricas', + 'descripcion': 'Permite ver dashboards y metricas personales.', + 'tipo_acceso': 'analisis' + }, + { + 'codigo': 'gestion_equipos', + 'nombre_display': 'Gestion de Equipos', + 'descripcion': 'Permite gestionar equipos de trabajo, asignar miembros y planificar.', + 'tipo_acceso': 'gestion' + }, + { + 'codigo': 'gestion_horarios', + 'nombre_display': 'Gestion de Horarios', + 'descripcion': 'Permite crear, editar y aprobar horarios y turnos.', + 'tipo_acceso': 'gestion' + }, + { + 'codigo': 'analisis_avanzado', + 'nombre_display': 'Analisis Avanzado', + 'descripcion': 'Permite generar reportes y analizar metricas avanzadas.', + 'tipo_acceso': 'analisis' + }, + { + 'codigo': 'administracion_usuarios', + 'nombre_display': 'Administracion de Usuarios', + 'descripcion': 'Permite gestionar usuarios del sistema (crear, editar, eliminar).', + 'tipo_acceso': 'gestion' + }, + { + 'codigo': 'finanzas_aprobaciones', + 'nombre_display': 'Finanzas - Aprobaciones', + 'descripcion': 'Permite aprobar pagos y transacciones financieras.', + 'tipo_acceso': 'finanzas' + }, + { + 'codigo': 'configuracion_sistema', + 'nombre_display': 'Configuracion del Sistema', + 'descripcion': 'Permite configurar parametros tecnicos del sistema.', + 'tipo_acceso': 'tecnico' + }, + ] + + for grupo_data in grupos: + grupo, created = GrupoPermisos.objects.get_or_create( + codigo=grupo_data['codigo'], + defaults=grupo_data + ) + if created: + self.stdout.write(f' Grupo creado: {grupo.nombre_display}') + + def _vincular_grupos_capacidades(self): + """Vincula capacidades a grupos.""" + vinculaciones = [ + # Grupo: atencion_cliente + ('atencion_cliente', 'sistema.operaciones.llamadas.ver'), + ('atencion_cliente', 'sistema.operaciones.llamadas.realizar'), + ('atencion_cliente', 'sistema.operaciones.tickets.ver'), + ('atencion_cliente', 'sistema.operaciones.tickets.crear'), + ('atencion_cliente', 'sistema.operaciones.tickets.editar'), + ('atencion_cliente', 'sistema.operaciones.clientes.ver'), + + # Grupo: visualizacion_metricas + ('visualizacion_metricas', 'sistema.vistas.dashboards.ver'), + ('visualizacion_metricas', 'sistema.analisis.metricas.ver'), + + # Grupo: gestion_equipos + ('gestion_equipos', 'sistema.supervision.equipos.ver'), + ('gestion_equipos', 'sistema.supervision.equipos.crear'), + ('gestion_equipos', 'sistema.supervision.equipos.editar'), + ('gestion_equipos', 'sistema.supervision.equipos.asignar_miembros'), + + # Grupo: gestion_horarios + ('gestion_horarios', 'sistema.supervision.horarios.ver'), + ('gestion_horarios', 'sistema.supervision.horarios.crear'), + ('gestion_horarios', 'sistema.supervision.horarios.editar'), + ('gestion_horarios', 'sistema.supervision.horarios.aprobar'), + + # Grupo: analisis_avanzado + ('analisis_avanzado', 'sistema.vistas.dashboards.ver'), + ('analisis_avanzado', 'sistema.analisis.metricas.ver'), + ('analisis_avanzado', 'sistema.analisis.reportes.ver'), + ('analisis_avanzado', 'sistema.analisis.reportes.generar'), + + # Grupo: administracion_usuarios + ('administracion_usuarios', 'sistema.administracion.usuarios.ver'), + ('administracion_usuarios', 'sistema.administracion.usuarios.crear'), + ('administracion_usuarios', 'sistema.administracion.usuarios.editar'), + ('administracion_usuarios', 'sistema.administracion.usuarios.eliminar'), + + # Grupo: finanzas_aprobaciones + ('finanzas_aprobaciones', 'sistema.finanzas.pagos.ver'), + ('finanzas_aprobaciones', 'sistema.finanzas.pagos.aprobar'), + + # Grupo: configuracion_sistema + ('configuracion_sistema', 'sistema.tecnico.configuracion.ver'), + ('configuracion_sistema', 'sistema.tecnico.configuracion.editar'), + ] + + for grupo_codigo, capacidad_nc in vinculaciones: + try: + grupo = GrupoPermisos.objects.get(codigo=grupo_codigo) + capacidad = Capacidad.objects.get(nombre_completo=capacidad_nc) + + vinc, created = GrupoCapacidad.objects.get_or_create( + grupo=grupo, + capacidad=capacidad + ) + if created: + self.stdout.write(f' Vinculacion creada: {grupo_codigo} -> {capacidad_nc}') + except (GrupoPermisos.DoesNotExist, Capacidad.DoesNotExist) as e: + self.stdout.write(self.style.ERROR(f' Error vinculando: {e}')) diff --git a/api/callcentersite/callcentersite/apps/permissions/middleware.py b/api/callcentersite/callcentersite/apps/permissions/middleware.py new file mode 100644 index 00000000..a0c48ee2 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/middleware.py @@ -0,0 +1,178 @@ +""" +Middleware para proteccion de endpoints con permisos granulares. + +Sistema de Permisos Granular - Prioridad 1 +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from __future__ import annotations + +from functools import wraps +from typing import Callable, TYPE_CHECKING + +from django.http import JsonResponse + +from callcentersite.apps.permissions.services import PermisoService + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + + +def verificar_permiso( + capacidad_requerida: str | list[str], + auditar: bool = False, + mensaje_error: str | None = None +) -> Callable: + """ + Decorator para proteger views con verificacion de permisos. + + Uso: + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def realizar_llamada(request): + # Solo usuarios con capacidad 'realizar llamadas' llegan aqui + ... + + @verificar_permiso([ + "sistema.finanzas.pagos.aprobar", + "sistema.finanzas.pagos.validar" + ]) + def aprobar_pago(request, pago_id): + # Usuario debe tener TODAS las capacidades listadas + ... + + Args: + capacidad_requerida: Capacidad o lista de capacidades requeridas + auditar: Si True, registra acceso en auditoria (default: False) + mensaje_error: Mensaje personalizado para error 403 (opcional) + + Returns: + Decorator que envuelve la view + + Raises: + HTTP 401: Usuario no autenticado + HTTP 403: Usuario sin permiso requerido + """ + def decorator(view_func: Callable) -> Callable: + @wraps(view_func) + def wrapper(request: HttpRequest, *args, **kwargs) -> HttpResponse: + # 1. Verificar autenticacion + if not request.user.is_authenticated: + return JsonResponse( + { + "error": "Autenticacion requerida", + "detalle": "Debe autenticarse para acceder a este recurso" + }, + status=401 + ) + + usuario_id = request.user.id + + # 2. Convertir capacidad_requerida a lista si es string + capacidades = ( + [capacidad_requerida] + if isinstance(capacidad_requerida, str) + else capacidad_requerida + ) + + # 3. Verificar TODAS las capacidades requeridas + permisos_faltantes = [] + for capacidad in capacidades: + if not PermisoService.usuario_tiene_permiso(usuario_id, capacidad): + permisos_faltantes.append(capacidad) + + # 4. Si falta alguna capacidad, denegar acceso + if permisos_faltantes: + # Registrar intento de acceso denegado si auditar=True + if auditar: + PermisoService.registrar_acceso( + usuario_id=usuario_id, + capacidad=", ".join(permisos_faltantes), + accion="ACCESO_DENEGADO", + ip_address=_obtener_ip_cliente(request), + user_agent=request.META.get("HTTP_USER_AGENT"), + metadata={ + "path": request.path, + "method": request.method, + "permisos_faltantes": permisos_faltantes + } + ) + + error_msg = mensaje_error or ( + f"Permiso denegado. Requiere: {', '.join(permisos_faltantes)}" + ) + + return JsonResponse( + { + "error": error_msg, + "capacidades_requeridas": permisos_faltantes + }, + status=403 + ) + + # 5. Usuario tiene todos los permisos, registrar acceso si requerido + if auditar: + PermisoService.registrar_acceso( + usuario_id=usuario_id, + capacidad=", ".join(capacidades), + accion="ACCESO_PERMITIDO", + ip_address=_obtener_ip_cliente(request), + user_agent=request.META.get("HTTP_USER_AGENT"), + metadata={ + "path": request.path, + "method": request.method + } + ) + + # 6. Ejecutar view original + return view_func(request, *args, **kwargs) + + return wrapper + return decorator + + +def _obtener_ip_cliente(request: HttpRequest) -> str | None: + """ + Extrae IP del cliente desde request. + + Maneja casos de: + - Conexion directa + - Detras de proxy (X-Forwarded-For) + - Detras de load balancer + + Args: + request: HttpRequest de Django + + Returns: + IP del cliente o None + """ + # Intentar obtener IP real si esta detras de proxy + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + if x_forwarded_for: + # X-Forwarded-For puede contener multiples IPs separadas por coma + # La primera es la del cliente original + ip = x_forwarded_for.split(",")[0].strip() + return ip + + # Si no hay proxy, usar REMOTE_ADDR + return request.META.get("REMOTE_ADDR") + + +def verificar_permiso_auditable(capacidad_requerida: str | list[str]) -> Callable: + """ + Decorator para proteger views que SIEMPRE requieren auditoria. + + Equivalente a verificar_permiso con auditar=True. + + Uso: + @verificar_permiso_auditable("sistema.finanzas.pagos.aprobar") + def aprobar_pago(request, pago_id): + # Siempre se auditara el acceso + ... + + Args: + capacidad_requerida: Capacidad o lista de capacidades requeridas + + Returns: + Decorator que envuelve la view + """ + return verificar_permiso(capacidad_requerida, auditar=True) diff --git a/api/callcentersite/callcentersite/apps/permissions/migrations/0001_initial.py b/api/callcentersite/callcentersite/apps/permissions/migrations/0001_initial.py new file mode 100644 index 00000000..fb662cbe --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/migrations/0001_initial.py @@ -0,0 +1,297 @@ +""" +Migracion inicial del sistema de permisos granular. + +Sistema de Permisos Granular - Prioridad 1 +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md + +Crea 8 tablas: +1. permissions_funciones +2. permissions_capacidades +3. permissions_funcion_capacidades +4. permissions_grupos_permisos +5. permissions_grupo_capacidades +6. permissions_usuarios_grupos +7. permissions_permisos_excepcionales +8. permissions_auditoria_permisos +""" + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # 1. Tabla: funciones + migrations.CreateModel( + name='Funcion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre', models.CharField(max_length=100, help_text='Nombre corto del recurso')), + ('nombre_completo', models.CharField(max_length=200, unique=True, help_text='Nombre completo: sistema.dominio.recurso')), + ('descripcion', models.TextField(blank=True, help_text='Descripcion de la funcionalidad')), + ('dominio', models.CharField(max_length=100, help_text='Dominio al que pertenece (operaciones, finanzas, etc)')), + ('categoria', models.CharField(max_length=50, blank=True, help_text='Categoria para agrupar en menu')), + ('icono', models.CharField(max_length=50, blank=True, help_text='Icono para UI')), + ('orden_menu', models.IntegerField(default=0, help_text='Orden en menu (menor = primero)')), + ('activa', models.BooleanField(default=True, help_text='Si esta activa')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Funcion del Sistema', + 'verbose_name_plural': 'Funciones del Sistema', + 'db_table': 'permissions_funciones', + 'ordering': ['orden_menu', 'nombre'], + 'indexes': [ + models.Index(fields=['dominio'], name='perm_func_dominio_idx'), + models.Index(fields=['activa'], name='perm_func_activa_idx'), + models.Index(fields=['categoria'], name='perm_func_categoria_idx'), + ], + }, + ), + + # 2. Tabla: capacidades + migrations.CreateModel( + name='Capacidad', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('nombre_completo', models.CharField(max_length=200, unique=True, help_text='Formato: sistema.dominio.recurso.accion')), + ('descripcion', models.TextField(blank=True, help_text='Descripcion de la capacidad')), + ('accion', models.CharField(max_length=50, help_text='Accion (ver, crear, editar, eliminar, aprobar, etc)')), + ('recurso', models.CharField(max_length=100, help_text='Recurso sobre el que actua')), + ('dominio', models.CharField(max_length=100, help_text='Dominio del sistema')), + ('nivel_sensibilidad', models.CharField( + max_length=20, + default='normal', + choices=[ + ('bajo', 'Bajo'), + ('normal', 'Normal'), + ('alto', 'Alto'), + ('critico', 'Critico') + ], + help_text='Nivel de sensibilidad de la capacidad' + )), + ('requiere_auditoria', models.BooleanField(default=False, help_text='Si accesos deben auditarse')), + ('activa', models.BooleanField(default=True, help_text='Si esta activa')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': 'Capacidad', + 'verbose_name_plural': 'Capacidades', + 'db_table': 'permissions_capacidades', + 'ordering': ['dominio', 'recurso', 'accion'], + 'indexes': [ + models.Index(fields=['accion'], name='perm_cap_accion_idx'), + models.Index(fields=['recurso'], name='perm_cap_recurso_idx'), + models.Index(fields=['nivel_sensibilidad'], name='perm_cap_sensib_idx'), + ], + }, + ), + + # 3. Tabla: grupos_permisos + migrations.CreateModel( + name='GrupoPermisos', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('codigo', models.CharField(max_length=100, unique=True, help_text='Codigo unico del grupo (snake_case)')), + ('nombre_display', models.CharField(max_length=200, help_text='Nombre legible para UI')), + ('descripcion', models.TextField(blank=True, help_text='Descripcion del grupo')), + ('tipo_acceso', models.CharField( + max_length=50, + blank=True, + choices=[ + ('operativo', 'Operativo'), + ('gestion', 'Gestion'), + ('analisis', 'Analisis'), + ('estrategico', 'Estrategico'), + ('tecnico', 'Tecnico'), + ('finanzas', 'Finanzas'), + ('calidad', 'Calidad') + ], + help_text='Tipo de acceso del grupo' + )), + ('activo', models.BooleanField(default=True, help_text='Si esta activo')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Grupo de Permisos', + 'verbose_name_plural': 'Grupos de Permisos', + 'db_table': 'permissions_grupos_permisos', + 'ordering': ['nombre_display'], + }, + ), + + # 4. Tabla: funcion_capacidades (N:M entre Funcion y Capacidad) + migrations.CreateModel( + name='FuncionCapacidad', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('requerida', models.BooleanField(default=False, help_text='Si capacidad es requerida para la funcion')), + ('visible_en_ui', models.BooleanField(default=True, help_text='Si mostrar en UI')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('funcion', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.funcion', + related_name='funcion_capacidades' + )), + ('capacidad', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.capacidad', + related_name='capacidad_funciones' + )), + ], + options={ + 'verbose_name': 'Funcion-Capacidad', + 'verbose_name_plural': 'Funciones-Capacidades', + 'db_table': 'permissions_funcion_capacidades', + 'unique_together': {('funcion', 'capacidad')}, + }, + ), + + # 5. Tabla: grupo_capacidades (N:M entre GrupoPermisos y Capacidad) + migrations.CreateModel( + name='GrupoCapacidad', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('grupo', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.grupopermisos', + related_name='grupo_capacidades' + )), + ('capacidad', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.capacidad', + related_name='capacidad_grupos' + )), + ], + options={ + 'verbose_name': 'Grupo-Capacidad', + 'verbose_name_plural': 'Grupos-Capacidades', + 'db_table': 'permissions_grupo_capacidades', + 'unique_together': {('grupo', 'capacidad')}, + }, + ), + + # 6. Tabla: usuarios_grupos (N:M entre User y GrupoPermisos) + migrations.CreateModel( + name='UsuarioGrupo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fecha_asignacion', models.DateTimeField(auto_now_add=True)), + ('fecha_expiracion', models.DateTimeField(null=True, blank=True, help_text='Fecha de expiracion (opcional)')), + ('activo', models.BooleanField(default=True, help_text='Si asignacion esta activa')), + ('usuario', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + related_name='usuario_grupos' + )), + ('grupo', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.grupopermisos', + related_name='grupo_usuarios' + )), + ('asignado_por', models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + related_name='asignaciones_realizadas' + )), + ], + options={ + 'verbose_name': 'Usuario-Grupo', + 'verbose_name_plural': 'Usuarios-Grupos', + 'db_table': 'permissions_usuarios_grupos', + 'unique_together': {('usuario', 'grupo')}, + }, + ), + + # 7. Tabla: permisos_excepcionales + migrations.CreateModel( + name='PermisoExcepcional', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tipo', models.CharField( + max_length=20, + choices=[ + ('conceder', 'Conceder'), + ('revocar', 'Revocar') + ], + help_text='Tipo de permiso excepcional' + )), + ('fecha_inicio', models.DateTimeField(auto_now_add=True)), + ('fecha_fin', models.DateTimeField(null=True, blank=True, help_text='Fecha de fin (opcional)')), + ('motivo', models.TextField(help_text='Razon del permiso excepcional')), + ('activo', models.BooleanField(default=True, help_text='Si esta activo')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('usuario', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + related_name='permisos_excepcionales' + )), + ('capacidad', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='permissions.capacidad', + related_name='excepciones' + )), + ('autorizado_por', models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + related_name='autorizaciones_excepcionales' + )), + ], + options={ + 'verbose_name': 'Permiso Excepcional', + 'verbose_name_plural': 'Permisos Excepcionales', + 'db_table': 'permissions_permisos_excepcionales', + 'ordering': ['-created_at'], + 'indexes': [ + models.Index(fields=['usuario', 'activo'], name='perm_exc_usr_activo_idx'), + ], + }, + ), + + # 8. Tabla: auditoria_permisos + migrations.CreateModel( + name='AuditoriaPermiso', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('capacidad', models.CharField(max_length=200, help_text='Capacidad utilizada')), + ('accion_realizada', models.CharField(max_length=100, help_text='Accion realizada')), + ('recurso_accedido', models.CharField(max_length=200, blank=True, null=True, help_text='ID del recurso accedido')), + ('ip_address', models.CharField(max_length=50, blank=True, null=True, help_text='IP del usuario')), + ('user_agent', models.TextField(blank=True, null=True, help_text='User agent del navegador')), + ('metadata', models.JSONField(default=dict, blank=True, help_text='Metadatos adicionales')), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('usuario', models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + related_name='auditorias_permisos' + )), + ], + options={ + 'verbose_name': 'Auditoria de Permiso', + 'verbose_name_plural': 'Auditorias de Permisos', + 'db_table': 'permissions_auditoria_permisos', + 'ordering': ['-timestamp'], + 'indexes': [ + models.Index(fields=['usuario'], name='perm_aud_usuario_idx'), + models.Index(fields=['timestamp'], name='perm_aud_timestamp_idx'), + models.Index(fields=['accion_realizada'], name='perm_aud_accion_idx'), + ], + }, + ), + ] diff --git a/api/callcentersite/callcentersite/apps/permissions/migrations/__init__.py b/api/callcentersite/callcentersite/apps/permissions/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/models.py b/api/callcentersite/callcentersite/apps/permissions/models.py new file mode 100644 index 00000000..4e5702e2 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/models.py @@ -0,0 +1,498 @@ +""" +Modelos del Sistema de Permisos Granular. + +Prioridad 1: Estructura Base de Datos (8 tablas core) + +Filosofia: SIN roles jerarquicos (NO admin/supervisor/agent) + SOLO grupos funcionales de capacidades combinables + +Referencias: +- REQ-PERM-001: Sistema de Permisos Granular +- ADR-XXX: Decision de NO usar roles jerarquicos +""" + +from django.db import models +from django.contrib.auth import get_user_model +from django.utils import timezone + +User = get_user_model() + + +class Funcion(models.Model): + """ + Funcion: Recurso del sistema (ej: dashboards, usuarios, metricas). + + Una funcion agrupa capacidades relacionadas. + Ejemplo: funcion 'dashboards' tiene capacidades 'ver', 'exportar', 'personalizar' + """ + + nombre = models.CharField( + max_length=100, + help_text="Nombre corto: 'dashboards', 'usuarios'" + ) + nombre_completo = models.CharField( + max_length=200, + unique=True, + help_text="Nombre completo: 'sistema.vistas.dashboards'" + ) + descripcion = models.TextField( + blank=True, + help_text="Descripcion de la funcion" + ) + dominio = models.CharField( + max_length=100, + help_text="Dominio: vistas, administracion, operaciones, etc" + ) + categoria = models.CharField( + max_length=50, + blank=True, + help_text="Categoria: visualizacion, gestion, analisis, etc" + ) + icono = models.CharField( + max_length=50, + blank=True, + help_text="Icono para UI" + ) + orden_menu = models.IntegerField( + default=0, + help_text="Orden en menu de navegacion" + ) + activa = models.BooleanField( + default=True, + help_text="Si la funcion esta activa" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'permissions_funciones' + verbose_name = 'Funcion' + verbose_name_plural = 'Funciones' + ordering = ['orden_menu', 'nombre'] + indexes = [ + models.Index(fields=['dominio']), + models.Index(fields=['activa']), + models.Index(fields=['categoria']), + ] + + def __str__(self): + return self.nombre_completo + + +class Capacidad(models.Model): + """ + Capacidad: Accion especifica sobre un recurso. + + Formato: sistema.dominio.recurso.accion + Ejemplo: sistema.vistas.dashboards.ver + + Niveles de sensibilidad: + - bajo: Consultas basicas + - normal: Operaciones estandar + - alto: Modificaciones importantes + - critico: Acciones de alto impacto (requieren auditoria) + """ + + SENSIBILIDAD_CHOICES = [ + ('bajo', 'Bajo'), + ('normal', 'Normal'), + ('alto', 'Alto'), + ('critico', 'Critico'), + ] + + nombre_completo = models.CharField( + max_length=200, + unique=True, + help_text="sistema.dominio.recurso.accion" + ) + descripcion = models.TextField( + blank=True, + help_text="Descripcion de la capacidad" + ) + accion = models.CharField( + max_length=50, + help_text="ver, crear, editar, eliminar, aprobar, etc" + ) + recurso = models.CharField( + max_length=100, + help_text="dashboards, usuarios, metricas, etc" + ) + dominio = models.CharField( + max_length=100, + help_text="vistas, administracion, operaciones, etc" + ) + nivel_sensibilidad = models.CharField( + max_length=20, + choices=SENSIBILIDAD_CHOICES, + default='normal', + help_text="Nivel de sensibilidad de la accion" + ) + requiere_auditoria = models.BooleanField( + default=False, + help_text="Si se debe auditar cada uso de esta capacidad" + ) + activa = models.BooleanField( + default=True, + help_text="Si la capacidad esta activa" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'permissions_capacidades' + verbose_name = 'Capacidad' + verbose_name_plural = 'Capacidades' + ordering = ['nombre_completo'] + indexes = [ + models.Index(fields=['accion']), + models.Index(fields=['recurso']), + models.Index(fields=['nivel_sensibilidad']), + ] + + def __str__(self): + return self.nombre_completo + + +class FuncionCapacidad(models.Model): + """ + FuncionCapacidad: Relacion entre Funcion y Capacidad. + + Define que capacidades tiene cada funcion. + """ + + funcion = models.ForeignKey( + Funcion, + on_delete=models.CASCADE, + related_name='capacidades' + ) + capacidad = models.ForeignKey( + Capacidad, + on_delete=models.CASCADE, + related_name='funciones' + ) + requerida = models.BooleanField( + default=False, + help_text="Si es capacidad obligatoria para la funcion" + ) + visible_en_ui = models.BooleanField( + default=True, + help_text="Si se muestra en interfaz de usuario" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'permissions_funcion_capacidades' + verbose_name = 'Funcion-Capacidad' + verbose_name_plural = 'Funcion-Capacidades' + unique_together = [['funcion', 'capacidad']] + + def __str__(self): + return f"{self.funcion.nombre} -> {self.capacidad.accion}" + + +class GrupoPermisos(models.Model): + """ + GrupoPermisos: Agrupacion funcional de capacidades. + + IMPORTANTE: NO son roles jerarquicos (NO admin/supervisor/agent). + Son grupos FUNCIONALES y DESCRIPTIVOS de lo que puede hacer: + - 'atencion_cliente': puede atender clientes + - 'gestion_equipos': puede gestionar equipos + - 'visualizacion_metricas': puede ver metricas + + Un usuario puede tener MULTIPLES grupos simultaneamente. + No hay jerarquia ni niveles entre grupos. + + Tipos de acceso (NO jerarquicos): + - operativo: Operaciones diarias + - gestion: Gestion de equipos y recursos + - analisis: Analisis y reportes + - estrategico: Decisiones estrategicas + - tecnico: Configuracion tecnica + - finanzas: Operaciones financieras + - calidad: Control de calidad + """ + + TIPO_ACCESO_CHOICES = [ + ('operativo', 'Operativo'), + ('gestion', 'Gestion'), + ('analisis', 'Analisis'), + ('estrategico', 'Estrategico'), + ('tecnico', 'Tecnico'), + ('finanzas', 'Finanzas'), + ('calidad', 'Calidad'), + ] + + codigo = models.CharField( + max_length=100, + unique=True, + help_text="Codigo unico: 'atencion_cliente', 'gestion_equipos'" + ) + nombre_display = models.CharField( + max_length=200, + help_text="Nombre para mostrar: 'Atencion al Cliente'" + ) + descripcion = models.TextField( + blank=True, + help_text="Descripcion funcional del grupo" + ) + tipo_acceso = models.CharField( + max_length=50, + choices=TIPO_ACCESO_CHOICES, + blank=True, + help_text="Tipo de acceso (NO jerarquico)" + ) + activo = models.BooleanField( + default=True, + help_text="Si el grupo esta activo" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'permissions_grupos_permisos' + verbose_name = 'Grupo de Permisos' + verbose_name_plural = 'Grupos de Permisos' + ordering = ['nombre_display'] + + def __str__(self): + return f"{self.nombre_display} ({self.codigo})" + + +class GrupoCapacidad(models.Model): + """ + GrupoCapacidad: Relacion entre Grupo y Capacidad. + + Define que capacidades tiene cada grupo. + """ + + grupo = models.ForeignKey( + GrupoPermisos, + on_delete=models.CASCADE, + related_name='capacidades' + ) + capacidad = models.ForeignKey( + Capacidad, + on_delete=models.CASCADE, + related_name='grupos' + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'permissions_grupo_capacidades' + verbose_name = 'Grupo-Capacidad' + verbose_name_plural = 'Grupo-Capacidades' + unique_together = [['grupo', 'capacidad']] + + def __str__(self): + return f"{self.grupo.codigo} -> {self.capacidad.nombre_completo}" + + +class UsuarioGrupo(models.Model): + """ + UsuarioGrupo: Usuario asignado a uno o mas grupos. + + IMPORTANTE: Usuario puede tener MULTIPLES grupos simultaneamente. + No hay jerarquia: un usuario puede tener 'atencion_cliente' + + 'visualizacion_metricas' + 'gestion_horarios' al mismo tiempo. + + Permite asignaciones temporales con fecha_expiracion. + """ + + usuario = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='grupos_asignados' + ) + grupo = models.ForeignKey( + GrupoPermisos, + on_delete=models.CASCADE, + related_name='usuarios' + ) + fecha_asignacion = models.DateTimeField( + default=timezone.now, + help_text="Fecha de asignacion" + ) + fecha_expiracion = models.DateTimeField( + null=True, + blank=True, + help_text="Fecha de expiracion (NULL = permanente)" + ) + asignado_por = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='grupos_asignados_por' + ) + activo = models.BooleanField( + default=True, + help_text="Si la asignacion esta activa" + ) + + class Meta: + db_table = 'permissions_usuarios_grupos' + verbose_name = 'Usuario-Grupo' + verbose_name_plural = 'Usuarios-Grupos' + unique_together = [['usuario', 'grupo']] + + def __str__(self): + return f"{self.usuario.username} -> {self.grupo.codigo}" + + def is_expired(self): + """Verifica si la asignacion ha expirado.""" + if self.fecha_expiracion is None: + return False + return timezone.now() > self.fecha_expiracion + + +class PermisoExcepcional(models.Model): + """ + PermisoExcepcional: Conceder o revocar capacidad especifica a usuario. + + Permite otorgar temporalmente una capacidad que el usuario no tiene + en sus grupos, o revocar una capacidad especifica que si tiene. + + Casos de uso: + - Conceder: Usuario necesita capacidad temporal para proyecto especial + - Revocar: Usuario tiene incidente y se le revoca capacidad temporalmente + + Puede ser temporal (con fecha_inicio/fecha_fin) o permanente. + """ + + TIPO_CHOICES = [ + ('conceder', 'Conceder'), + ('revocar', 'Revocar'), + ] + + usuario = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='permisos_excepcionales' + ) + capacidad = models.ForeignKey( + Capacidad, + on_delete=models.CASCADE, + related_name='permisos_excepcionales' + ) + tipo = models.CharField( + max_length=20, + choices=TIPO_CHOICES, + help_text="conceder o revocar" + ) + fecha_inicio = models.DateTimeField( + default=timezone.now, + help_text="Fecha de inicio" + ) + fecha_fin = models.DateTimeField( + null=True, + blank=True, + help_text="Fecha fin (NULL = permanente)" + ) + motivo = models.TextField( + help_text="Justificacion del permiso excepcional" + ) + autorizado_por = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='permisos_autorizados' + ) + activo = models.BooleanField( + default=True, + help_text="Si el permiso excepcional esta activo" + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'permissions_permisos_excepcionales' + verbose_name = 'Permiso Excepcional' + verbose_name_plural = 'Permisos Excepcionales' + indexes = [ + models.Index(fields=['usuario', 'activo']), + ] + + def __str__(self): + return f"{self.tipo}: {self.usuario.username} -> {self.capacidad.nombre_completo}" + + def is_active_now(self): + """Verifica si el permiso excepcional esta activo en este momento.""" + if not self.activo: + return False + + now = timezone.now() + + if self.fecha_inicio > now: + return False + + if self.fecha_fin and self.fecha_fin < now: + return False + + return True + + +class AuditoriaPermiso(models.Model): + """ + AuditoriaPermiso: Registro de cada acceso a recursos protegidos. + + Registra TODOS los intentos de acceso (concedidos y denegados) + para capacidades que requieren auditoria. + + Almacena: + - Quien: usuario + - Que: capacidad requerida + - Cuando: timestamp + - Donde: IP, user agent + - Resultado: acceso_concedido o acceso_denegado + - Metadata: informacion adicional en JSONB + """ + + usuario = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='auditorias' + ) + capacidad = models.CharField( + max_length=200, + help_text="Capacidad que se intento usar" + ) + accion_realizada = models.CharField( + max_length=100, + help_text="acceso_concedido, acceso_denegado" + ) + recurso_accedido = models.CharField( + max_length=200, + blank=True, + help_text="URL o recurso especifico accedido" + ) + ip_address = models.CharField( + max_length=50, + blank=True, + help_text="IP del usuario" + ) + user_agent = models.TextField( + blank=True, + help_text="User agent del navegador" + ) + metadata = models.JSONField( + default=dict, + blank=True, + help_text="Datos adicionales en formato JSON" + ) + timestamp = models.DateTimeField( + default=timezone.now, + help_text="Timestamp del acceso" + ) + + class Meta: + db_table = 'permissions_auditoria_permisos' + verbose_name = 'Auditoria de Permiso' + verbose_name_plural = 'Auditorias de Permisos' + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['usuario']), + models.Index(fields=['timestamp']), + models.Index(fields=['accion_realizada']), + ] + + def __str__(self): + return f"{self.accion_realizada}: {self.usuario.username if self.usuario else 'N/A'} -> {self.capacidad}" diff --git a/api/callcentersite/callcentersite/apps/permissions/serializers.py b/api/callcentersite/callcentersite/apps/permissions/serializers.py new file mode 100644 index 00000000..4e04c347 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/serializers.py @@ -0,0 +1,300 @@ +""" +Serializers para el sistema de permisos granular. + +Sistema de Permisos Granular - Prioridad 2: API Layer +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from __future__ import annotations + +from rest_framework import serializers + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, + AuditoriaPermiso, +) + + +class FuncionSerializer(serializers.ModelSerializer): + """Serializer para Funcion del sistema.""" + + class Meta: + model = Funcion + fields = [ + 'id', + 'nombre', + 'nombre_completo', + 'descripcion', + 'dominio', + 'categoria', + 'icono', + 'orden_menu', + 'activa', + 'created_at', + 'updated_at', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class CapacidadSerializer(serializers.ModelSerializer): + """Serializer para Capacidad.""" + + class Meta: + model = Capacidad + fields = [ + 'id', + 'nombre_completo', + 'descripcion', + 'accion', + 'recurso', + 'dominio', + 'nivel_sensibilidad', + 'requiere_auditoria', + 'activa', + 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + +class FuncionCapacidadSerializer(serializers.ModelSerializer): + """Serializer para relacion Funcion-Capacidad.""" + + funcion_nombre = serializers.CharField(source='funcion.nombre_completo', read_only=True) + capacidad_nombre = serializers.CharField(source='capacidad.nombre_completo', read_only=True) + + class Meta: + model = FuncionCapacidad + fields = [ + 'id', + 'funcion', + 'capacidad', + 'funcion_nombre', + 'capacidad_nombre', + 'requerida', + 'visible_en_ui', + 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + +class GrupoPermisosSerializer(serializers.ModelSerializer): + """Serializer para GrupoPermisos.""" + + capacidades_count = serializers.SerializerMethodField() + + class Meta: + model = GrupoPermisos + fields = [ + 'id', + 'codigo', + 'nombre_display', + 'descripcion', + 'tipo_acceso', + 'activo', + 'created_at', + 'updated_at', + 'capacidades_count', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_capacidades_count(self, obj) -> int: + """Retorna cantidad de capacidades del grupo.""" + return obj.grupo_capacidades.count() + + +class GrupoPermisosDetailSerializer(serializers.ModelSerializer): + """Serializer detallado para GrupoPermisos con capacidades incluidas.""" + + capacidades = serializers.SerializerMethodField() + + class Meta: + model = GrupoPermisos + fields = [ + 'id', + 'codigo', + 'nombre_display', + 'descripcion', + 'tipo_acceso', + 'activo', + 'created_at', + 'updated_at', + 'capacidades', + ] + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_capacidades(self, obj) -> list[dict]: + """Retorna capacidades del grupo.""" + grupo_caps = obj.grupo_capacidades.select_related('capacidad').all() + return [ + { + 'id': gc.capacidad.id, + 'nombre_completo': gc.capacidad.nombre_completo, + 'nivel_sensibilidad': gc.capacidad.nivel_sensibilidad, + } + for gc in grupo_caps + ] + + +class GrupoCapacidadSerializer(serializers.ModelSerializer): + """Serializer para relacion Grupo-Capacidad.""" + + grupo_nombre = serializers.CharField(source='grupo.nombre_display', read_only=True) + capacidad_nombre = serializers.CharField(source='capacidad.nombre_completo', read_only=True) + + class Meta: + model = GrupoCapacidad + fields = [ + 'id', + 'grupo', + 'capacidad', + 'grupo_nombre', + 'capacidad_nombre', + 'created_at', + ] + read_only_fields = ['id', 'created_at'] + + +class UsuarioGrupoSerializer(serializers.ModelSerializer): + """Serializer para UsuarioGrupo (asignaciones).""" + + usuario_username = serializers.CharField(source='usuario.username', read_only=True) + grupo_nombre = serializers.CharField(source='grupo.nombre_display', read_only=True) + + class Meta: + model = UsuarioGrupo + fields = [ + 'id', + 'usuario', + 'grupo', + 'fecha_asignacion', + 'fecha_expiracion', + 'asignado_por', + 'activo', + 'usuario_username', + 'grupo_nombre', + ] + read_only_fields = ['id', 'fecha_asignacion'] + + +class UsuarioGrupoCreateSerializer(serializers.ModelSerializer): + """Serializer para crear asignaciones Usuario-Grupo.""" + + class Meta: + model = UsuarioGrupo + fields = [ + 'usuario', + 'grupo', + 'fecha_expiracion', + 'asignado_por', + 'activo', + ] + + def validate(self, attrs): + """Valida que no exista asignacion duplicada activa.""" + usuario = attrs.get('usuario') + grupo = attrs.get('grupo') + + # Verificar si ya existe asignacion activa + if UsuarioGrupo.objects.filter( + usuario=usuario, + grupo=grupo + ).exists(): + raise serializers.ValidationError({ + 'non_field_errors': [ + f'Usuario {usuario.username} ya esta asignado al grupo {grupo.nombre_display}' + ] + }) + + return attrs + + +class PermisoExcepcionalSerializer(serializers.ModelSerializer): + """Serializer para PermisoExcepcional.""" + + usuario_username = serializers.CharField(source='usuario.username', read_only=True) + capacidad_nombre = serializers.CharField(source='capacidad.nombre_completo', read_only=True) + autorizado_por_username = serializers.CharField( + source='autorizado_por.username', + read_only=True, + allow_null=True + ) + + class Meta: + model = PermisoExcepcional + fields = [ + 'id', + 'usuario', + 'capacidad', + 'tipo', + 'fecha_inicio', + 'fecha_fin', + 'motivo', + 'autorizado_por', + 'activo', + 'created_at', + 'usuario_username', + 'capacidad_nombre', + 'autorizado_por_username', + ] + read_only_fields = ['id', 'created_at'] + + +class PermisoExcepcionalCreateSerializer(serializers.ModelSerializer): + """Serializer para crear permisos excepcionales.""" + + class Meta: + model = PermisoExcepcional + fields = [ + 'usuario', + 'capacidad', + 'tipo', + 'fecha_inicio', + 'fecha_fin', + 'motivo', + 'autorizado_por', + 'activo', + ] + + def validate(self, attrs): + """Validaciones adicionales.""" + fecha_inicio = attrs.get('fecha_inicio') + fecha_fin = attrs.get('fecha_fin') + + if fecha_fin and fecha_inicio and fecha_fin <= fecha_inicio: + raise serializers.ValidationError({ + 'fecha_fin': 'Fecha de fin debe ser posterior a fecha de inicio' + }) + + return attrs + + +class AuditoriaPermisoSerializer(serializers.ModelSerializer): + """Serializer para AuditoriaPermiso (solo lectura).""" + + usuario_username = serializers.CharField( + source='usuario.username', + read_only=True, + allow_null=True + ) + + class Meta: + model = AuditoriaPermiso + fields = [ + 'id', + 'usuario', + 'capacidad', + 'accion_realizada', + 'recurso_accedido', + 'ip_address', + 'user_agent', + 'metadata', + 'timestamp', + 'usuario_username', + ] + read_only_fields = fields # Todos read-only (auditoria no se modifica) diff --git a/api/callcentersite/callcentersite/apps/permissions/services.py b/api/callcentersite/callcentersite/apps/permissions/services.py new file mode 100644 index 00000000..81d4255f --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/services.py @@ -0,0 +1,336 @@ +""" +Servicios para el sistema de permisos granular. + +Sistema de Permisos Granular - Prioridad 1 +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.db.models import Q +from django.utils import timezone + +from callcentersite.apps.permissions.models import ( + Capacidad, + Funcion, + GrupoCapacidad, + PermisoExcepcional, + UsuarioGrupo, + AuditoriaPermiso, +) + +if TYPE_CHECKING: + from django.contrib.auth.models import User + + +class PermisoService: + """ + Servicio principal para verificacion de permisos. + + NO usa roles jerarquicos (Admin, Supervisor, Agent). + SI usa grupos funcionales (atencion_cliente, gestion_equipos). + + Implementa: + - Verificacion de capacidades por usuario + - Manejo de permisos excepcionales (conceder/revocar) + - Auditoria de accesos + """ + + @staticmethod + def usuario_tiene_permiso(usuario_id: int, capacidad_requerida: str) -> bool: + """ + Verifica si usuario tiene una capacidad especifica. + + Algoritmo: + 1. Obtener grupos activos del usuario + 2. Obtener capacidades de esos grupos + 3. Verificar permisos excepcionales: + - Si existe 'revocar' activo: NO tiene permiso + - Si existe 'conceder' activo: SI tiene permiso + 4. Verificar si capacidad esta en grupos + + Args: + usuario_id: ID del usuario a verificar + capacidad_requerida: Capacidad en formato sistema.dominio.recurso.accion + + Returns: + True si usuario tiene el permiso, False en caso contrario + + Ejemplos: + >>> PermisoService.usuario_tiene_permiso(1, "sistema.operaciones.llamadas.ver") + True + """ + # Verificar si usuario existe + from django.contrib.auth import get_user_model + User = get_user_model() + + if not User.objects.filter(id=usuario_id).exists(): + return False + + # Verificar si capacidad existe + try: + capacidad_obj = Capacidad.objects.get( + nombre_completo=capacidad_requerida, + activa=True + ) + except Capacidad.DoesNotExist: + return False + + # 1. Verificar permisos excepcionales (tienen prioridad) + ahora = timezone.now() + + # Verificar si existe revocacion activa + revocacion = PermisoExcepcional.objects.filter( + usuario_id=usuario_id, + capacidad=capacidad_obj, + tipo="revocar", + activo=True, + fecha_inicio__lte=ahora + ).filter( + Q(fecha_fin__isnull=True) | Q(fecha_fin__gte=ahora) + ).exists() + + if revocacion: + return False + + # Verificar si existe concesion activa + concesion = PermisoExcepcional.objects.filter( + usuario_id=usuario_id, + capacidad=capacidad_obj, + tipo="conceder", + activo=True, + fecha_inicio__lte=ahora + ).filter( + Q(fecha_fin__isnull=True) | Q(fecha_fin__gte=ahora) + ).exists() + + if concesion: + return True + + # 2. Verificar permisos por grupos + # Obtener grupos activos del usuario (no expirados) + grupos_activos = UsuarioGrupo.objects.filter( + usuario_id=usuario_id, + activo=True + ).filter( + Q(fecha_expiracion__isnull=True) | Q(fecha_expiracion__gte=ahora) + ).values_list("grupo_id", flat=True) + + if not grupos_activos: + return False + + # Verificar si alguno de los grupos tiene la capacidad + tiene_capacidad = GrupoCapacidad.objects.filter( + grupo_id__in=grupos_activos, + capacidad=capacidad_obj + ).exists() + + return tiene_capacidad + + @staticmethod + def obtener_capacidades_usuario(usuario_id: int) -> list[str]: + """ + Obtiene todas las capacidades que tiene un usuario. + + Combina: + - Capacidades de grupos activos + - Permisos excepcionales concedidos + - EXCLUYE permisos excepcionales revocados + + Args: + usuario_id: ID del usuario + + Returns: + Lista de capacidades (sin duplicados) + + Ejemplos: + >>> PermisoService.obtener_capacidades_usuario(1) + [ + "sistema.operaciones.llamadas.ver", + "sistema.operaciones.llamadas.realizar", + "sistema.vistas.dashboards.ver" + ] + """ + ahora = timezone.now() + capacidades = set() + + # 1. Obtener capacidades de grupos activos + grupos_activos = UsuarioGrupo.objects.filter( + usuario_id=usuario_id, + activo=True + ).filter( + Q(fecha_expiracion__isnull=True) | Q(fecha_expiracion__gte=ahora) + ).values_list("grupo_id", flat=True) + + if grupos_activos: + capacidades_grupos = GrupoCapacidad.objects.filter( + grupo_id__in=grupos_activos + ).select_related("capacidad").values_list( + "capacidad__nombre_completo", flat=True + ) + capacidades.update(capacidades_grupos) + + # 2. Agregar permisos excepcionales concedidos + concedidas = PermisoExcepcional.objects.filter( + usuario_id=usuario_id, + tipo="conceder", + activo=True, + fecha_inicio__lte=ahora + ).filter( + Q(fecha_fin__isnull=True) | Q(fecha_fin__gte=ahora) + ).select_related("capacidad").values_list( + "capacidad__nombre_completo", flat=True + ) + capacidades.update(concedidas) + + # 3. Excluir permisos excepcionales revocados + revocadas = PermisoExcepcional.objects.filter( + usuario_id=usuario_id, + tipo="revocar", + activo=True, + fecha_inicio__lte=ahora + ).filter( + Q(fecha_fin__isnull=True) | Q(fecha_fin__gte=ahora) + ).select_related("capacidad").values_list( + "capacidad__nombre_completo", flat=True + ) + capacidades.difference_update(revocadas) + + return sorted(list(capacidades)) + + @staticmethod + def obtener_funciones_accesibles(usuario_id: int) -> list[dict]: + """ + Obtiene funciones del sistema a las que el usuario tiene acceso. + + Una funcion es accesible si el usuario tiene AL MENOS UNA capacidad + vinculada a esa funcion. + + Args: + usuario_id: ID del usuario + + Returns: + Lista de funciones con metadatos + + Ejemplos: + >>> PermisoService.obtener_funciones_accesibles(1) + [ + { + "id": 1, + "nombre": "llamadas", + "nombre_completo": "sistema.operaciones.llamadas", + "dominio": "operaciones", + "categoria": "operaciones", + "icono": "phone", + "orden_menu": 10 + } + ] + """ + # Obtener capacidades del usuario + capacidades_usuario = PermisoService.obtener_capacidades_usuario(usuario_id) + + if not capacidades_usuario: + return [] + + # Obtener capacidades objetos + capacidades_objs = Capacidad.objects.filter( + nombre_completo__in=capacidades_usuario, + activa=True + ).values_list("id", flat=True) + + # Obtener funciones que tienen esas capacidades + from callcentersite.apps.permissions.models import FuncionCapacidad + + funciones_ids = FuncionCapacidad.objects.filter( + capacidad_id__in=capacidades_objs + ).values_list("funcion_id", flat=True).distinct() + + # Obtener funciones activas + funciones = Funcion.objects.filter( + id__in=funciones_ids, + activa=True + ).order_by("orden_menu", "nombre").values( + "id", + "nombre", + "nombre_completo", + "dominio", + "categoria", + "icono", + "orden_menu" + ) + + return list(funciones) + + @staticmethod + def registrar_acceso( + usuario_id: int, + capacidad: str, + accion: str, + recurso_id: str | None = None, + ip_address: str | None = None, + user_agent: str | None = None, + metadata: dict | None = None + ) -> AuditoriaPermiso: + """ + Registra acceso a recurso protegido en auditoria. + + Se debe llamar DESPUES de verificar permiso y PERMITIR acceso. + + Args: + usuario_id: ID del usuario que accede + capacidad: Capacidad utilizada (formato completo) + accion: Accion realizada (ej: "LLAMADA_INICIADA", "PAGO_APROBADO") + recurso_id: ID del recurso accedido (opcional) + ip_address: IP del usuario (opcional) + user_agent: User agent del navegador (opcional) + metadata: Metadatos adicionales JSON (opcional) + + Returns: + Registro de auditoria creado + + Ejemplos: + >>> PermisoService.registrar_acceso( + ... usuario_id=1, + ... capacidad="sistema.finanzas.pagos.aprobar", + ... accion="PAGO_APROBADO", + ... recurso_id="PAY-12345", + ... ip_address="192.168.1.100", + ... metadata={"monto": 1000.00, "moneda": "USD"} + ... ) + + """ + return AuditoriaPermiso.objects.create( + usuario_id=usuario_id, + capacidad=capacidad, + accion_realizada=accion, + recurso_accedido=recurso_id, + ip_address=ip_address, + user_agent=user_agent, + metadata=metadata or {} + ) + + @staticmethod + def verificar_capacidad_requiere_auditoria(capacidad: str) -> bool: + """ + Verifica si una capacidad requiere auditoria obligatoria. + + Args: + capacidad: Nombre completo de la capacidad + + Returns: + True si requiere auditoria, False en caso contrario + + Ejemplos: + >>> PermisoService.verificar_capacidad_requiere_auditoria( + ... "sistema.finanzas.pagos.aprobar" + ... ) + True + """ + try: + cap = Capacidad.objects.get(nombre_completo=capacidad) + return cap.requiere_auditoria + except Capacidad.DoesNotExist: + # Por seguridad, asumir que requiere auditoria + return True diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/__init__.py b/api/callcentersite/callcentersite/apps/permissions/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/test_middleware.py b/api/callcentersite/callcentersite/apps/permissions/tests/test_middleware.py new file mode 100644 index 00000000..e8d1fb6a --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/tests/test_middleware.py @@ -0,0 +1,280 @@ +""" +Tests para middleware de permisos. + +Sistema de Permisos Granular - Prioridad 1 +TDD: Tests escritos ANTES de implementar middleware.py +""" + +from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model +from django.http import JsonResponse +from unittest.mock import Mock, patch + +from callcentersite.apps.permissions.models import ( + Capacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, +) +from callcentersite.apps.permissions.middleware import verificar_permiso + + +User = get_user_model() + + +class VerificarPermisoMiddlewareTestCase(TestCase): + """Tests para el decorator verificar_permiso.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.factory = RequestFactory() + + # Crear usuarios + self.user_con_permiso = User.objects.create_user( + username="user_con_permiso", + email="conpermiso@test.com", + password="testpass123" + ) + self.user_sin_permiso = User.objects.create_user( + username="user_sin_permiso", + email="sinpermiso@test.com", + password="testpass123" + ) + + # Crear capacidad + self.capacidad = Capacidad.objects.create( + nombre_completo="sistema.operaciones.llamadas.realizar", + accion="realizar", + recurso="llamadas", + dominio="operaciones", + nivel_sensibilidad="normal" + ) + + # Crear grupo + self.grupo = GrupoPermisos.objects.create( + codigo="atencion_cliente", + nombre_display="Atencion al Cliente", + tipo_acceso="operativo", + activo=True + ) + + # Vincular capacidad a grupo + GrupoCapacidad.objects.create( + grupo=self.grupo, + capacidad=self.capacidad + ) + + # Asignar usuario a grupo + UsuarioGrupo.objects.create( + usuario=self.user_con_permiso, + grupo=self.grupo, + activo=True + ) + + def test_decorator_permite_acceso_con_permiso(self): + """Decorator permite acceso si usuario tiene permiso.""" + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_con_permiso + + response = view_protegida(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "ok") + + def test_decorator_bloquea_acceso_sin_permiso(self): + """Decorator bloquea acceso si usuario no tiene permiso.""" + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_sin_permiso + + response = view_protegida(request) + + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertIn("error", data) + self.assertIn("permiso denegado", data["error"].lower()) + + def test_decorator_bloquea_usuario_anonimo(self): + """Decorator bloquea acceso a usuario no autenticado.""" + from django.contrib.auth.models import AnonymousUser + + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = AnonymousUser() + + response = view_protegida(request) + + self.assertEqual(response.status_code, 401) + data = response.json() + self.assertIn("error", data) + self.assertIn("autenticacion", data["error"].lower()) + + def test_decorator_registra_auditoria_en_acceso_exitoso(self): + """Decorator registra en auditoria cuando permite acceso.""" + from callcentersite.apps.permissions.models import AuditoriaPermiso + + @verificar_permiso("sistema.operaciones.llamadas.realizar", auditar=True) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_con_permiso + request.META["REMOTE_ADDR"] = "192.168.1.100" + request.META["HTTP_USER_AGENT"] = "Mozilla/5.0" + + inicial_count = AuditoriaPermiso.objects.count() + response = view_protegida(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(AuditoriaPermiso.objects.count(), inicial_count + 1) + + auditoria = AuditoriaPermiso.objects.latest("timestamp") + self.assertEqual(auditoria.usuario, self.user_con_permiso) + self.assertEqual(auditoria.capacidad, "sistema.operaciones.llamadas.realizar") + self.assertEqual(auditoria.ip_address, "192.168.1.100") + + def test_decorator_registra_auditoria_en_acceso_denegado(self): + """Decorator registra en auditoria cuando deniega acceso.""" + from callcentersite.apps.permissions.models import AuditoriaPermiso + + @verificar_permiso("sistema.operaciones.llamadas.realizar", auditar=True) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_sin_permiso + request.META["REMOTE_ADDR"] = "192.168.1.100" + + inicial_count = AuditoriaPermiso.objects.count() + response = view_protegida(request) + + self.assertEqual(response.status_code, 403) + self.assertEqual(AuditoriaPermiso.objects.count(), inicial_count + 1) + + auditoria = AuditoriaPermiso.objects.latest("timestamp") + self.assertEqual(auditoria.usuario, self.user_sin_permiso) + self.assertEqual(auditoria.accion_realizada, "ACCESO_DENEGADO") + + def test_decorator_no_audita_si_no_requerido(self): + """Decorator no audita si auditar=False.""" + from callcentersite.apps.permissions.models import AuditoriaPermiso + + @verificar_permiso("sistema.operaciones.llamadas.realizar", auditar=False) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_con_permiso + + inicial_count = AuditoriaPermiso.objects.count() + response = view_protegida(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(AuditoriaPermiso.objects.count(), inicial_count) + + def test_decorator_funciona_con_parametros_view(self): + """Decorator pasa correctamente parametros a la view.""" + @verificar_permiso("sistema.operaciones.llamadas.realizar") + def view_con_parametros(request, llamada_id, tipo=None): + return JsonResponse({ + "llamada_id": llamada_id, + "tipo": tipo + }) + + request = self.factory.get("/api/llamadas/123") + request.user = self.user_con_permiso + + response = view_con_parametros(request, llamada_id=123, tipo="entrante") + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["llamada_id"], 123) + self.assertEqual(data["tipo"], "entrante") + + def test_decorator_mensaje_personalizado(self): + """Decorator permite mensaje de error personalizado.""" + @verificar_permiso( + "sistema.operaciones.llamadas.realizar", + mensaje_error="No puedes realizar llamadas en este momento" + ) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_sin_permiso + + response = view_protegida(request) + + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertIn("No puedes realizar llamadas", data["error"]) + + def test_decorator_multiples_capacidades_require_todas(self): + """Decorator con lista de capacidades requiere TODAS.""" + # Crear segunda capacidad + cap2 = Capacidad.objects.create( + nombre_completo="sistema.operaciones.tickets.crear", + accion="crear", + recurso="tickets", + dominio="operaciones", + nivel_sensibilidad="normal" + ) + + # Usuario solo tiene una de las dos capacidades + @verificar_permiso([ + "sistema.operaciones.llamadas.realizar", + "sistema.operaciones.tickets.crear" + ]) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/operacion") + request.user = self.user_con_permiso + + # Debe denegar porque no tiene tickets.crear + response = view_protegida(request) + self.assertEqual(response.status_code, 403) + + def test_decorator_captura_ip_desde_request(self): + """Decorator captura IP desde request.META.""" + from callcentersite.apps.permissions.models import AuditoriaPermiso + + @verificar_permiso("sistema.operaciones.llamadas.realizar", auditar=True) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_con_permiso + request.META["REMOTE_ADDR"] = "203.0.113.42" + + view_protegida(request) + + auditoria = AuditoriaPermiso.objects.latest("timestamp") + self.assertEqual(auditoria.ip_address, "203.0.113.42") + + def test_decorator_captura_user_agent_desde_request(self): + """Decorator captura User-Agent desde request.META.""" + from callcentersite.apps.permissions.models import AuditoriaPermiso + + @verificar_permiso("sistema.operaciones.llamadas.realizar", auditar=True) + def view_protegida(request): + return JsonResponse({"status": "ok"}) + + request = self.factory.get("/api/llamadas/realizar") + request.user = self.user_con_permiso + request.META["HTTP_USER_AGENT"] = "Custom Agent/1.0" + + view_protegida(request) + + auditoria = AuditoriaPermiso.objects.latest("timestamp") + self.assertEqual(auditoria.user_agent, "Custom Agent/1.0") diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/test_models.py b/api/callcentersite/callcentersite/apps/permissions/tests/test_models.py new file mode 100644 index 00000000..29664c99 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/tests/test_models.py @@ -0,0 +1,551 @@ +""" +Tests para modelos del sistema de permisos granular. + +Prioridad 1: Estructura Base de Datos (8 tablas core) +- funciones +- capacidades +- funcion_capacidades +- grupos_permisos +- grupo_capacidades +- usuarios_grupos +- permisos_excepcionales +- auditoria_permisos + +Filosofia: SIN roles jerarquicos, solo grupos funcionales de capacidades. +""" + +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.db import IntegrityError +from django.utils import timezone +from datetime import timedelta + +from ..models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, + AuditoriaPermiso +) + +User = get_user_model() + + +class FuncionModelTest(TestCase): + """Tests para modelo Funcion (Recursos del sistema).""" + + def setUp(self): + """Setup para tests.""" + self.funcion_data = { + 'nombre': 'dashboards', + 'nombre_completo': 'sistema.vistas.dashboards', + 'descripcion': 'Visualizacion de dashboards del sistema', + 'dominio': 'vistas', + 'categoria': 'visualizacion', + 'icono': 'dashboard-icon', + 'orden_menu': 1, + 'activa': True + } + + def test_crear_funcion(self): + """Test: Crear funcion basica.""" + funcion = Funcion.objects.create(**self.funcion_data) + + self.assertEqual(funcion.nombre, 'dashboards') + self.assertEqual(funcion.nombre_completo, 'sistema.vistas.dashboards') + self.assertEqual(funcion.dominio, 'vistas') + self.assertTrue(funcion.activa) + self.assertIsNotNone(funcion.created_at) + self.assertIsNotNone(funcion.updated_at) + + def test_nombre_completo_unico(self): + """Test: nombre_completo debe ser unico.""" + Funcion.objects.create(**self.funcion_data) + + with self.assertRaises(IntegrityError): + Funcion.objects.create(**self.funcion_data) + + def test_funcion_str_representation(self): + """Test: Representacion string de funcion.""" + funcion = Funcion.objects.create(**self.funcion_data) + + self.assertEqual(str(funcion), 'sistema.vistas.dashboards') + + def test_funcion_defaults(self): + """Test: Valores por defecto de funcion.""" + funcion = Funcion.objects.create( + nombre='usuarios', + nombre_completo='sistema.administracion.usuarios' + ) + + self.assertTrue(funcion.activa) + self.assertIsNotNone(funcion.created_at) + + +class CapacidadModelTest(TestCase): + """Tests para modelo Capacidad (Acciones sobre recursos).""" + + def setUp(self): + """Setup para tests.""" + self.capacidad_data = { + 'nombre_completo': 'sistema.vistas.dashboards.ver', + 'descripcion': 'Permite visualizar dashboards', + 'accion': 'ver', + 'recurso': 'dashboards', + 'dominio': 'vistas', + 'nivel_sensibilidad': 'bajo', + 'requiere_auditoria': False, + 'activa': True + } + + def test_crear_capacidad(self): + """Test: Crear capacidad basica.""" + capacidad = Capacidad.objects.create(**self.capacidad_data) + + self.assertEqual(capacidad.nombre_completo, 'sistema.vistas.dashboards.ver') + self.assertEqual(capacidad.accion, 'ver') + self.assertEqual(capacidad.recurso, 'dashboards') + self.assertEqual(capacidad.nivel_sensibilidad, 'bajo') + self.assertFalse(capacidad.requiere_auditoria) + self.assertTrue(capacidad.activa) + + def test_nombre_completo_unico(self): + """Test: nombre_completo debe ser unico.""" + Capacidad.objects.create(**self.capacidad_data) + + with self.assertRaises(IntegrityError): + Capacidad.objects.create(**self.capacidad_data) + + def test_capacidad_sensibilidad_critica_requiere_auditoria(self): + """Test: Capacidades criticas deben requerir auditoria.""" + capacidad = Capacidad.objects.create( + nombre_completo='sistema.finanzas.pagos.aprobar', + accion='aprobar', + recurso='pagos', + dominio='finanzas', + nivel_sensibilidad='critico', + requiere_auditoria=True + ) + + self.assertEqual(capacidad.nivel_sensibilidad, 'critico') + self.assertTrue(capacidad.requiere_auditoria) + + def test_capacidad_str_representation(self): + """Test: Representacion string de capacidad.""" + capacidad = Capacidad.objects.create(**self.capacidad_data) + + self.assertEqual(str(capacidad), 'sistema.vistas.dashboards.ver') + + +class FuncionCapacidadModelTest(TestCase): + """Tests para modelo FuncionCapacidad (Relacion Funcion-Capacidad).""" + + def setUp(self): + """Setup para tests.""" + self.funcion = Funcion.objects.create( + nombre='dashboards', + nombre_completo='sistema.vistas.dashboards' + ) + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.vistas.dashboards.ver', + accion='ver', + recurso='dashboards', + dominio='vistas' + ) + + def test_crear_relacion_funcion_capacidad(self): + """Test: Crear relacion entre funcion y capacidad.""" + relacion = FuncionCapacidad.objects.create( + funcion=self.funcion, + capacidad=self.capacidad, + requerida=True, + visible_en_ui=True + ) + + self.assertEqual(relacion.funcion, self.funcion) + self.assertEqual(relacion.capacidad, self.capacidad) + self.assertTrue(relacion.requerida) + self.assertTrue(relacion.visible_en_ui) + + def test_unique_together_funcion_capacidad(self): + """Test: Combinacion funcion+capacidad debe ser unica.""" + FuncionCapacidad.objects.create( + funcion=self.funcion, + capacidad=self.capacidad + ) + + with self.assertRaises(IntegrityError): + FuncionCapacidad.objects.create( + funcion=self.funcion, + capacidad=self.capacidad + ) + + +class GrupoPermisosModelTest(TestCase): + """Tests para modelo GrupoPermisos (Grupos funcionales, NO roles jerarquicos).""" + + def setUp(self): + """Setup para tests.""" + self.grupo_data = { + 'codigo': 'atencion_cliente', + 'nombre_display': 'Atencion al Cliente', + 'descripcion': 'Capacidades para atender clientes directamente', + 'tipo_acceso': 'operativo', + 'activo': True + } + + def test_crear_grupo_permisos(self): + """Test: Crear grupo de permisos funcional (sin jerarquia).""" + grupo = GrupoPermisos.objects.create(**self.grupo_data) + + self.assertEqual(grupo.codigo, 'atencion_cliente') + self.assertEqual(grupo.nombre_display, 'Atencion al Cliente') + self.assertEqual(grupo.tipo_acceso, 'operativo') + self.assertTrue(grupo.activo) + # IMPORTANTE: NO hay campo 'nivel' o 'jerarquia' + self.assertFalse(hasattr(grupo, 'nivel')) + self.assertFalse(hasattr(grupo, 'jerarquia')) + + def test_codigo_unico(self): + """Test: codigo debe ser unico.""" + GrupoPermisos.objects.create(**self.grupo_data) + + with self.assertRaises(IntegrityError): + GrupoPermisos.objects.create(**self.grupo_data) + + def test_grupo_sin_roles_jerarquicos(self): + """Test: Grupos son funcionales, NO jerarquicos (no admin/supervisor/agent).""" + # Correcto: grupos funcionales descriptivos + grupo_correcto = GrupoPermisos.objects.create( + codigo='gestion_equipos', + nombre_display='Gestion de Equipos', + tipo_acceso='gestion' + ) + self.assertEqual(grupo_correcto.codigo, 'gestion_equipos') + + # Verificar que NO usamos roles tradicionales + # (esto es conceptual, no hay restriccion en DB, pero documentamos el patron) + self.assertNotIn('admin', grupo_correcto.codigo.lower()) + self.assertNotIn('supervisor', grupo_correcto.codigo.lower()) + self.assertNotIn('agent', grupo_correcto.codigo.lower()) + + def test_grupo_str_representation(self): + """Test: Representacion string de grupo.""" + grupo = GrupoPermisos.objects.create(**self.grupo_data) + + self.assertEqual(str(grupo), 'Atencion al Cliente (atencion_cliente)') + + +class GrupoCapacidadModelTest(TestCase): + """Tests para modelo GrupoCapacidad (Relacion Grupo-Capacidad).""" + + def setUp(self): + """Setup para tests.""" + self.grupo = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente' + ) + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.operaciones.llamadas.ver', + accion='ver', + recurso='llamadas', + dominio='operaciones' + ) + + def test_crear_relacion_grupo_capacidad(self): + """Test: Asignar capacidad a grupo.""" + relacion = GrupoCapacidad.objects.create( + grupo=self.grupo, + capacidad=self.capacidad + ) + + self.assertEqual(relacion.grupo, self.grupo) + self.assertEqual(relacion.capacidad, self.capacidad) + self.assertIsNotNone(relacion.created_at) + + def test_unique_together_grupo_capacidad(self): + """Test: Combinacion grupo+capacidad debe ser unica.""" + GrupoCapacidad.objects.create( + grupo=self.grupo, + capacidad=self.capacidad + ) + + with self.assertRaises(IntegrityError): + GrupoCapacidad.objects.create( + grupo=self.grupo, + capacidad=self.capacidad + ) + + +class UsuarioGrupoModelTest(TestCase): + """Tests para modelo UsuarioGrupo (Usuario puede tener MULTIPLES grupos).""" + + def setUp(self): + """Setup para tests.""" + self.usuario = User.objects.create_user( + username='maria', + email='maria@example.com', + password='testpass123' + ) + self.grupo1 = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente' + ) + self.grupo2 = GrupoPermisos.objects.create( + codigo='visualizacion_metricas', + nombre_display='Visualizacion de Metricas' + ) + self.asignador = User.objects.create_user( + username='admin', + email='admin@example.com' + ) + + def test_asignar_grupo_a_usuario(self): + """Test: Asignar un grupo a un usuario.""" + asignacion = UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + asignado_por=self.asignador + ) + + self.assertEqual(asignacion.usuario, self.usuario) + self.assertEqual(asignacion.grupo, self.grupo1) + self.assertTrue(asignacion.activo) + self.assertIsNone(asignacion.fecha_expiracion) + + def test_usuario_multiples_grupos(self): + """Test: Usuario puede tener MULTIPLES grupos (sin jerarquia).""" + # Maria tiene grupos de atencion + visualizacion + asig1 = UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + asignado_por=self.asignador + ) + asig2 = UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo2, + asignado_por=self.asignador + ) + + grupos_usuario = UsuarioGrupo.objects.filter( + usuario=self.usuario, + activo=True + ) + + self.assertEqual(grupos_usuario.count(), 2) + self.assertIn(asig1, grupos_usuario) + self.assertIn(asig2, grupos_usuario) + + def test_unique_together_usuario_grupo(self): + """Test: Combinacion usuario+grupo debe ser unica.""" + UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + asignado_por=self.asignador + ) + + with self.assertRaises(IntegrityError): + UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + asignado_por=self.asignador + ) + + def test_asignacion_temporal(self): + """Test: Grupo puede asignarse temporalmente con fecha_expiracion.""" + fecha_exp = timezone.now() + timedelta(days=30) + + asignacion = UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + fecha_expiracion=fecha_exp, + asignado_por=self.asignador + ) + + self.assertEqual(asignacion.fecha_expiracion, fecha_exp) + + def test_asignacion_expirada(self): + """Test: Asignaciones expiradas no deben considerarse activas.""" + fecha_pasada = timezone.now() - timedelta(days=1) + + asignacion = UsuarioGrupo.objects.create( + usuario=self.usuario, + grupo=self.grupo1, + fecha_expiracion=fecha_pasada, + asignado_por=self.asignador + ) + + # Verificar que hay logica para filtrar expiradas + ahora = timezone.now() + grupos_activos = UsuarioGrupo.objects.filter( + usuario=self.usuario, + activo=True + ).exclude( + fecha_expiracion__lt=ahora + ) + + self.assertEqual(grupos_activos.count(), 0) + + +class PermisoExcepcionalModelTest(TestCase): + """Tests para modelo PermisoExcepcional (Conceder/Revocar capacidades especificas).""" + + def setUp(self): + """Setup para tests.""" + self.usuario = User.objects.create_user( + username='carlos', + email='carlos@example.com' + ) + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.direccion.presupuestos.aprobar', + accion='aprobar', + recurso='presupuestos', + dominio='direccion', + nivel_sensibilidad='critico' + ) + self.autorizador = User.objects.create_user( + username='director', + email='director@example.com' + ) + + def test_conceder_permiso_excepcional(self): + """Test: Conceder permiso excepcional temporal a usuario.""" + permiso = PermisoExcepcional.objects.create( + usuario=self.usuario, + capacidad=self.capacidad, + tipo='conceder', + fecha_inicio=timezone.now(), + fecha_fin=timezone.now() + timedelta(days=7), + motivo='Proyecto especial de fin de año', + autorizado_por=self.autorizador + ) + + self.assertEqual(permiso.tipo, 'conceder') + self.assertEqual(permiso.usuario, self.usuario) + self.assertEqual(permiso.capacidad, self.capacidad) + self.assertTrue(permiso.activo) + + def test_revocar_permiso_excepcional(self): + """Test: Revocar capacidad especifica de usuario.""" + permiso = PermisoExcepcional.objects.create( + usuario=self.usuario, + capacidad=self.capacidad, + tipo='revocar', + motivo='Incidente de seguridad', + autorizado_por=self.autorizador + ) + + self.assertEqual(permiso.tipo, 'revocar') + self.assertEqual(permiso.motivo, 'Incidente de seguridad') + + def test_permiso_temporal(self): + """Test: Permiso excepcional con fecha_inicio y fecha_fin.""" + inicio = timezone.now() + fin = inicio + timedelta(days=14) + + permiso = PermisoExcepcional.objects.create( + usuario=self.usuario, + capacidad=self.capacidad, + tipo='conceder', + fecha_inicio=inicio, + fecha_fin=fin, + motivo='Temporal para proyecto', + autorizado_por=self.autorizador + ) + + self.assertEqual(permiso.fecha_inicio, inicio) + self.assertEqual(permiso.fecha_fin, fin) + + def test_permiso_permanente(self): + """Test: Permiso excepcional sin fecha_fin (permanente).""" + permiso = PermisoExcepcional.objects.create( + usuario=self.usuario, + capacidad=self.capacidad, + tipo='conceder', + motivo='Rol especial permanente', + autorizado_por=self.autorizador + ) + + self.assertIsNone(permiso.fecha_fin) + + +class AuditoriaPermisoModelTest(TestCase): + """Tests para modelo AuditoriaPermiso (Trazabilidad de accesos).""" + + def setUp(self): + """Setup para tests.""" + self.usuario = User.objects.create_user( + username='laura', + email='laura@example.com' + ) + self.capacidad_consultada = 'sistema.finanzas.pagos.ver' + + def test_registrar_acceso_concedido(self): + """Test: Registrar acceso concedido en auditoria.""" + auditoria = AuditoriaPermiso.objects.create( + usuario=self.usuario, + capacidad=self.capacidad_consultada, + accion_realizada='acceso_concedido', + recurso_accedido='/api/finanzas/pagos', + ip_address='192.168.1.100', + user_agent='Mozilla/5.0', + metadata={'metodo': 'GET', 'status': 200} + ) + + self.assertEqual(auditoria.accion_realizada, 'acceso_concedido') + self.assertEqual(auditoria.usuario, self.usuario) + self.assertEqual(auditoria.capacidad, self.capacidad_consultada) + + def test_registrar_acceso_denegado(self): + """Test: Registrar acceso denegado en auditoria.""" + auditoria = AuditoriaPermiso.objects.create( + usuario=self.usuario, + capacidad='sistema.direccion.politicas.publicar', + accion_realizada='acceso_denegado', + recurso_accedido='/api/politicas/123/publicar', + ip_address='192.168.1.100', + metadata={'razon': 'usuario no tiene capacidad requerida'} + ) + + self.assertEqual(auditoria.accion_realizada, 'acceso_denegado') + + def test_auditoria_timestamp_automatico(self): + """Test: Timestamp se asigna automaticamente.""" + auditoria = AuditoriaPermiso.objects.create( + usuario=self.usuario, + capacidad=self.capacidad_consultada, + accion_realizada='acceso_concedido' + ) + + self.assertIsNotNone(auditoria.timestamp) + self.assertLessEqual( + auditoria.timestamp, + timezone.now() + ) + + def test_auditoria_metadata_jsonb(self): + """Test: metadata se almacena como JSONB.""" + metadata_compleja = { + 'request': { + 'method': 'POST', + 'path': '/api/finanzas/pagos/procesar', + 'body': {'monto': 1000} + }, + 'response': { + 'status': 200, + 'duration_ms': 234 + } + } + + auditoria = AuditoriaPermiso.objects.create( + usuario=self.usuario, + capacidad='sistema.finanzas.pagos.procesar', + accion_realizada='acceso_concedido', + metadata=metadata_compleja + ) + + self.assertEqual(auditoria.metadata['request']['method'], 'POST') + self.assertEqual(auditoria.metadata['response']['status'], 200) diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/test_serializers.py b/api/callcentersite/callcentersite/apps/permissions/tests/test_serializers.py new file mode 100644 index 00000000..fee876f4 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/tests/test_serializers.py @@ -0,0 +1,315 @@ +""" +Tests para serializers de permisos. + +Sistema de Permisos Granular - Prioridad 2: API Layer +TDD: Tests escritos ANTES de implementar serializers.py +""" + +from django.test import TestCase +from django.contrib.auth import get_user_model + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + GrupoPermisos, + UsuarioGrupo, +) +from callcentersite.apps.permissions.serializers import ( + FuncionSerializer, + CapacidadSerializer, + GrupoPermisosSerializer, + UsuarioGrupoSerializer, + UsuarioGrupoCreateSerializer, +) + + +User = get_user_model() + + +class FuncionSerializerTestCase(TestCase): + """Tests para FuncionSerializer.""" + + def test_serializer_contiene_campos_esperados(self): + """Serializer contiene todos los campos necesarios.""" + serializer = FuncionSerializer() + campos = set(serializer.fields.keys()) + + campos_esperados = { + 'id', 'nombre', 'nombre_completo', 'descripcion', + 'dominio', 'categoria', 'icono', 'orden_menu', + 'activa', 'created_at', 'updated_at' + } + + self.assertEqual(campos, campos_esperados) + + def test_serializar_funcion_exitosamente(self): + """Serializa funcion correctamente.""" + funcion = Funcion.objects.create( + nombre='llamadas', + nombre_completo='sistema.operaciones.llamadas', + dominio='operaciones', + categoria='operaciones' + ) + + serializer = FuncionSerializer(funcion) + data = serializer.data + + self.assertEqual(data['nombre'], 'llamadas') + self.assertEqual(data['nombre_completo'], 'sistema.operaciones.llamadas') + self.assertEqual(data['dominio'], 'operaciones') + + def test_deserializar_funcion_exitosamente(self): + """Deserializa y crea funcion correctamente.""" + data = { + 'nombre': 'tickets', + 'nombre_completo': 'sistema.operaciones.tickets', + 'descripcion': 'Gestion de tickets', + 'dominio': 'operaciones', + 'categoria': 'operaciones' + } + + serializer = FuncionSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + funcion = serializer.save() + self.assertEqual(funcion.nombre, 'tickets') + self.assertTrue(funcion.activa) + + +class CapacidadSerializerTestCase(TestCase): + """Tests para CapacidadSerializer.""" + + def test_serializer_contiene_campos_esperados(self): + """Serializer contiene todos los campos necesarios.""" + serializer = CapacidadSerializer() + campos = set(serializer.fields.keys()) + + campos_esperados = { + 'id', 'nombre_completo', 'descripcion', 'accion', + 'recurso', 'dominio', 'nivel_sensibilidad', + 'requiere_auditoria', 'activa', 'created_at' + } + + self.assertEqual(campos, campos_esperados) + + def test_serializar_capacidad_exitosamente(self): + """Serializa capacidad correctamente.""" + capacidad = Capacidad.objects.create( + nombre_completo='sistema.operaciones.llamadas.ver', + accion='ver', + recurso='llamadas', + dominio='operaciones', + nivel_sensibilidad='bajo' + ) + + serializer = CapacidadSerializer(capacidad) + data = serializer.data + + self.assertEqual(data['nombre_completo'], 'sistema.operaciones.llamadas.ver') + self.assertEqual(data['accion'], 'ver') + self.assertEqual(data['nivel_sensibilidad'], 'bajo') + self.assertFalse(data['requiere_auditoria']) + + def test_validar_nivel_sensibilidad_invalido(self): + """Valida que nivel_sensibilidad debe ser valor valido.""" + data = { + 'nombre_completo': 'sistema.test.test.test', + 'accion': 'test', + 'recurso': 'test', + 'dominio': 'test', + 'nivel_sensibilidad': 'invalido' + } + + serializer = CapacidadSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('nivel_sensibilidad', serializer.errors) + + +class GrupoPermisosSerializerTestCase(TestCase): + """Tests para GrupoPermisosSerializer.""" + + def test_serializer_contiene_campos_esperados(self): + """Serializer contiene todos los campos necesarios.""" + serializer = GrupoPermisosSerializer() + campos = set(serializer.fields.keys()) + + campos_esperados = { + 'id', 'codigo', 'nombre_display', 'descripcion', + 'tipo_acceso', 'activo', 'created_at', 'updated_at', + 'capacidades_count' + } + + self.assertEqual(campos, campos_esperados) + + def test_serializar_grupo_con_capacidades_count(self): + """Serializer incluye count de capacidades.""" + from callcentersite.apps.permissions.models import GrupoCapacidad + + grupo = GrupoPermisos.objects.create( + codigo='test_grupo', + nombre_display='Test Grupo', + tipo_acceso='operativo' + ) + + capacidad1 = Capacidad.objects.create( + nombre_completo='sistema.test.test1.ver', + accion='ver', + recurso='test1', + dominio='test' + ) + capacidad2 = Capacidad.objects.create( + nombre_completo='sistema.test.test2.ver', + accion='ver', + recurso='test2', + dominio='test' + ) + + GrupoCapacidad.objects.create(grupo=grupo, capacidad=capacidad1) + GrupoCapacidad.objects.create(grupo=grupo, capacidad=capacidad2) + + serializer = GrupoPermisosSerializer(grupo) + data = serializer.data + + self.assertEqual(data['capacidades_count'], 2) + + def test_codigo_debe_ser_unico(self): + """Valida que codigo debe ser unico.""" + GrupoPermisos.objects.create( + codigo='codigo_existente', + nombre_display='Grupo 1', + tipo_acceso='operativo' + ) + + data = { + 'codigo': 'codigo_existente', + 'nombre_display': 'Grupo 2', + 'tipo_acceso': 'operativo' + } + + serializer = GrupoPermisosSerializer(data=data) + self.assertFalse(serializer.is_valid()) + self.assertIn('codigo', serializer.errors) + + +class UsuarioGrupoSerializerTestCase(TestCase): + """Tests para UsuarioGrupoSerializer.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='test_grupo', + nombre_display='Test Grupo', + tipo_acceso='operativo' + ) + + def test_serializer_contiene_campos_esperados(self): + """Serializer contiene todos los campos necesarios.""" + serializer = UsuarioGrupoSerializer() + campos = set(serializer.fields.keys()) + + campos_esperados = { + 'id', 'usuario', 'grupo', 'fecha_asignacion', + 'fecha_expiracion', 'asignado_por', 'activo', + 'usuario_username', 'grupo_nombre' + } + + self.assertEqual(campos, campos_esperados) + + def test_serializar_asignacion_usuario_grupo(self): + """Serializa asignacion correctamente.""" + asignacion = UsuarioGrupo.objects.create( + usuario=self.user, + grupo=self.grupo, + activo=True + ) + + serializer = UsuarioGrupoSerializer(asignacion) + data = serializer.data + + self.assertEqual(data['usuario'], self.user.id) + self.assertEqual(data['grupo'], self.grupo.id) + self.assertEqual(data['usuario_username'], self.user.username) + self.assertEqual(data['grupo_nombre'], self.grupo.nombre_display) + self.assertTrue(data['activo']) + + +class UsuarioGrupoCreateSerializerTestCase(TestCase): + """Tests para UsuarioGrupoCreateSerializer.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + self.admin = User.objects.create_user( + username='admin', + email='admin@test.com', + password='admin123' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='test_grupo', + nombre_display='Test Grupo', + tipo_acceso='operativo' + ) + + def test_crear_asignacion_exitosamente(self): + """Crea asignacion usuario-grupo exitosamente.""" + data = { + 'usuario': self.user.id, + 'grupo': self.grupo.id, + 'asignado_por': self.admin.id + } + + serializer = UsuarioGrupoCreateSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + asignacion = serializer.save() + self.assertEqual(asignacion.usuario, self.user) + self.assertEqual(asignacion.grupo, self.grupo) + self.assertEqual(asignacion.asignado_por, self.admin) + self.assertTrue(asignacion.activo) + + def test_validar_asignacion_duplicada(self): + """Valida que no se puede asignar mismo grupo dos veces.""" + UsuarioGrupo.objects.create( + usuario=self.user, + grupo=self.grupo, + activo=True + ) + + data = { + 'usuario': self.user.id, + 'grupo': self.grupo.id + } + + serializer = UsuarioGrupoCreateSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_crear_asignacion_con_fecha_expiracion(self): + """Crea asignacion con fecha de expiracion.""" + from datetime import timedelta + from django.utils import timezone + + fecha_exp = timezone.now() + timedelta(days=30) + + data = { + 'usuario': self.user.id, + 'grupo': self.grupo.id, + 'fecha_expiracion': fecha_exp.isoformat() + } + + serializer = UsuarioGrupoCreateSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) + + asignacion = serializer.save() + self.assertIsNotNone(asignacion.fecha_expiracion) diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/test_services.py b/api/callcentersite/callcentersite/apps/permissions/tests/test_services.py new file mode 100644 index 00000000..7348aae6 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/tests/test_services.py @@ -0,0 +1,479 @@ +""" +Tests para PermisoService. + +Sistema de Permisos Granular - Prioridad 1 +TDD: Tests escritos ANTES de implementar services.py +""" + +from datetime import datetime, timedelta +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, + AuditoriaPermiso, +) +from callcentersite.apps.permissions.services import PermisoService + + +User = get_user_model() + + +class PermisoServiceTestCase(TestCase): + """Tests para el servicio de permisos.""" + + def setUp(self): + """Configurar datos de prueba.""" + # Crear usuarios + self.user_agent = User.objects.create_user( + username="agent1", + email="agent1@test.com", + password="testpass123" + ) + self.user_coordinador = User.objects.create_user( + username="coordinador1", + email="coord1@test.com", + password="testpass123" + ) + self.user_sin_permisos = User.objects.create_user( + username="user_no_perms", + email="noperms@test.com", + password="testpass123" + ) + + # Crear funciones + self.funcion_llamadas = Funcion.objects.create( + nombre="llamadas", + nombre_completo="sistema.operaciones.llamadas", + dominio="operaciones", + categoria="operaciones" + ) + self.funcion_pagos = Funcion.objects.create( + nombre="pagos", + nombre_completo="sistema.finanzas.pagos", + dominio="finanzas", + categoria="finanzas" + ) + + # Crear capacidades + self.cap_llamadas_ver = Capacidad.objects.create( + nombre_completo="sistema.operaciones.llamadas.ver", + accion="ver", + recurso="llamadas", + dominio="operaciones", + nivel_sensibilidad="bajo" + ) + self.cap_llamadas_realizar = Capacidad.objects.create( + nombre_completo="sistema.operaciones.llamadas.realizar", + accion="realizar", + recurso="llamadas", + dominio="operaciones", + nivel_sensibilidad="normal" + ) + self.cap_pagos_aprobar = Capacidad.objects.create( + nombre_completo="sistema.finanzas.pagos.aprobar", + accion="aprobar", + recurso="pagos", + dominio="finanzas", + nivel_sensibilidad="critico", + requiere_auditoria=True + ) + + # Vincular capacidades a funciones + FuncionCapacidad.objects.create( + funcion=self.funcion_llamadas, + capacidad=self.cap_llamadas_ver, + requerida=True + ) + FuncionCapacidad.objects.create( + funcion=self.funcion_llamadas, + capacidad=self.cap_llamadas_realizar, + requerida=False + ) + FuncionCapacidad.objects.create( + funcion=self.funcion_pagos, + capacidad=self.cap_pagos_aprobar, + requerida=True + ) + + # Crear grupos + self.grupo_atencion = GrupoPermisos.objects.create( + codigo="atencion_cliente", + nombre_display="Atencion al Cliente", + descripcion="Grupo para agentes de atencion", + tipo_acceso="operativo", + activo=True + ) + self.grupo_coordinacion = GrupoPermisos.objects.create( + codigo="coordinacion_equipos", + nombre_display="Coordinacion de Equipos", + descripcion="Grupo para coordinadores", + tipo_acceso="gestion", + activo=True + ) + + # Vincular capacidades a grupos + GrupoCapacidad.objects.create( + grupo=self.grupo_atencion, + capacidad=self.cap_llamadas_ver + ) + GrupoCapacidad.objects.create( + grupo=self.grupo_atencion, + capacidad=self.cap_llamadas_realizar + ) + GrupoCapacidad.objects.create( + grupo=self.grupo_coordinacion, + capacidad=self.cap_llamadas_ver + ) + GrupoCapacidad.objects.create( + grupo=self.grupo_coordinacion, + capacidad=self.cap_llamadas_realizar + ) + + # Asignar usuarios a grupos + UsuarioGrupo.objects.create( + usuario=self.user_agent, + grupo=self.grupo_atencion, + activo=True + ) + UsuarioGrupo.objects.create( + usuario=self.user_coordinador, + grupo=self.grupo_coordinacion, + activo=True + ) + + def test_usuario_tiene_permiso_via_grupo(self): + """Usuario tiene permiso si su grupo activo lo otorga.""" + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + self.assertTrue(tiene_permiso) + + def test_usuario_no_tiene_permiso_sin_grupo(self): + """Usuario sin grupos no tiene permisos.""" + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_sin_permisos.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + self.assertFalse(tiene_permiso) + + def test_usuario_no_tiene_permiso_capacidad_no_asignada(self): + """Usuario no tiene permiso si capacidad no esta en sus grupos.""" + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + self.assertFalse(tiene_permiso) + + def test_usuario_con_grupo_inactivo_no_tiene_permiso(self): + """Usuario con grupo inactivo no tiene permisos de ese grupo.""" + # Desactivar el grupo + asignacion = UsuarioGrupo.objects.get( + usuario=self.user_agent, + grupo=self.grupo_atencion + ) + asignacion.activo = False + asignacion.save() + + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + self.assertFalse(tiene_permiso) + + def test_usuario_con_grupo_expirado_no_tiene_permiso(self): + """Usuario con grupo expirado no tiene permisos de ese grupo.""" + # Expirar el grupo + asignacion = UsuarioGrupo.objects.get( + usuario=self.user_agent, + grupo=self.grupo_atencion + ) + asignacion.fecha_expiracion = timezone.now() - timedelta(days=1) + asignacion.save() + + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + self.assertFalse(tiene_permiso) + + def test_permiso_excepcional_conceder(self): + """Permiso excepcional tipo 'conceder' otorga capacidad.""" + # Usuario no tiene permiso inicialmente + self.assertFalse( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + ) + + # Conceder permiso excepcional + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_pagos_aprobar, + tipo="conceder", + motivo="Proyecto especial", + autorizado_por=self.user_coordinador, + activo=True + ) + + # Ahora debe tener permiso + self.assertTrue( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + ) + + def test_permiso_excepcional_revocar(self): + """Permiso excepcional tipo 'revocar' quita capacidad.""" + # Usuario tiene permiso via grupo + self.assertTrue( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + ) + + # Revocar permiso excepcional + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_llamadas_ver, + tipo="revocar", + motivo="Suspension temporal", + autorizado_por=self.user_coordinador, + activo=True + ) + + # Ahora NO debe tener permiso + self.assertFalse( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + ) + + def test_permiso_excepcional_expirado_no_aplica(self): + """Permiso excepcional expirado no aplica.""" + # Conceder permiso excepcional expirado + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_pagos_aprobar, + tipo="conceder", + fecha_fin=timezone.now() - timedelta(days=1), + motivo="Proyecto ya terminado", + autorizado_por=self.user_coordinador, + activo=True + ) + + # No debe tener permiso porque ya expiro + self.assertFalse( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + ) + + def test_permiso_excepcional_inactivo_no_aplica(self): + """Permiso excepcional inactivo no aplica.""" + # Conceder permiso excepcional inactivo + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_pagos_aprobar, + tipo="conceder", + motivo="Desactivado", + autorizado_por=self.user_coordinador, + activo=False + ) + + # No debe tener permiso + self.assertFalse( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + ) + + def test_obtener_capacidades_usuario(self): + """Obtiene todas las capacidades de un usuario.""" + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=self.user_agent.id + ) + + self.assertIsInstance(capacidades, list) + self.assertGreater(len(capacidades), 0) + self.assertIn("sistema.operaciones.llamadas.ver", capacidades) + self.assertIn("sistema.operaciones.llamadas.realizar", capacidades) + + def test_obtener_capacidades_usuario_sin_grupos(self): + """Usuario sin grupos retorna lista vacia.""" + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=self.user_sin_permisos.id + ) + + self.assertIsInstance(capacidades, list) + self.assertEqual(len(capacidades), 0) + + def test_obtener_capacidades_incluye_excepcionales(self): + """Capacidades incluyen permisos excepcionales concedidos.""" + # Conceder permiso excepcional + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_pagos_aprobar, + tipo="conceder", + motivo="Proyecto especial", + autorizado_por=self.user_coordinador, + activo=True + ) + + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=self.user_agent.id + ) + + self.assertIn("sistema.finanzas.pagos.aprobar", capacidades) + + def test_obtener_capacidades_excluye_revocadas(self): + """Capacidades excluyen permisos excepcionales revocados.""" + # Revocar permiso + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_llamadas_ver, + tipo="revocar", + motivo="Suspension", + autorizado_por=self.user_coordinador, + activo=True + ) + + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=self.user_agent.id + ) + + self.assertNotIn("sistema.operaciones.llamadas.ver", capacidades) + + def test_obtener_funciones_accesibles(self): + """Obtiene funciones a las que el usuario tiene acceso.""" + funciones = PermisoService.obtener_funciones_accesibles( + usuario_id=self.user_agent.id + ) + + self.assertIsInstance(funciones, list) + self.assertGreater(len(funciones), 0) + + # Debe incluir la funcion de llamadas + nombres_completos = [f["nombre_completo"] for f in funciones] + self.assertIn("sistema.operaciones.llamadas", nombres_completos) + + def test_obtener_funciones_accesibles_sin_permisos(self): + """Usuario sin permisos no tiene funciones accesibles.""" + funciones = PermisoService.obtener_funciones_accesibles( + usuario_id=self.user_sin_permisos.id + ) + + self.assertIsInstance(funciones, list) + self.assertEqual(len(funciones), 0) + + def test_registrar_acceso_crea_auditoria(self): + """Registrar acceso crea entrada en auditoria.""" + inicial_count = AuditoriaPermiso.objects.count() + + PermisoService.registrar_acceso( + usuario_id=self.user_agent.id, + capacidad="sistema.operaciones.llamadas.realizar", + accion="LLAMADA_INICIADA", + recurso_id="CALL-12345", + ip_address="192.168.1.100", + user_agent="Mozilla/5.0", + metadata={"duracion": 120, "resultado": "exitosa"} + ) + + self.assertEqual(AuditoriaPermiso.objects.count(), inicial_count + 1) + + auditoria = AuditoriaPermiso.objects.latest("timestamp") + self.assertEqual(auditoria.usuario_id, self.user_agent.id) + self.assertEqual(auditoria.capacidad, "sistema.operaciones.llamadas.realizar") + self.assertEqual(auditoria.accion_realizada, "LLAMADA_INICIADA") + self.assertEqual(auditoria.recurso_accedido, "CALL-12345") + self.assertEqual(auditoria.ip_address, "192.168.1.100") + self.assertIsNotNone(auditoria.metadata) + + def test_registrar_acceso_capacidad_sensible(self): + """Capacidades sensibles siempre se auditan.""" + inicial_count = AuditoriaPermiso.objects.count() + + # Capacidad critica debe auditarse + PermisoService.registrar_acceso( + usuario_id=self.user_coordinador.id, + capacidad="sistema.finanzas.pagos.aprobar", + accion="PAGO_APROBADO", + recurso_id="PAY-12345" + ) + + self.assertEqual(AuditoriaPermiso.objects.count(), inicial_count + 1) + + def test_usuario_tiene_permiso_capacidad_inexistente(self): + """Verificar permiso de capacidad inexistente retorna False.""" + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.dominio.recurso.inexistente" + ) + self.assertFalse(tiene_permiso) + + def test_usuario_inexistente_no_tiene_permiso(self): + """Usuario inexistente no tiene permisos.""" + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=99999, + capacidad_requerida="sistema.operaciones.llamadas.ver" + ) + self.assertFalse(tiene_permiso) + + def test_multiples_grupos_combinan_capacidades(self): + """Usuario con multiples grupos tiene capacidades de todos.""" + # Asignar usuario a segundo grupo + UsuarioGrupo.objects.create( + usuario=self.user_agent, + grupo=self.grupo_coordinacion, + activo=True + ) + + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=self.user_agent.id + ) + + # Debe tener capacidades de ambos grupos (sin duplicados) + self.assertIn("sistema.operaciones.llamadas.ver", capacidades) + self.assertIn("sistema.operaciones.llamadas.realizar", capacidades) + + # No debe haber duplicados + self.assertEqual( + len(capacidades), + len(set(capacidades)) + ) + + def test_permiso_excepcional_futuro_no_aplica_aun(self): + """Permiso excepcional con fecha_inicio futura no aplica aun.""" + # Conceder permiso que empieza en 7 dias + PermisoExcepcional.objects.create( + usuario=self.user_agent, + capacidad=self.cap_pagos_aprobar, + tipo="conceder", + fecha_inicio=timezone.now() + timedelta(days=7), + motivo="Proyecto futuro", + autorizado_por=self.user_coordinador, + activo=True + ) + + # No debe tener permiso aun + self.assertFalse( + PermisoService.usuario_tiene_permiso( + usuario_id=self.user_agent.id, + capacidad_requerida="sistema.finanzas.pagos.aprobar" + ) + ) diff --git a/api/callcentersite/callcentersite/apps/permissions/tests/test_views.py b/api/callcentersite/callcentersite/apps/permissions/tests/test_views.py new file mode 100644 index 00000000..5a8ad759 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/tests/test_views.py @@ -0,0 +1,356 @@ +""" +Tests para views/viewsets de permisos. + +Sistema de Permisos Granular - Prioridad 2: API Layer +TDD: Tests escritos ANTES de implementar views.py +""" + +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, +) + + +User = get_user_model() + + +class FuncionViewSetTestCase(TestCase): + """Tests para FuncionViewSet.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Crear funciones de prueba + self.funcion1 = Funcion.objects.create( + nombre='llamadas', + nombre_completo='sistema.operaciones.llamadas', + dominio='operaciones', + categoria='operaciones' + ) + self.funcion2 = Funcion.objects.create( + nombre='tickets', + nombre_completo='sistema.operaciones.tickets', + dominio='operaciones', + categoria='operaciones' + ) + + def test_listar_funciones_requiere_autenticacion(self): + """Listar funciones requiere autenticacion.""" + response = self.client.get('/api/permissions/funciones/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_listar_funciones_autenticado(self): + """Usuario autenticado puede listar funciones.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/funciones/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data['results']), 2) + + def test_obtener_funcion_por_id(self): + """Usuario puede obtener funcion por ID.""" + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/permissions/funciones/{self.funcion1.id}/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['nombre'], 'llamadas') + + def test_filtrar_funciones_por_dominio(self): + """Usuario puede filtrar funciones por dominio.""" + Funcion.objects.create( + nombre='pagos', + nombre_completo='sistema.finanzas.pagos', + dominio='finanzas', + categoria='finanzas' + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/funciones/?dominio=finanzas') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['dominio'], 'finanzas') + + +class CapacidadViewSetTestCase(TestCase): + """Tests para CapacidadViewSet.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.operaciones.llamadas.ver', + accion='ver', + recurso='llamadas', + dominio='operaciones', + nivel_sensibilidad='bajo' + ) + + def test_listar_capacidades_requiere_autenticacion(self): + """Listar capacidades requiere autenticacion.""" + response = self.client.get('/api/permissions/capacidades/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_listar_capacidades_autenticado(self): + """Usuario autenticado puede listar capacidades.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/capacidades/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data['results']), 1) + + def test_filtrar_capacidades_por_sensibilidad(self): + """Usuario puede filtrar capacidades por sensibilidad.""" + Capacidad.objects.create( + nombre_completo='sistema.finanzas.pagos.aprobar', + accion='aprobar', + recurso='pagos', + dominio='finanzas', + nivel_sensibilidad='critico' + ) + + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/capacidades/?nivel_sensibilidad=critico') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['nivel_sensibilidad'], 'critico') + + +class GrupoPermisosViewSetTestCase(TestCase): + """Tests para GrupoPermisosViewSet.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente', + tipo_acceso='operativo' + ) + + def test_listar_grupos_requiere_autenticacion(self): + """Listar grupos requiere autenticacion.""" + response = self.client.get('/api/permissions/grupos/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_listar_grupos_autenticado(self): + """Usuario autenticado puede listar grupos.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/grupos/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data['results']), 1) + + def test_obtener_grupo_detalle_incluye_capacidades(self): + """Detalle de grupo incluye capacidades.""" + capacidad = Capacidad.objects.create( + nombre_completo='sistema.test.test.ver', + accion='ver', + recurso='test', + dominio='test' + ) + GrupoCapacidad.objects.create(grupo=self.grupo, capacidad=capacidad) + + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/permissions/grupos/{self.grupo.id}/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('capacidades', response.data) + self.assertGreater(len(response.data['capacidades']), 0) + + +class UsuarioGrupoViewSetTestCase(TestCase): + """Tests para UsuarioGrupoViewSet.""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + self.otro_user = User.objects.create_user( + username='otheruser', + email='other@test.com', + password='testpass123' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente', + tipo_acceso='operativo' + ) + + self.asignacion = UsuarioGrupo.objects.create( + usuario=self.user, + grupo=self.grupo, + activo=True + ) + + def test_listar_asignaciones_requiere_autenticacion(self): + """Listar asignaciones requiere autenticacion.""" + response = self.client.get('/api/permissions/usuarios-grupos/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_listar_asignaciones_autenticado(self): + """Usuario autenticado puede listar asignaciones.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/usuarios-grupos/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreaterEqual(len(response.data['results']), 1) + + def test_crear_asignacion_usuario_grupo(self): + """Usuario puede crear asignacion usuario-grupo.""" + self.client.force_authenticate(user=self.user) + + data = { + 'usuario': self.otro_user.id, + 'grupo': self.grupo.id, + 'asignado_por': self.user.id, + 'activo': True + } + + response = self.client.post('/api/permissions/usuarios-grupos/', data) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['usuario'], self.otro_user.id) + self.assertEqual(response.data['grupo'], self.grupo.id) + + def test_filtrar_asignaciones_por_usuario(self): + """Usuario puede filtrar asignaciones por usuario.""" + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/permissions/usuarios-grupos/?usuario={self.user.id}') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertGreater(len(response.data['results']), 0) + self.assertEqual(response.data['results'][0]['usuario'], self.user.id) + + +class MisCapacidadesViewTestCase(TestCase): + """Tests para MisCapacidadesView (endpoint personalizado).""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + # Crear capacidad y grupo + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.operaciones.llamadas.ver', + accion='ver', + recurso='llamadas', + dominio='operaciones' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente', + tipo_acceso='operativo' + ) + + GrupoCapacidad.objects.create(grupo=self.grupo, capacidad=self.capacidad) + UsuarioGrupo.objects.create(usuario=self.user, grupo=self.grupo, activo=True) + + def test_obtener_mis_capacidades_requiere_autenticacion(self): + """Endpoint requiere autenticacion.""" + response = self.client.get('/api/permissions/mis-capacidades/') + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_obtener_mis_capacidades_autenticado(self): + """Usuario autenticado obtiene sus capacidades.""" + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/permissions/mis-capacidades/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('capacidades', response.data) + self.assertIn('sistema.operaciones.llamadas.ver', response.data['capacidades']) + + +class VerificarPermisoViewTestCase(TestCase): + """Tests para VerificarPermisoView (endpoint de verificacion).""" + + def setUp(self): + """Configurar datos de prueba.""" + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@test.com', + password='testpass123' + ) + + self.capacidad = Capacidad.objects.create( + nombre_completo='sistema.operaciones.llamadas.ver', + accion='ver', + recurso='llamadas', + dominio='operaciones' + ) + + self.grupo = GrupoPermisos.objects.create( + codigo='atencion_cliente', + nombre_display='Atencion al Cliente', + tipo_acceso='operativo' + ) + + GrupoCapacidad.objects.create(grupo=self.grupo, capacidad=self.capacidad) + UsuarioGrupo.objects.create(usuario=self.user, grupo=self.grupo, activo=True) + + def test_verificar_permiso_requiere_autenticacion(self): + """Endpoint requiere autenticacion.""" + response = self.client.post('/api/permissions/verificar-permiso/', { + 'capacidad': 'sistema.operaciones.llamadas.ver' + }) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_verificar_permiso_que_usuario_tiene(self): + """Usuario puede verificar permiso que tiene.""" + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/permissions/verificar-permiso/', { + 'capacidad': 'sistema.operaciones.llamadas.ver' + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data['tiene_permiso']) + + def test_verificar_permiso_que_usuario_no_tiene(self): + """Usuario puede verificar permiso que NO tiene.""" + self.client.force_authenticate(user=self.user) + response = self.client.post('/api/permissions/verificar-permiso/', { + 'capacidad': 'sistema.finanzas.pagos.aprobar' + }) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(response.data['tiene_permiso']) diff --git a/api/callcentersite/callcentersite/apps/permissions/urls.py b/api/callcentersite/callcentersite/apps/permissions/urls.py new file mode 100644 index 00000000..973c857c --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/urls.py @@ -0,0 +1,32 @@ +""" +URLs para el sistema de permisos granular. + +Sistema de Permisos Granular - Prioridad 2: API Layer +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from callcentersite.apps.permissions import views + + +# Router para ViewSets +router = DefaultRouter() +router.register(r'funciones', views.FuncionViewSet, basename='funcion') +router.register(r'capacidades', views.CapacidadViewSet, basename='capacidad') +router.register(r'funcion-capacidades', views.FuncionCapacidadViewSet, basename='funcion-capacidad') +router.register(r'grupos', views.GrupoPermisosViewSet, basename='grupo-permisos') +router.register(r'grupo-capacidades', views.GrupoCapacidadViewSet, basename='grupo-capacidad') +router.register(r'usuarios-grupos', views.UsuarioGrupoViewSet, basename='usuario-grupo') +router.register(r'permisos-excepcionales', views.PermisoExcepcionalViewSet, basename='permiso-excepcional') +router.register(r'auditoria', views.AuditoriaPermisoViewSet, basename='auditoria-permiso') + + +# URLs adicionales (views personalizadas) +urlpatterns = [ + path('mis-capacidades/', views.MisCapacidadesView.as_view(), name='mis-capacidades'), + path('mis-funciones/', views.MisFuncionesView.as_view(), name='mis-funciones'), + path('verificar-permiso/', views.VerificarPermisoView.as_view(), name='verificar-permiso'), + path('', include(router.urls)), +] diff --git a/api/callcentersite/callcentersite/apps/permissions/views.py b/api/callcentersite/callcentersite/apps/permissions/views.py new file mode 100644 index 00000000..572c0536 --- /dev/null +++ b/api/callcentersite/callcentersite/apps/permissions/views.py @@ -0,0 +1,332 @@ +""" +Views y ViewSets para el sistema de permisos granular. + +Sistema de Permisos Granular - Prioridad 2: API Layer +REF: ADR-012-sistema-permisos-sin-roles-jerarquicos.md +""" + +from __future__ import annotations + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter + +from callcentersite.apps.permissions.models import ( + Funcion, + Capacidad, + FuncionCapacidad, + GrupoPermisos, + GrupoCapacidad, + UsuarioGrupo, + PermisoExcepcional, + AuditoriaPermiso, +) +from callcentersite.apps.permissions.serializers import ( + FuncionSerializer, + CapacidadSerializer, + FuncionCapacidadSerializer, + GrupoPermisosSerializer, + GrupoPermisosDetailSerializer, + GrupoCapacidadSerializer, + UsuarioGrupoSerializer, + UsuarioGrupoCreateSerializer, + PermisoExcepcionalSerializer, + PermisoExcepcionalCreateSerializer, + AuditoriaPermisoSerializer, +) +from callcentersite.apps.permissions.services import PermisoService + + +class FuncionViewSet(viewsets.ModelViewSet): + """ + ViewSet para Funciones del sistema. + + Operaciones CRUD para funciones (dashboards, usuarios, llamadas, etc). + """ + + queryset = Funcion.objects.all() + serializer_class = FuncionSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['dominio', 'categoria', 'activa'] + search_fields = ['nombre', 'nombre_completo', 'descripcion'] + ordering_fields = ['orden_menu', 'nombre', 'created_at'] + ordering = ['orden_menu', 'nombre'] + + +class CapacidadViewSet(viewsets.ModelViewSet): + """ + ViewSet para Capacidades. + + Operaciones CRUD para capacidades atomicas. + """ + + queryset = Capacidad.objects.all() + serializer_class = CapacidadSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['dominio', 'recurso', 'accion', 'nivel_sensibilidad', 'activa'] + search_fields = ['nombre_completo', 'descripcion'] + ordering_fields = ['dominio', 'recurso', 'accion', 'created_at'] + ordering = ['dominio', 'recurso', 'accion'] + + +class FuncionCapacidadViewSet(viewsets.ModelViewSet): + """ + ViewSet para relaciones Funcion-Capacidad. + + Gestiona que capacidades estan vinculadas a que funciones. + """ + + queryset = FuncionCapacidad.objects.select_related('funcion', 'capacidad').all() + serializer_class = FuncionCapacidadSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['funcion', 'capacidad', 'requerida'] + ordering = ['funcion__orden_menu', 'funcion__nombre'] + + +class GrupoPermisosViewSet(viewsets.ModelViewSet): + """ + ViewSet para Grupos de Permisos. + + Gestiona grupos funcionales (NO roles jerarquicos). + """ + + queryset = GrupoPermisos.objects.all() + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = ['tipo_acceso', 'activo'] + search_fields = ['codigo', 'nombre_display', 'descripcion'] + ordering_fields = ['nombre_display', 'created_at'] + ordering = ['nombre_display'] + + def get_serializer_class(self): + """Usa serializer detallado para retrieve.""" + if self.action == 'retrieve': + return GrupoPermisosDetailSerializer + return GrupoPermisosSerializer + + @action(detail=True, methods=['get']) + def capacidades(self, request, pk=None): + """ + Endpoint para obtener capacidades de un grupo. + + GET /api/permissions/grupos/{id}/capacidades/ + """ + grupo = self.get_object() + grupo_caps = grupo.grupo_capacidades.select_related('capacidad').all() + + capacidades_data = [ + CapacidadSerializer(gc.capacidad).data + for gc in grupo_caps + ] + + return Response({'capacidades': capacidades_data}) + + @action(detail=True, methods=['post']) + def agregar_capacidad(self, request, pk=None): + """ + Agrega capacidad a grupo. + + POST /api/permissions/grupos/{id}/agregar_capacidad/ + Body: {"capacidad_id": 123} + """ + grupo = self.get_object() + capacidad_id = request.data.get('capacidad_id') + + if not capacidad_id: + return Response( + {'error': 'capacidad_id requerido'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + capacidad = Capacidad.objects.get(id=capacidad_id) + except Capacidad.DoesNotExist: + return Response( + {'error': 'Capacidad no encontrada'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Crear vinculacion si no existe + vinc, created = GrupoCapacidad.objects.get_or_create( + grupo=grupo, + capacidad=capacidad + ) + + if created: + return Response( + {'message': 'Capacidad agregada exitosamente'}, + status=status.HTTP_201_CREATED + ) + else: + return Response( + {'message': 'Capacidad ya existe en el grupo'}, + status=status.HTTP_200_OK + ) + + +class GrupoCapacidadViewSet(viewsets.ModelViewSet): + """ + ViewSet para relaciones Grupo-Capacidad. + + Gestiona que capacidades estan asignadas a que grupos. + """ + + queryset = GrupoCapacidad.objects.select_related('grupo', 'capacidad').all() + serializer_class = GrupoCapacidadSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['grupo', 'capacidad'] + ordering = ['grupo__nombre_display', 'capacidad__nombre_completo'] + + +class UsuarioGrupoViewSet(viewsets.ModelViewSet): + """ + ViewSet para asignaciones Usuario-Grupo. + + Gestiona a que grupos pertenecen los usuarios. + """ + + queryset = UsuarioGrupo.objects.select_related('usuario', 'grupo', 'asignado_por').all() + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['usuario', 'grupo', 'activo'] + ordering = ['-fecha_asignacion'] + + def get_serializer_class(self): + """Usa serializer de creacion para POST.""" + if self.action == 'create': + return UsuarioGrupoCreateSerializer + return UsuarioGrupoSerializer + + @action(detail=True, methods=['post']) + def desactivar(self, request, pk=None): + """ + Desactiva asignacion de usuario a grupo. + + POST /api/permissions/usuarios-grupos/{id}/desactivar/ + """ + asignacion = self.get_object() + asignacion.activo = False + asignacion.save() + + return Response({'message': 'Asignacion desactivada'}) + + +class PermisoExcepcionalViewSet(viewsets.ModelViewSet): + """ + ViewSet para Permisos Excepcionales. + + Gestiona concesiones y revocaciones temporales de capacidades. + """ + + queryset = PermisoExcepcional.objects.select_related( + 'usuario', 'capacidad', 'autorizado_por' + ).all() + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['usuario', 'capacidad', 'tipo', 'activo'] + ordering = ['-created_at'] + + def get_serializer_class(self): + """Usa serializer de creacion para POST.""" + if self.action == 'create': + return PermisoExcepcionalCreateSerializer + return PermisoExcepcionalSerializer + + +class AuditoriaPermisoViewSet(viewsets.ReadOnlyModelViewSet): + """ + ViewSet para Auditoria de Permisos (solo lectura). + + Consulta logs de auditoria de accesos a recursos protegidos. + """ + + queryset = AuditoriaPermiso.objects.select_related('usuario').all() + serializer_class = AuditoriaPermisoSerializer + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend, OrderingFilter] + filterset_fields = ['usuario', 'capacidad', 'accion_realizada'] + ordering = ['-timestamp'] + + +class MisCapacidadesView(APIView): + """ + Endpoint personalizado para obtener capacidades del usuario actual. + + GET /api/permissions/mis-capacidades/ + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + """Retorna capacidades del usuario autenticado.""" + capacidades = PermisoService.obtener_capacidades_usuario( + usuario_id=request.user.id + ) + + return Response({ + 'usuario_id': request.user.id, + 'username': request.user.username, + 'capacidades': capacidades + }) + + +class MisFuncionesView(APIView): + """ + Endpoint personalizado para obtener funciones accesibles del usuario. + + GET /api/permissions/mis-funciones/ + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + """Retorna funciones accesibles para el usuario autenticado.""" + funciones = PermisoService.obtener_funciones_accesibles( + usuario_id=request.user.id + ) + + return Response({ + 'usuario_id': request.user.id, + 'username': request.user.username, + 'funciones': funciones + }) + + +class VerificarPermisoView(APIView): + """ + Endpoint para verificar si usuario tiene una capacidad especifica. + + POST /api/permissions/verificar-permiso/ + Body: {"capacidad": "sistema.operaciones.llamadas.ver"} + """ + + permission_classes = [IsAuthenticated] + + def post(self, request): + """Verifica si usuario tiene la capacidad solicitada.""" + capacidad = request.data.get('capacidad') + + if not capacidad: + return Response( + {'error': 'Campo "capacidad" requerido'}, + status=status.HTTP_400_BAD_REQUEST + ) + + tiene_permiso = PermisoService.usuario_tiene_permiso( + usuario_id=request.user.id, + capacidad_requerida=capacidad + ) + + return Response({ + 'usuario_id': request.user.id, + 'capacidad': capacidad, + 'tiene_permiso': tiene_permiso + }) diff --git a/api/callcentersite/callcentersite/apps/politicas/__init__.py b/api/callcentersite/callcentersite/apps/politicas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/apps.py b/api/callcentersite/callcentersite/apps/politicas/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/migrations/__init__.py b/api/callcentersite/callcentersite/apps/politicas/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/models.py b/api/callcentersite/callcentersite/apps/politicas/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/serializers.py b/api/callcentersite/callcentersite/apps/politicas/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/tests/__init__.py b/api/callcentersite/callcentersite/apps/politicas/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/tests/test_models.py b/api/callcentersite/callcentersite/apps/politicas/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/urls.py b/api/callcentersite/callcentersite/apps/politicas/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/politicas/views.py b/api/callcentersite/callcentersite/apps/politicas/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/__init__.py b/api/callcentersite/callcentersite/apps/presupuestos/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/apps.py b/api/callcentersite/callcentersite/apps/presupuestos/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/migrations/__init__.py b/api/callcentersite/callcentersite/apps/presupuestos/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/models.py b/api/callcentersite/callcentersite/apps/presupuestos/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/serializers.py b/api/callcentersite/callcentersite/apps/presupuestos/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/tests/__init__.py b/api/callcentersite/callcentersite/apps/presupuestos/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/tests/test_models.py b/api/callcentersite/callcentersite/apps/presupuestos/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/urls.py b/api/callcentersite/callcentersite/apps/presupuestos/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/presupuestos/views.py b/api/callcentersite/callcentersite/apps/presupuestos/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/__init__.py b/api/callcentersite/callcentersite/apps/reportes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/apps.py b/api/callcentersite/callcentersite/apps/reportes/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/migrations/__init__.py b/api/callcentersite/callcentersite/apps/reportes/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/models.py b/api/callcentersite/callcentersite/apps/reportes/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/serializers.py b/api/callcentersite/callcentersite/apps/reportes/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/tests/__init__.py b/api/callcentersite/callcentersite/apps/reportes/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/tests/test_models.py b/api/callcentersite/callcentersite/apps/reportes/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/urls.py b/api/callcentersite/callcentersite/apps/reportes/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/reportes/views.py b/api/callcentersite/callcentersite/apps/reportes/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/__init__.py b/api/callcentersite/callcentersite/apps/tickets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/apps.py b/api/callcentersite/callcentersite/apps/tickets/apps.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/migrations/__init__.py b/api/callcentersite/callcentersite/apps/tickets/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/models.py b/api/callcentersite/callcentersite/apps/tickets/models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/serializers.py b/api/callcentersite/callcentersite/apps/tickets/serializers.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/tests/__init__.py b/api/callcentersite/callcentersite/apps/tickets/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/tests/test_models.py b/api/callcentersite/callcentersite/apps/tickets/tests/test_models.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/urls.py b/api/callcentersite/callcentersite/apps/tickets/urls.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/apps/tickets/views.py b/api/callcentersite/callcentersite/apps/tickets/views.py new file mode 100644 index 00000000..e69de29b diff --git a/api/callcentersite/callcentersite/settings/base.py b/api/callcentersite/callcentersite/settings/base.py index 57c67de4..f7675350 100644 --- a/api/callcentersite/callcentersite/settings/base.py +++ b/api/callcentersite/callcentersite/settings/base.py @@ -34,6 +34,8 @@ "callcentersite.apps.common", "callcentersite.apps.users", "callcentersite.apps.authentication", + "callcentersite.apps.permissions", + "callcentersite.apps.llamadas", "callcentersite.apps.notifications", "callcentersite.apps.analytics", "callcentersite.apps.ivr_legacy", diff --git a/api/callcentersite/callcentersite/urls.py b/api/callcentersite/callcentersite/urls.py index 15855c7b..64f6e082 100644 --- a/api/callcentersite/callcentersite/urls.py +++ b/api/callcentersite/callcentersite/urls.py @@ -21,6 +21,8 @@ def health_check(_request): name="swagger-ui", ), path("api/v1/dashboard/", include("callcentersite.apps.dashboard.urls")), + path("api/v1/permissions/", include("callcentersite.apps.permissions.urls")), + path("api/v1/llamadas/", include("callcentersite.apps.llamadas.urls")), path("api/dora/", include("dora_metrics.urls")), path("health/", health_check, name="health"), ] diff --git a/docs/adr/ADR-012-sistema-permisos-sin-roles-jerarquicos.md b/docs/adr/ADR-012-sistema-permisos-sin-roles-jerarquicos.md new file mode 100644 index 00000000..0572584e --- /dev/null +++ b/docs/adr/ADR-012-sistema-permisos-sin-roles-jerarquicos.md @@ -0,0 +1,336 @@ +# ADR-012: Sistema de Permisos Granular SIN Roles Jerarquicos + +**Estado:** Aceptado +**Fecha:** 2025-11-07 +**Decidido por:** Arquitecto Senior, Tech Lead +**Relevancia:** Prioridad 1 - CRITICA + +--- + +## Contexto + +El sistema IACT requiere un sistema de permisos que controle el acceso a recursos y acciones. Tradicionalmente, los sistemas usan roles jerarquicos (Admin, Supervisor, Agent) con permisos heredados. Sin embargo, este enfoque presenta problemas de rigidez y escalabilidad. + +### Problema con Roles Tradicionales Jerarquicos + +**Sistema tradicional:** +``` +Admin (nivel 1) + |-- Puede hacer TODO + | +Supervisor (nivel 2) + |-- Puede hacer menos que Admin + | +Agent (nivel 3) + |-- Puede hacer solo operaciones basicas +``` + +**Problemas:** + +1. **Rigidez:** Un usuario solo puede tener UN rol. No se puede ser "Agent + Visualizador de Metricas" sin crear nuevo rol. + +2. **Explosion de Roles:** Necesitas crear "AgentConMetricas", "AgentConReportes", "AgentConHorarios", etc. + +3. **Jerarquia Artificial:** "Supervisor" implica autoridad, pero a veces solo necesitas "gestion de horarios" sin ser "supervisor" de nadie. + +4. **Mantenimiento Complejo:** Cambiar permisos de "Supervisor" afecta a TODOS los supervisores, cuando quizas solo algunos necesitan un permiso especifico. + +5. **Estigmatizacion:** Etiquetas como "Admin" o "Agent" crean percepcion de jerarquia que no siempre refleja responsabilidades reales. + +## Decision + +**Implementar Sistema de Permisos Granular BASADO EN CAPACIDADES, SIN roles jerarquicos.** + +### Arquitectura Decidida + +``` +USUARIO + | + +-- GRUPOS DE PERMISOS (multiples, combinables) + | + +-- CAPACIDADES (permisos atomicos) + | + +-- FUNCIONES (recursos del sistema) +``` + +### Principios Clave + +1. **NO Roles Jerarquicos:** No usamos "Admin", "Supervisor", "Agent", "Manager", etc. + +2. **Grupos Funcionales:** Usamos grupos DESCRIPTIVOS de lo que puede hacer: + - `atencion_cliente`: Capacidades para atender clientes + - `gestion_equipos`: Capacidades para gestionar equipos + - `visualizacion_metricas`: Capacidades para ver metricas + +3. **Multiples Grupos:** Un usuario puede tener VARIOS grupos simultaneamente: + ``` + Usuario: Maria + - atencion_cliente + - visualizacion_metricas + - gestion_horarios + ``` + +4. **Sin Jerarquia:** Grupos NO tienen niveles. `gestion_equipos` NO es "superior" a `atencion_cliente`. + +5. **Combinable:** Permisos se suman. Maria tiene capacidades de los 3 grupos. + +### Componentes del Sistema + +#### 1. Funcion (Recurso) +Representa un recurso del sistema. +``` +Ejemplo: dashboards, usuarios, metricas, llamadas, tickets +``` + +#### 2. Capacidad (Accion sobre Recurso) +Accion atomica sobre un recurso. +``` +Formato: sistema.dominio.recurso.accion +Ejemplo: sistema.vistas.dashboards.ver + sistema.operaciones.llamadas.realizar + sistema.finanzas.pagos.aprobar +``` + +#### 3. Grupo de Permisos (Agrupacion Funcional) +Conjunto de capacidades que forman una funcion logica. +``` +Ejemplo: + Grupo: atencion_cliente + - sistema.operaciones.llamadas.ver + - sistema.operaciones.llamadas.realizar + - sistema.operaciones.tickets.crear + - sistema.operaciones.clientes.ver +``` + +#### 4. Usuario-Grupo (Asignacion) +Usuario asignado a uno o mas grupos. +``` +Usuario: Carlos + - atencion_cliente + - gestion_cobranza + - visualizacion_metricas +``` + +## Ventajas del Enfoque + +### 1. Flexibilidad Total + +**Antes (jerarquico):** +``` +Usuario: Maria (rol: Agent) + Problema: Necesita ver metricas, pero Agents no tienen ese permiso. + Solucion: Crear nuevo rol "AgentConMetricas" (explosion de roles) +``` + +**Ahora (grupos funcionales):** +``` +Usuario: Maria + - atencion_cliente + - visualizacion_metricas + (Simplemente agregar segundo grupo) +``` + +### 2. Descripcion Clara de Responsabilidades + +**Antes:** +``` +Rol: Supervisor + Que puede hacer? No esta claro sin ver codigo. +``` + +**Ahora:** +``` +Grupos: + - gestion_equipos: Gestiona equipos de trabajo + - gestion_horarios: Planifica y aprueba turnos + - aprobacion_excepciones: Aprueba casos especiales + (Nombres descriptivos y auto-explicativos) +``` + +### 3. Sin Estigma Jerarquico + +**Antes:** +``` +Pedro (Agent) solicita algo a Maria (Supervisor) + Percepcion: Maria tiene "autoridad" sobre Pedro +``` + +**Ahora:** +``` +Pedro (atencion_cliente) solicita a Maria (aprobacion_excepciones) + Percepcion: Maria tiene FUNCION de aprobar, no "autoridad" jerarquica +``` + +### 4. Facil Escalabilidad + +Agregar nuevas funcionalidades: +``` +Nueva funcion: sistema.calidad.evaluaciones + + Capacidades: + - sistema.calidad.evaluaciones.ver + - sistema.calidad.evaluaciones.crear + + Nuevo grupo: evaluacion_desempeno + - Incluye las capacidades de evaluaciones + + Asignar grupo a usuarios relevantes + (No necesita cambiar roles existentes) +``` + +### 5. Permisos Excepcionales + +Sistema permite otorgar/revocar capacidades especificas: +``` +Usuario: Juan (atencion_cliente) + Problema: Necesita aprobar pagos por 1 mes (proyecto especial) + + Solucion: PermisoExcepcional + - tipo: conceder + - capacidad: sistema.finanzas.pagos.aprobar + - fecha_inicio: 2025-11-01 + - fecha_fin: 2025-11-30 + - motivo: "Proyecto especial fin de año" +``` + +## Comparacion con Alternativas + +### Alternativa 1: Roles Jerarquicos (RBAC Tradicional) + +**RECHAZADO** + +Razones: +- Rigidez (un usuario = un rol) +- Explosion de roles para combinaciones +- Jerarquia artificial +- Estigmatizacion + +### Alternativa 2: ACLs (Access Control Lists) + +**RECHAZADO** + +Razones: +- Demasiado granular (permisos por usuario por recurso) +- Dificil de mantener +- No reusable (cada usuario tiene su propia ACL) + +### Alternativa 3: Grupos + Capacidades (ELEGIDO) + +**ACEPTADO** + +Razones: +- Balance entre flexibilidad y mantenibilidad +- Reusable (grupos se asignan a multiples usuarios) +- Sin jerarquia +- Escalable +- Descripcion clara de funciones + +## Consecuencias + +### Positivas + +1. **Flexibilidad:** Usuarios pueden tener multiples funciones sin explosion de roles + +2. **Claridad:** Nombres descriptivos explican que puede hacer cada grupo + +3. **Escalabilidad:** Agregar nuevas funcionalidades no afecta estructura existente + +4. **Sin Estigma:** No hay percepcion de jerarquia artificial + +5. **Auditoria:** Trazabilidad completa de quien tiene que permisos + +### Negativas + +1. **Complejidad Inicial:** Mas tablas y relaciones que RBAC simple + +2. **Curva de Aprendizaje:** Equipo debe entender el concepto de grupos funcionales vs roles + +3. **UI Mas Compleja:** Interfaz de asignacion de permisos mas sofisticada + +## Mitigaciones + +### Para Complejidad Inicial +- Documentacion exhaustiva del modelo +- Diagramas ER claros +- Ejemplos practicos de casos de uso + +### Para Curva de Aprendizaje +- Training session para equipo +- Guias de como asignar grupos +- Presets de grupos comunes + +### Para UI Compleja +- Interfaz intuitiva con checkboxes de grupos +- Preview de capacidades al seleccionar grupo +- Busqueda y filtrado de grupos + +## Implementacion + +### Fase 1: Base de Datos (8 tablas) + +**COMPLETADO** + +``` +1. funciones: Recursos del sistema +2. capacidades: Acciones sobre recursos +3. funcion_capacidades: Relacion funcion-capacidad +4. grupos_permisos: Grupos funcionales (NO roles) +5. grupo_capacidades: Relacion grupo-capacidad +6. usuarios_grupos: Usuario asignado a grupos +7. permisos_excepcionales: Conceder/revocar capacidades especificas +8. auditoria_permisos: Trazabilidad de accesos +``` + +### Fase 2: Datos Semilla + +Crear grupos predefinidos: +- atencion_cliente +- gestion_equipos +- visualizacion_metricas +- administracion_usuarios +- gestion_cobranza +- auditoria_llamadas +- (etc...) + +### Fase 3: Servicio de Permisos + +Implementar `PermisoService` para: +- Verificar si usuario tiene capacidad +- Obtener capacidades de usuario +- Obtener funciones accesibles +- Registrar auditorias + +### Fase 4: Middleware + +Middleware `verificarPermiso(capacidad_requerida)` en endpoints: +```python +@verificarPermiso('sistema.finanzas.pagos.aprobar') +def aprobar_pago(request, pago_id): + # Codigo del endpoint + pass +``` + +### Fase 5: Frontend + +- Interfaz de asignacion de grupos +- Menu dinamico basado en capacidades +- Visualizacion de permisos de usuario + +## Referencias + +- REQ-PERM-001: Requisito Sistema de Permisos Granular +- Codigo: `api/callcentersite/callcentersite/apps/permissions/` +- Tests: `api/callcentersite/callcentersite/apps/permissions/tests/test_models.py` +- Documentacion: `docs/backend/permisos/` + +## Aprobacion + +- **Propuesto por:** Arquitecto Senior +- **Revisado por:** Tech Lead, DevOps Lead +- **Aprobado por:** Arquitecto Senior, Product Owner +- **Fecha de aprobacion:** 2025-11-07 + +--- + +**Version:** 1.0 +**Estado:** ACEPTADO e IMPLEMENTADO (Prioridad 1) diff --git a/docs/backend/ARQUITECTURA-MODULOS-COMPLETA.md b/docs/backend/ARQUITECTURA-MODULOS-COMPLETA.md new file mode 100644 index 00000000..bb812a4f --- /dev/null +++ b/docs/backend/ARQUITECTURA-MODULOS-COMPLETA.md @@ -0,0 +1,690 @@ +# Arquitectura Completa de Modulos - Sistema IACT + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Estado:** Implementacion en progreso + +--- + +## Vision General + +Sistema modular de call center con permisos granulares sin jerarquias. Implementacion por prioridades 1-6. + +## Principios Arquitectonicos + +1. **Sin Roles Jerarquicos**: Grupos funcionales combinables (REF: ADR-012) +2. **Permisos Granulares**: Capacidades atomicas formato `sistema.dominio.recurso.accion` +3. **Modularidad**: Cada modulo es independiente con su BD, API y UI +4. **TDD**: Desarrollo guiado por tests +5. **API RESTful**: Django REST Framework con ViewSets +6. **Frontend Modular**: React + Redux Toolkit por modulo + +--- + +## Modulos Implementados + +### Prioridad 1: Sistema de Permisos (COMPLETADO 100%) + +**Estado**: Implementado y documentado +**Ubicacion**: `api/callcentersite/callcentersite/apps/permissions/` +**Documentacion**: `docs/backend/permisos/`, `docs/adr/ADR-012*.md` + +**Modelos (8 tablas)**: +- `Funcion`: Recursos del sistema +- `Capacidad`: Acciones atomicas +- `FuncionCapacidad`: Relacion N:M +- `GrupoPermisos`: Grupos funcionales +- `GrupoCapacidad`: Relacion N:M +- `UsuarioGrupo`: Asignaciones con temporalidad +- `PermisoExcepcional`: Concesion/revocacion temporal +- `AuditoriaPermiso`: Log completo + +**API Endpoints**: `/api/v1/permissions/` +- `/funciones/`, `/capacidades/`, `/grupos/` +- `/usuarios-grupos/`, `/permisos-excepcionales/`, `/auditoria/` +- `/mis-capacidades/`, `/mis-funciones/`, `/verificar-permiso/` + +**Tests**: 87+ tests completos + +--- + +### Prioridad 2: API Layer Permisos (COMPLETADO 100%) + +**Estado**: Implementado +**Componentes**: +- Serializers completos con validaciones +- ViewSets con filtros, busqueda, paginacion +- Middleware `verificar_permiso` para proteger endpoints +- Servicio `PermisoService` con logica centralizada + +--- + +### Prioridad 3: Modulos Operativos + +#### 3.1 Llamadas (IMPLEMENTADO 80%) + +**Estado**: Backend completado, frontend pendiente +**Ubicacion**: `api/callcentersite/callcentersite/apps/llamadas/` +**API**: `/api/v1/llamadas/` + +**Modelos**: +``` +EstadoLlamada +├── codigo (unique) +├── nombre +├── es_final +└── activo + +TipoLlamada +├── codigo (unique) +├── nombre +└── activo + +Llamada +├── codigo (unique, auto-generado CALL-XXXX) +├── numero_telefono +├── tipo (FK) +├── estado (FK) +├── agente (FK User) +├── cliente_nombre, cliente_email, cliente_id +├── fecha_inicio, fecha_fin +├── metadata (JSON) +└── notas + +LlamadaTranscripcion +├── llamada (FK) +├── texto +├── timestamp_inicio, timestamp_fin +├── hablante (agente/cliente) +└── confianza (float) + +LlamadaGrabacion +├── llamada (OneToOne) +├── archivo_url +├── formato (mp3, wav) +├── duracion_segundos +└── tamano_bytes +``` + +**Capacidades Requeridas**: +- `sistema.operaciones.llamadas.ver` +- `sistema.operaciones.llamadas.realizar` + +**Endpoints**: +- `GET /api/v1/llamadas/llamadas/` - Listar llamadas +- `POST /api/v1/llamadas/llamadas/` - Crear llamada +- `GET /api/v1/llamadas/llamadas/{id}/` - Detalle +- `POST /api/v1/llamadas/llamadas/{id}/finalizar/` - Finalizar llamada +- `GET /api/v1/llamadas/estados/` - Listar estados +- `GET /api/v1/llamadas/tipos/` - Listar tipos +- `GET /api/v1/llamadas/transcripciones/` - Transcripciones +- `GET /api/v1/llamadas/grabaciones/` - Grabaciones + +**Mock Data**: `ui/src/mocks/llamadas.json` + +#### 3.2 Tickets (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/tickets/` +**API**: `/api/v1/tickets/` + +**Modelos Planificados**: +``` +EstadoTicket, PrioridadTicket, CategoriaTicket + +Ticket +├── codigo (TKT-XXXX) +├── titulo, descripcion +├── estado, prioridad, categoria +├── creado_por, asignado_a +├── cliente_id, cliente_nombre, cliente_email +├── fecha_creacion, fecha_cierre, fecha_limite +└── metadata + +ComentarioTicket +├── ticket (FK) +├── usuario (FK) +├── contenido +└── es_interno (boolean) +``` + +**Capacidades Requeridas**: +- `sistema.operaciones.tickets.ver` +- `sistema.operaciones.tickets.crear` +- `sistema.operaciones.tickets.editar` +- `sistema.operaciones.tickets.eliminar` + +#### 3.3 Clientes (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/clientes/` +**API**: `/api/v1/clientes/` + +**Modelos Planificados**: +``` +Cliente +├── codigo (CLI-XXXX) +├── nombre, apellido +├── email, telefono +├── fecha_nacimiento +├── metadata +└── activo + +ClienteContacto +├── cliente (FK) +├── tipo (telefono, email, direccion) +├── valor +└── principal (boolean) + +ClienteHistorial +├── cliente (FK) +├── usuario (FK) +├── tipo_evento +├── descripcion +└── metadata +``` + +**Capacidades Requeridas**: +- `sistema.operaciones.clientes.ver` +- `sistema.operaciones.clientes.editar` + +#### 3.4 Metricas (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/metricas/` +**API**: `/api/v1/metricas/` + +**Modelos Planificados**: +``` +Metrica +├── codigo (MET-XXXX) +├── nombre +├── descripcion +├── tipo (contador, gauge, histograma) +└── unidad + +MetricaValor +├── metrica (FK) +├── valor (float) +├── timestamp +└── metadata + +MetricaAgregacion +├── metrica (FK) +├── periodo (hora, dia, semana, mes) +├── valor_min, valor_max, valor_promedio +└── fecha_inicio, fecha_fin +``` + +**Capacidades Requeridas**: +- `sistema.analisis.metricas.ver` + +#### 3.5 Reportes (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/reportes/` +**API**: `/api/v1/reportes/` + +**Modelos Planificados**: +``` +Reporte +├── codigo (REP-XXXX) +├── nombre +├── descripcion +├── query_template +└── parametros_schema (JSON) + +ReporteEjecucion +├── reporte (FK) +├── usuario (FK) +├── parametros (JSON) +├── estado (pendiente, procesando, completado, error) +├── resultado_url +└── fecha_ejecucion + +ReporteParametro +├── reporte (FK) +├── nombre +├── tipo_dato +└── requerido +``` + +**Capacidades Requeridas**: +- `sistema.analisis.reportes.ver` +- `sistema.analisis.reportes.generar` + +#### 3.6 Alertas (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/alertas/` +**API**: `/api/v1/alertas/` + +**Modelos Planificados**: +``` +AlertaRegla +├── codigo (ALT-XXXX) +├── nombre +├── condicion (expresion) +├── severidad (baja, media, alta, critica) +└── activa + +Alerta +├── regla (FK) +├── estado (nueva, reconocida, resuelta) +├── valores_trigger (JSON) +└── fecha_disparo, fecha_resolucion + +AlertaNotificacion +├── alerta (FK) +├── usuario (FK) +├── canal (email, sms, push) +├── enviada +└── fecha_envio +``` + +**Capacidades Requeridas**: +- `sistema.operaciones.alertas.ver` +- `sistema.operaciones.alertas.gestionar` + +--- + +### Prioridad 4: Modulos de Gestion + +#### 4.1 Equipos (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/equipos/` +**API**: `/api/v1/equipos/` + +**Modelos Planificados**: +``` +Equipo +├── codigo (EQP-XXXX) +├── nombre +├── supervisor (FK User) +└── activo + +EquipoMiembro +├── equipo (FK) +├── usuario (FK) +├── fecha_inicio, fecha_fin +└── rol_en_equipo + +EquipoMetrica +├── equipo (FK) +├── metrica (FK) +├── objetivo +└── periodo +``` + +**Capacidades Requeridas**: +- `sistema.supervision.equipos.ver` +- `sistema.supervision.equipos.crear` +- `sistema.supervision.equipos.editar` +- `sistema.supervision.equipos.asignar_miembros` + +#### 4.2 Horarios (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/horarios/` +**API**: `/api/v1/horarios/` + +**Modelos Planificados**: +``` +Horario +├── codigo (HOR-XXXX) +├── nombre +├── fecha_inicio, fecha_fin +└── estado (borrador, publicado, archivado) + +HorarioTurno +├── horario (FK) +├── usuario (FK) +├── dia_semana +├── hora_inicio, hora_fin +└── tipo_turno + +HorarioExcepcion +├── horario (FK) +├── fecha +├── motivo +└── aplicar_a (todos, equipo, usuario) +``` + +**Capacidades Requeridas**: +- `sistema.supervision.horarios.ver` +- `sistema.supervision.horarios.crear` +- `sistema.supervision.horarios.editar` +- `sistema.supervision.horarios.aprobar` + +#### 4.3 Evaluaciones (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/evaluaciones/` +**API**: `/api/v1/evaluaciones/` + +**Modelos Planificados**: +``` +EvaluacionCriterio +├── codigo (EVL-XXXX) +├── nombre +├── peso (porcentaje) +└── activo + +Evaluacion +├── evaluado (FK User) +├── evaluador (FK User) +├── periodo_inicio, periodo_fin +├── puntaje_total +└── estado (borrador, enviada, aprobada) + +EvaluacionResultado +├── evaluacion (FK) +├── criterio (FK) +├── puntaje +└── comentarios +``` + +**Capacidades Requeridas**: +- `sistema.supervision.evaluaciones.ver` +- `sistema.supervision.evaluaciones.crear` +- `sistema.supervision.evaluaciones.aprobar` + +--- + +### Prioridad 5: Modulos Financieros + +#### 5.1 Pagos (PLANIFICADO - CRITICO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/pagos/` +**API**: `/api/v1/pagos/` + +**Modelos Planificados**: +``` +Pago +├── codigo (PAY-XXXX) +├── monto +├── moneda +├── estado (pendiente, aprobado, rechazado, pagado) +├── beneficiario +└── metadata + +PagoDetalle +├── pago (FK) +├── concepto +├── monto +└── porcentaje + +PagoAprobacion +├── pago (FK) +├── aprobador (FK User) +├── nivel_aprobacion +├── decision (aprobar/rechazar) +├── comentarios +└── fecha_decision +``` + +**Capacidades Requeridas** (CRITICAS): +- `sistema.finanzas.pagos.ver` (alto) +- `sistema.finanzas.pagos.aprobar` (critico, auditoria obligatoria) + +#### 5.2 Facturas (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/facturas/` +**API**: `/api/v1/facturas/` + +**Modelos Planificados**: +``` +Factura +├── codigo (FAC-XXXX) +├── cliente (FK) +├── fecha_emision, fecha_vencimiento +├── subtotal, impuestos, total +└── estado (borrador, emitida, pagada, cancelada) + +FacturaLinea +├── factura (FK) +├── descripcion +├── cantidad +├── precio_unitario +└── subtotal + +FacturaPago +├── factura (FK) +├── pago (FK) +├── monto_aplicado +└── fecha_aplicacion +``` + +**Capacidades Requeridas**: +- `sistema.finanzas.facturas.ver` +- `sistema.finanzas.facturas.crear` +- `sistema.finanzas.facturas.emitir` + +#### 5.3 Cobranza (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/cobranza/` +**API**: `/api/v1/cobranza/` + +**Modelos Planificados**: +``` +Cobranza +├── codigo (COB-XXXX) +├── cliente (FK) +├── monto_total, monto_pendiente +├── dias_vencido +└── prioridad + +CobranzaAccion +├── cobranza (FK) +├── usuario (FK) +├── tipo_accion (llamada, email, visita) +├── resultado +└── fecha_accion + +CobranzaHistorial +├── cobranza (FK) +├── estado_anterior, estado_nuevo +├── usuario (FK) +└── fecha_cambio +``` + +**Capacidades Requeridas**: +- `sistema.finanzas.cobranza.ver` +- `sistema.finanzas.cobranza.gestionar` + +--- + +### Prioridad 6: Modulos Estrategicos + +#### 6.1 Presupuestos (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/presupuestos/` +**API**: `/api/v1/presupuestos/` + +**Modelos Planificados**: +``` +Presupuesto +├── codigo (PRE-XXXX) +├── periodo_fiscal +├── monto_total +├── estado (borrador, aprobado, activo, cerrado) +└── aprobado_por (FK User) + +PresupuestoCategoria +├── presupuesto (FK) +├── categoria +├── monto_asignado, monto_ejecutado +└── porcentaje_ejecucion + +PresupuestoEjecucion +├── presupuesto (FK) +├── categoria (FK) +├── monto +├── concepto +└── fecha_ejecucion +``` + +**Capacidades Requeridas** (SOLO DIRECTORES): +- `sistema.direccion.presupuestos.ver` +- `sistema.direccion.presupuestos.crear` +- `sistema.direccion.presupuestos.aprobar` + +#### 6.2 Politicas (PLANIFICADO) + +**Ubicacion**: `api/callcentersite/callcentersite/apps/politicas/` +**API**: `/api/v1/politicas/` + +**Modelos Planificados**: +``` +Politica +├── codigo (POL-XXXX) +├── titulo +├── categoria +├── vigente +└── fecha_vigencia + +PoliticaVersion +├── politica (FK) +├── version +├── contenido +├── cambios +└── autor (FK User) + +PoliticaAceptacion +├── politica_version (FK) +├── usuario (FK) +├── fecha_aceptacion +└── ip_address +``` + +**Capacidades Requeridas**: +- `sistema.direccion.politicas.ver` +- `sistema.direccion.politicas.crear` +- `sistema.direccion.politicas.aprobar` + +--- + +## Diagrama de Arquitectura General + +``` +┌──────────────────────────────────────────────────────────────┐ +│ FRONTEND (React) │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Permissions│ │ Llamadas │ │ Tickets │ │ Clientes │ │ +│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │ +│ │ │ │ │ │ +│ └─────────────┴──────────────┴──────────────┘ │ +│ │ │ +│ v │ +│ Redux Store + RTK Query │ +│ │ +└───────────────────────────────┬───────────────────────────────┘ + │ HTTP/REST + v +┌──────────────────────────────────────────────────────────────┐ +│ BACKEND API (Django REST) │ +│ │ +│ /api/v1/permissions/ Sistema de permisos granular │ +│ /api/v1/llamadas/ Gestion de llamadas │ +│ /api/v1/tickets/ Sistema de tickets │ +│ /api/v1/clientes/ Gestion de clientes │ +│ /api/v1/metricas/ Metricas y KPIs │ +│ /api/v1/reportes/ Generacion de reportes │ +│ /api/v1/alertas/ Sistema de alertas │ +│ /api/v1/equipos/ Gestion de equipos │ +│ /api/v1/horarios/ Planificacion horarios │ +│ /api/v1/evaluaciones/ Evaluaciones de desempeno │ +│ /api/v1/pagos/ Aprobacion de pagos │ +│ /api/v1/facturas/ Gestion de facturas │ +│ /api/v1/cobranza/ Gestion de cobranza │ +│ /api/v1/presupuestos/ Gestion presupuestaria │ +│ /api/v1/politicas/ Politicas corporativas │ +│ │ +└───────────────────────────────┬───────────────────────────────┘ + │ + v +┌──────────────────────────────────────────────────────────────┐ +│ CAPA DE SERVICIOS │ +│ │ +│ PermisoService Verificacion de permisos │ +│ LlamadaService Logica de llamadas │ +│ TicketService Logica de tickets │ +│ ClienteService Logica de clientes │ +│ MetricaService Calculo de metricas │ +│ ReporteService Generacion de reportes │ +│ AlertaService Procesamiento de alertas │ +│ ... │ +│ │ +└───────────────────────────────┬───────────────────────────────┘ + │ + v +┌──────────────────────────────────────────────────────────────┐ +│ BASE DE DATOS (PostgreSQL) │ +│ │ +│ permissions_* 8 tablas permisos │ +│ llamadas_* 5 tablas llamadas │ +│ tickets_* 5 tablas tickets │ +│ clientes_* 3 tablas clientes │ +│ metricas_* 3 tablas metricas │ +│ reportes_* 3 tablas reportes │ +│ alertas_* 3 tablas alertas │ +│ equipos_* 3 tablas equipos │ +│ horarios_* 3 tablas horarios │ +│ evaluaciones_* 3 tablas evaluaciones │ +│ pagos_* 3 tablas pagos │ +│ facturas_* 3 tablas facturas │ +│ cobranza_* 3 tablas cobranza │ +│ presupuestos_* 3 tablas presupuestos │ +│ politicas_* 3 tablas politicas │ +│ │ +│ Total estimado: ~55 tablas │ +│ │ +└──────────────────────────────────────────────────────────────┘ +``` + +--- + +## Resumen de Estado + +**Completado (Prioridad 1-2)**: +- Sistema de Permisos: 100% +- API Layer Permisos: 100% +- Documentacion Permisos: 100% + +**En Progreso (Prioridad 3)**: +- Llamadas Backend: 80% +- Llamadas Frontend: 0% + +**Planificado (Prioridad 3-6)**: +- 12 modulos adicionales backend +- Frontend completo para 13 modulos +- Integraciones entre modulos + +--- + +## Estimaciones + +**Backend**: +- Modelos: ~55 tablas +- Endpoints: ~150 endpoints +- Tests: ~500 tests + +**Frontend**: +- Componentes: ~200 componentes React +- Tests: ~400 tests +- Paginas: ~50 paginas + +**Documentacion**: +- ADRs: 15-20 documentos +- APIs: 13 documentos completos +- Guias: 30+ guias operativas + +--- + +## Referencias + +- ADR-012: Permisos sin roles jerarquicos +- API Permisos: `docs/backend/permisos/API-permisos.md` +- Arquitectura Permisos: `docs/backend/permisos/arquitectura-permisos-granular.md` +- CODEOWNERS: `/CODEOWNERS` +- Mock Data: `ui/src/mocks/` + +--- + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Mantenedores:** Architecture Team, Backend Team, Frontend Team diff --git a/docs/backend/permisos/API-permisos.md b/docs/backend/permisos/API-permisos.md new file mode 100644 index 00000000..553adddf --- /dev/null +++ b/docs/backend/permisos/API-permisos.md @@ -0,0 +1,679 @@ +# API del Sistema de Permisos Granular + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Base URL:** `/api/v1/permissions/` +**Autenticacion:** JWT (Bearer token) + +--- + +## Descripcion General + +API RESTful para el sistema de permisos granular sin roles jerarquicos. Permite gestionar funciones, capacidades, grupos de permisos, asignaciones de usuarios y permisos excepcionales. + +## Autenticacion + +Todos los endpoints requieren autenticacion JWT: + +```bash +Authorization: Bearer +``` + +## Endpoints + +### Funciones + +Gestion de funciones del sistema (dashboards, usuarios, llamadas, etc) + +#### Listar Funciones + +``` +GET /api/v1/permissions/funciones/ +``` + +**Parametros de query:** +- `dominio` (string): Filtrar por dominio +- `categoria` (string): Filtrar por categoria +- `activa` (boolean): Filtrar por estado +- `search` (string): Buscar por nombre o descripcion +- `ordering` (string): Ordenar resultados + +**Respuesta:** +```json +{ + "count": 10, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "nombre": "llamadas", + "nombre_completo": "sistema.operaciones.llamadas", + "descripcion": "Gestion de llamadas telefonicas", + "dominio": "operaciones", + "categoria": "operaciones", + "icono": "phone", + "orden_menu": 20, + "activa": true, + "created_at": "2025-11-07T10:00:00Z", + "updated_at": "2025-11-07T10:00:00Z" + } + ] +} +``` + +#### Obtener Funcion + +``` +GET /api/v1/permissions/funciones/{id}/ +``` + +**Respuesta:** Objeto Funcion individual + +#### Crear Funcion + +``` +POST /api/v1/permissions/funciones/ +``` + +**Body:** +```json +{ + "nombre": "reportes", + "nombre_completo": "sistema.analisis.reportes", + "descripcion": "Generacion de reportes", + "dominio": "analisis", + "categoria": "analisis", + "icono": "report", + "orden_menu": 70 +} +``` + +#### Actualizar Funcion + +``` +PUT /api/v1/permissions/funciones/{id}/ +PATCH /api/v1/permissions/funciones/{id}/ +``` + +#### Eliminar Funcion + +``` +DELETE /api/v1/permissions/funciones/{id}/ +``` + +--- + +### Capacidades + +Gestion de capacidades atomicas (acciones especificas) + +#### Listar Capacidades + +``` +GET /api/v1/permissions/capacidades/ +``` + +**Parametros de query:** +- `dominio` (string): Filtrar por dominio +- `recurso` (string): Filtrar por recurso +- `accion` (string): Filtrar por accion +- `nivel_sensibilidad` (string): bajo|normal|alto|critico +- `activa` (boolean): Filtrar por estado +- `search` (string): Buscar + +**Respuesta:** +```json +{ + "count": 27, + "results": [ + { + "id": 1, + "nombre_completo": "sistema.operaciones.llamadas.ver", + "descripcion": "Ver llamadas", + "accion": "ver", + "recurso": "llamadas", + "dominio": "operaciones", + "nivel_sensibilidad": "bajo", + "requiere_auditoria": false, + "activa": true, + "created_at": "2025-11-07T10:00:00Z" + } + ] +} +``` + +#### Crear Capacidad + +``` +POST /api/v1/permissions/capacidades/ +``` + +**Body:** +```json +{ + "nombre_completo": "sistema.operaciones.tickets.cerrar", + "descripcion": "Cerrar tickets de soporte", + "accion": "cerrar", + "recurso": "tickets", + "dominio": "operaciones", + "nivel_sensibilidad": "normal", + "requiere_auditoria": false +} +``` + +--- + +### Grupos de Permisos + +Gestion de grupos funcionales (NO roles jerarquicos) + +#### Listar Grupos + +``` +GET /api/v1/permissions/grupos/ +``` + +**Parametros de query:** +- `tipo_acceso` (string): operativo|gestion|analisis|estrategico|tecnico|finanzas|calidad +- `activo` (boolean): Filtrar por estado +- `search` (string): Buscar + +**Respuesta:** +```json +{ + "count": 8, + "results": [ + { + "id": 1, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente", + "descripcion": "Grupo para agentes de atencion", + "tipo_acceso": "operativo", + "activo": true, + "created_at": "2025-11-07T10:00:00Z", + "updated_at": "2025-11-07T10:00:00Z", + "capacidades_count": 6 + } + ] +} +``` + +#### Obtener Grupo (Detalle con Capacidades) + +``` +GET /api/v1/permissions/grupos/{id}/ +``` + +**Respuesta:** +```json +{ + "id": 1, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente", + "descripcion": "Grupo para agentes de atencion", + "tipo_acceso": "operativo", + "activo": true, + "created_at": "2025-11-07T10:00:00Z", + "updated_at": "2025-11-07T10:00:00Z", + "capacidades": [ + { + "id": 1, + "nombre_completo": "sistema.operaciones.llamadas.ver", + "nivel_sensibilidad": "bajo" + }, + { + "id": 2, + "nombre_completo": "sistema.operaciones.llamadas.realizar", + "nivel_sensibilidad": "normal" + } + ] +} +``` + +#### Obtener Capacidades de Grupo + +``` +GET /api/v1/permissions/grupos/{id}/capacidades/ +``` + +**Respuesta:** +```json +{ + "capacidades": [ + { + "id": 1, + "nombre_completo": "sistema.operaciones.llamadas.ver", + "descripcion": "Ver llamadas", + "accion": "ver", + "recurso": "llamadas", + "dominio": "operaciones", + "nivel_sensibilidad": "bajo", + "requiere_auditoria": false, + "activa": true + } + ] +} +``` + +#### Agregar Capacidad a Grupo + +``` +POST /api/v1/permissions/grupos/{id}/agregar_capacidad/ +``` + +**Body:** +```json +{ + "capacidad_id": 5 +} +``` + +**Respuesta:** +```json +{ + "message": "Capacidad agregada exitosamente" +} +``` + +--- + +### Asignaciones Usuario-Grupo + +Gestion de usuarios asignados a grupos + +#### Listar Asignaciones + +``` +GET /api/v1/permissions/usuarios-grupos/ +``` + +**Parametros de query:** +- `usuario` (int): ID del usuario +- `grupo` (int): ID del grupo +- `activo` (boolean): Filtrar por estado + +**Respuesta:** +```json +{ + "count": 15, + "results": [ + { + "id": 1, + "usuario": 10, + "grupo": 2, + "fecha_asignacion": "2025-11-07T10:00:00Z", + "fecha_expiracion": null, + "asignado_por": 1, + "activo": true, + "usuario_username": "maria.garcia", + "grupo_nombre": "Atencion al Cliente" + } + ] +} +``` + +#### Crear Asignacion + +``` +POST /api/v1/permissions/usuarios-grupos/ +``` + +**Body:** +```json +{ + "usuario": 10, + "grupo": 2, + "asignado_por": 1, + "activo": true, + "fecha_expiracion": "2025-12-31T23:59:59Z" +} +``` + +**Validaciones:** +- Usuario y grupo deben existir +- No puede haber asignaciones duplicadas (usuario-grupo) +- fecha_expiracion debe ser futura (opcional) + +#### Desactivar Asignacion + +``` +POST /api/v1/permissions/usuarios-grupos/{id}/desactivar/ +``` + +**Respuesta:** +```json +{ + "message": "Asignacion desactivada" +} +``` + +--- + +### Permisos Excepcionales + +Concesion o revocacion temporal de capacidades especificas + +#### Listar Permisos Excepcionales + +``` +GET /api/v1/permissions/permisos-excepcionales/ +``` + +**Parametros de query:** +- `usuario` (int): ID del usuario +- `capacidad` (int): ID de la capacidad +- `tipo` (string): conceder|revocar +- `activo` (boolean): Filtrar por estado + +**Respuesta:** +```json +{ + "count": 3, + "results": [ + { + "id": 1, + "usuario": 10, + "capacidad": 25, + "tipo": "conceder", + "fecha_inicio": "2025-11-07T10:00:00Z", + "fecha_fin": "2025-11-30T23:59:59Z", + "motivo": "Proyecto especial fin de año", + "autorizado_por": 1, + "activo": true, + "created_at": "2025-11-07T10:00:00Z", + "usuario_username": "juan.perez", + "capacidad_nombre": "sistema.finanzas.pagos.aprobar", + "autorizado_por_username": "director" + } + ] +} +``` + +#### Crear Permiso Excepcional + +``` +POST /api/v1/permissions/permisos-excepcionales/ +``` + +**Body:** +```json +{ + "usuario": 10, + "capacidad": 25, + "tipo": "conceder", + "fecha_inicio": "2025-11-07T10:00:00Z", + "fecha_fin": "2025-11-30T23:59:59Z", + "motivo": "Proyecto especial fin de año requiere aprobaciones adicionales", + "autorizado_por": 1, + "activo": true +} +``` + +**Validaciones:** +- `tipo` debe ser "conceder" o "revocar" +- `fecha_fin` debe ser posterior a `fecha_inicio` +- `motivo` es obligatorio (para auditoria) + +--- + +### Auditoria de Permisos + +Consulta de logs de auditoria (solo lectura) + +#### Listar Logs de Auditoria + +``` +GET /api/v1/permissions/auditoria/ +``` + +**Parametros de query:** +- `usuario` (int): ID del usuario +- `capacidad` (string): Capacidad utilizada +- `accion_realizada` (string): Accion realizada + +**Respuesta:** +```json +{ + "count": 1250, + "results": [ + { + "id": 1000, + "usuario": 10, + "capacidad": "sistema.finanzas.pagos.aprobar", + "accion_realizada": "PAGO_APROBADO", + "recurso_accedido": "PAY-12345", + "ip_address": "192.168.1.100", + "user_agent": "Mozilla/5.0...", + "metadata": { + "monto": 1000.00, + "moneda": "USD", + "aprobador": "director" + }, + "timestamp": "2025-11-07T10:00:00Z", + "usuario_username": "juan.perez" + } + ] +} +``` + +**Nota:** Endpoint de solo lectura. No permite POST, PUT, DELETE. + +--- + +### Endpoints Personalizados + +#### Mis Capacidades + +Obtiene capacidades del usuario autenticado + +``` +GET /api/v1/permissions/mis-capacidades/ +``` + +**Respuesta:** +```json +{ + "usuario_id": 10, + "username": "maria.garcia", + "capacidades": [ + "sistema.operaciones.llamadas.ver", + "sistema.operaciones.llamadas.realizar", + "sistema.operaciones.tickets.ver", + "sistema.operaciones.tickets.crear", + "sistema.vistas.dashboards.ver" + ] +} +``` + +#### Mis Funciones + +Obtiene funciones accesibles para el usuario autenticado + +``` +GET /api/v1/permissions/mis-funciones/ +``` + +**Respuesta:** +```json +{ + "usuario_id": 10, + "username": "maria.garcia", + "funciones": [ + { + "id": 1, + "nombre": "llamadas", + "nombre_completo": "sistema.operaciones.llamadas", + "dominio": "operaciones", + "categoria": "operaciones", + "icono": "phone", + "orden_menu": 20 + }, + { + "id": 2, + "nombre": "tickets", + "nombre_completo": "sistema.operaciones.tickets", + "dominio": "operaciones", + "categoria": "operaciones", + "icono": "ticket", + "orden_menu": 30 + } + ] +} +``` + +#### Verificar Permiso + +Verifica si usuario autenticado tiene una capacidad especifica + +``` +POST /api/v1/permissions/verificar-permiso/ +``` + +**Body:** +```json +{ + "capacidad": "sistema.finanzas.pagos.aprobar" +} +``` + +**Respuesta:** +```json +{ + "usuario_id": 10, + "capacidad": "sistema.finanzas.pagos.aprobar", + "tiene_permiso": false +} +``` + +--- + +## Errores Comunes + +### 401 Unauthorized + +```json +{ + "detail": "Authentication credentials were not provided." +} +``` + +**Causa:** Token JWT no proporcionado o invalido + +**Solucion:** Incluir header `Authorization: Bearer ` + +### 403 Forbidden + +```json +{ + "error": "Permiso denegado. Requiere: sistema.administracion.usuarios.crear", + "capacidades_requeridas": ["sistema.administracion.usuarios.crear"] +} +``` + +**Causa:** Usuario no tiene capacidad requerida + +**Solucion:** Solicitar asignacion de capacidad a administrador + +### 400 Bad Request + +```json +{ + "codigo": ["Este campo debe ser unico."] +} +``` + +**Causa:** Violacion de constraint (ej: codigo duplicado) + +**Solucion:** Usar valor unico + +### 404 Not Found + +```json +{ + "detail": "No encontrado." +} +``` + +**Causa:** Recurso no existe + +**Solucion:** Verificar ID del recurso + +--- + +## Ejemplos de Uso + +### Ejemplo 1: Obtener capacidades de mi usuario + +```bash +curl -X GET \ + 'https://api.example.com/api/v1/permissions/mis-capacidades/' \ + -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' +``` + +### Ejemplo 2: Asignar usuario a grupo + +```bash +curl -X POST \ + 'https://api.example.com/api/v1/permissions/usuarios-grupos/' \ + -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' \ + -H 'Content-Type: application/json' \ + -d '{ + "usuario": 15, + "grupo": 2, + "asignado_por": 1, + "activo": true + }' +``` + +### Ejemplo 3: Conceder permiso excepcional temporal + +```bash +curl -X POST \ + 'https://api.example.com/api/v1/permissions/permisos-excepcionales/' \ + -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' \ + -H 'Content-Type: application/json' \ + -d '{ + "usuario": 15, + "capacidad": 25, + "tipo": "conceder", + "fecha_inicio": "2025-11-07T00:00:00Z", + "fecha_fin": "2025-11-30T23:59:59Z", + "motivo": "Proyecto especial requiere aprobaciones financieras", + "autorizado_por": 1, + "activo": true + }' +``` + +### Ejemplo 4: Consultar auditoria de pagos aprobados + +```bash +curl -X GET \ + 'https://api.example.com/api/v1/permissions/auditoria/?capacidad=sistema.finanzas.pagos.aprobar&accion_realizada=PAGO_APROBADO' \ + -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIs...' +``` + +--- + +## Limitaciones + +- Paginacion: 50 resultados por pagina por defecto +- Rate limiting: 100 requests/minuto por usuario +- Auditoria: Solo lectura, no se puede modificar/eliminar +- Permisos excepcionales: Se aplican inmediatamente, sin necesidad de re-login + +--- + +## Referencias + +- ADR: `docs/adr/ADR-012-sistema-permisos-sin-roles-jerarquicos.md` +- Arquitectura: `docs/backend/permisos/arquitectura-permisos-granular.md` +- Codigo: `api/callcentersite/callcentersite/apps/permissions/` +- OpenAPI Schema: `/api/schema/` +- Swagger UI: `/api/docs/` + +--- + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Mantenedores:** Backend Team, Security Team diff --git a/docs/backend/permisos/arquitectura-permisos-granular.md b/docs/backend/permisos/arquitectura-permisos-granular.md new file mode 100644 index 00000000..bbc9a559 --- /dev/null +++ b/docs/backend/permisos/arquitectura-permisos-granular.md @@ -0,0 +1,533 @@ +# Arquitectura del Sistema de Permisos Granular + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Estado:** Implementado (Prioridad 1) +**ADR:** ADR-012-sistema-permisos-sin-roles-jerarquicos.md + +--- + +## Vision General + +Sistema de permisos granular basado en capacidades, SIN roles jerarquicos. Permite control fino de acceso a recursos mediante grupos funcionales combinables. + +### Principio Fundamental + +**NO usamos roles jerarquicos** (Admin, Supervisor, Agent) +**SI usamos grupos funcionales** (atencion_cliente, gestion_equipos, visualizacion_metricas) + +--- + +## Arquitectura de Componentes + +### Diagrama de Alto Nivel + +``` +┌─────────────────────────────────────────────────────────────┐ +│ USUARIO │ +│ (User de Django Auth) │ +└────────────────────┬────────────────────────────────────────┘ + │ + │ puede tener MULTIPLES + v +┌─────────────────────────────────────────────────────────────┐ +│ GRUPOS DE PERMISOS │ +│ (Agrupaciones Funcionales) │ +│ │ +│ - atencion_cliente │ +│ - gestion_equipos │ +│ - visualizacion_metricas │ +│ - administracion_usuarios │ +│ - (combinables, sin jerarquia) │ +└────────────────────┬────────────────────────────────────────┘ + │ + │ contiene + v +┌─────────────────────────────────────────────────────────────┐ +│ CAPACIDADES │ +│ (Permisos Atomicos) │ +│ │ +│ Formato: sistema.dominio.recurso.accion │ +│ │ +│ Ejemplos: │ +│ - sistema.vistas.dashboards.ver │ +│ - sistema.operaciones.llamadas.realizar │ +│ - sistema.finanzas.pagos.aprobar │ +└────────────────────┬────────────────────────────────────────┘ + │ + │ actua sobre + v +┌─────────────────────────────────────────────────────────────┐ +│ FUNCIONES │ +│ (Recursos del Sistema) │ +│ │ +│ - dashboards │ +│ - usuarios │ +│ - llamadas │ +│ - tickets │ +│ - metricas │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Flujo de Verificacion de Permiso + +``` +1. Request HTTP llega al endpoint + | + v +2. Middleware extrae usuario autenticado + | + v +3. Middleware verifica capacidad requerida + | + +-- 3a. Obtener grupos activos del usuario + | + +-- 3b. Obtener capacidades de esos grupos + | + +-- 3c. Verificar permisos excepcionales + | (conceder o revocar) + | + v +4. Decisión + | + +-- SI tiene permiso: + | - Registrar auditoria (si requiere) + | - Permitir acceso + | - Continuar request + | + +-- NO tiene permiso: + - Registrar auditoria + - HTTP 403 Forbidden + - Respuesta error +``` + +--- + +## Modelo de Datos + +### Diagrama Entidad-Relacion + +``` +┌──────────────────┐ +│ Usuario │ +│ (Django User) │ +└────────┬─────────┘ + │ + │ N:M (usuarios_grupos) + │ + v +┌──────────────────┐ ┌──────────────────┐ +│ GrupoPermisos │-------->│ Capacidad │ +│ │ │ │ +│ codigo │ N:M │ nombre_completo │ +│ nombre_display │(grupo_ │ accion │ +│ descripcion │capaci- │ recurso │ +│ tipo_acceso │dades) │ dominio │ +│ activo │ │ sensibilidad │ +└──────────────────┘ │ requiere_audit │ + └────────┬─────────┘ + │ + │ N:M (funcion_capacidades) + │ + v + ┌──────────────────┐ + │ Funcion │ + │ │ + │ nombre │ + │ nombre_completo │ + │ dominio │ + │ categoria │ + │ orden_menu │ + └──────────────────┘ + +┌──────────────────┐ +│PermisoExcepcional│ +│ │ +│ usuario │────┐ +│ capacidad │ │ FK a Usuario +│ tipo │ │ +│ fecha_inicio │ │ +│ fecha_fin │ │ +│ motivo │ │ +│ autorizado_por │────┘ +└──────────────────┘ + +┌──────────────────┐ +│AuditoriaPermiso │ +│ │ +│ usuario │────┐ +│ capacidad │ │ FK a Usuario +│ accion_realizada│ │ +│ recurso_accedido│ │ +│ ip_address │ │ +│ user_agent │ │ +│ metadata (JSON) │ │ +│ timestamp │ │ +└──────────────────┘ +``` + +### Relaciones Clave + +1. **Usuario → GrupoPermisos:** N:M (un usuario puede tener multiples grupos) + +2. **GrupoPermisos → Capacidad:** N:M (un grupo puede tener multiples capacidades) + +3. **Capacidad → Funcion:** N:M (una capacidad puede aplicar a multiples funciones) + +4. **Usuario → PermisoExcepcional:** 1:N (un usuario puede tener multiples excepciones) + +5. **Usuario → AuditoriaPermiso:** 1:N (un usuario genera multiples registros de auditoria) + +--- + +## Tablas de Base de Datos + +### 1. funciones + +Recursos del sistema (dashboards, usuarios, metricas, etc.) + +```sql +CREATE TABLE permissions_funciones ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + nombre VARCHAR(100) NOT NULL, + nombre_completo VARCHAR(200) UNIQUE NOT NULL, + descripcion TEXT, + dominio VARCHAR(100) NOT NULL, + categoria VARCHAR(50), + icono VARCHAR(50), + orden_menu INT DEFAULT 0, + activa BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_dominio (dominio), + INDEX idx_activa (activa), + INDEX idx_categoria (categoria) +); +``` + +### 2. capacidades + +Acciones atomicas sobre recursos. + +```sql +CREATE TABLE permissions_capacidades ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + nombre_completo VARCHAR(200) UNIQUE NOT NULL, + descripcion TEXT, + accion VARCHAR(50) NOT NULL, + recurso VARCHAR(100) NOT NULL, + dominio VARCHAR(100) NOT NULL, + nivel_sensibilidad VARCHAR(20) DEFAULT 'normal', + requiere_auditoria BOOLEAN DEFAULT FALSE, + activa BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_accion (accion), + INDEX idx_recurso (recurso), + INDEX idx_sensibilidad (nivel_sensibilidad), + + CHECK (nivel_sensibilidad IN ('bajo', 'normal', 'alto', 'critico')) +); +``` + +### 3. funcion_capacidades + +Relacion N:M entre Funcion y Capacidad. + +```sql +CREATE TABLE permissions_funcion_capacidades ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + funcion_id BIGINT NOT NULL, + capacidad_id BIGINT NOT NULL, + requerida BOOLEAN DEFAULT FALSE, + visible_en_ui BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (funcion_id) REFERENCES permissions_funciones(id) ON DELETE CASCADE, + FOREIGN KEY (capacidad_id) REFERENCES permissions_capacidades(id) ON DELETE CASCADE, + + UNIQUE (funcion_id, capacidad_id) +); +``` + +### 4. grupos_permisos + +Grupos funcionales de capacidades (NO roles jerarquicos). + +```sql +CREATE TABLE permissions_grupos_permisos ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + codigo VARCHAR(100) UNIQUE NOT NULL, + nombre_display VARCHAR(200) NOT NULL, + descripcion TEXT, + tipo_acceso VARCHAR(50), + activo BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + CHECK (tipo_acceso IN ('operativo', 'gestion', 'analisis', 'estrategico', 'tecnico', 'finanzas', 'calidad')) +); +``` + +### 5. grupo_capacidades + +Relacion N:M entre Grupo y Capacidad. + +```sql +CREATE TABLE permissions_grupo_capacidades ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + grupo_id BIGINT NOT NULL, + capacidad_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (grupo_id) REFERENCES permissions_grupos_permisos(id) ON DELETE CASCADE, + FOREIGN KEY (capacidad_id) REFERENCES permissions_capacidades(id) ON DELETE CASCADE, + + UNIQUE (grupo_id, capacidad_id) +); +``` + +### 6. usuarios_grupos + +Usuario asignado a grupos (multiples, temporales opcionales). + +```sql +CREATE TABLE permissions_usuarios_grupos ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + usuario_id BIGINT NOT NULL, + grupo_id BIGINT NOT NULL, + fecha_asignacion TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + fecha_expiracion TIMESTAMP NULL, + asignado_por BIGINT, + activo BOOLEAN DEFAULT TRUE, + + FOREIGN KEY (usuario_id) REFERENCES auth_user(id) ON DELETE CASCADE, + FOREIGN KEY (grupo_id) REFERENCES permissions_grupos_permisos(id) ON DELETE CASCADE, + FOREIGN KEY (asignado_por) REFERENCES auth_user(id) ON DELETE SET NULL, + + UNIQUE (usuario_id, grupo_id) +); +``` + +### 7. permisos_excepcionales + +Conceder o revocar capacidad especifica temporalmente. + +```sql +CREATE TABLE permissions_permisos_excepcionales ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + usuario_id BIGINT NOT NULL, + capacidad_id BIGINT NOT NULL, + tipo VARCHAR(20) NOT NULL, + fecha_inicio TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + fecha_fin TIMESTAMP NULL, + motivo TEXT NOT NULL, + autorizado_por BIGINT, + activo BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (usuario_id) REFERENCES auth_user(id) ON DELETE CASCADE, + FOREIGN KEY (capacidad_id) REFERENCES permissions_capacidades(id) ON DELETE CASCADE, + FOREIGN KEY (autorizado_por) REFERENCES auth_user(id) ON DELETE SET NULL, + + INDEX idx_usuario_activo (usuario_id, activo), + + CHECK (tipo IN ('conceder', 'revocar')) +); +``` + +### 8. auditoria_permisos + +Registro de TODOS los accesos a recursos protegidos. + +```sql +CREATE TABLE permissions_auditoria_permisos ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + usuario_id BIGINT, + capacidad VARCHAR(200) NOT NULL, + accion_realizada VARCHAR(100) NOT NULL, + recurso_accedido VARCHAR(200), + ip_address VARCHAR(50), + user_agent TEXT, + metadata JSON, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + FOREIGN KEY (usuario_id) REFERENCES auth_user(id) ON DELETE SET NULL, + + INDEX idx_usuario (usuario_id), + INDEX idx_timestamp (timestamp), + INDEX idx_accion (accion_realizada) +); +``` + +--- + +## Dominios del Sistema + +| Dominio | Descripcion | Ejemplos de Recursos | +|---------|-------------|---------------------| +| vistas | Visualizaciones y dashboards | dashboards | +| administracion | Gestion del sistema | usuarios | +| analisis | Metricas y reportes | metricas, reportes | +| operaciones | Operaciones diarias | llamadas, tickets, clientes | +| finanzas | Gestion financiera | pagos, facturas, cobranza | +| calidad | Control de calidad | auditoria, evaluaciones | +| supervision | Gestion de equipos | equipos, horarios, excepciones | +| direccion | Decisiones estrategicas | presupuestos, politicas | +| tecnico | Configuracion tecnica | configuracion | +| monitoreo | Alertas y seguimiento | alertas | + +--- + +## Niveles de Sensibilidad + +| Nivel | Descripcion | Requiere Auditoria | Ejemplos | +|-------|-------------|-------------------|----------| +| bajo | Consultas basicas | NO | ver dashboards, ver metricas | +| normal | Operaciones estandar | NO | crear tickets, editar clientes | +| alto | Modificaciones importantes | SI | editar usuarios, eliminar tickets | +| critico | Acciones de alto impacto | SI | aprobar pagos, publicar politicas | + +--- + +## Ejemplos de Uso + +### Caso 1: Usuario de Atencion al Cliente + +**Usuario:** Maria +**Responsabilidad:** Atender clientes, gestionar tickets + +**Grupos asignados:** +- `atencion_cliente` +- `visualizacion_metricas` + +**Capacidades resultantes:** +``` +sistema.operaciones.llamadas.ver +sistema.operaciones.llamadas.realizar +sistema.operaciones.tickets.ver +sistema.operaciones.tickets.crear +sistema.operaciones.tickets.editar +sistema.operaciones.clientes.ver +sistema.vistas.dashboards.ver +sistema.analisis.metricas.ver +``` + +**Puede hacer:** +- Ver y realizar llamadas +- Crear y editar tickets +- Consultar informacion de clientes +- Ver sus metricas personales + +**NO puede hacer:** +- Aprobar pagos (no tiene `sistema.finanzas.pagos.aprobar`) +- Eliminar usuarios (no tiene `sistema.administracion.usuarios.eliminar`) +- Configurar sistema (no tiene `sistema.tecnico.configuracion.*`) + +### Caso 2: Coordinador de Equipo + +**Usuario:** Carlos +**Responsabilidad:** Gestionar equipo, planificar horarios, atender clientes + +**Grupos asignados:** +- `atencion_cliente` +- `gestion_equipos` +- `gestion_horarios` +- `analisis_avanzado` + +**Capacidades resultantes:** +``` +(Todas de atencion_cliente) ++ sistema.supervision.equipos.ver ++ sistema.supervision.equipos.crear ++ sistema.supervision.equipos.editar ++ sistema.supervision.equipos.asignar_miembros ++ sistema.supervision.horarios.ver ++ sistema.supervision.horarios.crear ++ sistema.supervision.horarios.editar ++ sistema.supervision.horarios.aprobar ++ sistema.analisis.reportes.generar +``` + +**Puede hacer:** +- TODO lo que Maria puede hacer (atencion_cliente) +- PLUS: Gestionar su equipo +- PLUS: Aprobar horarios +- PLUS: Generar reportes + +**NO puede hacer:** +- Aprobar pagos +- Publicar politicas +- Configurar sistema + +### Caso 3: Permiso Excepcional Temporal + +**Usuario:** Juan (normalmente solo `atencion_cliente`) +**Necesidad:** Aprobar pagos durante 1 mes (proyecto especial) + +**Solucion: PermisoExcepcional** +```python +PermisoExcepcional.objects.create( + usuario=juan, + capacidad=Capacidad.objects.get( + nombre_completo='sistema.finanzas.pagos.aprobar' + ), + tipo='conceder', + fecha_inicio=datetime(2025, 11, 1), + fecha_fin=datetime(2025, 11, 30), + motivo='Proyecto especial fin de año requiere aprobaciones adicionales', + autorizado_por=director +) +``` + +**Resultado:** +- Juan puede aprobar pagos del 1 al 30 de noviembre +- Despues del 30 de noviembre, pierde el permiso automaticamente +- Queda registrado quien autorizo y por que + +--- + +## Servicios y APIs + +### PermisoService + +Servicio principal para verificar permisos. + +**Metodos:** +- `usuarioTienePermiso(usuario_id, capacidad_requerida)`: Verifica si usuario tiene capacidad +- `obtenerCapacidadesUsuario(usuario_id)`: Obtiene todas las capacidades del usuario +- `obtenerFuncionesAccesibles(usuario_id)`: Obtiene funciones que el usuario puede acceder +- `registrarAcceso(...)`: Registra acceso en auditoria + +### Middleware: verificarPermiso + +Middleware para proteger endpoints. + +**Uso:** +```python +from callcentersite.apps.permissions.middleware import verificarPermiso + +@verificarPermiso('sistema.finanzas.pagos.aprobar') +def aprobar_pago(request, pago_id): + # Solo usuarios con capacidad 'aprobar pagos' llegan aqui + pago = Pago.objects.get(id=pago_id) + pago.estado = 'aprobado' + pago.save() + return JsonResponse({'status': 'aprobado'}) +``` + +--- + +## Referencias + +- **ADR:** ADR-012-sistema-permisos-sin-roles-jerarquicos.md +- **Codigo:** `api/callcentersite/callcentersite/apps/permissions/` +- **Tests:** `api/callcentersite/callcentersite/apps/permissions/tests/test_models.py` +- **Especificaciones:** Documento Sistema de Permisos Granular (Prioridad 1-6) + +--- + +**Version:** 1.0 +**Fecha:** 2025-11-07 +**Estado:** Implementado y Documentado diff --git a/docs/guias/scripts/check_no_emojis.md b/docs/guias/scripts/check_no_emojis.md new file mode 100644 index 00000000..6b8a0184 --- /dev/null +++ b/docs/guias/scripts/check_no_emojis.md @@ -0,0 +1,279 @@ +# Script: check_no_emojis.py + +**Ubicacion:** `scripts/check_no_emojis.py` +**Proposito:** Detectar y prevenir uso de emojis en codigo y documentacion +**Ownership:** Tech Lead +**Prioridad:** P0 (CRITICO - Pre-commit hook) + +## Descripcion + +Script Python que valida que NO se usen emojis en archivos del proyecto. Se ejecuta como pre-commit hook para mantener profesionalismo y compatibilidad del codigo. + +## Uso + +### Sintaxis Basica + +```bash +# Verificar archivos especificos (git staged) +python scripts/check_no_emojis.py archivo1.py archivo2.md + +# Verificar todo el proyecto +python scripts/check_no_emojis.py --all +``` + +## Funcionamiento + +### Patrones de Emojis Detectados + +El script detecta emojis usando: + +1. **Rangos Unicode de emojis:** + - U+1F600-U+1F64F: Emoticons + - U+1F300-U+1F5FF: Simbolos y pictogramas + - U+1F680-U+1F6FF: Transporte + - U+1F1E0-U+1F1FF: Banderas + - Y otros rangos Unicode comunes + +2. **Lista de emojis comunes:** El script detecta emojis comunes de checkmarks, simbolos de advertencia, iconos de herramientas, indicadores de estado, iconos de metricas, y simbolos de color + +### Archivos Validados + +**Extensiones validadas:** +- `.md`, `.txt` (Documentacion) +- `.py` (Python) +- `.js`, `.ts`, `.jsx`, `.tsx` (JavaScript/TypeScript) +- `.yaml`, `.yml`, `.json` (Configuracion) +- `.sh`, `.bash` (Scripts shell) + +**Directorios excluidos:** +- `.git`, `.venv`, `venv`, `node_modules` +- `__pycache__`, `.pytest_cache`, `htmlcov` +- `.mypy_cache`, `dist`, `build` + +### Excepciones Permitidas + +**Box-drawing characters (U+2500-U+257F):** Permitidos para arboles de directorios +``` +├── directorio/ +│ ├── archivo1.py +│ └── archivo2.py +└── otro/ +``` + +## Exit Codes + +| Codigo | Significado | +|--------|-------------| +| 0 | No se encontraron emojis (SUCCESS) | +| 1 | Se encontraron emojis (FAIL) | + +## Output + +### Cuando encuentra emojis: + +``` +ERROR: Emojis detectados en docs/README.md +====================================================================== + Linea 12: [checkmark-emoji] + Contexto: - [checkmark-emoji] Feature completada + + Linea 45: [rocket-emoji] + Contexto: ## [rocket-emoji] Deployment + +====================================================================== +TOTAL: 2 emojis encontrados en 1 archivos +====================================================================== + +El proyecto NO permite emojis en documentacion o codigo. +Ver: docs/gobernanza/GUIA_ESTILO.md para mas informacion. + +Alternativas recomendadas: + - En lugar de checkmark usar: [x] o 'Completado' + - En lugar de X-mark usar: [ ] o 'Pendiente' + - En lugar de rocket usar: simplemente omitir + - En lugar de warning usar: 'ADVERTENCIA:' o 'Nota:' +``` + +### Cuando NO encuentra emojis: + +``` +OK: No se encontraron emojis en 42 archivos verificados. +``` + +## Integracion en Git Hooks + +### Pre-commit Hook + +El script se ejecuta automaticamente antes de cada commit. + +**Setup:** +```bash +# Instalar pre-commit hook +python scripts/install_hooks.sh + +# El hook se ejecutara automaticamente en: +git add . +git commit -m "mensaje" +# -> check_no_emojis.py se ejecuta aqui +# -> Si falla, commit es rechazado +``` + +## Alternativas Recomendadas + +### En lugar de emojis, usar: + +| Emoji Descripcion | Alternativa | +|-------------------|-------------| +| checkmark | `[x]` o `Completado` | +| X-mark | `[ ]` o `Pendiente` | +| rocket | Omitir o `Release` | +| warning | `ADVERTENCIA:` o `Nota:` | +| wrench | `Config` o `Setup` | +| memo | `Docs` o `Documentacion` | +| lightbulb | `Tip:` o `Sugerencia:` | +| siren | `ALERTA:` o `CRITICO:` | +| target | `Objetivo:` | +| bar-chart | `Metricas` o `Estadisticas` | + +## Troubleshooting + +### False Positive: Box-drawing characters + +**Problema:** Script marca como emoji caracteres de arbol de directorios + +**Solucion:** Los box-drawing characters estan permitidos. Si marca error, reporta bug. + +```bash +# Estos estan PERMITIDOS: +├── directorio/ +│ └── archivo.py +└── otro/ +``` + +### Error: ModuleNotFoundError + +**Problema:** +``` +ModuleNotFoundError: No module named 'X' +``` + +**Causa:** Dependencias Python faltantes + +**Solucion:** +```bash +pip install -r requirements.txt +``` + +### Script muy lento con --all + +**Problema:** Script tarda mucho en validar todo el proyecto + +**Causa:** Muchos archivos a validar + +**Solucion:** Validar solo archivos staged en lugar de --all: +```bash +# En lugar de: +python scripts/check_no_emojis.py --all + +# Usar: +git add +python scripts/check_no_emojis.py $(git diff --cached --name-only) +``` + +## Arquitectura del Script + +```python +# Flujo principal: + +1. Parsear argumentos CLI + | + v +2. Identificar archivos a validar + - Si --all: buscar todos los archivos del proyecto + - Si archivos especificos: usar los proporcionados + | + v +3. Filtrar archivos por extension y directorio + - Solo extensiones validas (.py, .md, etc) + - Excluir directorios (.git, .venv, etc) + | + v +4. Para cada archivo: + - Leer contenido linea por linea + - Buscar emojis con regex Unicode + - Buscar emojis comunes de lista + - Filtrar box-drawing characters (permitidos) + - Registrar hallazgos + | + v +5. Si hallazgos > 0: + - Imprimir errores con contexto + - Imprimir alternativas recomendadas + - Exit code 1 (FAIL) + | + v +6. Si hallazgos == 0: + - Imprimir OK + - Exit code 0 (SUCCESS) +``` + +## Mejores Practicas + +1. **Ejecutar antes de commit:** + ```bash + # Validar antes de commitear: + python scripts/check_no_emojis.py $(git diff --cached --name-only) + ``` + +2. **Integrar en CI/CD:** + ```yaml + # .github/workflows/backend-ci.yml + - name: Check no emojis + run: python scripts/check_no_emojis.py --all + ``` + +3. **No desactivar el hook:** + - NO uses `git commit --no-verify` para saltarte el hook + - Si encuentras emojis, reemplazalos con alternativas + +4. **Documentacion sin emojis:** + - Usa texto descriptivo en lugar de emojis + - Mejora legibilidad para lectores de pantalla + - Profesionalismo en documentacion tecnica + +## Justificacion: Por que NO emojis? + +### Razones tecnicas: + +1. **Compatibilidad:** No todos los terminales/editores renderizan emojis correctamente +2. **Accesibilidad:** Lectores de pantalla no leen emojis adecuadamente +3. **Profesionalismo:** Codigo tecnico debe ser formal y claro +4. **Legibilidad:** Emojis pueden ser ambiguos o interpretarse diferente +5. **Git diff:** Emojis complican visualizacion de diffs en terminal +6. **Busqueda:** Dificil buscar/grep texto que usa emojis + +### Alternativa: + +Usar texto descriptivo y claro que es: +- Universal: funciona en cualquier terminal +- Accesible: lectores de pantalla lo leen correctamente +- Profesional: apropiado para documentacion tecnica +- Searchable: facil de buscar con grep/find + +## Referencias + +- Codigo fuente: `scripts/check_no_emojis.py` +- Guia de estilo: `docs/gobernanza/GUIA_ESTILO.md` +- Hook installer: `scripts/install_hooks.sh` +- Pre-commit config: `.pre-commit-config.yaml` + +## Ownership + +- **Maintainer:** Tech Lead +- **Reviewers:** DevOps Team +- **Approvers:** Arquitecto Senior + +--- + +**Ultima actualizacion:** 2025-11-07 +**Version:** 1.0.0 diff --git a/docs/guias/scripts/generate_guides.md b/docs/guias/scripts/generate_guides.md new file mode 100644 index 00000000..c36738a8 --- /dev/null +++ b/docs/guias/scripts/generate_guides.md @@ -0,0 +1,538 @@ +# Script: generate_guides.py + +**Ubicacion:** `scripts/generate_guides.py` +**Proposito:** Generar guias operativas automaticamente desde templates +**Ownership:** Tech Lead, Doc Lead +**Prioridad:** P1 (ALTO - Automatizacion de documentacion) + +## Descripcion + +Script Python que genera automaticamente guias operativas completas basandose en templates predefinidos y metadata estructurada. Acelera la creacion de documentacion consistente y de alta calidad. + +## Uso + +### Sintaxis Basica + +```bash +# Generar guias P0 (onboarding prioritario) +python scripts/generate_guides.py --priority P0 + +# Generar guias de categoria especifica +python scripts/generate_guides.py --category onboarding + +# Generar todas las guias +python scripts/generate_guides.py --priority all + +# Dry-run (simular sin escribir archivos) +python scripts/generate_guides.py --priority P0 --dry-run + +# Ver reporte de coverage +python scripts/generate_guides.py --report +``` + +## Argumentos + +| Argumento | Opciones | Default | Descripcion | +|-----------|----------|---------|-------------| +| --priority | P0, P1, P2, P3, all | P0 | Prioridad de guias a generar | +| --category | onboarding, workflows, testing, deployment, troubleshooting, all | - | Categoria especifica | +| --dry-run | - | False | Simular sin escribir archivos | +| --report | - | False | Generar solo reporte de coverage | + +## Categorias de Guias + +### 1. Onboarding (7 guias P0) + +Guias para nuevos desarrolladores: +- `GUIA-ONBOARDING-001`: Configurar Entorno de Desarrollo Local +- `GUIA-ONBOARDING-002`: Ejecutar Proyecto Localmente +- `GUIA-ONBOARDING-003`: Estructura del Proyecto IACT +- `GUIA-ONBOARDING-004`: Configurar Variables de Entorno +- `GUIA-ONBOARDING-005`: Usar Agentes SDLC - Planning +- `GUIA-ONBOARDING-006`: Validar Documentacion +- `GUIA-ONBOARDING-007`: Generar Indices de Requisitos + +### 2. Workflows (4 guias P0) + +Flujos de trabajo Git y CI/CD: +- `GUIA-WORKFLOWS-001`: Crear Feature Branch +- `GUIA-WORKFLOWS-002`: Hacer Commits Convencionales +- `GUIA-WORKFLOWS-003`: Crear Pull Request +- `GUIA-WORKFLOWS-004`: Interpretar Resultados de CI/CD + +### 3. Testing (3 guias P0) + +Ejecucion y validacion de tests: +- `GUIA-TESTING-001`: Ejecutar Tests Backend Localmente +- `GUIA-TESTING-002`: Ejecutar Tests Frontend Localmente +- `GUIA-TESTING-003`: Validar Test Pyramid + +### 4. Deployment (2 guias P0) + +Deployment y validaciones: +- `GUIA-DEPLOYMENT-001`: Workflow de Deployment +- `GUIA-DEPLOYMENT-002`: Validar Restricciones Criticas + +### 5. Troubleshooting (1 guia P0) + +Solucion de problemas comunes: +- `GUIA-TROUBLESHOOTING-001`: Problemas Comunes de Setup + +**Total P0:** 20 guias criticas para onboarding + +## Estructura de Metadata + +Cada guia se define con: + +```python +GuideMetadata( + id="GUIA-CATEGORY-###", + titulo="Titulo Descriptivo", + categoria="onboarding|workflows|testing|deployment|troubleshooting", + audiencia="desarrollador-nuevo|desarrollador|tech-lead", + prioridad="P0|P1|P2|P3", + tiempo_lectura=10, # minutos + descripcion="Breve descripcion de la guia", + pasos=[ + { + "titulo": "Paso 1", + "descripcion": "Que hacer", + "comando": "comando shell", + "output": "output esperado" + } + ], + prerequisitos=["Pre-requisito 1", "Pre-requisito 2"], + validaciones=["Validacion 1", "Validacion 2"], + troubleshooting=[ + { + "titulo": "Error Comun", + "sintomas": "Como se ve el error", + "causa": "Por que ocurre", + "solucion": "Como resolverlo" + } + ], + proximos_pasos=["Siguiente guia", "Otra accion"], + referencias={ + "Documentacion": "path/to/doc.md", + "Script": "path/to/script.sh" + }, + mantenedores=["tech-lead", "devops-lead"] +) +``` + +## Template de Guia + +El script usa `docs/plantillas/guia-template.md` que contiene: + +```markdown +# {TITULO} + +**Categoria:** {CATEGORIA} +**Audiencia:** {AUDIENCIA} +**Prioridad:** {PRIORIDAD} +**Tiempo de lectura:** {MINUTOS} minutos +**Fecha:** {FECHA} + +## Descripcion + +{DESCRIPCION_BREVE} + +## Pre-requisitos + +{PREREQUISITOS} + +## Pasos + +{PASOS_DETALLADOS} + +## Validaciones + +{VALIDACIONES} + +## Troubleshooting + +{TROUBLESHOOTING} + +## Proximos Pasos + +{PROXIMOS_PASOS} + +## Referencias + +{REFERENCIAS} + +## Ownership + +**Mantenedores:** {MANTENEDORES} +``` + +## Output + +### Generacion Exitosa + +``` +================================================================================ +GENERANDO GUIAS P0 DE ONBOARDING +================================================================================ + +Generada: docs/guias/onboarding/configurar_entorno_desarrollo_local.md +Generada: docs/guias/onboarding/ejecutar_proyecto_localmente.md +Generada: docs/guias/onboarding/estructura_proyecto_iact.md +Generada: docs/guias/workflows/crear_feature_branch.md +Generada: docs/guias/workflows/hacer_commits_convencionales.md +Generada: docs/guias/workflows/crear_pull_request.md +Generada: docs/guias/workflows/interpretar_resultados_cicd.md +Generada: docs/guias/testing/ejecutar_tests_backend_localmente.md +Generada: docs/guias/testing/ejecutar_tests_frontend_localmente.md +Generada: docs/guias/testing/validar_test_pyramid.md +Generada: docs/guias/deployment/workflow_deployment.md +Generada: docs/guias/deployment/validar_restricciones_criticas.md +Generada: docs/guias/troubleshooting/problemas_comunes_setup.md + +================================================================================ +RESUMEN DE GENERACION +================================================================================ + +Guias generadas: 20/20 +Completitud: 100% +Guias omitidas: 0 + +Guias creadas en: + - docs/guias/onboarding/configurar_entorno_desarrollo_local.md + - docs/guias/onboarding/ejecutar_proyecto_localmente.md + - docs/guias/onboarding/estructura_proyecto_iact.md + - docs/guias/workflows/crear_feature_branch.md + - docs/guias/workflows/hacer_commits_convencionales.md + ... y 15 mas + +================================================================================ +``` + +### Modo Dry-Run + +```bash +python scripts/generate_guides.py --priority P0 --dry-run +``` + +Output: +``` +[DRY-RUN] Guardaria guia en: docs/guias/onboarding/configurar_entorno_desarrollo_local.md +[DRY-RUN] Guardaria guia en: docs/guias/onboarding/ejecutar_proyecto_localmente.md +... + +Guias generadas: 20/20 +Completitud: 100% +``` + +### Reporte de Coverage + +```bash +python scripts/generate_guides.py --report +``` + +Output: +```json +{ + "timestamp": "2025-11-07T10:30:00Z", + "guides_generated": 20, + "guides_skipped": 0, + "total_planned": 147, + "p0_completed": 20, + "p0_target": 20, + "completion_percentage": 100.0 +} +``` + +## Directorio de Salida + +Guias generadas en: +``` +docs/guias/ +├── onboarding/ +│ ├── configurar_entorno_desarrollo_local.md +│ ├── ejecutar_proyecto_localmente.md +│ ├── estructura_proyecto_iact.md +│ ├── configurar_variables_entorno.md +│ ├── usar_agentes_sdlc_planning.md +│ ├── validar_documentacion.md +│ └── generar_indices_requisitos.md +├── workflows/ +│ ├── crear_feature_branch.md +│ ├── hacer_commits_convencionales.md +│ ├── crear_pull_request.md +│ └── interpretar_resultados_cicd.md +├── testing/ +│ ├── ejecutar_tests_backend_localmente.md +│ ├── ejecutar_tests_frontend_localmente.md +│ └── validar_test_pyramid.md +├── deployment/ +│ ├── workflow_deployment.md +│ └── validar_restricciones_criticas.md +└── troubleshooting/ + └── problemas_comunes_setup.md +``` + +## Troubleshooting + +### Template no encontrado + +**Error:** +``` +FileNotFoundError: Template no encontrado: docs/plantillas/guia-template.md +``` + +**Causa:** Template de guia no existe + +**Solucion:** +```bash +# Verificar que template existe: +ls docs/plantillas/guia-template.md + +# Si no existe, crear desde ejemplo: +cp docs/plantillas/template_general.md docs/plantillas/guia-template.md +``` + +### Error al escribir archivo + +**Error:** +``` +PermissionError: [Errno 13] Permission denied: 'docs/guias/onboarding/file.md' +``` + +**Causa:** Sin permisos de escritura + +**Solucion:** +```bash +# Dar permisos de escritura: +chmod +w docs/guias/ + +# O ejecutar con permisos: +sudo python scripts/generate_guides.py --priority P0 +``` + +### Metadata invalida + +**Error:** +``` +ERROR generando GUIA-ONBOARDING-001: Missing required field: 'prerequisitos' +``` + +**Causa:** Metadata de guia incompleta + +**Solucion:** Verificar que metadata tiene todos los campos requeridos: +```python +# Campos obligatorios: +- id +- titulo +- categoria +- audiencia +- prioridad +- tiempo_lectura +- descripcion +- pasos +- prerequisitos +- validaciones +- troubleshooting +- proximos_pasos +- referencias +- mantenedores +``` + +## Arquitectura del Script + +```python +# Flujo principal: + +1. Parsear argumentos CLI + | + v +2. Inicializar DocumentationGuideGenerator + | + v +3. Si --report: generar reporte y salir + | + v +4. Cargar template desde docs/plantillas/guia-template.md + | + v +5. Obtener metadata de guias P0: + - get_p0_guides_metadata() + - Retorna lista de 20 GuideMetadata + | + v +6. Para cada GuideMetadata: + | + +-- a. Generar contenido desde template + | - Reemplazar placeholders + | - Insertar pasos, validaciones, troubleshooting + | + +-- b. Determinar path de salida + | - docs/guias/{categoria}/{id}.md + | + +-- c. Escribir archivo (o skip si --dry-run) + | + v +7. Generar reporte resumen: + - Guias generadas + - Guias omitidas + - Porcentaje de completitud + | + v +8. Exit 0 (SUCCESS) +``` + +## Extender con Nuevas Guias + +### Agregar nueva guia P0: + +1. Editar `get_p0_guides_metadata()` en `generate_guides.py` + +2. Agregar nuevo `GuideMetadata`: +```python +guides.append(GuideMetadata( + id="GUIA-CATEGORY-008", + titulo="Mi Nueva Guia", + categoria="onboarding", + audiencia="desarrollador-nuevo", + prioridad="P0", + tiempo_lectura=10, + descripcion="Descripcion de la guia", + pasos=[ + { + "titulo": "Paso 1", + "descripcion": "Que hacer", + "comando": "comando shell", + "output": "output esperado" + } + ], + prerequisitos=["Pre-req 1"], + validaciones=["Validacion 1"], + troubleshooting=[ + { + "titulo": "Error X", + "sintomas": "Sintomas", + "causa": "Causa", + "solucion": "Solucion" + } + ], + proximos_pasos=["Siguiente paso"], + referencias={"Doc": "path"}, + mantenedores=["owner"] +)) +``` + +3. Ejecutar generador: +```bash +python scripts/generate_guides.py --priority P0 +``` + +4. Verificar guia generada: +```bash +cat docs/guias/{categoria}/mi_nueva_guia.md +``` + +## Integracion en CI/CD + +### Auto-generar guias en PR + +```yaml +# .github/workflows/docs-generation.yml + +name: Auto-generate Guides + +on: + pull_request: + paths: + - 'scripts/generate_guides.py' + - 'docs/plantillas/guia-template.md' + +jobs: + generate-guides: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Generate guides + run: | + python scripts/generate_guides.py --priority P0 + + - name: Commit generated guides + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/guias/ + git commit -m "docs: auto-generate guides [skip ci]" || echo "No changes" + git push +``` + +## Mejores Practicas + +1. **Mantener template actualizado:** + - Template es base para todas las guias + - Cambios en template afectan todas las guias generadas + +2. **Metadata completa:** + - No dejar campos vacios + - Pasos deben tener comando + output esperado + - Troubleshooting debe cubrir errores comunes + +3. **Regenerar despues de cambios:** + ```bash + # Despues de modificar template o metadata: + python scripts/generate_guides.py --priority P0 + git diff docs/guias/ # Ver cambios + ``` + +4. **Dry-run para validar:** + ```bash + # Antes de generar, validar con dry-run: + python scripts/generate_guides.py --priority P0 --dry-run + ``` + +5. **Versionado:** + - Guias generadas van en git + - Commitear guias generadas junto con cambios en script + +## Road map + +### Guias Planeadas + +**Total:** 147 guias operativas + +**Por prioridad:** +- **P0:** 20 guias (COMPLETO) +- **P1:** 40 guias (TODO) +- **P2:** 50 guias (TODO) +- **P3:** 37 guias (TODO) + +**Por categoria:** +- Onboarding: 15 guias +- Workflows: 20 guias +- Testing: 25 guias +- Deployment: 18 guias +- Troubleshooting: 30 guias +- Operations: 20 guias +- Security: 19 guias + +## Referencias + +- Codigo fuente: `scripts/generate_guides.py` +- Template: `docs/plantillas/guia-template.md` +- Guias generadas: `docs/guias/` +- SDLC Process: `docs/gobernanza/procesos/SDLC_PROCESS.md` + +## Ownership + +- **Maintainer:** Tech Lead, Doc Lead +- **Reviewers:** Arquitecto Senior +- **Contributors:** All team members (agregar nuevas guias) + +--- + +**Ultima actualizacion:** 2025-11-07 +**Version:** 1.0.0 diff --git a/docs/guias/scripts/validate_critical_restrictions.md b/docs/guias/scripts/validate_critical_restrictions.md new file mode 100644 index 00000000..6f4872dc --- /dev/null +++ b/docs/guias/scripts/validate_critical_restrictions.md @@ -0,0 +1,499 @@ +# Script: validate_critical_restrictions.sh + +**Ubicacion:** `scripts/validate_critical_restrictions.sh` +**Proposito:** Validar restricciones criticas del proyecto IACT (RNF-002) +**Ownership:** Arquitecto Senior +**Prioridad:** P0 (CRITICO - Bloquea merges) + +## Descripcion + +Script Bash que valida el cumplimiento de restricciones criticas definidas en RNF-002. Se ejecuta en CI/CD para asegurar que el codigo no use tecnologias prohibidas. + +## Restricciones Validadas + +### 1. NO Email (RNF-003) + +**Prohibido:** +- `send_mail()` +- `EmailMessage` +- `smtp.SMTP` +- `smtplib` + +**Alternativa:** `InternalMessage` para notificaciones + +### 2. NO Sentry + +**Prohibido:** +- `import sentry_sdk` +- `from sentry_sdk` +- `sentry` en requirements + +**Alternativa:** Logging local con archivos rotativos + +### 3. NO Redis/Memcached para Sesiones (RNF-002) + +**Prohibido:** +- `redis` en requirements +- `memcached` en requirements + +**Alternativa:** Sesiones en base de datos (MySQL/PostgreSQL) + +### 4. NO Codigo Peligroso + +**Prohibido:** +- `eval()` +- `exec()` +- `pickle.load()` + +**Alternativa:** Parsing seguro (JSON, YAML) + +### 5. NO WebSockets/SSE + +**Prohibido:** +- `websocket` +- `channels` (Django Channels) +- `text/event-stream` +- `EventSource` + +**Alternativa:** ETL programado cada 6-12 horas + +### 6. Database Router (REQUERIDO) + +**Validacion:** Verifica que `database_router.py` exista y proteja BD IVR + +**Ubicacion esperada:** `api/callcentersite/callcentersite/database_router.py` + +### 7. Session Engine (REQUERIDO) + +**Validacion:** Verifica que `SESSION_ENGINE` use base de datos + +**Config esperada:** +```python +SESSION_ENGINE = 'django.contrib.sessions.backends.db' +``` + +### 8. InternalMessage Model (REQUERIDO) + +**Validacion:** Verifica que modelo `InternalMessage` exista + +**Ubicacion esperada:** `api/callcentersite/callcentersite/apps/notifications/models.py` + +## Uso + +### Sintaxis Basica + +```bash +# Ejecutar desde raiz del proyecto: +./scripts/validate_critical_restrictions.sh + +# O con path absoluto: +bash /path/to/scripts/validate_critical_restrictions.sh +``` + +## Exit Codes + +| Codigo | Significado | +|--------|-------------| +| 0 | Todas las restricciones pasan (SUCCESS) | +| 1 | Una o mas restricciones fallan (FAIL) | + +## Output + +### Cuando TODAS las restricciones pasan: + +``` +[INFO] Validando restricciones criticas del proyecto IACT... +[INFO] Directorio del proyecto: /home/user/IACT---project + +[1] Verificando NO uso de email... +[OK] Sin uso de email + +[2] Verificando NO Sentry... +[OK] Sin Sentry + +[3] Verificando NO Redis/Memcached... +[OK] Sin Redis/Memcached + +[4] Verificando NO codigo peligroso (eval/exec/pickle)... +[OK] Sin codigo peligroso + +[5] Verificando NO WebSockets/SSE (real-time updates)... +[OK] Sin WebSockets/SSE + +[6] Verificando Database Router... +[OK] Database router existe y protege BD IVR + +[7] Verificando configuracion de sesiones... +[OK] SESSION_ENGINE configurado para usar DB + +[8] Verificando modelo InternalMessage... +[OK] Modelo InternalMessage existe + +========================================================================= +[OK] TODAS LAS RESTRICCIONES CRITICAS PASARON +========================================================================= +``` + +### Cuando HAY FALLOS: + +``` +[INFO] Validando restricciones criticas del proyecto IACT... + +[1] Verificando NO uso de email... +api/apps/notifications/email_sender.py:15:from django.core.mail import send_mail +[FAIL] FALLO: Se encontro uso de email en el codigo + Restriccion: NO se permite envio de correos electronicos + Usar: InternalMessage para notificaciones + +[3] Verificando NO Redis/Memcached... +api/callcentersite/requirements/base.txt:redis==4.5.0 +[FAIL] FALLO: Redis/Memcached encontrado en requirements + Restriccion: NO se permite Redis/Memcached para sesiones + Usar: Sesiones en base de datos + +========================================================================= +[FAIL] FALLOS ENCONTRADOS: 2 +========================================================================= + +ACCIONES REQUERIDAS: + 1. Revisar los fallos reportados arriba + 2. Corregir el codigo segun las restricciones + 3. Volver a ejecutar este script + 4. Consultar: docs/requisitos/restricciones_completas.md +``` + +## Integracion en CI/CD + +### GitHub Actions + +El script se ejecuta automaticamente en `backend-ci.yml`: + +```yaml +name: Backend CI + +on: + push: + branches: ['**'] + pull_request: + types: [opened, synchronize, reopened] + +jobs: + validate-restrictions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate critical restrictions + run: | + chmod +x scripts/validate_critical_restrictions.sh + ./scripts/validate_critical_restrictions.sh +``` + +**Efecto:** Si el script falla, el PR NO puede ser mergeado. + +## Troubleshooting + +### Fallo 1: Se encontro uso de email + +**Error:** +``` +[FAIL] FALLO: Se encontro uso de email en el codigo +``` + +**Causa:** Codigo usa `send_mail()` o `EmailMessage` + +**Solucion:** +```python +# INCORRECTO: +from django.core.mail import send_mail +send_mail('Asunto', 'Mensaje', 'from@example.com', ['to@example.com']) + +# CORRECTO: +from apps.notifications.models import InternalMessage + +InternalMessage.objects.create( + usuario=usuario, + tipo='notificacion', + titulo='Asunto', + mensaje='Mensaje', + prioridad='normal' +) +``` + +### Fallo 2: Redis en requirements + +**Error:** +``` +[FAIL] FALLO: Redis/Memcached encontrado en requirements +``` + +**Causa:** `redis` o `memcached` en `requirements/base.txt` + +**Solucion:** +```bash +# Remover de requirements: +# requirements/base.txt: +# redis==4.5.0 # <- REMOVER + +# Usar sesiones en DB (ya configurado): +# settings/base.py: +SESSION_ENGINE = 'django.contrib.sessions.backends.db' +``` + +### Fallo 3: Codigo peligroso (eval/exec) + +**Error:** +``` +[FAIL] FALLO: Codigo peligroso encontrado: +api/utils/parser.py:42:result = eval(expression) +``` + +**Causa:** Uso de `eval()`, `exec()` o `pickle.load()` + +**Solucion:** +```python +# INCORRECTO: +result = eval(expression) # PELIGROSO + +# CORRECTO: +import json +result = json.loads(expression) # SEGURO + +# O usa ast.literal_eval para expresiones Python: +import ast +result = ast.literal_eval(expression) +``` + +### Fallo 4: WebSockets detectados + +**Error:** +``` +[FAIL] FALLO: WebSockets/SSE encontrado +``` + +**Causa:** Uso de Django Channels, WebSockets o SSE + +**Solucion:** +```python +# INCORRECTO: +# Actualizaciones en tiempo real con WebSockets +from channels.generic.websocket import WebsocketConsumer + +# CORRECTO: +# ETL programado con Celery Beat (o cron) +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + def handle(self, *args, **options): + # Ejecutar ETL cada 6-12 horas + SyncService.sync_data_from_ivr() +``` + +### Fallo 5: Database router no encontrado + +**Error:** +``` +[FAIL] FALLO: Database router no encontrado +``` + +**Causa:** Archivo `database_router.py` no existe + +**Solucion:** +```bash +# Crear database router: +# api/callcentersite/callcentersite/database_router.py + +class IVRReadOnlyRouter: + """Router que protege BD IVR (solo lectura).""" + + def db_for_write(self, model, **hints): + if model._meta.app_label == 'ivr': + raise ValueError("IVR database is READ-ONLY") + return 'default' + + # ... resto del router +``` + +### Fallo 6: SESSION_ENGINE no configurado + +**Error:** +``` +[WARN] WARNING: SESSION_ENGINE no explicitamente configurado +``` + +**Causa:** `SESSION_ENGINE` no esta en `settings/base.py` + +**Solucion:** +```python +# api/callcentersite/callcentersite/settings/base.py + +# Configurar sesiones en base de datos: +SESSION_ENGINE = 'django.contrib.sessions.backends.db' +SESSION_COOKIE_AGE = 1209600 # 2 semanas +SESSION_COOKIE_SECURE = True # Solo HTTPS +SESSION_COOKIE_HTTPONLY = True # No accesible desde JS +``` + +### Fallo 7: InternalMessage no encontrado + +**Error:** +``` +[FAIL] FALLO: InternalMessage no encontrado en models.py +``` + +**Causa:** Modelo `InternalMessage` no existe + +**Solucion:** +```python +# api/callcentersite/callcentersite/apps/notifications/models.py + +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + +class InternalMessage(models.Model): + """Modelo para notificaciones internas (sin email).""" + + usuario = models.ForeignKey(User, on_delete=models.CASCADE) + tipo = models.CharField(max_length=50) + titulo = models.CharField(max_length=200) + mensaje = models.TextField() + prioridad = models.CharField( + max_length=20, + choices=[('baja', 'Baja'), ('normal', 'Normal'), ('alta', 'Alta')], + default='normal' + ) + leido = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'notifications_internal_message' + ordering = ['-created_at'] +``` + +## Arquitectura del Script + +```bash +# Estructura del script: + +1. Setup inicial + - Set -e (exit on error) + - Definir directorios del proyecto + | + v +2. VALIDACION 1: NO Email + - grep -r "send_mail|EmailMessage|smtp" + - Si encuentra: FAIL, incrementar contador + | + v +3. VALIDACION 2: NO Sentry + - grep -ri "sentry" en requirements/ + - Si encuentra: FAIL, incrementar contador + | + v +4. VALIDACION 3: NO Redis/Memcached + - grep -ri "redis|memcached" en requirements/ + - Si encuentra: FAIL, incrementar contador + | + v +5. VALIDACION 4: NO Codigo Peligroso + - grep -rn "eval(|exec(|pickle.load" + - Si encuentra: FAIL, incrementar contador + | + v +6. VALIDACION 5: NO WebSockets/SSE + - grep -r "websocket|channels|EventSource" + - Si encuentra: FAIL, incrementar contador + | + v +7. VALIDACION 6: Database Router Existe + - Verificar que archivo database_router.py existe + - Verificar que contiene "raise ValueError" y "READ-ONLY" + - Si no existe: FAIL, incrementar contador + | + v +8. VALIDACION 7: SESSION_ENGINE Correcto + - grep "SESSION_ENGINE.*db" en settings/base.py + - Si no encuentra: WARNING (no FAIL) + | + v +9. VALIDACION 8: InternalMessage Existe + - Verificar que archivo notifications/models.py existe + - grep "class InternalMessage" + - Si no encuentra: FAIL, incrementar contador + | + v +10. RESUMEN + - Si FAILED == 0: Exit 0 (SUCCESS) + - Si FAILED > 0: Exit 1 (FAIL) +``` + +## Mejores Practicas + +1. **Ejecutar antes de push:** + ```bash + # Validar localmente antes de push: + ./scripts/validate_critical_restrictions.sh + ``` + +2. **Integrar en pre-push hook:** + ```bash + # .git/hooks/pre-push + #!/bin/bash + ./scripts/validate_critical_restrictions.sh + ``` + +3. **CI/CD como gatekeeper:** + - CI/CD DEBE ejecutar este script + - PR NO puede mergearse si falla + +4. **Documentar excepciones:** + - Si necesitas tecnologia prohibida, crear ADR + - Justificar excepcion con arquitecto senior + +## Justificacion: Por que estas restricciones? + +### NO Redis (RNF-002): +- **Razon:** Cliente no tiene infraestructura Redis +- **Alternativa:** Sesiones en BD MySQL existente +- **Impacto:** Simplifica ops, usa BD existente + +### NO Email (RNF-003): +- **Razon:** Sin servidor SMTP, falla compliance +- **Alternativa:** Notificaciones internas en sistema +- **Impacto:** Notificaciones sin dependencias externas + +### NO Sentry: +- **Razon:** Datos sensibles no pueden salir del servidor +- **Alternativa:** Logging local con rotacion +- **Impacto:** Control total sobre logs + +### NO Codigo Peligroso: +- **Razon:** Seguridad - evitar code injection +- **Alternativa:** Parsing seguro (JSON, YAML) +- **Impacto:** Mayor seguridad + +### NO WebSockets/SSE: +- **Razon:** No soportado en infraestructura cliente +- **Alternativa:** ETL batch programado +- **Impacto:** Simplifica arquitectura + +## Referencias + +- Codigo fuente: `scripts/validate_critical_restrictions.sh` +- RNF-002: `docs/requisitos/rnf-002-restricciones-criticas.md` +- ADRs relevantes: `docs/adr/` +- Database Router: `api/callcentersite/callcentersite/database_router.py` +- InternalMessage: `api/callcentersite/callcentersite/apps/notifications/models.py` + +## Ownership + +- **Maintainer:** Arquitecto Senior +- **Reviewers:** Tech Lead, DevOps Lead +- **Approvers:** Arquitecto Senior (para excepciones) + +--- + +**Ultima actualizacion:** 2025-11-07 +**Version:** 1.0.0 diff --git a/ui/src/mocks/llamadas.json b/ui/src/mocks/llamadas.json new file mode 100644 index 00000000..caea5753 --- /dev/null +++ b/ui/src/mocks/llamadas.json @@ -0,0 +1,141 @@ +{ + "llamadas": [ + { + "id": 1, + "codigo": "CALL-A1B2C3D4E5F6", + "numero_telefono": "+521234567890", + "tipo": { + "id": 1, + "codigo": "ENTRANTE", + "nombre": "Llamada Entrante" + }, + "estado": { + "id": 2, + "codigo": "COMPLETADA", + "nombre": "Completada", + "es_final": true + }, + "agente": { + "id": 10, + "username": "maria.garcia" + }, + "cliente_nombre": "Juan Perez", + "cliente_email": "juan.perez@example.com", + "fecha_inicio": "2025-11-07T10:15:00Z", + "fecha_fin": "2025-11-07T10:20:30Z", + "duracion": 330, + "metadata": { + "motivo": "consulta", + "producto": "tarjeta_credito", + "prioridad": "normal" + }, + "notas": "Cliente consulto sobre su saldo disponible. Se proporciono informacion correctamente." + }, + { + "id": 2, + "codigo": "CALL-F6E5D4C3B2A1", + "numero_telefono": "+529876543210", + "tipo": { + "id": 2, + "codigo": "SALIENTE", + "nombre": "Llamada Saliente" + }, + "estado": { + "id": 1, + "codigo": "EN_CURSO", + "nombre": "En Curso", + "es_final": false + }, + "agente": { + "id": 10, + "username": "maria.garcia" + }, + "cliente_nombre": "Maria Lopez", + "cliente_email": "maria.lopez@example.com", + "fecha_inicio": "2025-11-07T11:00:00Z", + "fecha_fin": null, + "duracion": null, + "metadata": { + "motivo": "seguimiento", + "producto": "prestamo", + "prioridad": "alta" + }, + "notas": "" + }, + { + "id": 3, + "codigo": "CALL-123ABC456DEF", + "numero_telefono": "+525555551234", + "tipo": { + "id": 1, + "codigo": "ENTRANTE", + "nombre": "Llamada Entrante" + }, + "estado": { + "id": 2, + "codigo": "COMPLETADA", + "nombre": "Completada", + "es_final": true + }, + "agente": { + "id": 11, + "username": "carlos.rodriguez" + }, + "cliente_nombre": "Ana Martinez", + "cliente_email": "ana.martinez@example.com", + "fecha_inicio": "2025-11-07T09:30:00Z", + "fecha_fin": "2025-11-07T09:45:15Z", + "duracion": 915, + "metadata": { + "motivo": "reclamo", + "producto": "cuenta_ahorros", + "prioridad": "alta" + }, + "notas": "Cliente reporto cargo no reconocido. Se inicio investigacion y se proporcionara respuesta en 48 horas." + } + ], + "estados": [ + { + "id": 1, + "codigo": "EN_CURSO", + "nombre": "En Curso", + "es_final": false, + "activo": true + }, + { + "id": 2, + "codigo": "COMPLETADA", + "nombre": "Completada", + "es_final": true, + "activo": true + }, + { + "id": 3, + "codigo": "CANCELADA", + "nombre": "Cancelada", + "es_final": true, + "activo": true + }, + { + "id": 4, + "codigo": "NO_CONTESTADA", + "nombre": "No Contestada", + "es_final": true, + "activo": true + } + ], + "tipos": [ + { + "id": 1, + "codigo": "ENTRANTE", + "nombre": "Llamada Entrante", + "activo": true + }, + { + "id": 2, + "codigo": "SALIENTE", + "nombre": "Llamada Saliente", + "activo": true + } + ] +} diff --git a/ui/src/mocks/permissions.json b/ui/src/mocks/permissions.json new file mode 100644 index 00000000..634c5803 --- /dev/null +++ b/ui/src/mocks/permissions.json @@ -0,0 +1,71 @@ +{ + "user": { + "id": 10, + "username": "maria.garcia", + "email": "maria.garcia@callcenter.com", + "grupos": [ + { + "id": 1, + "codigo": "atencion_cliente", + "nombre_display": "Atencion al Cliente" + }, + { + "id": 2, + "codigo": "visualizacion_metricas", + "nombre_display": "Visualizacion de Metricas" + } + ] + }, + "capacidades": [ + "sistema.vistas.dashboards.ver", + "sistema.operaciones.llamadas.ver", + "sistema.operaciones.llamadas.realizar", + "sistema.operaciones.tickets.ver", + "sistema.operaciones.tickets.crear", + "sistema.operaciones.tickets.editar", + "sistema.operaciones.clientes.ver", + "sistema.analisis.metricas.ver" + ], + "funciones_accesibles": [ + { + "id": 1, + "nombre": "dashboards", + "nombre_completo": "sistema.vistas.dashboards", + "dominio": "vistas", + "icono": "dashboard", + "orden_menu": 10 + }, + { + "id": 2, + "nombre": "llamadas", + "nombre_completo": "sistema.operaciones.llamadas", + "dominio": "operaciones", + "icono": "phone", + "orden_menu": 20 + }, + { + "id": 3, + "nombre": "tickets", + "nombre_completo": "sistema.operaciones.tickets", + "dominio": "operaciones", + "icono": "ticket", + "orden_menu": 30 + }, + { + "id": 4, + "nombre": "clientes", + "nombre_completo": "sistema.operaciones.clientes", + "dominio": "operaciones", + "icono": "people", + "orden_menu": 40 + }, + { + "id": 5, + "nombre": "metricas", + "nombre_completo": "sistema.analisis.metricas", + "dominio": "analisis", + "icono": "chart", + "orden_menu": 60 + } + ] +}