diff --git a/app/api_config/settings.py b/app/api_config/settings.py index c92f0fb..394d9d0 100644 --- a/app/api_config/settings.py +++ b/app/api_config/settings.py @@ -56,6 +56,7 @@ INSTALLED_APPS = [ 'corsheaders', # Tus Apps (Asegúrate de que el path sea correcto) + 'general', 'promociones', 'backend_admin', 'common', diff --git a/app/api_config/urls.py b/app/api_config/urls.py index 29116c0..66fafab 100644 --- a/app/api_config/urls.py +++ b/app/api_config/urls.py @@ -2,6 +2,7 @@ from django.urls import path, include from backend_admin import views as admin_views urlpatterns = [ + path('general/', include('general.urls')), path('admin/', include('backend_admin.urls')), path('promociones/', include('promociones.urls')), path('api/token/', admin_views.api_token, name='token_obtain_pair'), diff --git a/app/general/__init__.py b/app/general/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/general/admin.py b/app/general/admin.py new file mode 100644 index 0000000..694323f --- /dev/null +++ b/app/general/admin.py @@ -0,0 +1 @@ +from django.contrib import admin diff --git a/app/general/apps.py b/app/general/apps.py new file mode 100644 index 0000000..bf61ccb --- /dev/null +++ b/app/general/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GeneralConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'general' diff --git a/app/general/exception.py b/app/general/exception.py new file mode 100644 index 0000000..22071cf --- /dev/null +++ b/app/general/exception.py @@ -0,0 +1,21 @@ +from rest_framework.views import exception_handler +from rest_framework.response import Response + + +def custom_exception_handler(exc, context): + """ + Handler global de excepciones para DRF. + Añadir en settings.py: + REST_FRAMEWORK = { + 'EXCEPTION_HANDLER': 'general.exception.custom_exception_handler', + } + """ + response = exception_handler(exc, context) + + if response is not None: + response.data = { + 'body': {'data': [], 'error': response.data}, + 'mensaje': str(exc), + } + + return response diff --git a/app/general/migrations/__init__.py b/app/general/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/general/models.py b/app/general/models.py new file mode 100644 index 0000000..b4189cb --- /dev/null +++ b/app/general/models.py @@ -0,0 +1,5 @@ +from django.db import models + +# La app general no define modelos propios. +# El modelo Log vive en backend_admin.models y se accede desde +# general.utilidades.acciones.LogService. diff --git a/app/general/request.py b/app/general/request.py new file mode 100644 index 0000000..97f1e2c --- /dev/null +++ b/app/general/request.py @@ -0,0 +1,24 @@ +import json +from django.http import JsonResponse + + +def parse_body(request): + """ + Parsea el body de la request como JSON. + Lanza ValueError si el body está vacío o no es JSON válido. + """ + raw = request.body + if not raw: + raise ValueError('El body de la petición está vacío.') + return json.loads(raw) + + +def build_error_response(message, status=400, data=None): + """Construye una respuesta de error en el formato estándar del proyecto.""" + body = {'data': data if data is not None else [], 'error': message} + return JsonResponse({'body': body, 'mensaje': message}, status=status, safe=False) + + +def build_success_response(data, status=200): + """Construye una respuesta de éxito en el formato estándar del proyecto.""" + return JsonResponse(data, status=status, safe=False) diff --git a/app/general/serializers.py b/app/general/serializers.py new file mode 100644 index 0000000..42ae46a --- /dev/null +++ b/app/general/serializers.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class EmptySerializer(serializers.Serializer): + """Serializer base vacío para endpoints sin body estructurado.""" + pass diff --git a/app/general/tests.py b/app/general/tests.py new file mode 100644 index 0000000..b9ce935 --- /dev/null +++ b/app/general/tests.py @@ -0,0 +1,7 @@ +from django.test import TestCase + + +class LogServiceTest(TestCase): + def test_placeholder(self): + """Placeholder: añadir tests de LogService aquí.""" + pass diff --git a/app/general/urls.py b/app/general/urls.py new file mode 100644 index 0000000..e7b3996 --- /dev/null +++ b/app/general/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import status_view + +urlpatterns = [ + path('status/', status_view, name='general_status'), +] diff --git a/app/general/utilidades/__init__.py b/app/general/utilidades/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/general/utilidades/acciones.py b/app/general/utilidades/acciones.py new file mode 100644 index 0000000..5624a3d --- /dev/null +++ b/app/general/utilidades/acciones.py @@ -0,0 +1,81 @@ +from backend_admin.models import Log +from django.utils import timezone +from . import utils + + +class LogService: + """ + Servicio centralizado de gestión de logs. + Única fuente de inserción en la tabla audit_logs. + + Uso en vistas: + log_id = LogService.gestionar_log(self, request, path='/mi/path/') + LogService.gestionar_log(self, request, log_id=log_id, body_request=data, status_code=200) + """ + + def gestionar_log( + self, + request, + log_id=None, + path=None, + user=None, + body_request=None, + body_response=None, + status_code=None, + ): + # Determinar la app que llama a partir del módulo de la vista + modulo = self.__class__.__module__ + app_nombre = modulo.split('.')[0] + + tag_header = request.headers.get('tag') + + if tag_header: + usuario_final = tag_header + elif app_nombre: + apps_automatizadas = ['automatizados'] + if app_nombre.lower() in apps_automatizadas: + usuario_final = 'Jenkins' + else: + usuario_final = app_nombre + else: + usuario_final = user if user else 'orquestador' + + if log_id is None: + # --- CREACIÓN: primer registro del ciclo de vida de la petición --- + path_final = path if path else request.path + + data_log = { + 'user_id': 0, + 'user': usuario_final, + 'app_id': 0, + 'remote_address': utils.get_client_ip(request), + 'request': '', + 'response': '', + 'status_code': status_code if status_code else '0', + 'path': path_final, + 'method': request.method, + 'createdAt': timezone.now(), + } + + nuevo_log = Log.objects.create(**data_log) + return nuevo_log.pk + + else: + # --- ACTUALIZACIÓN: enriquece el log con datos del procesamiento --- + datos_a_actualizar = { + 'updatedAt': timezone.now(), + } + + if user: + datos_a_actualizar['user'] = user + if path: + datos_a_actualizar['path'] = path + if body_request is not None: + datos_a_actualizar['request'] = body_request + if body_response is not None: + datos_a_actualizar['response'] = body_response + if status_code: + datos_a_actualizar['status_code'] = str(status_code) + + Log.objects.filter(pk=log_id).update(**datos_a_actualizar) + return log_id diff --git a/app/general/utilidades/custom_errors.py b/app/general/utilidades/custom_errors.py new file mode 100644 index 0000000..ddd71af --- /dev/null +++ b/app/general/utilidades/custom_errors.py @@ -0,0 +1,33 @@ +class ValidationError(Exception): + """Error de validación de datos de entrada.""" + def __init__(self, message, field=None): + self.message = message + self.field = field + super().__init__(message) + + def to_response(self): + detail = {'error': self.message} + if self.field: + detail['field'] = self.field + return {'body': {'data': [], **detail}, 'mensaje': self.message} + + +class ExternalServiceError(Exception): + """Error en la comunicación con un servicio externo.""" + def __init__(self, message, service=None): + self.message = message + self.service = service + super().__init__(message) + + def to_response(self): + return {'body': {'data': [], 'error': self.message}, 'mensaje': self.message} + + +class NotFoundError(Exception): + """Recurso no encontrado.""" + def __init__(self, message='Recurso no encontrado'): + self.message = message + super().__init__(message) + + def to_response(self): + return {'body': {'data': [], 'error': self.message}, 'mensaje': self.message} diff --git a/app/general/utilidades/utils.py b/app/general/utilidades/utils.py new file mode 100644 index 0000000..62ab876 --- /dev/null +++ b/app/general/utilidades/utils.py @@ -0,0 +1,6 @@ +def get_client_ip(request): + """Obtiene la IP real del cliente, considerando proxies.""" + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + return x_forwarded_for.split(',')[0].strip() + return request.META.get('REMOTE_ADDR') diff --git a/app/general/validaciones/__init__.py b/app/general/validaciones/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/general/views.py b/app/general/views.py new file mode 100644 index 0000000..15c7523 --- /dev/null +++ b/app/general/views.py @@ -0,0 +1,5 @@ +from django.http import JsonResponse + + +def status_view(request): + return JsonResponse({'status': 'ok', 'service': 'general'}) diff --git a/app/promociones/views.py b/app/promociones/views.py index 21181ae..fb9bfc4 100644 --- a/app/promociones/views.py +++ b/app/promociones/views.py @@ -2,45 +2,45 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework_simplejwt.authentication import JWTAuthentication from django.http import JsonResponse -from backend_admin.models import Log + +from general.utilidades.acciones import LogService from .acciones import getData -from django.utils import timezone # Esta es la forma correcta + + class PromocionObtener(APIView): authentication_classes = [JWTAuthentication] permission_classes = [IsAuthenticated] def post(self, request): - # --- BLOQUE 1: LOG INITIATION --- - log_entry = Log.objects.create( - user=request.user.username, - path='promociones/obtener/', - method='POST', - status_code='0' - ) + path = '/promociones/obtener/' - print('llega a despues de log entry') + # --- LOG: inicio de la petición --- + log_id = LogService.gestionar_log(self, request, path=path) try: - # --- BLOQUE 2: DATA CLEANING --- + # --- BLOQUE 2: limpieza y validación del body --- data = request.data - log_entry.request = data - log_entry.save() + status = 100 + LogService.gestionar_log(self, request, log_id=log_id, path=path, body_request=data, status_code=status) params = {'id': data.get('id'), 'activo': data.get('activo')} - # --- BLOQUE 3: ACTION CALL --- - # Aquí ya recibimos las fechas como strings gracias al paso 1 + except Exception as error: + response = {'body': {'data': [], 'error': str(error)}, 'mensaje': str(error)} + status = 400 + LogService.gestionar_log(self, request, log_id=log_id, path=path, body_response=response, status_code=status) + return JsonResponse(response, status=status, safe=False) + + try: + # --- BLOQUE 3: llamada a la acción --- resultado = getData(params) + response = resultado + status = 200 + LogService.gestionar_log(self, request, log_id=log_id, path=path, body_response=response, status_code=status) + return JsonResponse(response, safe=False, status=status) - # --- BLOQUE 4: LOG CLOSURE & RESPONSE --- - log_entry.response = {"count": len(resultado)} # No guardes todo el JSON si es muy grande - log_entry.status_code = '200' - log_entry.save() - - return JsonResponse(resultado, safe=False, status=200) - - except Exception as e: - log_entry.status_code = '500' - log_entry.response = {'error': str(e)} - log_entry.save() - return JsonResponse({'error': str(e)}, status=500) \ No newline at end of file + except Exception as error: + response = {'body': {'data': [], 'error': str(error)}, 'error': str(error)} + status = 500 + LogService.gestionar_log(self, request, log_id=log_id, path=path, body_response=response, status_code=status) + return JsonResponse(response, status=status, safe=False)