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
+ }
+ ]
+}