From d39df8080ec8350622f2ad22cd4c2fd11fd060b2 Mon Sep 17 00:00:00 2001 From: Coatl <121911012+2-Coatl@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:49:21 -0600 Subject: [PATCH] feat(configuration): unificar app en ingles --- .../apps/configuracion/__init__.py | 1 - .../callcentersite/apps/configuracion/apps.py | 11 - .../configuracion/migrations/0001_initial.py | 129 -------- ...ter_configuracionsistema_modificado_por.py | 28 -- .../apps/configuracion/migrations/__init__.py | 0 .../apps/configuracion/models.py | 137 --------- .../apps/configuracion/serializers.py | 68 ----- .../apps/configuracion/services.py | 279 ------------------ .../callcentersite/apps/configuracion/urls.py | 16 - .../apps/configuracion/views.py | 191 ------------ .../apps/configuration/services.py | 97 ++++++ .../callcentersite/apps/configuration/urls.py | 4 + .../apps/configuration/views.py | 69 +++++ .../callcentersite/settings/base.py | 1 - api/callcentersite/callcentersite/urls.py | 11 +- .../unit/configuration/test_consolidation.py | 28 ++ .../configuration/test_views_historial.py | 116 ++++++++ 17 files changed, 324 insertions(+), 862 deletions(-) delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/__init__.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/apps.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/migrations/0001_initial.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/migrations/0002_alter_configuracionsistema_modificado_por.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/migrations/__init__.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/models.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/serializers.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/services.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/urls.py delete mode 100644 api/callcentersite/callcentersite/apps/configuracion/views.py create mode 100644 api/callcentersite/tests/unit/configuration/test_consolidation.py create mode 100644 api/callcentersite/tests/unit/configuration/test_views_historial.py diff --git a/api/callcentersite/callcentersite/apps/configuracion/__init__.py b/api/callcentersite/callcentersite/apps/configuracion/__init__.py deleted file mode 100644 index 640d763c..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""App de configuración del sistema.""" diff --git a/api/callcentersite/callcentersite/apps/configuracion/apps.py b/api/callcentersite/callcentersite/apps/configuracion/apps.py deleted file mode 100644 index 36997417..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/apps.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Configuración de la app configuracion.""" - -from django.apps import AppConfig - - -class ConfiguracionConfig(AppConfig): - """Configuración de la app.""" - - default_auto_field = 'django.db.models.BigAutoField' - name = 'callcentersite.apps.configuracion' - verbose_name = 'Configuración del Sistema' diff --git a/api/callcentersite/callcentersite/apps/configuracion/migrations/0001_initial.py b/api/callcentersite/callcentersite/apps/configuracion/migrations/0001_initial.py deleted file mode 100644 index c14d15d5..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/migrations/0001_initial.py +++ /dev/null @@ -1,129 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-11 01:57 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="ConfiguracionSistema", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ( - "clave", - models.CharField( - help_text="Clave única de la configuración (ej: max_intentos_login)", - max_length=200, - unique=True, - ), - ), - ("valor", models.TextField(help_text="Valor de la configuración")), - ( - "tipo", - models.CharField( - choices=[ - ("string", "Cadena de texto"), - ("integer", "Número entero"), - ("float", "Número decimal"), - ("boolean", "Booleano"), - ("json", "JSON"), - ], - default="string", - help_text="Tipo de dato del valor", - max_length=20, - ), - ), - ( - "descripcion", - models.TextField( - blank=True, help_text="Descripción de qué hace este parámetro" - ), - ), - ("valor_default", models.TextField(help_text="Valor por defecto si se resetea")), - ( - "created_at", - models.DateTimeField(auto_now_add=True, help_text="Fecha de creación"), - ), - ( - "updated_at", - models.DateTimeField(auto_now=True, help_text="Fecha de última modificación"), - ), - ( - "modificado_por", - models.ForeignKey( - blank=True, - help_text="Usuario que modificó este parámetro", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="configuraciones_modificadas", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Configuración del Sistema", - "verbose_name_plural": "Configuraciones del Sistema", - "db_table": "configuracion_sistema", - "ordering": ["clave"], - }, - ), - migrations.CreateModel( - name="AuditoriaConfiguracion", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("valor_anterior", models.TextField(help_text="Valor antes del cambio")), - ("valor_nuevo", models.TextField(help_text="Valor después del cambio")), - ( - "timestamp", - models.DateTimeField( - auto_now_add=True, help_text="Cuándo se realizó el cambio" - ), - ), - ("motivo", models.TextField(blank=True, help_text="Razón del cambio (opcional)")), - ( - "modificado_por", - models.ForeignKey( - help_text="Usuario que realizó el cambio", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="auditorias_configuracion", - to=settings.AUTH_USER_MODEL, - ), - ), - ( - "configuracion", - models.ForeignKey( - help_text="Configuración que fue modificada", - on_delete=django.db.models.deletion.CASCADE, - related_name="historial", - to="configuracion.configuracionsistema", - ), - ), - ], - options={ - "verbose_name": "Auditoría de Configuración", - "verbose_name_plural": "Auditorías de Configuración", - "db_table": "auditoria_configuracion", - "ordering": ["-timestamp"], - }, - ), - ] diff --git a/api/callcentersite/callcentersite/apps/configuracion/migrations/0002_alter_configuracionsistema_modificado_por.py b/api/callcentersite/callcentersite/apps/configuracion/migrations/0002_alter_configuracionsistema_modificado_por.py deleted file mode 100644 index b0482984..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/migrations/0002_alter_configuracionsistema_modificado_por.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.2.8 on 2025-11-18 15:17 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("configuracion", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name="configuracionsistema", - name="modificado_por", - field=models.ForeignKey( - blank=True, - help_text="Usuario que modificó este parámetro", - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="configuracion_sistema_modificaciones", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/api/callcentersite/callcentersite/apps/configuracion/migrations/__init__.py b/api/callcentersite/callcentersite/apps/configuracion/migrations/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/api/callcentersite/callcentersite/apps/configuracion/models.py b/api/callcentersite/callcentersite/apps/configuracion/models.py deleted file mode 100644 index f67df76e..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/models.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Modelos para configuración del sistema.""" - -from django.conf import settings -from django.db import models - - -class ConfiguracionSistema(models.Model): - """ - Parámetros de configuración del sistema. - - Almacena configuraciones clave-valor con metadatos. - """ - - TIPO_CHOICES = [ - ('string', 'Cadena de texto'), - ('integer', 'Número entero'), - ('float', 'Número decimal'), - ('boolean', 'Booleano'), - ('json', 'JSON'), - ] - - clave = models.CharField( - max_length=200, - unique=True, - help_text='Clave única de la configuración (ej: max_intentos_login)' - ) - - valor = models.TextField( - help_text='Valor de la configuración' - ) - - tipo = models.CharField( - max_length=20, - choices=TIPO_CHOICES, - default='string', - help_text='Tipo de dato del valor' - ) - - descripcion = models.TextField( - blank=True, - help_text='Descripción de qué hace este parámetro' - ) - - valor_default = models.TextField( - help_text='Valor por defecto si se resetea' - ) - - modificado_por = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - blank=True, - related_name='configuracion_sistema_modificaciones', - help_text='Usuario que modificó este parámetro' - ) - - created_at = models.DateTimeField( - auto_now_add=True, - help_text='Fecha de creación' - ) - - updated_at = models.DateTimeField( - auto_now=True, - help_text='Fecha de última modificación' - ) - - class Meta: - db_table = 'configuracion_sistema' - verbose_name = 'Configuración del Sistema' - verbose_name_plural = 'Configuraciones del Sistema' - ordering = ['clave'] - - def __str__(self): - return f'{self.clave} = {self.valor}' - - def get_valor_typed(self): - """Retorna el valor convertido al tipo correcto.""" - if self.tipo == 'integer': - return int(self.valor) - elif self.tipo == 'float': - return float(self.valor) - elif self.tipo == 'boolean': - return self.valor.lower() in ('true', '1', 'yes', 'si') - elif self.tipo == 'json': - import json - return json.loads(self.valor) - return self.valor - - -class AuditoriaConfiguracion(models.Model): - """ - Historial de cambios en configuraciones del sistema. - - Permite auditar quién cambió qué y cuándo. - """ - - configuracion = models.ForeignKey( - ConfiguracionSistema, - on_delete=models.CASCADE, - related_name='historial', - help_text='Configuración que fue modificada' - ) - - valor_anterior = models.TextField( - help_text='Valor antes del cambio' - ) - - valor_nuevo = models.TextField( - help_text='Valor después del cambio' - ) - - modificado_por = models.ForeignKey( - settings.AUTH_USER_MODEL, - on_delete=models.SET_NULL, - null=True, - related_name='auditorias_configuracion', - help_text='Usuario que realizó el cambio' - ) - - timestamp = models.DateTimeField( - auto_now_add=True, - help_text='Cuándo se realizó el cambio' - ) - - motivo = models.TextField( - blank=True, - help_text='Razón del cambio (opcional)' - ) - - class Meta: - db_table = 'auditoria_configuracion' - verbose_name = 'Auditoría de Configuración' - verbose_name_plural = 'Auditorías de Configuración' - ordering = ['-timestamp'] - - def __str__(self): - return f'{self.configuracion.clave} cambió de {self.valor_anterior} a {self.valor_nuevo}' diff --git a/api/callcentersite/callcentersite/apps/configuracion/serializers.py b/api/callcentersite/callcentersite/apps/configuracion/serializers.py deleted file mode 100644 index 00e1f739..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/serializers.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Serializers para API REST de configuración.""" - -from rest_framework import serializers - -from .models import AuditoriaConfiguracion, ConfiguracionSistema - - -class ConfiguracionSistemaSerializer(serializers.ModelSerializer): - """Serializer para configuraciones del sistema.""" - - modificado_por_username = serializers.CharField( - source='modificado_por.username', - read_only=True - ) - - class Meta: - model = ConfiguracionSistema - fields = [ - 'id', 'clave', 'valor', 'tipo', 'descripcion', - 'valor_default', 'modificado_por', 'modificado_por_username', - 'created_at', 'updated_at' - ] - read_only_fields = ['id', 'created_at', 'updated_at', 'modificado_por'] - - -class ModificarConfiguracionSerializer(serializers.Serializer): - """Serializer para modificar una configuración.""" - - nuevo_valor = serializers.CharField( - help_text='Nuevo valor para la configuración' - ) - - motivo = serializers.CharField( - required=False, - allow_blank=True, - help_text='Razón del cambio (opcional)' - ) - - -class ImportarConfiguracionSerializer(serializers.Serializer): - """Serializer para importar configuraciones.""" - - json_data = serializers.CharField( - help_text='JSON con array de configuraciones' - ) - - -class AuditoriaConfiguracionSerializer(serializers.ModelSerializer): - """Serializer para auditoría de configuraciones.""" - - configuracion_clave = serializers.CharField( - source='configuracion.clave', - read_only=True - ) - - modificado_por_username = serializers.CharField( - source='modificado_por.username', - read_only=True - ) - - class Meta: - model = AuditoriaConfiguracion - fields = [ - 'id', 'configuracion', 'configuracion_clave', - 'valor_anterior', 'valor_nuevo', 'modificado_por', - 'modificado_por_username', 'timestamp', 'motivo' - ] - read_only_fields = fields diff --git a/api/callcentersite/callcentersite/apps/configuracion/services.py b/api/callcentersite/callcentersite/apps/configuracion/services.py deleted file mode 100644 index 5011f89c..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/services.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Servicios de negocio para configuración del sistema.""" - -from __future__ import annotations - -import json -from typing import Dict, List - -from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist, ValidationError - -from .models import AuditoriaConfiguracion, ConfiguracionSistema - -User = get_user_model() - - -class ConfigService: - """ - Servicio de gestión de configuraciones del sistema. - - Implementa casos de uso para administración de parámetros. - """ - - @staticmethod - def ver_configuracion(clave: str) -> ConfiguracionSistema: - """ - UC-024: Ver Configuración específica. - - Args: - clave: Clave de la configuración - - Returns: - Configuración solicitada - - Raises: - ObjectDoesNotExist: Si configuración no existe - - Ejemplo: - >>> config = ConfigService.ver_configuracion(clave='max_intentos_login') - """ - return ConfiguracionSistema.objects.get(clave=clave) - - @staticmethod - def listar_configuraciones() -> List[ConfiguracionSistema]: - """ - UC-024: Listar todas las configuraciones. - - Returns: - Lista de todas las configuraciones ordenadas por clave - - Ejemplo: - >>> configs = ConfigService.listar_configuraciones() - """ - return list(ConfiguracionSistema.objects.all().order_by('clave')) - - @staticmethod - def modificar_configuracion( - clave: str, - nuevo_valor: str, - usuario_id: int, - motivo: str = '' - ) -> ConfiguracionSistema: - """ - UC-025: Modificar Configuración. - - Args: - clave: Clave de la configuración - nuevo_valor: Nuevo valor a establecer - usuario_id: ID del usuario que modifica - motivo: Razón del cambio (opcional) - - Returns: - Configuración actualizada - - Raises: - ObjectDoesNotExist: Si configuración no existe - ValidationError: Si valor no es válido para el tipo - - Ejemplo: - >>> config = ConfigService.modificar_configuracion( - ... clave='timeout_session', - ... nuevo_valor='60', - ... usuario_id=1, - ... motivo='Aumentar tiempo' - ... ) - """ - usuario = User.objects.get(id=usuario_id) - config = ConfiguracionSistema.objects.get(clave=clave) - - # Validar tipo de dato - ConfigService._validar_tipo_valor(config.tipo, nuevo_valor) - - # Guardar valor anterior para auditoría - valor_anterior = config.valor - - # Actualizar configuración - config.valor = nuevo_valor - config.modificado_por = usuario - config.save() - - # Crear registro de auditoría - AuditoriaConfiguracion.objects.create( - configuracion=config, - valor_anterior=valor_anterior, - valor_nuevo=nuevo_valor, - modificado_por=usuario, - motivo=motivo - ) - - return config - - @staticmethod - def _validar_tipo_valor(tipo: str, valor: str) -> None: - """Valida que el valor sea compatible con el tipo esperado.""" - try: - if tipo == 'integer': - int(valor) - elif tipo == 'float': - float(valor) - elif tipo == 'boolean': - if valor.lower() not in ('true', 'false', '1', '0', 'yes', 'no', 'si'): - raise ValueError - elif tipo == 'json': - json.loads(valor) - except (ValueError, json.JSONDecodeError): - raise ValidationError(f'Valor "{valor}" no es válido para tipo {tipo}') - - @staticmethod - def exportar_configuracion() -> str: - """ - UC-026: Exportar Configuración. - - Exporta todas las configuraciones a formato JSON. - - Returns: - String JSON con todas las configuraciones - - Ejemplo: - >>> json_data = ConfigService.exportar_configuracion() - """ - configs = ConfiguracionSistema.objects.all() - - data = [ - { - 'clave': c.clave, - 'valor': c.valor, - 'tipo': c.tipo, - 'descripcion': c.descripcion, - 'valor_default': c.valor_default - } - for c in configs - ] - - return json.dumps(data, indent=2, ensure_ascii=False) - - @staticmethod - def importar_configuracion( - json_data: str, - usuario_id: int - ) -> Dict[str, int]: - """ - UC-027: Importar Configuración. - - Importa configuraciones desde JSON, creando o actualizando según corresponda. - - Args: - json_data: String JSON con configuraciones - usuario_id: ID del usuario que importa - - Returns: - Dict con contadores de creadas/actualizadas - - Raises: - ValidationError: Si JSON es inválido - ObjectDoesNotExist: Si usuario no existe - - Ejemplo: - >>> resultado = ConfigService.importar_configuracion( - ... json_data='[{"clave": "param1", ...}]', - ... usuario_id=1 - ... ) - >>> print(resultado) # {'creadas': 5, 'actualizadas': 3} - """ - usuario = User.objects.get(id=usuario_id) - - try: - configs_data = json.loads(json_data) - except json.JSONDecodeError: - raise ValidationError('JSON inválido') - - if not isinstance(configs_data, list): - raise ValidationError('JSON debe ser una lista de configuraciones') - - creadas = 0 - actualizadas = 0 - - for config_data in configs_data: - clave = config_data.get('clave') - if not clave: - continue - - # Validar tipo de valor antes de guardar - tipo = config_data.get('tipo', 'string') - valor = config_data.get('valor', '') - ConfigService._validar_tipo_valor(tipo, valor) - - # Verificar si existe - existing = ConfiguracionSistema.objects.filter(clave=clave).first() - - if existing: - # Actualizar existente - valor_anterior = existing.valor - existing.valor = valor - existing.tipo = tipo - existing.descripcion = config_data.get('descripcion', '') - existing.valor_default = config_data.get('valor_default', valor) - existing.modificado_por = usuario - existing.save() - - # Auditar - AuditoriaConfiguracion.objects.create( - configuracion=existing, - valor_anterior=valor_anterior, - valor_nuevo=valor, - modificado_por=usuario, - motivo='Importación de configuración' - ) - actualizadas += 1 - else: - # Crear nueva - ConfiguracionSistema.objects.create( - clave=clave, - valor=valor, - tipo=tipo, - descripcion=config_data.get('descripcion', ''), - valor_default=config_data.get('valor_default', valor), - modificado_por=usuario - ) - creadas += 1 - - return { - 'creadas': creadas, - 'actualizadas': actualizadas - } - - @staticmethod - def ver_historial_configuracion(clave: str) -> List[AuditoriaConfiguracion]: - """ - UC-028: Ver historial de una configuración específica. - - Args: - clave: Clave de la configuración - - Returns: - Lista de auditorías ordenadas por timestamp descendente - - Raises: - ObjectDoesNotExist: Si configuración no existe - - Ejemplo: - >>> historial = ConfigService.ver_historial_configuracion(clave='timeout') - """ - config = ConfiguracionSistema.objects.get(clave=clave) - return list( - AuditoriaConfiguracion.objects.filter(configuracion=config) - .order_by('-timestamp') - ) - - @staticmethod - def ver_historial_completo() -> List[AuditoriaConfiguracion]: - """ - UC-028: Ver historial completo de todas las configuraciones. - - Returns: - Lista de todas las auditorías ordenadas por timestamp descendente - - Ejemplo: - >>> historial = ConfigService.ver_historial_completo() - """ - return list(AuditoriaConfiguracion.objects.all().order_by('-timestamp')) diff --git a/api/callcentersite/callcentersite/apps/configuracion/urls.py b/api/callcentersite/callcentersite/apps/configuracion/urls.py deleted file mode 100644 index 6248ba61..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Rutas para API REST de configuración.""" - -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from .views import ConfiguracionViewSet - -app_name = "configuracion" - -# Router para ConfiguracionViewSet -router = DefaultRouter() -router.register(r'', ConfiguracionViewSet, basename='configuracion') - -urlpatterns = [ - path("", include(router.urls)), -] diff --git a/api/callcentersite/callcentersite/apps/configuracion/views.py b/api/callcentersite/callcentersite/apps/configuracion/views.py deleted file mode 100644 index 63fb1c92..00000000 --- a/api/callcentersite/callcentersite/apps/configuracion/views.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Vistas API REST para configuración del sistema.""" - -from django.http import HttpResponse -from rest_framework import status -from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.viewsets import ViewSet - -from .serializers import ( - AuditoriaConfiguracionSerializer, - ConfiguracionSistemaSerializer, - ImportarConfiguracionSerializer, - ModificarConfiguracionSerializer, -) -from .services import ConfigService - - -class ConfiguracionViewSet(ViewSet): - """ - ViewSet para gestión de configuraciones del sistema. - - Endpoints: - - GET /api/v1/configuracion/ - Listar todas las configuraciones - - GET /api/v1/configuracion/{clave}/ - Ver configuración específica - - PATCH /api/v1/configuracion/{clave}/modificar/ - Modificar configuración - - POST /api/v1/configuracion/exportar/ - Exportar a JSON - - POST /api/v1/configuracion/importar/ - Importar desde JSON - - GET /api/v1/configuracion/{clave}/historial/ - Ver historial de cambios - - GET /api/v1/configuracion/auditar/ - Ver historial completo - """ - - permission_classes = [IsAuthenticated] - - def list(self, request): - """ - GET /api/v1/configuracion/ - Listar todas las configuraciones. - - Retorna lista completa de configuraciones del sistema. - """ - try: - configs = ConfigService.listar_configuraciones() - serializer = ConfiguracionSistemaSerializer(configs, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - def retrieve(self, request, pk=None): - """ - GET /api/v1/configuracion/{clave}/ - Ver configuración específica. - - Args: - pk: Clave de la configuración (no es ID numérico) - """ - try: - config = ConfigService.ver_configuracion(clave=pk) - serializer = ConfiguracionSistemaSerializer(config) - return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_404_NOT_FOUND - ) - - @action(detail=True, methods=['patch']) - def modificar(self, request, pk=None): - """ - PATCH /api/v1/configuracion/{clave}/modificar/ - Modificar configuración. - - Body: - { - "nuevo_valor": "valor", - "motivo": "razón del cambio (opcional)" - } - """ - serializer = ModificarConfiguracionSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {'error': serializer.errors}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - config = ConfigService.modificar_configuracion( - clave=pk, - nuevo_valor=serializer.validated_data['nuevo_valor'], - usuario_id=request.user.id, - motivo=serializer.validated_data.get('motivo', '') - ) - return Response( - { - 'message': f'Configuración {pk} actualizada exitosamente', - 'config': ConfiguracionSistemaSerializer(config).data - }, - status=status.HTTP_200_OK - ) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=False, methods=['post']) - def exportar(self, request): - """ - POST /api/v1/configuracion/exportar/ - Exportar todas las configuraciones. - - Retorna archivo JSON para descarga. - """ - try: - json_data = ConfigService.exportar_configuracion() - response = HttpResponse(json_data, content_type='application/json') - response['Content-Disposition'] = 'attachment; filename="configuracion.json"' - return response - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - @action(detail=False, methods=['post']) - def importar(self, request): - """ - POST /api/v1/configuracion/importar/ - Importar configuraciones. - - Body: - { - "json_data": "[{\"clave\": \"param1\", ...}]" - } - """ - serializer = ImportarConfiguracionSerializer(data=request.data) - if not serializer.is_valid(): - return Response( - {'error': serializer.errors}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - resultado = ConfigService.importar_configuracion( - json_data=serializer.validated_data['json_data'], - usuario_id=request.user.id - ) - return Response( - { - 'message': 'Configuraciones importadas exitosamente', - 'resultado': resultado - }, - status=status.HTTP_200_OK - ) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_400_BAD_REQUEST - ) - - @action(detail=True, methods=['get']) - def historial(self, request, pk=None): - """ - GET /api/v1/configuracion/{clave}/historial/ - Ver historial de configuración. - - Retorna historial de cambios de una configuración específica. - """ - try: - historial = ConfigService.ver_historial_configuracion(clave=pk) - serializer = AuditoriaConfiguracionSerializer(historial, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_404_NOT_FOUND - ) - - @action(detail=False, methods=['get']) - def auditar(self, request): - """ - GET /api/v1/configuracion/auditar/ - Ver historial completo. - - Retorna todo el historial de cambios de todas las configuraciones. - """ - try: - historial = ConfigService.ver_historial_completo() - serializer = AuditoriaConfiguracionSerializer(historial, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - except Exception as e: - return Response( - {'error': str(e)}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) diff --git a/api/callcentersite/callcentersite/apps/configuration/services.py b/api/callcentersite/callcentersite/apps/configuration/services.py index 9d8d01a3..0bed618d 100644 --- a/api/callcentersite/callcentersite/apps/configuration/services.py +++ b/api/callcentersite/callcentersite/apps/configuration/services.py @@ -87,6 +87,41 @@ def obtener_configuracion( return configuraciones + @staticmethod + def obtener_configuracion_detalle( + usuario_id: int, + clave: str, + ) -> Configuracion: + """Obtiene una configuracion puntual. + + Usa el mismo permiso de consulta que el listado general + para mantener una unica superficie de autorizacion. + """ + + verificar_permiso_y_auditar( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + mensaje_error='No tiene permiso para ver configuraciones', + ) + + try: + configuracion = Configuracion.objects.get(clave=clave, activa=True) + except Configuracion.DoesNotExist: + raise ValidationError(f'Configuracion no encontrada: {clave}') + + auditar_accion_exitosa( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + recurso_id=configuracion.id, + detalles=f'Detalle de {clave}', + ) + + return configuracion + @staticmethod def editar_configuracion( usuario_id: int, @@ -161,6 +196,42 @@ def editar_configuracion( return configuracion + @staticmethod + def obtener_historial_configuracion( + usuario_id: int, + clave: str, + ) -> List[ConfiguracionHistorial]: + """Recupera el historial de una configuracion especifica.""" + + verificar_permiso_y_auditar( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + mensaje_error='No tiene permiso para ver configuraciones', + ) + + try: + configuracion = Configuracion.objects.get(clave=clave, activa=True) + except Configuracion.DoesNotExist: + raise ValidationError(f'Configuracion no encontrada: {clave}') + + historial = list( + ConfiguracionHistorial.objects.filter(configuracion=configuracion) + .order_by('-timestamp') + ) + + auditar_accion_exitosa( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + recurso_id=configuracion.id, + detalles=f'Historial de {clave} ({len(historial)} eventos)', + ) + + return historial + @staticmethod def exportar_configuracion( usuario_id: int, @@ -319,6 +390,32 @@ def importar_configuracion( 'errores': errores, } + @staticmethod + def obtener_historial_general(usuario_id: int) -> List[ConfiguracionHistorial]: + """Devuelve el historial completo de configuraciones.""" + + verificar_permiso_y_auditar( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + mensaje_error='No tiene permiso para ver configuraciones', + ) + + historial = list( + ConfiguracionHistorial.objects.all().order_by('-timestamp') + ) + + auditar_accion_exitosa( + usuario_id=usuario_id, + capacidad_codigo='sistema.tecnico.configuracion.ver', + recurso_tipo='configuracion', + accion='ver', + detalles=f'Historial completo ({len(historial)} eventos)', + ) + + return historial + @staticmethod def restaurar_configuracion( usuario_id: int, diff --git a/api/callcentersite/callcentersite/apps/configuration/urls.py b/api/callcentersite/callcentersite/apps/configuration/urls.py index fbfecb9f..4c02b413 100644 --- a/api/callcentersite/callcentersite/apps/configuration/urls.py +++ b/api/callcentersite/callcentersite/apps/configuration/urls.py @@ -3,10 +3,12 @@ from django.urls import path from .views import ( + ConfiguracionAuditoriaView, ConfiguracionEditarView, ConfiguracionExportarView, ConfiguracionImportarView, ConfiguracionListView, + ConfiguracionHistorialView, ConfiguracionRestaurarView, ) @@ -15,7 +17,9 @@ urlpatterns = [ path("", ConfiguracionListView.as_view(), name="list"), path("/", ConfiguracionEditarView.as_view(), name="editar"), + path("/historial/", ConfiguracionHistorialView.as_view(), name="historial"), path("exportar/", ConfiguracionExportarView.as_view(), name="exportar"), path("importar/", ConfiguracionImportarView.as_view(), name="importar"), + path("auditar/", ConfiguracionAuditoriaView.as_view(), name="auditar"), path("/restaurar/", ConfiguracionRestaurarView.as_view(), name="restaurar"), ] diff --git a/api/callcentersite/callcentersite/apps/configuration/views.py b/api/callcentersite/callcentersite/apps/configuration/views.py index f8142486..82959b84 100644 --- a/api/callcentersite/callcentersite/apps/configuration/views.py +++ b/api/callcentersite/callcentersite/apps/configuration/views.py @@ -3,7 +3,10 @@ Endpoints: - GET /api/v1/configuracion/ (obtener) + - GET /api/v1/configuracion/:clave/ (detalle) - PUT /api/v1/configuracion/:clave/ (editar) + - GET /api/v1/configuracion/:clave/historial/ (historial) + - GET /api/v1/configuracion/auditar/ (auditoria) - GET /api/v1/configuracion/exportar/ (exportar) - POST /api/v1/configuracion/importar/ (importar) - POST /api/v1/configuracion/:clave/restaurar/ (restaurar) @@ -20,6 +23,7 @@ from .serializers import ( ConfiguracionSerializer, + ConfiguracionHistorialSerializer, EditarConfiguracionSerializer, ImportarConfiguracionSerializer, ) @@ -61,6 +65,27 @@ class ConfiguracionEditarView(APIView): Permiso: sistema.tecnico.configuracion.editar """ + def get(self, request, clave): # type: ignore[override] + try: + configuracion = ConfiguracionService.obtener_configuracion_detalle( + usuario_id=request.user.id, + clave=clave, + ) + + output_serializer = ConfiguracionSerializer(configuracion) + return Response(output_serializer.data) + + except PermissionDenied as e: + return Response( + {'error': str(e)}, + status=status.HTTP_403_FORBIDDEN, + ) + except ValidationError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + def put(self, request, clave): # type: ignore[override] serializer = EditarConfiguracionSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -181,3 +206,47 @@ def post(self, request, clave): # type: ignore[override] {'error': str(e)}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ConfiguracionHistorialView(APIView): + """Expone el historial de cambios de una configuracion.""" + + def get(self, request, clave): # type: ignore[override] + try: + historial = ConfiguracionService.obtener_historial_configuracion( + usuario_id=request.user.id, + clave=clave, + ) + + serializer = ConfiguracionHistorialSerializer(historial, many=True) + return Response(serializer.data) + + except PermissionDenied as e: + return Response( + {'error': str(e)}, + status=status.HTTP_403_FORBIDDEN, + ) + except ValidationError as e: + return Response( + {'error': str(e)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ConfiguracionAuditoriaView(APIView): + """Devuelve el historial completo de configuraciones.""" + + def get(self, request): # type: ignore[override] + try: + historial = ConfiguracionService.obtener_historial_general( + usuario_id=request.user.id, + ) + + serializer = ConfiguracionHistorialSerializer(historial, many=True) + return Response(serializer.data) + + except PermissionDenied as e: + return Response( + {'error': str(e)}, + status=status.HTTP_403_FORBIDDEN, + ) diff --git a/api/callcentersite/callcentersite/settings/base.py b/api/callcentersite/callcentersite/settings/base.py index eb4ba7bb..fdd33c73 100644 --- a/api/callcentersite/callcentersite/settings/base.py +++ b/api/callcentersite/callcentersite/settings/base.py @@ -44,7 +44,6 @@ "callcentersite.apps.audit", "callcentersite.apps.dashboard", "callcentersite.apps.configuration", - "callcentersite.apps.configuracion", "callcentersite.apps.presupuestos", "callcentersite.apps.politicas", "callcentersite.apps.excepciones", diff --git a/api/callcentersite/callcentersite/urls.py b/api/callcentersite/callcentersite/urls.py index a600a2a9..4d2e7eb2 100644 --- a/api/callcentersite/callcentersite/urls.py +++ b/api/callcentersite/callcentersite/urls.py @@ -22,9 +22,18 @@ def health_check(_request): ), path("api/v1/", include("callcentersite.apps.users.urls")), path("api/v1/configuration/", include("callcentersite.apps.configuration.urls")), + path( + "api/v1/configuracion/", + include( + ( + "callcentersite.apps.configuration.urls", + "configuration", + ), + namespace="configuracion", + ), + ), path("api/v1/auth/", include("callcentersite.apps.authentication.urls")), path("api/v1/dashboard/", include("callcentersite.apps.dashboard.urls")), - path("api/v1/configuracion/", include("callcentersite.apps.configuracion.urls")), path("api/v1/presupuestos/", include("callcentersite.apps.presupuestos.urls")), path("api/v1/politicas/", include("callcentersite.apps.politicas.urls")), path("api/v1/excepciones/", include("callcentersite.apps.excepciones.urls")), diff --git a/api/callcentersite/tests/unit/configuration/test_consolidation.py b/api/callcentersite/tests/unit/configuration/test_consolidation.py new file mode 100644 index 00000000..aebe490d --- /dev/null +++ b/api/callcentersite/tests/unit/configuration/test_consolidation.py @@ -0,0 +1,28 @@ +"""Pruebas de consolidación para endpoints de configuración. + +Estas pruebas aseguran que la ruta en español (`/api/v1/configuracion/`) +utiliza la implementación moderna del módulo `configuration` y que la app +legacy `configuracion` ya no está habilitada en `INSTALLED_APPS`. +""" + +from django.conf import settings +from django.test import SimpleTestCase +from django.urls import resolve + +from callcentersite.apps.configuration.views import ConfiguracionListView + + +class TestConfiguracionConsolidation(SimpleTestCase): + """Valida que exista una única implementación de configuraciones.""" + + def test_ruta_espanol_resuelve_a_configuration(self): + """La ruta legacy debe usar las vistas de `configuration`.""" + + resolver = resolve("/api/v1/configuracion/") + + self.assertIs(resolver.func.view_class, ConfiguracionListView) + + def test_app_configuracion_no_esta_instalada(self): + """La app duplicada `configuracion` no debe estar instalada.""" + + self.assertNotIn("callcentersite.apps.configuracion", settings.INSTALLED_APPS) diff --git a/api/callcentersite/tests/unit/configuration/test_views_historial.py b/api/callcentersite/tests/unit/configuration/test_views_historial.py new file mode 100644 index 00000000..a9acf61c --- /dev/null +++ b/api/callcentersite/tests/unit/configuration/test_views_historial.py @@ -0,0 +1,116 @@ +"""Pruebas de vistas de configuraciones consolidadas. + +Validan que la app en inglés expone las rutas y comportamientos +antes disponibles en la versión en español (detalle e historial). +""" + +from types import SimpleNamespace +from unittest import mock + +from django.test import SimpleTestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory + +from callcentersite.apps.configuration.models import Configuracion, ConfiguracionHistorial +from callcentersite.apps.configuration.views import ( + ConfiguracionEditarView, + ConfiguracionAuditoriaView, + ConfiguracionHistorialView, +) + + +class TestConfiguracionViewsEspanol(SimpleTestCase): + """Valida que las vistas en inglés soporten las rutas legacy.""" + + def setUp(self): + self.factory = APIRequestFactory() + self.user = SimpleNamespace(id=1) + + def test_get_detalle_reutiliza_la_vista_de_edicion(self): + """El GET al detalle debe devolver la configuración solicitada.""" + + config = Configuracion( + categoria="general", + clave="timeout", + valor="30", + tipo_dato="integer", + valor_default="30", + ) + + with mock.patch( + "callcentersite.apps.configuration.views.ConfiguracionService.obtener_configuracion_detalle", + return_value=config, + ) as mock_service: + request = self.factory.get("/api/v1/configuration/timeout/") + request.user = self.user + + response = ConfiguracionEditarView.as_view()(request, clave="timeout") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["clave"], "timeout") + mock_service.assert_called_once_with(usuario_id=self.user.id, clave="timeout") + + def test_historial_view_serializa_cambios(self): + """El historial expone los cambios registrados.""" + + config = Configuracion( + categoria="general", + clave="timeout", + valor="30", + tipo_dato="integer", + valor_default="30", + ) + historial = [ + ConfiguracionHistorial( + configuracion=config, + clave="timeout", + valor_anterior="30", + valor_nuevo="60", + timestamp=timezone.now(), + ) + ] + + with mock.patch( + "callcentersite.apps.configuration.views.ConfiguracionService.obtener_historial_configuracion", + return_value=historial, + ) as mock_service: + request = self.factory.get("/api/v1/configuration/timeout/historial/") + request.user = self.user + + response = ConfiguracionHistorialView.as_view()(request, clave="timeout") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["valor_nuevo"], "60") + mock_service.assert_called_once_with(usuario_id=self.user.id, clave="timeout") + + def test_auditoria_view_expone_historial_global(self): + """La auditoría general retorna todas las modificaciones.""" + + eventos = [ + ConfiguracionHistorial( + configuracion=Configuracion( + categoria="general", + clave="timeout", + valor="30", + tipo_dato="integer", + valor_default="30", + ), + clave="timeout", + valor_anterior="30", + valor_nuevo="35", + timestamp=timezone.now(), + ) + ] + + with mock.patch( + "callcentersite.apps.configuration.views.ConfiguracionService.obtener_historial_general", + return_value=eventos, + ) as mock_service: + request = self.factory.get("/api/v1/configuration/auditar/") + request.user = self.user + + response = ConfiguracionAuditoriaView.as_view()(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["clave"], "timeout") + mock_service.assert_called_once_with(usuario_id=self.user.id)