diff --git a/api/cuser/hub_auth.py b/api/cuser/hub_auth.py new file mode 100644 index 0000000..4b76ee9 --- /dev/null +++ b/api/cuser/hub_auth.py @@ -0,0 +1,239 @@ +""" +Autenticación vía Hub de Aduanasoft (Keycloak). +Tokens locales HS256 (~700 bytes) se emiten tras el exchange con el Hub +para no exceder el límite de 4096 bytes de cookies del browser. + +ORDEN CRÍTICO en verify_hub_token: + cache → local HS256 → Hub /auth/me +Si el token local se manda al Hub primero, Hub responde 401 y rompe la +sesión SSO silenciosamente. +""" +import logging +import time +from typing import Optional + +import jwt +import requests +from django.conf import settings +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +logger = logging.getLogger(__name__) + +# Cache en memoria: {token: (payload, expires_at)} +_token_cache: dict = {} +_CACHE_TTL = 60 # segundos + + +def _cache_get(token: str) -> Optional[dict]: + entry = _token_cache.get(token) + if entry and entry[1] > time.time(): + return entry[0] + _token_cache.pop(token, None) + return None + + +def _cache_set(token: str, payload: dict): + _token_cache[token] = (payload, time.time() + _CACHE_TTL) + + +# --------------------------------------------------------------------------- +# Tokens locales +# --------------------------------------------------------------------------- + +def create_local_tokens(user_data: dict) -> dict: + """Emite tokens locales compactos HS256. Caben en cookies del browser.""" + import uuid + from datetime import datetime, timedelta, timezone + + now = datetime.now(timezone.utc) + base = { + "sub": str(user_data.get("id") or user_data.get("username", "")), + "preferred_username": user_data.get("username", ""), + "email": user_data.get("email", ""), + "name": user_data.get("name", ""), + "given_name": user_data.get("first_name", ""), + "family_name": user_data.get("last_name", ""), + "is_hub_admin": user_data.get("is_hub_admin", False), + "tenant_id": user_data.get("tenant_id"), + "tenant_slug": user_data.get("tenant_slug") or getattr(settings, "HUB_TENANT_SLUG", ""), + "source": "local", + "iat": int(now.timestamp()), + } + access_payload = {**base, "exp": int((now + timedelta(hours=8)).timestamp())} + refresh_payload = {**base, "exp": int((now + timedelta(days=30)).timestamp())} + secret = settings.SECRET_KEY + + return { + "access_token": jwt.encode(access_payload, secret, algorithm="HS256"), + "refresh_token": jwt.encode(refresh_payload, secret, algorithm="HS256"), + "expires_in": 1800, + "source": "local", + } + + +def _verify_local_token(token: str) -> Optional[dict]: + """Decodifica token local HS256. Retorna payload o None si no es local.""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) + if payload.get("source") == "local": + return payload + return None + except jwt.ExpiredSignatureError: + raise AuthenticationFailed("Token expirado — inicia sesión de nuevo") + except jwt.InvalidTokenError: + return None + + +# --------------------------------------------------------------------------- +# Verificación contra Hub +# --------------------------------------------------------------------------- + +def verify_hub_token(token: str) -> dict: + """ORDEN: cache → local HS256 → Hub /auth/me.""" + cached = _cache_get(token) + if cached: + return cached + + # 1. Token local primero (evita 401 del Hub para tokens locales) + local = _verify_local_token(token) + if local: + _cache_set(token, local) + return local + + # 2. Validar contra Hub + hub_url = getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com") + me_url = f"{hub_url.rstrip('/')}/api/v1/auth/me" + try: + r = requests.get(me_url, headers={"Authorization": f"Bearer {token}"}, timeout=5) + except requests.exceptions.RequestException as exc: + # Fallback: si hay token local válido lo usamos + local = _verify_local_token(token) + if local: + _cache_set(token, local) + return local + logger.error("Hub no disponible: %s", exc) + raise AuthenticationFailed("Servicio de autenticación no disponible") + + if r.status_code == 200: + info = r.json() + _cache_set(token, info) + return info + + if r.status_code in (401, 403): + raise AuthenticationFailed("Token inválido o sesión expirada") + + logger.error("Hub respondió %s al verificar token", r.status_code) + raise AuthenticationFailed("No se pudo verificar el token") + + +def _get_django_user(hub_data: dict): + """Resuelve el CustomUser de Django a partir de los datos del Hub.""" + from api.cuser.models import CustomUser + + # Token local: sub puede ser Django UUID (login directo) o KC UUID (SSO exchange) + if hub_data.get("source") == "local": + from django.db.models import Q + sub = hub_data.get("sub", "") + if not sub: + return None + # Una sola query: busca por Django UUID o KC UUID simultáneamente + try: + return CustomUser.objects.filter( + Q(id=sub) | Q(keycloak_user_id=sub) + ).first() + except Exception: + # sub malformado (no es UUID válido) + return CustomUser.objects.filter(keycloak_user_id=sub).first() + + # Token Hub: buscar por keycloak_user_id → email + kc_id = hub_data.get("keycloak_user_id") or hub_data.get("sub") + email = hub_data.get("email") + + if kc_id: + user = CustomUser.objects.filter(keycloak_user_id=kc_id).first() + if user: + return user + + if email: + return CustomUser.objects.filter(email=email).first() + + return None + + +# --------------------------------------------------------------------------- +# DRF Authentication Backend +# --------------------------------------------------------------------------- + +class HubAuthBackend(BaseAuthentication): + """ + Drop-in para reemplazar JWTAuthentication. + Acepta tokens locales (HS256) y tokens del Hub indistintamente. + Se añade JUNTO a JWTAuthentication para compatibilidad durante la migración. + """ + + def authenticate(self, request): + token = self._extract_token(request) + if not token: + return None + + # Detectar tokens SimpleJWT sin llamar al Hub. + # Decodificamos sin verificar firma solo para leer claims. + # Si el token no tiene source="local" ni claims de KC (realm_access, azp) + # es un token SimpleJWT legacy → dejar que JWTAuthentication lo maneje. + try: + unverified = jwt.decode( + token, + options={"verify_signature": False}, + algorithms=["HS256", "RS256"], + ) + is_hub_token = ( + unverified.get("source") == "local" # token local HS256 + or "realm_access" in unverified # token KC directo + or "azp" in unverified # token KC (authorized party) + ) + if not is_hub_token: + return None # SimpleJWT — pasar al siguiente backend sin tocar el Hub + except Exception: + return None # JWT malformado — no es nuestro + + try: + hub_data = verify_hub_token(token) + except AuthenticationFailed: + return None + except Exception as exc: + logger.error("Error inesperado en HubAuthBackend: %s", exc) + return None + + user = _get_django_user(hub_data) + if not user: + # Retornar None permite que endpoints AllowAny pasen sin bloquear. + # Los endpoints IsAuthenticated quedarán como "no autenticado" (sin 401 engañoso). + return None + + return (user, token) + + @staticmethod + def _extract_token(request) -> Optional[str]: + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if auth_header.lower().startswith("bearer "): + return auth_header[7:].strip() or None + # Fallback: cookie (flujo SSO con cookies) + return request.COOKIES.get("access_token") + + def authenticate_header(self, request): + return "Bearer" + + +# --------------------------------------------------------------------------- +# Helper cookies +# --------------------------------------------------------------------------- + +def set_session_cookies(response, tokens: dict): + """Escribe las cookies de sesión HTTP-only.""" + secure = getattr(settings, "COOKIE_SECURE", not settings.DEBUG) + kw = dict(httponly=True, secure=secure, samesite="Lax") + response.set_cookie("access_token", tokens["access_token"], max_age=1800, **kw) + response.set_cookie("refresh_token", tokens["refresh_token"], max_age=60*60*24*7, **kw) + response.set_cookie("token_type", "bearer", max_age=60*60*24*7, + httponly=False, secure=secure, samesite="Lax") diff --git a/api/cuser/migrations/0007_customuser_keycloak_user_id.py b/api/cuser/migrations/0007_customuser_keycloak_user_id.py new file mode 100644 index 0000000..c22ec16 --- /dev/null +++ b/api/cuser/migrations/0007_customuser_keycloak_user_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2026-05-28 18:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cuser', '0006_customuser_active_organization'), + ] + + operations = [ + migrations.AddField( + model_name='customuser', + name='keycloak_user_id', + field=models.CharField(blank=True, help_text='UUID del usuario en Keycloak/Hub', max_length=36, null=True, unique=True), + ), + ] diff --git a/api/cuser/models.py b/api/cuser/models.py index 1fe423d..73a85d7 100644 --- a/api/cuser/models.py +++ b/api/cuser/models.py @@ -25,6 +25,9 @@ class CustomUser(AbstractUser): is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer") rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario") + # Identidad Keycloak — se llena con el script de migración masiva + keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub") + def __str__(self): return self.username diff --git a/api/cuser/sso_urls.py b/api/cuser/sso_urls.py new file mode 100644 index 0000000..9fdb9de --- /dev/null +++ b/api/cuser/sso_urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from .sso_views import login_view, sso_exchange_view, me_view, logout_view, refresh_view, session_refresh_view + +urlpatterns = [ + path("login/", login_view, name="hub-login"), + path("sso/exchange/", sso_exchange_view, name="hub-sso-exchange"), + path("me/", me_view, name="hub-me"), + path("logout/", logout_view, name="hub-logout"), + path("login/refresh/", refresh_view, name="hub-refresh"), # legacy + path("session/refresh/", session_refresh_view, name="hub-session-refresh"), # cookie-based +] diff --git a/api/cuser/sso_views.py b/api/cuser/sso_views.py new file mode 100644 index 0000000..2b5e177 --- /dev/null +++ b/api/cuser/sso_views.py @@ -0,0 +1,395 @@ +""" +Vistas SSO para integración con Hub de Aduanasoft. +Cuatro endpoints: + POST /api/v1/auth/login/ — login directo email/password (proxy Hub) + POST /api/v1/auth/sso/exchange/ — canjea relay token por sesión local + GET /api/v1/auth/me/ — usuario autenticado actual + POST /api/v1/auth/logout/ — cierra sesión (limpia cookies) +""" +import logging +from typing import Optional + +import requests as http +from django.conf import settings +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from .hub_auth import ( + create_local_tokens, + set_session_cookies, + verify_hub_token, + _get_django_user, +) + +logger = logging.getLogger(__name__) + +HUB_URL = lambda: getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com").rstrip("/") + + +def _provision_user_in_hub(username: str, password: str) -> bool: + """ + Crea/sincroniza el usuario en KC vía Hub /auth/provision-user. + Solo se llama cuando Hub devuelve 401 (usuario no existe en KC). + Retorna True si la provisión fue exitosa o el usuario ya existía. + """ + from django.db.models import Q + from api.cuser.models import CustomUser + + user = CustomUser.objects.filter( + Q(username=username) | Q(email=username), + is_active=True, + ).first() + + if not user: + return False + + tenant_slug = getattr(settings, "HUB_TENANT_SLUG", "efc") + provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "") + + try: + r = http.post( + f"{HUB_URL()}/api/v1/auth/provision-user", + json={ + "username": user.username, + "email": user.email or f"{user.username}@efc.local", + "password": password, + "first_name": user.first_name or "", + "last_name": user.last_name or "", + "tenant_slug": tenant_slug, + "role": "operador", + }, + headers={"X-Provision-Secret": provision_secret}, + timeout=15, + ) + + if r.status_code == 200: + data = r.json() + # Hub devuelve access_token (JWT KC) — extraer sub = KC user UUID + kc_id = data.get("user_id") or data.get("keycloak_user_id") + if not kc_id: + try: + import jwt as _jwt + payload = _jwt.decode( + data["access_token"], + options={"verify_signature": False}, + algorithms=["RS256", "HS256"], + ) + kc_id = payload.get("sub") + except Exception: + pass + + if kc_id: + CustomUser.objects.filter(pk=user.pk).update(keycloak_user_id=kc_id) + logger.info("[provision] Usuario %s provisionado — KC id: %s", user.username, kc_id) + else: + logger.warning("[provision] No se pudo extraer KC UUID para %s", user.username) + return True + + logger.error("[provision] Hub %s al provisionar %s: %s", + r.status_code, username, r.text[:200]) + return False + + except http.exceptions.RequestException as exc: + logger.error("[provision] Error de red provisionando %s: %s", username, exc) + return False + + +def _extract_token(request) -> Optional[str]: + auth = request.META.get("HTTP_AUTHORIZATION", "") + if auth.lower().startswith("bearer "): + t = auth[7:].strip() + if t: + return t + return request.COOKIES.get("access_token") + + +# --------------------------------------------------------------------------- +# POST /api/v1/auth/login/ +# --------------------------------------------------------------------------- + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def login_view(request): + """ + Login directo con Django auth + SimpleJWT. + No llama al Hub en cada login — solo la primera vez si el usuario + no tiene keycloak_user_id (provisión one-shot transparente). + Soporta ambos modos: login directo aquí O login vía Hub SSO. + """ + from django.contrib.auth import authenticate as django_auth + from django.db.models import Q + from api.cuser.models import CustomUser + from rest_framework_simplejwt.tokens import RefreshToken + + username = request.data.get("username", "").strip() + password = request.data.get("password", "") + + if not username or not password: + return Response({"detail": "username y password son requeridos"}, + status=status.HTTP_400_BAD_REQUEST) + + # Autenticar directamente con Django (rápido, sin tocar Hub) + user = django_auth(request, username=username, password=password) + + # Fallback: buscar por email si username no matcheó + if not user: + user_by_email = CustomUser.objects.filter( + Q(email=username), is_active=True + ).first() + if user_by_email: + user = django_auth(request, username=user_by_email.username, password=password) + + if not user or not user.is_active: + return Response({"detail": "Credenciales inválidas"}, status=401) + + # ── Provisión one-shot (solo primera vez, solo si no tiene KC id) ────────── + first_login = not bool(user.keycloak_user_id) + if first_login: + import threading + + def _provision_async(): + try: + _provision_user_in_hub(user.username, password) + except Exception as exc: + logger.warning("[login] Provisión async fallida para %s: %s", user.username, exc) + + threading.Thread(target=_provision_async, daemon=True).start() + logger.info("[login] Provisión iniciada en background para %s", user.username) + + # ── Emitir tokens SimpleJWT (igual que siempre) ──────────────────────────── + refresh = RefreshToken.for_user(user) + + return Response({ + "access": str(refresh.access_token), + "refresh": str(refresh), + "access_token": str(refresh.access_token), + "refresh_token": str(refresh), + "first_login": first_login, + "user_id": str(user.id), + "username": user.username, + "email": user.email, + }) + + + +# --------------------------------------------------------------------------- +# POST /api/v1/auth/sso/exchange/ +# --------------------------------------------------------------------------- + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def sso_exchange_view(request): + """ + Canjea relay token del Hub por sesión local. + Usado en: flujo SSO entre productos y login con Microsoft. + """ + relay_token = request.data.get("relay_token", "").strip() + if not relay_token: + return Response({"detail": "relay_token requerido"}, status=400) + + try: + r = http.post( + f"{HUB_URL()}/api/v1/auth/sso-exchange", + json={"relay_token": relay_token}, + timeout=10, + ) + except http.exceptions.RequestException as exc: + logger.error("Hub no disponible en SSO exchange: %s", exc) + return Response({"detail": "Servicio de autenticación no disponible"}, status=503) + + if r.status_code == 404: + return Response({"detail": "Relay token inválido o expirado"}, status=401) + if r.status_code != 200: + logger.error("Hub %s en SSO exchange: %s", r.status_code, r.text[:200]) + return Response({"detail": "No se pudo completar el inicio de sesión"}, status=401) + + data = r.json() + local_tokens = create_local_tokens({ + "id": data.get("user_id"), + "username": data.get("preferred_username") or data.get("email", ""), + "email": data.get("email", ""), + "name": data.get("name", ""), + "first_name": "", + "last_name": "", + "is_hub_admin": data.get("is_hub_admin", False), + "tenant_id": data.get("tenant_id"), + "tenant_slug": data.get("tenant_slug"), + }) + + response = Response({ + "user_id": data.get("user_id"), + "email": data.get("email"), + "name": data.get("name"), + "username": data.get("preferred_username"), + "tenant_id": data.get("tenant_id"), + "tenant_slug": data.get("tenant_slug"), + "is_hub_admin": data.get("is_hub_admin", False), + "avatar_url": data.get("avatar_url"), + "access_token": local_tokens["access_token"], + "refresh_token": local_tokens["refresh_token"], + }) + set_session_cookies(response, local_tokens) + logger.info("SSO exchange OK — usuario %s tenant %s", data.get("user_id"), data.get("tenant_slug")) + return response + + +# --------------------------------------------------------------------------- +# GET /api/v1/auth/me/ +# --------------------------------------------------------------------------- + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def me_view(request): + """Retorna el usuario autenticado actual desde token o cookie.""" + token = _extract_token(request) + if not token: + return Response({"detail": "No autenticado"}, status=401) + + try: + hub_data = verify_hub_token(token) + except Exception as exc: + return Response({"detail": str(exc)}, status=401) + + # Intentar enriquecer con datos Django si el usuario existe + user = _get_django_user(hub_data) + + if user: + return Response({ + "id": str(user.id), + "username": user.username, + "email": user.email, + "name": f"{user.first_name} {user.last_name}".strip() or hub_data.get("name", ""), + "first_name": user.first_name, + "last_name": user.last_name, + "is_superuser": user.is_superuser, + "is_hub_admin": hub_data.get("is_hub_admin", False), + "tenant_id": hub_data.get("tenant_id"), + "tenant_slug": hub_data.get("tenant_slug"), + "avatar_url": hub_data.get("avatar_url"), + "organizacion_id": str(user.organizacion_id) if user.organizacion_id else None, + }) + + # Usuario Hub sin cuenta Django (pre-migración) + return Response({ + "id": hub_data.get("sub"), + "username": hub_data.get("preferred_username") or hub_data.get("email", ""), + "email": hub_data.get("email"), + "name": hub_data.get("name", ""), + "first_name": hub_data.get("given_name", ""), + "last_name": hub_data.get("family_name", ""), + "is_superuser": hub_data.get("is_hub_admin", False), + "is_hub_admin": hub_data.get("is_hub_admin", False), + "tenant_id": hub_data.get("tenant_id"), + "tenant_slug": hub_data.get("tenant_slug"), + "avatar_url": hub_data.get("avatar_url"), + "organizacion_id": None, + }) + + +# --------------------------------------------------------------------------- +# POST /api/v1/auth/logout/ +# --------------------------------------------------------------------------- + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def logout_view(request): + """Limpia cookies de sesión. El frontend redirige al Hub para cerrar KC.""" + response = Response({"detail": "Sesión cerrada"}) + for cookie in ("access_token", "refresh_token", "token_type"): + response.delete_cookie(cookie, samesite="Lax") + return response + + +# --------------------------------------------------------------------------- +# POST /api/v1/auth/login/refresh/ +# --------------------------------------------------------------------------- + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def refresh_view(request): + """Renueva el access token usando el refresh token local.""" + refresh_token = ( + request.data.get("refresh_token") + or request.COOKIES.get("refresh_token") + ) + if not refresh_token: + return Response({"detail": "refresh_token requerido"}, status=400) + + try: + import jwt as pyjwt + payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"]) + if payload.get("source") != "local": + return Response({"detail": "Token de refresco inválido"}, status=401) + except pyjwt.ExpiredSignatureError: + return Response({"detail": "Refresh token expirado"}, status=401) + except pyjwt.InvalidTokenError: + return Response({"detail": "Refresh token inválido"}, status=401) + + # Emitir nuevos tokens locales con los mismos claims + new_tokens = create_local_tokens({ + "id": payload.get("sub"), + "username": payload.get("preferred_username", ""), + "email": payload.get("email", ""), + "name": payload.get("name", ""), + "first_name": payload.get("given_name", ""), + "last_name": payload.get("family_name", ""), + "is_hub_admin": payload.get("is_hub_admin", False), + "tenant_id": payload.get("tenant_id"), + "tenant_slug": payload.get("tenant_slug"), + }) + + response = Response({"access_token": new_tokens["access_token"]}) + set_session_cookies(response, new_tokens) + return response + + +# --------------------------------------------------------------------------- +# POST /api/v1/auth/session/refresh/ ← NUEVO (cookie-based) +# --------------------------------------------------------------------------- + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def session_refresh_view(request): + """ + Renueva la sesión usando SOLO la cookie HTTP-only refresh_token. + No requiere body. Diseñado para el flujo SSO donde el refresh_token + no vive en localStorage sino en cookie. + + Devuelve { access_token, access } — ambas claves para compatibilidad + con distintas versiones del frontend. + """ + refresh_token = request.COOKIES.get("refresh_token") + if not refresh_token: + return Response({"detail": "No hay sesión activa"}, status=401) + + try: + import jwt as pyjwt + payload = pyjwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"]) + if payload.get("source") != "local": + return Response({"detail": "Token de refresco inválido"}, status=401) + except pyjwt.ExpiredSignatureError: + return Response({"detail": "Sesión expirada — inicia sesión de nuevo"}, status=401) + except pyjwt.InvalidTokenError: + return Response({"detail": "Token de refresco inválido"}, status=401) + + new_tokens = create_local_tokens({ + "id": payload.get("sub"), + "username": payload.get("preferred_username", ""), + "email": payload.get("email", ""), + "name": payload.get("name", ""), + "first_name": payload.get("given_name", ""), + "last_name": payload.get("family_name", ""), + "is_hub_admin": payload.get("is_hub_admin", False), + "tenant_id": payload.get("tenant_id"), + "tenant_slug": payload.get("tenant_slug"), + }) + + access = new_tokens["access_token"] + response = Response({ + "access_token": access, + "access": access, # compatibilidad con fetchWithAuth legacy + }) + set_session_cookies(response, new_tokens) + return response diff --git a/api/customs/tasks/auditoria.py b/api/customs/tasks/auditoria.py index aaf2ff2..3a95e98 100644 --- a/api/customs/tasks/auditoria.py +++ b/api/customs/tasks/auditoria.py @@ -1,4 +1,5 @@ import os +import tempfile from datetime import datetime from django.db import models from celery import shared_task, group @@ -7,6 +8,7 @@ from core.utils import xml_controller import requests from core.utils import xml_remesas_controller from core.redis_events import publish_task_event +from api.utils.storage_service import storage_service import logging logger = logging.getLogger(__name__) @@ -144,7 +146,7 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id): xml_data = extraer_coves(pedimento) if xml_data: for remesa in xml_data: - numero_cove = remesa.get('remesaSA') + numero_cove = remesa.get('comprobanteVE') cove, creado = Cove.objects.get_or_create( pedimento=pedimento, numero_cove=numero_cove, @@ -533,6 +535,903 @@ def auditar_acuse_por_pedimento(pedimento_id): except Exception as e: return {'success': False, 'error': str(e), 'pedimento_id': str(pedimento_id)} +def _leer_xml_documento(documento): + """Lee el contenido de un documento desde MinIO (o filesystem de fallback).""" + ruta = str(documento.archivo) + with tempfile.NamedTemporaryFile(delete=False, suffix='.xml') as tmp: + tmp_path = tmp.name + try: + success = storage_service.download_file(ruta, tmp_path) + if not success: + logger.error(f"storage_service.download_file falló para {ruta}") + return None + with open(tmp_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + except Exception as exc: + logger.error(f"Error leyendo documento {ruta}: {exc}") + return None + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +def _leer_xml_pedimento_completo(pedimento): + """Lee el XML del pedimento completo (document_type=2) vía storage_service.""" + pc = pedimento.documents.filter(document_type__id=2).first() + if not pc: + return None + return _leer_xml_documento(pc) + + +# ────────────────────────────────────────────────────────────────────────────── +# Auditorías de integridad: comprueban que los registros en DB coincidan con +# lo que declara el XML del pedimento completo o la remesa. +# Son de solo lectura — no crean ni modifican registros de negocio. +# ────────────────────────────────────────────────────────────────────────────── + +@shared_task(bind=True) +def auditar_integridad_partidas(self, organizacion_id, user_id=None): + """ + Compara pedimento.numero_partidas (extraído del XML) vs partidas.count() en DB. + Detecta pedimentos donde faltan registros de Partida sin crear ninguno. + """ + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total_pedimentos = pedimentos.count() + + publish_task_event(task_id, "processing", f"Auditando integridad de partidas: {total_pedimentos} pedimentos", progress=0) + + completados = [] + sin_datos_xml = [] + con_faltantes = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + num_esperadas = pedimento.numero_partidas + num_en_db = pedimento.partidas.count() + + if not num_esperadas or num_esperadas <= 0: + sin_datos_xml.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'razon': f'numero_partidas no definido ({num_esperadas})', + }) + continue + + if num_en_db >= num_esperadas: + modificar_estado_procesamiento(pedimento, servicio_id=4, nuevo_estado=3) + completados.append(str(pedimento.id)) + else: + modificar_estado_procesamiento(pedimento, servicio_id=4, nuevo_estado=4) + con_faltantes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'esperadas': num_esperadas, + 'en_db': num_en_db, + 'faltantes': num_esperadas - num_en_db, + }) + + except Exception as exc: + errores.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error auditando integridad de partidas para pedimento {pedimento.id}: {exc}") + + if total_pedimentos > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total_pedimentos) * 100) + publish_task_event(task_id, "processing", f"Auditando partidas: {idx + 1}/{total_pedimentos}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'integridad_partidas', + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'sin_datos_xml': len(sin_datos_xml), + 'con_faltantes': len(con_faltantes), + 'con_errores': len(errores), + 'detalle_faltantes': con_faltantes, + 'detalle_sin_datos': sin_datos_xml, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Auditoría de integridad de partidas completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Integridad de Partidas", resultado) + + return resultado + + +@shared_task +def auditar_integridad_partidas_por_pedimento(pedimento_id): + """Versión por pedimento de auditar_integridad_partidas.""" + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + num_esperadas = pedimento.numero_partidas + num_en_db = pedimento.partidas.count() + + if not num_esperadas or num_esperadas <= 0: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_datos_xml', + 'mensaje': f'numero_partidas no definido ({num_esperadas})', + } + + if num_en_db >= num_esperadas: + modificar_estado_procesamiento(pedimento, servicio_id=4, nuevo_estado=3) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'completado', + 'esperadas': num_esperadas, + 'en_db': num_en_db, + } + + modificar_estado_procesamiento(pedimento, servicio_id=4, nuevo_estado=4) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'incompleto', + 'esperadas': num_esperadas, + 'en_db': num_en_db, + 'faltantes': num_esperadas - num_en_db, + } + + except Pedimento.DoesNotExist: + return {'error': f'Pedimento {pedimento_id} no encontrado'} + except Exception as exc: + return {'error': str(exc), 'pedimento_id': str(pedimento_id)} + + +@shared_task(bind=True) +def auditar_integridad_edocuments(self, organizacion_id, user_id=None): + """ + Compara la lista de e-documentos (identificadores_ed) del XML del pedimento completo + vs los EDocuments registrados en DB. Detecta registros faltantes sin crear nada. + """ + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total_pedimentos = pedimentos.count() + + publish_task_event(task_id, "processing", f"Auditando integridad de edocuments: {total_pedimentos} pedimentos", progress=0) + + completados = [] + sin_xml = [] + con_faltantes = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + sin_xml.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento}) + continue + + xml_data = xml_controller.extract_data(xml_content) + edocs_xml = xml_data.get('identificadores_ed', []) or [] + + numeros_xml = {e.get('complemento1') for e in edocs_xml if e.get('complemento1')} + numeros_db = set(pedimento.documentos.values_list('numero_edocument', flat=True)) + + faltantes_en_db = numeros_xml - numeros_db + + if not faltantes_en_db: + modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=3) + completados.append(str(pedimento.id)) + else: + modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=4) + con_faltantes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'esperados_xml': len(numeros_xml), + 'en_db': len(numeros_db), + 'faltantes_en_db': sorted(faltantes_en_db), + }) + + except Exception as exc: + errores.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error auditando integridad de edocuments para pedimento {pedimento.id}: {exc}") + + if total_pedimentos > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total_pedimentos) * 100) + publish_task_event(task_id, "processing", f"Auditando edocuments: {idx + 1}/{total_pedimentos}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'integridad_edocuments', + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'sin_xml': len(sin_xml), + 'con_faltantes': len(con_faltantes), + 'con_errores': len(errores), + 'detalle_faltantes': con_faltantes, + 'detalle_sin_xml': sin_xml, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Auditoría de integridad de edocuments completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Integridad de EDocuments", resultado) + + return resultado + + +@shared_task +def auditar_integridad_edocuments_por_pedimento(pedimento_id): + """Versión por pedimento de auditar_integridad_edocuments.""" + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + 'mensaje': 'No hay pedimento completo (document_type=2) descargado', + } + + xml_data = xml_controller.extract_data(xml_content) + edocs_xml = xml_data.get('identificadores_ed', []) or [] + + numeros_xml = {e.get('complemento1') for e in edocs_xml if e.get('complemento1')} + numeros_db = set(pedimento.documentos.values_list('numero_edocument', flat=True)) + faltantes_en_db = numeros_xml - numeros_db + + if not faltantes_en_db: + modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=3) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'completado', + 'esperados_xml': len(numeros_xml), + 'en_db': len(numeros_db), + } + + modificar_estado_procesamiento(pedimento, servicio_id=7, nuevo_estado=4) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'incompleto', + 'esperados_xml': len(numeros_xml), + 'en_db': len(numeros_db), + 'faltantes_en_db': sorted(faltantes_en_db), + } + + except Pedimento.DoesNotExist: + return {'error': f'Pedimento {pedimento_id} no encontrado'} + except Exception as exc: + return {'error': str(exc), 'pedimento_id': str(pedimento_id)} + + +@shared_task(bind=True) +def auditar_integridad_coves(self, organizacion_id, user_id=None): + """Verifica que los COVEs listados en el XML del pedimento completo existan en DB (nivel org).""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total_pedimentos = pedimentos.count() + + publish_task_event(task_id, "processing", f"Auditando integridad de COVEs (PC XML): {total_pedimentos} pedimentos", progress=0) + + completados = [] + sin_xml = [] + con_faltantes = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + sin_xml.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento}) + continue + + xml_data = xml_controller.extract_data(xml_content) + coves_xml = set(xml_data.get('coves', []) or []) + coves_db = set(pedimento.coves.values_list('numero_cove', flat=True)) + + faltantes = coves_xml - coves_db + if faltantes: + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=4) + con_faltantes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'coves_xml': len(coves_xml), + 'coves_db': len(coves_db), + 'faltantes': sorted(faltantes), + }) + else: + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=3) + completados.append(str(pedimento.id)) + + except Exception as exc: + errores.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error auditando integridad de COVEs para pedimento {pedimento.id}: {exc}") + + if total_pedimentos > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total_pedimentos) * 100) + publish_task_event(task_id, "processing", f"Auditando COVEs: {idx + 1}/{total_pedimentos}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'integridad_coves', + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'sin_xml': len(sin_xml), + 'con_faltantes': len(con_faltantes), + 'con_errores': len(errores), + 'detalle_faltantes': con_faltantes, + 'detalle_sin_xml': sin_xml, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Auditoría de integridad de COVEs completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Integridad de COVEs", resultado) + + return resultado + + +@shared_task(bind=True) +def auditar_integridad_remesa(self, organizacion_id, user_id=None): + """Verifica que los COVEs declarados en el XML de remesa existan en DB (nivel org).""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id).filter(remesas=True) + total_pedimentos = pedimentos.count() + + publish_task_event(task_id, "processing", f"Auditando integridad de remesas: {total_pedimentos} pedimentos con remesas", progress=0) + + completados = [] + sin_xml = [] + con_faltantes = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + doc_remesa = pedimento.documents.filter(document_type=3).first() + if not doc_remesa: + sin_xml.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'razon': 'Sin documento remesa (type=3)'}) + continue + + remesa_xml = _leer_xml_documento(doc_remesa) + if not remesa_xml: + sin_xml.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'razon': 'No se pudo leer el XML de remesa'}) + continue + + remesa_data = xml_remesas_controller.extract_remesas(remesa_xml) + coves_de_remesa = {r.get('comprobanteVE') for r in remesa_data if r.get('comprobanteVE')} + coves_db = set(pedimento.coves.values_list('numero_cove', flat=True)) + + faltantes = coves_de_remesa - coves_db + if faltantes: + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=4) + con_faltantes.append({ + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'total_en_remesa': len(coves_de_remesa), + 'en_db': len(coves_db), + 'faltantes': sorted(faltantes), + }) + else: + completados.append(str(pedimento.id)) + + except Exception as exc: + errores.append({'pedimento_id': str(pedimento.id), 'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error auditando integridad de remesa para pedimento {pedimento.id}: {exc}") + + if total_pedimentos > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total_pedimentos) * 100) + publish_task_event(task_id, "processing", f"Auditando remesas: {idx + 1}/{total_pedimentos}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'integridad_remesa', + 'total_pedimentos': total_pedimentos, + 'completados': len(completados), + 'sin_xml': len(sin_xml), + 'con_faltantes': len(con_faltantes), + 'con_errores': len(errores), + 'detalle_faltantes': con_faltantes, + 'detalle_sin_xml': sin_xml, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Auditoría de integridad de remesas completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Integridad de Remesas", resultado) + + return resultado + + +@shared_task +def auditar_integridad_coves_por_pedimento(pedimento_id): + """Verifica que los COVEs del PC XML existan en DB para un pedimento específico.""" + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + 'mensaje': 'No hay pedimento completo (document_type=2) descargado', + } + + xml_data = xml_controller.extract_data(xml_content) + coves_xml = set(xml_data.get('coves', []) or []) + coves_db = set(pedimento.coves.values_list('numero_cove', flat=True)) + + faltantes = coves_xml - coves_db + if not faltantes: + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=3) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'completado', + 'coves_xml': len(coves_xml), + 'coves_db': len(coves_db), + } + + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=4) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'incompleto', + 'coves_xml': len(coves_xml), + 'coves_db': len(coves_db), + 'faltantes': sorted(faltantes), + } + + except Pedimento.DoesNotExist: + return {'error': f'Pedimento {pedimento_id} no encontrado'} + except Exception as exc: + return {'error': str(exc), 'pedimento_id': str(pedimento_id)} + + +@shared_task +def auditar_integridad_remesa_por_pedimento(pedimento_id): + """Verifica que los COVEs del XML de remesa existan en DB para un pedimento específico.""" + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + + if not pedimento.remesas: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_remesas', + 'mensaje': 'Este pedimento no tiene remesas', + } + + doc_remesa = pedimento.documents.filter(document_type=3).first() + if not doc_remesa: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + 'mensaje': 'No hay documento de remesa (document_type=3) descargado', + } + + remesa_xml = _leer_xml_documento(doc_remesa) + if not remesa_xml: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + 'mensaje': 'No se pudo leer el archivo de remesa', + } + + remesa_data = xml_remesas_controller.extract_remesas(remesa_xml) + coves_de_remesa = {r.get('comprobanteVE') for r in remesa_data if r.get('comprobanteVE')} + coves_db = set(pedimento.coves.values_list('numero_cove', flat=True)) + + faltantes = coves_de_remesa - coves_db + if not faltantes: + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'completado', + 'total_en_remesa': len(coves_de_remesa), + 'coves_db': len(coves_db), + } + + modificar_estado_procesamiento(pedimento, servicio_id=8, nuevo_estado=4) + return { + 'pedimento_id': str(pedimento_id), + 'pedimento': pedimento.pedimento, + 'estado': 'incompleto', + 'total_en_remesa': len(coves_de_remesa), + 'coves_db': len(coves_db), + 'faltantes': sorted(faltantes), + } + + except Pedimento.DoesNotExist: + return {'error': f'Pedimento {pedimento_id} no encontrado'} + except Exception as exc: + return {'error': str(exc), 'pedimento_id': str(pedimento_id)} + + +# ────────────────────────────────────────────────────────────────────────────── +# Correcciones de integridad: crean registros faltantes en DB y disparan +# procesamiento VUCEM. Helpers sincrónicos + tasks Celery para nivel org. +# ────────────────────────────────────────────────────────────────────────────── + +def _corregir_integridad_partidas_pedimento(pedimento): + """Crea Partida records faltantes y dispara procesar_partida_individual.""" + from api.customs.models import Partida + from api.customs.tasks.microservice import procesar_partida_individual + + num_esperadas = pedimento.numero_partidas + if not num_esperadas or num_esperadas <= 0: + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_datos', + 'razon': f'numero_partidas no definido ({num_esperadas})', + } + + num_en_db = pedimento.partidas.count() + creadas = 0 + for i in range(1, num_esperadas + 1): + _, created = Partida.objects.get_or_create( + pedimento=pedimento, + numero_partida=i, + defaults={'organizacion_id': pedimento.organizacion_id}, + ) + if created: + creadas += 1 + + if creadas > 0: + procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'corregido', + 'partidas_en_db_antes': num_en_db, + 'esperadas': num_esperadas, + 'creadas': creadas, + 'procesamiento_iniciado': creadas > 0, + } + + +def _corregir_integridad_edocuments_pedimento(pedimento): + """Crea EDocument records faltantes desde el XML del pedimento completo y dispara procesamiento.""" + from api.customs.tasks.microservice import procesar_edoc_individual + + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + } + + xml_data = xml_controller.extract_data(xml_content) + edocs_xml = xml_data.get('identificadores_ed', []) or [] + + creados = [] + for edoc in edocs_xml: + numero = edoc.get('complemento1') + if not numero: + continue + try: + _, created = EDocument.objects.get_or_create( + pedimento=pedimento, + organizacion=pedimento.organizacion, + numero_edocument=numero, + defaults={ + 'clave': edoc.get('clave', ''), + 'descripcion': edoc.get('descripcion', ''), + }, + ) + if created: + creados.append(numero) + except Exception as exc: + logger.error(f"Error creando EDocument {numero} para pedimento {pedimento.id}: {exc}") + + if pedimento.documentos.exists(): + procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'corregido', + 'edocuments_creados': creados, + 'procesamiento_iniciado': pedimento.documentos.exists(), + } + + +def _corregir_integridad_coves_pedimento(pedimento): + """Crea COVE records faltantes del PC XML y dispara procesar_cove_individual.""" + from api.customs.tasks.microservice import procesar_cove_individual + + xml_content = _leer_xml_pedimento_completo(pedimento) + if not xml_content: + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_xml', + } + + xml_data = xml_controller.extract_data(xml_content) + coves_xml = xml_data.get('coves', []) or [] + + creados = [] + for numero_cove in coves_xml: + try: + _, created = Cove.objects.get_or_create( + pedimento=pedimento, + organizacion=pedimento.organizacion, + numero_cove=numero_cove, + ) + if created: + creados.append(numero_cove) + except Exception: + pass + + coves_procesados = False + if pedimento.coves.exists(): + procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + coves_procesados = True + + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'corregido', + 'coves_creados': creados, + 'procesamiento_iniciado': coves_procesados, + } + + +def _corregir_integridad_remesa_pedimento(pedimento): + """ + Crea COVE records faltantes del XML de remesa y dispara procesamiento. + Si no hay XML de remesa, dispara procesar_remesa_individual para descargarlo. + """ + from api.customs.tasks.microservice import procesar_cove_individual, procesar_remesa_individual + + if not pedimento.remesas: + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'sin_remesas', + 'mensaje': 'Este pedimento no tiene remesas', + } + + doc_remesa = pedimento.documents.filter(document_type=3).first() + if not doc_remesa: + procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'remesa_iniciada', + 'mensaje': 'XML de remesa no disponible — se inició la búsqueda en VUCEM', + 'procesamiento_remesa_iniciado': True, + } + + remesa_xml = _leer_xml_documento(doc_remesa) + if not remesa_xml: + procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'remesa_iniciada', + 'mensaje': 'No se pudo leer el XML de remesa — se reintentará la búsqueda en VUCEM', + 'procesamiento_remesa_iniciado': True, + } + + remesa_data = xml_remesas_controller.extract_remesas(remesa_xml) + creados = [] + for r in remesa_data: + numero_cove = r.get('comprobanteVE') + if numero_cove: + try: + _, created = Cove.objects.get_or_create( + pedimento=pedimento, + organizacion=pedimento.organizacion, + numero_cove=numero_cove, + ) + if created: + creados.append(numero_cove) + except Exception: + pass + + coves_procesados = False + if pedimento.coves.exists(): + procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)]) + coves_procesados = True + + return { + 'pedimento_id': str(pedimento.id), + 'pedimento': pedimento.pedimento, + 'estado': 'corregido', + 'coves_creados': creados, + 'procesamiento_coves_iniciado': coves_procesados, + } + + +@shared_task(bind=True) +def corregir_integridad_partidas(self, organizacion_id, user_id=None): + """Crea Partida records faltantes en todos los pedimentos de la org y dispara procesamiento.""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total = pedimentos.count() + + publish_task_event(task_id, "processing", f"Corrigiendo integridad de partidas: {total} pedimentos", progress=0) + + corregidos = [] + sin_datos = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + res = _corregir_integridad_partidas_pedimento(pedimento) + if res['estado'] == 'sin_datos': + sin_datos.append({'pedimento': pedimento.pedimento, 'razon': res.get('razon')}) + elif res.get('creadas', 0) > 0: + corregidos.append({'pedimento': pedimento.pedimento, 'creadas': res['creadas']}) + except Exception as exc: + errores.append({'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error corrigiendo partidas de pedimento {pedimento.id}: {exc}") + + if total > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total) * 100) + publish_task_event(task_id, "processing", f"Corrigiendo partidas: {idx + 1}/{total}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'correccion_partidas', + 'total_pedimentos': total, + 'con_nuevas_partidas': len(corregidos), + 'sin_datos': len(sin_datos), + 'con_errores': len(errores), + 'detalle_corregidos': corregidos, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Corrección de integridad de partidas completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Corrección de Partidas", resultado) + + return resultado + + +@shared_task(bind=True) +def corregir_integridad_edocuments(self, organizacion_id, user_id=None): + """Crea EDocument records faltantes en todos los pedimentos de la org y dispara procesamiento.""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total = pedimentos.count() + + publish_task_event(task_id, "processing", f"Corrigiendo integridad de edocuments: {total} pedimentos", progress=0) + + corregidos = [] + sin_xml = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + res = _corregir_integridad_edocuments_pedimento(pedimento) + if res['estado'] == 'sin_xml': + sin_xml.append({'pedimento': pedimento.pedimento}) + elif res.get('edocuments_creados'): + corregidos.append({'pedimento': pedimento.pedimento, 'creados': res['edocuments_creados']}) + except Exception as exc: + errores.append({'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error corrigiendo edocuments de pedimento {pedimento.id}: {exc}") + + if total > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total) * 100) + publish_task_event(task_id, "processing", f"Corrigiendo edocuments: {idx + 1}/{total}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'correccion_edocuments', + 'total_pedimentos': total, + 'con_nuevos_edocuments': len(corregidos), + 'sin_xml': len(sin_xml), + 'con_errores': len(errores), + 'detalle_corregidos': corregidos, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Corrección de integridad de edocuments completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Corrección de EDocuments", resultado) + + return resultado + + +@shared_task(bind=True) +def corregir_integridad_coves(self, organizacion_id, user_id=None): + """Crea COVE records faltantes (PC XML) en todos los pedimentos de la org y dispara procesamiento.""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id) + total = pedimentos.count() + + publish_task_event(task_id, "processing", f"Corrigiendo integridad de COVEs: {total} pedimentos", progress=0) + + corregidos = [] + sin_xml = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + res = _corregir_integridad_coves_pedimento(pedimento) + if res['estado'] == 'sin_xml': + sin_xml.append({'pedimento': pedimento.pedimento}) + elif res.get('coves_creados'): + corregidos.append({'pedimento': pedimento.pedimento, 'creados': res['coves_creados']}) + except Exception as exc: + errores.append({'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error corrigiendo COVEs de pedimento {pedimento.id}: {exc}") + + if total > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total) * 100) + publish_task_event(task_id, "processing", f"Corrigiendo COVEs: {idx + 1}/{total}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'correccion_coves', + 'total_pedimentos': total, + 'con_nuevos_coves': len(corregidos), + 'sin_xml': len(sin_xml), + 'con_errores': len(errores), + 'detalle_corregidos': corregidos, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Corrección de integridad de COVEs completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Corrección de COVEs", resultado) + + return resultado + + +@shared_task(bind=True) +def corregir_integridad_remesa(self, organizacion_id, user_id=None): + """Crea COVE records faltantes (remesa XML) en pedimentos con remesas y dispara procesamiento.""" + task_id = self.request.id + pedimentos = obtener_pedimentos(organizacion_id).filter(remesas=True) + total = pedimentos.count() + + publish_task_event(task_id, "processing", f"Corrigiendo integridad de remesas: {total} pedimentos con remesas", progress=0) + + corregidos = [] + sin_xml = [] + errores = [] + + for idx, pedimento in enumerate(pedimentos): + try: + res = _corregir_integridad_remesa_pedimento(pedimento) + if res['estado'] in ('sin_xml', 'remesa_iniciada'): + sin_xml.append({'pedimento': pedimento.pedimento, 'estado': res['estado']}) + elif res.get('coves_creados'): + corregidos.append({'pedimento': pedimento.pedimento, 'creados': res['coves_creados']}) + except Exception as exc: + errores.append({'pedimento': pedimento.pedimento, 'error': str(exc)}) + logger.error(f"Error corrigiendo remesa de pedimento {pedimento.id}: {exc}") + + if total > 0 and (idx + 1) % 10 == 0: + pct = int(((idx + 1) / total) * 100) + publish_task_event(task_id, "processing", f"Corrigiendo remesas: {idx + 1}/{total}", progress=pct) + + resultado = { + 'organizacion_id': str(organizacion_id), + 'auditoria': 'correccion_remesa', + 'total_pedimentos': total, + 'con_nuevos_coves': len(corregidos), + 'sin_xml': len(sin_xml), + 'con_errores': len(errores), + 'detalle_corregidos': corregidos, + 'detalle_errores': errores, + } + + publish_task_event(task_id, "completed", "Corrección de integridad de remesas completada", resultado=resultado, progress=100) + if user_id: + _crear_notificacion_auditoria(user_id, task_id, "Corrección de Remesas", resultado) + + return resultado + + @shared_task def auditar_pedimento_por_id(pedimento_id): """ diff --git a/api/customs/tasks/internal_services.py b/api/customs/tasks/internal_services.py index 25f3cd9..5123325 100644 --- a/api/customs/tasks/internal_services.py +++ b/api/customs/tasks/internal_services.py @@ -215,7 +215,7 @@ def auditar_pedimentos(self, organizacion_id, user_id=None): pedimento.fecha_pago = xml_data.get('fecha_pago') pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd - for edoc in xml_data.get('edocuments', []): + for edoc in xml_data.get('identificadores_ed', []): EDocument.objects.get_or_create( pedimento=pedimento, organizacion=pedimento.organizacion, diff --git a/api/customs/urls.py b/api/customs/urls.py index de45209..fe1b285 100644 --- a/api/customs/urls.py +++ b/api/customs/urls.py @@ -67,6 +67,22 @@ from .views_auditor import ( auditar_pedamentos_incompletos_endpoint, auditar_pedamento_incompleto_endpoint, auto_corregir_pedamento_endpoint, + auditar_integridad_partidas_endpoint, + auditar_integridad_partidas_pedimento_endpoint, + auditar_integridad_edocuments_endpoint, + auditar_integridad_edocuments_pedimento_endpoint, + auditar_integridad_coves_endpoint, + auditar_integridad_coves_pedimento_endpoint, + auditar_integridad_remesa_endpoint, + auditar_integridad_remesa_pedimento_endpoint, + corregir_integridad_partidas_endpoint, + corregir_integridad_partidas_pedimento_endpoint, + corregir_integridad_edocuments_endpoint, + corregir_integridad_edocuments_pedimento_endpoint, + corregir_integridad_coves_endpoint, + corregir_integridad_coves_pedimento_endpoint, + corregir_integridad_remesa_endpoint, + corregir_integridad_remesa_pedimento_endpoint, ) urlpatterns = [ @@ -111,4 +127,22 @@ urlpatterns = [ path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'), + path('auditor/auditar-integridad-partidas/', auditar_integridad_partidas_endpoint, name='auditar-integridad-partidas'), + path('auditor/auditar-integridad-partidas/pedimento/', auditar_integridad_partidas_pedimento_endpoint, name='auditar-integridad-partidas-pedimento'), + path('auditor/auditar-integridad-edocuments/', auditar_integridad_edocuments_endpoint, name='auditar-integridad-edocuments'), + path('auditor/auditar-integridad-edocuments/pedimento/', auditar_integridad_edocuments_pedimento_endpoint, name='auditar-integridad-edocuments-pedimento'), + path('auditor/auditar-integridad-coves/', auditar_integridad_coves_endpoint, name='auditar-integridad-coves'), + path('auditor/auditar-integridad-coves/pedimento/', auditar_integridad_coves_pedimento_endpoint, name='auditar-integridad-coves-pedimento'), + path('auditor/auditar-integridad-remesa/', auditar_integridad_remesa_endpoint, name='auditar-integridad-remesa'), + path('auditor/auditar-integridad-remesa/pedimento/', auditar_integridad_remesa_pedimento_endpoint, name='auditar-integridad-remesa-pedimento'), + + path('auditor/corregir-integridad-partidas/', corregir_integridad_partidas_endpoint, name='corregir-integridad-partidas'), + path('auditor/corregir-integridad-partidas/pedimento/', corregir_integridad_partidas_pedimento_endpoint, name='corregir-integridad-partidas-pedimento'), + path('auditor/corregir-integridad-edocuments/', corregir_integridad_edocuments_endpoint, name='corregir-integridad-edocuments'), + path('auditor/corregir-integridad-edocuments/pedimento/', corregir_integridad_edocuments_pedimento_endpoint, name='corregir-integridad-edocuments-pedimento'), + path('auditor/corregir-integridad-coves/', corregir_integridad_coves_endpoint, name='corregir-integridad-coves'), + path('auditor/corregir-integridad-coves/pedimento/', corregir_integridad_coves_pedimento_endpoint, name='corregir-integridad-coves-pedimento'), + path('auditor/corregir-integridad-remesa/', corregir_integridad_remesa_endpoint, name='corregir-integridad-remesa'), + path('auditor/corregir-integridad-remesa/pedimento/', corregir_integridad_remesa_pedimento_endpoint, name='corregir-integridad-remesa-pedimento'), + ] \ No newline at end of file diff --git a/api/customs/views.py b/api/customs/views.py index 71cc0f8..4b0d314 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -257,12 +257,16 @@ class PedimentoFilter(django_filters.FilterSet): # Rango de fecha de pago: ?fecha_pago_desde=YYYY-MM-DD&fecha_pago_hasta=YYYY-MM-DD fecha_pago_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte') fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte') + # CharFilter directo sobre contribuyente_id (RFC). ModelChoiceFilter silenciosamente + # omite el filtro cuando el RFC no existe, lo que causa fuga de pedimentos de otros + # importadores. Con CharFilter+exact: RFC inválido → cero resultados, nunca fuga. + contribuyente = django_filters.CharFilter(field_name='contribuyente', lookup_expr='exact') class Meta: model = Pedimento fields = [ 'patente', 'aduana', 'tipo_operacion', 'clave_pedimento', - 'pedimento', 'existe_expediente', 'contribuyente', + 'pedimento', 'existe_expediente', 'curp_apoderado', 'fecha_pago', 'pedimento_app', ] class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion @@ -1198,18 +1202,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada status=status.HTTP_400_BAD_REQUEST ) - # Validar organización del usuario - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + # Validar organización del usuario (superuser usa active_organization) + from core.permissions import get_org_context + organizacion = get_org_context(request.user) if request.user.is_authenticated else None + if not organizacion: return Response( { "tieneError": True, - "error": "Usuario no autenticado o sin organización" - }, + "error": "Usuario no autenticado o sin organización asignada" + }, status=status.HTTP_400_BAD_REQUEST ) - organizacion = request.user.organizacion - # Regex para validar nomenclatura: anio-aduana-patente-pedimento nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$') nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$') @@ -1744,17 +1748,17 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada partidas_input = request.data.get('partidas') fuente_archivos = request.data.get('partidas') - # Validar organización del usuario - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + # Validar organización del usuario (superuser usa active_organization) + from core.permissions import get_org_context + organizacion = get_org_context(request.user) if request.user.is_authenticated else None + if not organizacion: return Response( { "tieneError": True, - "error": "Usuario no autenticado o sin organización" - }, + "error": "Usuario no autenticado o sin organización asignada" + }, status=status.HTTP_400_BAD_REQUEST ) - - organizacion = request.user.organizacion # Regex para validar nomenclatura: anio-aduana-patente-pedimento nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$') @@ -2210,16 +2214,16 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada status=status.HTTP_400_BAD_REQUEST ) - # Validar organización del usuario - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + # Validar organización del usuario (superuser usa active_organization) + from core.permissions import get_org_context + organizacion = get_org_context(request.user) if request.user.is_authenticated else None + if not organizacion: return Response( {'tieneError': True, - "mensaje": "Usuario no autenticado o sin organización"}, + "mensaje": "Usuario no autenticado o sin organización asignada"}, status=status.HTTP_400_BAD_REQUEST ) - organizacion = request.user.organizacion - # Preparar parámetros parametros = { 'contribuyente': request.data.get('contribuyente'), diff --git a/api/customs/views_auditor.py b/api/customs/views_auditor.py index 5d16030..68bb23d 100644 --- a/api/customs/views_auditor.py +++ b/api/customs/views_auditor.py @@ -13,6 +13,22 @@ from .tasks.auditoria import ( auditar_edocuments, auditar_acuse, auditar_remesas, + auditar_integridad_partidas, + auditar_integridad_partidas_por_pedimento, + auditar_integridad_edocuments, + auditar_integridad_edocuments_por_pedimento, + auditar_integridad_coves, + auditar_integridad_coves_por_pedimento, + auditar_integridad_remesa, + auditar_integridad_remesa_por_pedimento, + corregir_integridad_partidas, + corregir_integridad_edocuments, + corregir_integridad_coves, + corregir_integridad_remesa, + _corregir_integridad_partidas_pedimento, + _corregir_integridad_edocuments_pedimento, + _corregir_integridad_coves_pedimento, + _corregir_integridad_remesa_pedimento, ) from .tasks.internal_services import auditar_pedimentos from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual @@ -2316,4 +2332,380 @@ def auto_corregir_pedamento_endpoint(request): 'mensaje': 'Corrección individual encolada.', }, status=status.HTTP_202_ACCEPTED, - ) \ No newline at end of file + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Endpoints de auditorías de integridad +# ────────────────────────────────────────────────────────────────────────────── + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de partidas: compara numero_partidas del XML vs partidas registradas en DB (solo lectura, no crea registros)", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={ + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), + 400: openapi.Response('Error en los parámetros'), + 403: openapi.Response('No tiene permisos suficientes'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def auditar_integridad_partidas_endpoint(request): + return _lanzar_auditoria_organizacion(request, auditar_integridad_partidas, 'integridad de partidas') + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de partidas para un pedimento específico", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={ + 200: openapi.Response('Resultado de integridad de partidas del pedimento'), + 400: openapi.Response('Error en los parámetros'), + 403: openapi.Response('No tiene permisos suficientes'), + 404: openapi.Response('Pedimento no encontrado'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) +def auditar_integridad_partidas_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + resultado = auditar_integridad_partidas_por_pedimento(pedimento_id) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de edocuments: compara lista del XML del pedimento completo vs EDocuments registrados en DB", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={ + 202: openapi.Response('Tarea iniciada — usar task_id para consultar resultado'), + 400: openapi.Response('Error en los parámetros'), + 403: openapi.Response('No tiene permisos suficientes'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def auditar_integridad_edocuments_endpoint(request): + return _lanzar_auditoria_organizacion(request, auditar_integridad_edocuments, 'integridad de edocuments') + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de edocuments para un pedimento específico", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={ + 200: openapi.Response('Resultado de integridad de edocuments del pedimento'), + 400: openapi.Response('Error en los parámetros'), + 403: openapi.Response('No tiene permisos suficientes'), + 404: openapi.Response('Pedimento no encontrado'), + } +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) +def auditar_integridad_edocuments_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + + resultado = auditar_integridad_edocuments_por_pedimento(pedimento_id) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de COVEs del PC XML contra los registrados en DB (nivel organización)", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def auditar_integridad_coves_endpoint(request): + return _lanzar_auditoria_organizacion(request, auditar_integridad_coves, 'integridad de COVEs') + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de COVEs del PC XML para un pedimento específico", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) +def auditar_integridad_coves_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = auditar_integridad_coves_por_pedimento(pedimento_id) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de COVEs del XML de remesa contra los registrados en DB (nivel organización)", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def auditar_integridad_remesa_endpoint(request): + return _lanzar_auditoria_organizacion(request, auditar_integridad_remesa, 'integridad de remesas') + + +@swagger_auto_schema( + method='post', + operation_description="Audita integridad de COVEs del XML de remesa para un pedimento específico", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) +def auditar_integridad_remesa_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = auditar_integridad_remesa_por_pedimento(pedimento_id) + return Response(resultado, status=status.HTTP_200_OK) + + +# ────────────────────────────────────────────────────────────────────────────── +# Endpoints de CORRECCIÓN de integridad +# ────────────────────────────────────────────────────────────────────────────── + +@swagger_auto_schema( + method='post', + operation_description="Crea Partidas faltantes en toda la organización y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_partidas_endpoint(request): + return _lanzar_auditoria_organizacion(request, corregir_integridad_partidas, 'corrección de partidas') + + +@swagger_auto_schema( + method='post', + operation_description="Crea Partidas faltantes para un pedimento específico y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_partidas_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = _corregir_integridad_partidas_pedimento(pedimento) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Crea EDocuments faltantes en toda la organización desde el XML del pedimento completo y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_edocuments_endpoint(request): + return _lanzar_auditoria_organizacion(request, corregir_integridad_edocuments, 'corrección de edocuments') + + +@swagger_auto_schema( + method='post', + operation_description="Crea EDocuments faltantes para un pedimento específico y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_edocuments_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = _corregir_integridad_edocuments_pedimento(pedimento) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Crea COVEs faltantes del PC XML en toda la organización y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_coves_endpoint(request): + return _lanzar_auditoria_organizacion(request, corregir_integridad_coves, 'corrección de COVEs') + + +@swagger_auto_schema( + method='post', + operation_description="Crea COVEs faltantes del PC XML para un pedimento específico y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_coves_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = _corregir_integridad_coves_pedimento(pedimento) + return Response(resultado, status=status.HTTP_200_OK) + + +@swagger_auto_schema( + method='post', + operation_description="Crea COVEs faltantes del XML de remesa en toda la organización y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'organizacion_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['organizacion_id'] + ), + responses={202: openapi.Response('Tarea iniciada'), 400: 'Error en parámetros', 403: 'Sin permisos'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_remesa_endpoint(request): + return _lanzar_auditoria_organizacion(request, corregir_integridad_remesa, 'corrección de remesas') + + +@swagger_auto_schema( + method='post', + operation_description="Crea COVEs faltantes del XML de remesa para un pedimento específico y dispara procesamiento VUCEM", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING)}, + required=['pedimento_id'] + ), + responses={200: 'Resultado', 400: 'Error en parámetros', 403: 'Sin permisos', 404: 'No encontrado'}, +) +@api_view(['POST']) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) +def corregir_integridad_remesa_pedimento_endpoint(request): + pedimento_id = request.data.get('pedimento_id') + if not pedimento_id: + return Response({'error': 'Debe proporcionar pedimento_id'}, status=status.HTTP_400_BAD_REQUEST) + try: + pedimento = Pedimento.objects.select_related('organizacion').get(id=pedimento_id) + except Pedimento.DoesNotExist: + return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND) + user = request.user + if not user.is_superuser and str(pedimento.organizacion.id) != str(user.organizacion.id): + return Response({'error': 'No tiene permisos para este pedimento'}, status=status.HTTP_403_FORBIDDEN) + resultado = _corregir_integridad_remesa_pedimento(pedimento) + return Response(resultado, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/api/datastage/management/__init__.py b/api/datastage/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/datastage/management/commands/__init__.py b/api/datastage/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/datastage/management/commands/reprocesar_datastages.py b/api/datastage/management/commands/reprocesar_datastages.py new file mode 100644 index 0000000..01258e4 --- /dev/null +++ b/api/datastage/management/commands/reprocesar_datastages.py @@ -0,0 +1,195 @@ +""" +Reprocesa datastages ya cargados: elimina los Registro* existentes del datastage +y reprocesa los archivos .asc de forma SINCRÓNICA (sin Celery). + +Casos de uso: + - Los registros quedaron vacíos por un bug y ya fue corregido. + - Se quiere refrescar los datos sin que el usuario vuelva a subir el archivo. + +Los Pedimentos existentes NO se tocan (el create en la task falla silenciosamente +por unique_together si ya existen). + +Uso: + python manage.py reprocesar_datastages # todos los datastages + python manage.py reprocesar_datastages --organizacion # solo una org + python manage.py reprocesar_datastages --datastage 4 7 12 # IDs específicos + python manage.py reprocesar_datastages --organizacion --datastage 4 + python manage.py reprocesar_datastages --dry-run # sin cambios +""" + +import os +import tempfile +import zipfile + +from django.core.management.base import BaseCommand, CommandError + +from api.datastage.models import ( + DataStage, + Registro500, Registro501, Registro502, Registro503, Registro504, + Registro505, Registro506, Registro507, Registro508, Registro509, + Registro510, Registro511, Registro512, Registro520, + Registro551, Registro552, Registro553, Registro554, Registro555, + Registro556, Registro557, Registro558, + RegistroSel, + Registro701, Registro702, +) + +REGISTRO_MODELS = [ + Registro500, Registro501, Registro502, Registro503, Registro504, + Registro505, Registro506, Registro507, Registro508, Registro509, + Registro510, Registro511, Registro512, Registro520, + Registro551, Registro552, Registro553, Registro554, Registro555, + Registro556, Registro557, Registro558, + RegistroSel, + Registro701, Registro702, +] + + +class Command(BaseCommand): + help = "Elimina los Registro* de datastages procesados y vuelve a procesarlos de forma sincrónica." + + def add_arguments(self, parser): + parser.add_argument( + "--organizacion", metavar="UUID", + help="UUID de la organización. Sin este arg: todas las orgs.", + ) + parser.add_argument( + "--datastage", metavar="ID", nargs="+", type=int, + help="Uno o más IDs de DataStage a reprocesar.", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="Solo muestra lo que haría, sin borrar ni insertar.", + ) + + def handle(self, *args, **options): + org_id = options.get("organizacion") + ds_ids = options.get("datastage") + dry_run = options["dry_run"] + + if dry_run: + self.stdout.write(self.style.WARNING( + "=== MODO PRUEBA (--dry-run): sin cambios en BD ===\n" + )) + + qs = DataStage.objects.select_related("organizacion").order_by("id") + if org_id: + qs = qs.filter(organizacion_id=org_id) + if ds_ids: + qs = qs.filter(id__in=ds_ids) + + total = qs.count() + if total == 0: + self.stdout.write(self.style.WARNING("No se encontraron datastages con los filtros indicados.")) + return + + self.stdout.write(f"Datastages a reprocesar: {total}\n") + + ok = err = 0 + for ds in qs: + exito = self._reprocesar(ds, dry_run) + if exito: + ok += 1 + else: + err += 1 + + self._print_summary(ok, err, dry_run) + + # ------------------------------------------------------------------ # + + def _reprocesar(self, ds, dry_run): + org_nombre = ds.organizacion.nombre if ds.organizacion else "sin organización" + self.stdout.write( + f"\nDataStage ID={ds.id} | org={org_nombre} | archivo={ds.archivo or '—'}" + ) + + if not ds.archivo: + self.stdout.write(self.style.ERROR(" → Sin archivo asociado, se omite.")) + return False + + # 1. Eliminar Registro* existentes + total_borrados = 0 + for Model in REGISTRO_MODELS: + qs_modelo = Model.objects.filter(datastage=ds) + count = qs_modelo.count() + if count == 0: + continue + if not dry_run: + qs_modelo.delete() + estado = "[dry-run]" if dry_run else "borrados" + self.stdout.write(f" {Model.__name__}: {count} {estado}") + total_borrados += count + + if total_borrados == 0: + self.stdout.write(" → Sin registros existentes en ninguna tabla.") + else: + self.stdout.write(f" Total eliminados: {total_borrados}") + + if dry_run: + self.stdout.write(self.style.WARNING( + " → [dry-run] Se procesarían los archivos .asc del datastage." + )) + return True + + # 2. Descargar ZIP una vez para obtener la lista de .asc + from api.utils.storage_service import storage_service + + ruta = str(ds.archivo) + if not storage_service.file_exists(ruta): + self.stdout.write(self.style.ERROR( + f" El archivo no existe en storage: {ruta}" + )) + return False + + tmp_path = None + try: + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + tmp_path = tmp.name + + if not storage_service.download_file(ruta, tmp_path): + self.stdout.write(self.style.ERROR( + f" No se pudo descargar '{ruta}' — verifica conectividad con MinIO." + )) + return False + + with zipfile.ZipFile(tmp_path, "r") as zf: + asc_files = [n for n in zf.namelist() if n.endswith(".asc")] + + finally: + if tmp_path and os.path.exists(tmp_path): + os.unlink(tmp_path) + + if not asc_files: + self.stdout.write(self.style.WARNING(" → No se encontraron archivos .asc en el ZIP.")) + return True + + self.stdout.write(f" Archivos .asc encontrados: {len(asc_files)}") + + # 3. Procesar cada .asc de forma sincrónica (sin Celery) + from api.datastage.tasks import procesar_archivo_asc_task + + total_insertados = 0 + for asc_name in asc_files: + self.stdout.write(f" {asc_name} ... ", ending="") + result = procesar_archivo_asc_task(ds.id, ds.organizacion_id, asc_name) + if "error" in result: + self.stdout.write(self.style.ERROR(f"ERROR: {result['error']}")) + else: + insertados = result.get("insertados", 0) + total_insertados += insertados + self.stdout.write(self.style.SUCCESS(f"{insertados} registros")) + + self.stdout.write(f" Total insertados: {total_insertados}") + return True + + # ------------------------------------------------------------------ # + + def _print_summary(self, ok, err, dry_run): + self.stdout.write(f"\n{'─' * 60}") + self.stdout.write(f"RESUMEN: {ok} exitosos, {err} con error.") + if dry_run: + self.stdout.write(self.style.WARNING( + "MODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios." + )) + else: + self.stdout.write(self.style.SUCCESS("Reprocesado completado.")) diff --git a/api/record/views.py b/api/record/views.py index ef16e2e..8f301eb 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -23,7 +23,6 @@ from django.http import HttpResponse from rest_framework.decorators import action from datetime import timedelta from django.utils import timezone -from django.db.models import Q from api.utils.storage_service import storage_service from rest_framework.authentication import TokenAuthentication @@ -39,6 +38,7 @@ import logging logger = logging.getLogger(__name__) import os +import tempfile from django.core.files.storage import default_storage from django.conf import settings import requests @@ -171,6 +171,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): 'bulk_upload': 'documentos.upload', 'bulk_upload_vu': 'documentos.upload', 'create_vu_record': 'documentos.upload', + 'bulk_download_partidas_vu': 'documentos.view', + 'bulk_download_coves_vu': 'documentos.view', + 'bulk_download_edocs_vu': 'documentos.view', } codename = perms.get(self.action, 'documentos.view') return [IsAuthenticated(), require_permission(codename)()] @@ -631,117 +634,62 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): @action(detail=False, methods=['post'], url_path='bulk-delete-partidas-vu') def bulk_delete_partidas_vu(self, request): - """ - Endpoint para eliminar múltiples archivos xlm de partidas de vu de manera masiva. - - Payload esperado: - { - "ids": ["uuid1", "uuid2", "uuid3", ...] - } - - Respuesta exitosa: - { - "message": "Documentos eliminados exitosamente", - "deleted_count": 3, - "deleted_ids": ["uuid1", "uuid2", "uuid3"], - "space_freed_mb": 25.6 - } - - Respuesta con errores: - { - "message": "Algunos documentos no pudieron ser eliminados", - "deleted_count": 2, - "deleted_ids": ["uuid1", "uuid2"], - "failed_ids": ["uuid3"], - "errors": ["No se encontró el documento con ID uuid3"], - "space_freed_mb": 15.2 - } - """ - # Obtener los IDs del payload - ids_vu = request.data.get('ids', []) + from ..customs.models import Partida - if not ids_vu: + ids_partidas = request.data.get('ids', []) + + if not ids_partidas: return Response( {"error": "Se requiere una lista de IDs para eliminar"}, status=status.HTTP_400_BAD_REQUEST ) - - if not isinstance(ids_vu, list): + + if not isinstance(ids_partidas, list): return Response( {"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST ) - - # Obtener el queryset filtrado por organización - queryset = self.get_queryset() - - from ..customs.models import Partida - partidas = Partida.objects.filter(id__in=ids_vu) + partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento') if not partidas.exists(): return Response( - {"error": "No se encontraron Partidas"}, - status=status.HTTP_404_NOT_FOUND - ) - ids = [] - for partida in partidas: - - pedimento_partida = partida.pedimento - pedimento_app = pedimento_partida.pedimento_app - pedimento_id= pedimento_partida.id - - numero_partida = partida.numero_partida - - documents = Document.objects.filter( - archivo__startswith=f'documents/vu_PT_{pedimento_app}_{numero_partida}', - pedimento_id=pedimento_id - ).values_list('id', flat=True) # <-- solo los IDs - - if documents.exists(): - # agregar los IDs a la lista - ids.extend(documents) - - - if len(ids) <= 0: - return Response( - {"error": "No se encontraron docuemntos para eliminar"}, + {"error": "No se encontraron partidas con los IDs proporcionados"}, status=status.HTTP_404_NOT_FOUND ) - # Filtrar solo los documentos que existen y pertenecen a la organización del usuario - existing_documents = queryset.filter(id__in=ids) + # Buscar documentos vu_PT_ asociados a cada partida por pedimento + numero_partida + doc_ids = [] + for partida in partidas: + docs = Document.objects.filter( + pedimento_id=partida.pedimento.id, + archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_' + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + existing_documents = queryset.filter(id__in=doc_ids) existing_ids = list(existing_documents.values_list('id', flat=True)) - - # Convertir UUIDs a strings para comparación - existing_ids_str = [str(id) for id in existing_ids] - requested_ids_str = [str(id) for id in ids] - - # Identificar IDs que no existen o no pertenecen a la organización - failed_ids = [id for id in requested_ids_str if id not in existing_ids_str] - + existing_ids_str = [str(i) for i in existing_ids] + deleted_count = 0 total_space_freed = 0 errors = [] - - if existing_documents.exists(): - try: - # Usar transacción atómica para consistencia - with transaction.atomic(): - # Calcular el espacio total a liberar + failed_ids = [] + + try: + with transaction.atomic(): + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response( + {"error": "Usuario no autenticado o sin organización"}, + status=status.HTTP_400_BAD_REQUEST + ) + + organizacion = request.user.organizacion + + if existing_documents.exists(): total_space_freed = sum(doc.size for doc in existing_documents) - - # Obtener la organización del usuario para actualizar el uso de almacenamiento - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): - return Response( - {"error": "Usuario no autenticado o sin organización"}, - status=status.HTTP_400_BAD_REQUEST - ) - - organizacion = request.user.organizacion - - # Si es superusuario, puede eliminar documentos de cualquier organización + if request.user.is_superuser: - # Para superusuario, actualizar el uso de cada organización afectada organizaciones_afectadas = {} for doc in existing_documents: if doc.organizacion.id not in organizaciones_afectadas: @@ -750,8 +698,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): 'espacio_liberado': 0 } organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size - - # Actualizar uso de almacenamiento para cada organización for org_data in organizaciones_afectadas.values(): try: uso = UsoAlmacenamiento.objects.select_for_update().get( @@ -760,10 +706,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): uso.espacio_utilizado -= org_data['espacio_liberado'] uso.save() except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar pass else: - # Para usuarios normales, solo documentos de su organización try: uso = UsoAlmacenamiento.objects.select_for_update().get( organizacion=organizacion @@ -771,49 +715,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): uso.espacio_utilizado -= total_space_freed uso.save() except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar pass - - # Eliminar los documentos + archivos_eliminados = 0 for doc in existing_documents: try: if doc.archivo: - ruta = str(doc.archivo) - storage_service.delete_file(ruta) - - # Eliminar registro de la base de datos + storage_service.delete_file(str(doc.archivo)) doc.delete() archivos_eliminados += 1 except Exception as e: errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}") failed_ids.append(str(doc.id)) - # deleted_count = existing_documents.count() deleted_count = archivos_eliminados - # existing_documents.delete() - - except Exception as e: - return Response( - {"error": f"Error al eliminar documentos: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - # Agregar errores para IDs no encontrados + + # Eliminar los registros de Partida + partidas.delete() + + except Exception as e: + return Response( + {"error": f"Error al eliminar: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + if failed_ids: - errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids] - - # Convertir bytes a MB para la respuesta + errors.extend([ + f"No se encontró el documento con ID {i} o no pertenece a su organización" + for i in failed_ids + ]) + space_freed_mb = round(total_space_freed / (1024 * 1024), 2) - - # Preparar respuesta + response_data = { "deleted_count": deleted_count, "deleted_ids": existing_ids_str, "space_freed_mb": space_freed_mb } - - if failed_ids: + + if errors or failed_ids: response_data.update({ "message": "Algunos documentos no pudieron ser eliminados", "failed_ids": failed_ids, @@ -821,324 +761,70 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): }) response_status = status.HTTP_207_MULTI_STATUS else: - response_data["message"] = "Documentos eliminados exitosamente" + response_data["message"] = "Partidas y documentos eliminados exitosamente" response_status = status.HTTP_200_OK - + return Response(response_data, status=response_status) @action(detail=False, methods=['post'], url_path='bulk-delete-coves-vu') def bulk_delete_coves_vu(self, request): - """ - Endpoint para eliminar múltiples archivos xlm de coves de vu de manera masiva. - - Payload esperado: - { - "ids": ["uuid1", "uuid2", "uuid3", ...] - } - - Respuesta exitosa: - { - "message": "Documentos eliminados exitosamente", - "deleted_count": 3, - "deleted_ids": ["uuid1", "uuid2", "uuid3"], - "space_freed_mb": 25.6 - } - - Respuesta con errores: - { - "message": "Algunos documentos no pudieron ser eliminados", - "deleted_count": 2, - "deleted_ids": ["uuid1", "uuid2"], - "failed_ids": ["uuid3"], - "errors": ["No se encontró el documento con ID uuid3"], - "space_freed_mb": 15.2 - } - """ - # Obtener los IDs del payload - ids_vu = request.data.get('ids', []) - - if not ids_vu: - return Response( - {"error": "Se requiere una lista de IDs para eliminar"}, - status=status.HTTP_400_BAD_REQUEST - ) - - if not isinstance(ids_vu, list): - return Response( - {"error": "El campo 'ids' debe ser una lista"}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Obtener el queryset filtrado por organización - queryset = self.get_queryset() - from ..customs.models import Cove - coves = Cove.objects.filter(id__in=ids_vu) - if not coves.exists(): - return Response( - {"error": "No se encontraron COVEs"}, - status=status.HTTP_404_NOT_FOUND - ) - ids = [] - for cove in coves: + ids_coves = request.data.get('ids', []) - pedimento_cove = cove.pedimento - pedimento_app = pedimento_cove.pedimento_app - pedimento_id=pedimento_cove.id - - numero_cove = cove.numero_cove - - documents = Document.objects.filter( - Q(archivo__startswith=f'documents/vu_COVE_{pedimento_app}_{numero_cove}') | - Q(archivo__startswith=f'documents/vu_AC_COVE_{pedimento_app}_{numero_cove}'), - pedimento_id=pedimento_id - ).values_list('id', flat=True) # <-- solo los IDs - - if documents.exists(): - # agregar los IDs a la lista - ids.extend(documents) - - - if len(ids) <= 0: - return Response( - {"error": "No se encontraron docuemntos para eliminar"}, - status=status.HTTP_404_NOT_FOUND - ) - - # Filtrar solo los documentos que existen y pertenecen a la organización del usuario - existing_documents = queryset.filter(id__in=ids) - existing_ids = list(existing_documents.values_list('id', flat=True)) - - # Convertir UUIDs a strings para comparación - existing_ids_str = [str(id) for id in existing_ids] - requested_ids_str = [str(id) for id in ids] - - # Identificar IDs que no existen o no pertenecen a la organización - failed_ids = [id for id in requested_ids_str if id not in existing_ids_str] - - deleted_count = 0 - total_space_freed = 0 - errors = [] - - if existing_documents.exists(): - try: - # Usar transacción atómica para consistencia - with transaction.atomic(): - # Calcular el espacio total a liberar - total_space_freed = sum(doc.size for doc in existing_documents) - - # Obtener la organización del usuario para actualizar el uso de almacenamiento - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): - return Response( - {"error": "Usuario no autenticado o sin organización"}, - status=status.HTTP_400_BAD_REQUEST - ) - - organizacion = request.user.organizacion - - # Si es superusuario, puede eliminar documentos de cualquier organización - if request.user.is_superuser: - # Para superusuario, actualizar el uso de cada organización afectada - organizaciones_afectadas = {} - for doc in existing_documents: - if doc.organizacion.id not in organizaciones_afectadas: - organizaciones_afectadas[doc.organizacion.id] = { - 'organizacion': doc.organizacion, - 'espacio_liberado': 0 - } - organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size - - # Actualizar uso de almacenamiento para cada organización - for org_data in organizaciones_afectadas.values(): - try: - uso = UsoAlmacenamiento.objects.select_for_update().get( - organizacion=org_data['organizacion'] - ) - uso.espacio_utilizado -= org_data['espacio_liberado'] - uso.save() - except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar - pass - else: - # Para usuarios normales, solo documentos de su organización - try: - uso = UsoAlmacenamiento.objects.select_for_update().get( - organizacion=organizacion - ) - uso.espacio_utilizado -= total_space_freed - uso.save() - except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar - pass - - # Eliminar los documentos - archivos_eliminados = 0 - for doc in existing_documents: - try: - if doc.archivo: - ruta = str(doc.archivo) - storage_service.delete_file(ruta) - - # Eliminar registro de la base de datos - doc.delete() - archivos_eliminados += 1 - except Exception as e: - errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}") - failed_ids.append(str(doc.id)) - - # deleted_count = existing_documents.count() - deleted_count = archivos_eliminados - # existing_documents.delete() - - except Exception as e: - return Response( - {"error": f"Error al eliminar documentos: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - # Agregar errores para IDs no encontrados - if failed_ids: - errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids] - - # Convertir bytes a MB para la respuesta - space_freed_mb = round(total_space_freed / (1024 * 1024), 2) - - # Preparar respuesta - response_data = { - "deleted_count": deleted_count, - "deleted_ids": existing_ids_str, - "space_freed_mb": space_freed_mb - } - - if failed_ids: - response_data.update({ - "message": "Algunos documentos no pudieron ser eliminados", - "failed_ids": failed_ids, - "errors": errors - }) - response_status = status.HTTP_207_MULTI_STATUS - else: - response_data["message"] = "Documentos eliminados exitosamente" - response_status = status.HTTP_200_OK - - return Response(response_data, status=response_status) - - @action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu') - def bulk_delete_edocs_vu(self, request): - """ - Endpoint para eliminar múltiples archivos xlm de edocs de vu de manera masiva. - - Payload esperado: - { - "ids": ["uuid1", "uuid2", "uuid3", ...] - } - - Respuesta exitosa: - { - "message": "Documentos eliminados exitosamente", - "deleted_count": 3, - "deleted_ids": ["uuid1", "uuid2", "uuid3"], - "space_freed_mb": 25.6 - } - - Respuesta con errores: - { - "message": "Algunos documentos no pudieron ser eliminados", - "deleted_count": 2, - "deleted_ids": ["uuid1", "uuid2"], - "failed_ids": ["uuid3"], - "errors": ["No se encontró el documento con ID uuid3"], - "space_freed_mb": 15.2 - } - """ - # Obtener los IDs del payload - ids_vu = request.data.get('ids', []) - - if not ids_vu: + if not ids_coves: return Response( {"error": "Se requiere una lista de IDs para eliminar"}, status=status.HTTP_400_BAD_REQUEST ) - - if not isinstance(ids_vu, list): + + if not isinstance(ids_coves, list): return Response( {"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST ) - - # Obtener el queryset filtrado por organización - queryset = self.get_queryset() - - from ..customs.models import EDocument - edocs = EDocument.objects.filter(id__in=ids_vu) - if not edocs.exists(): + coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento') + if not coves.exists(): return Response( - {"error": "No se encontraron COVEs"}, - status=status.HTTP_404_NOT_FOUND - ) - ids = [] - for edoc in edocs: - - pedimento_edoc = edoc.pedimento - pedimento_id = pedimento_edoc.id - pedimento_app = pedimento_edoc.pedimento_app - - numero_edocument = edoc.numero_edocument - - documents = Document.objects.filter( - Q(archivo__startswith=f'documents/vu_ED_{pedimento_app}_{numero_edocument}') | - Q(archivo__startswith=f'documents/vu_AC_{pedimento_app}_{numero_edocument}'), - pedimento_id=pedimento_id - ).values_list('id', flat=True) # <-- solo los IDs - - if documents.exists(): - # agregar los IDs a la lista - ids.extend(documents) - - - if len(ids) <= 0: - return Response( - {"error": "No se encontraron docuemntos para eliminar"}, + {"error": "No se encontraron COVEs con los IDs proporcionados"}, status=status.HTTP_404_NOT_FOUND ) - # Filtrar solo los documentos que existen y pertenecen a la organización del usuario - existing_documents = queryset.filter(id__in=ids) + # Buscar documentos que contengan el numero_cove en el nombre de archivo + doc_ids = [] + for cove in coves: + docs = Document.objects.filter( + pedimento_id=cove.pedimento.id, + archivo__icontains=cove.numero_cove + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + existing_documents = queryset.filter(id__in=doc_ids) existing_ids = list(existing_documents.values_list('id', flat=True)) - - # Convertir UUIDs a strings para comparación - existing_ids_str = [str(id) for id in existing_ids] - requested_ids_str = [str(id) for id in ids] - - # Identificar IDs que no existen o no pertenecen a la organización - failed_ids = [id for id in requested_ids_str if id not in existing_ids_str] - + existing_ids_str = [str(i) for i in existing_ids] + deleted_count = 0 total_space_freed = 0 errors = [] - - if existing_documents.exists(): - try: - # Usar transacción atómica para consistencia - with transaction.atomic(): - # Calcular el espacio total a liberar + failed_ids = [] + + try: + with transaction.atomic(): + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response( + {"error": "Usuario no autenticado o sin organización"}, + status=status.HTTP_400_BAD_REQUEST + ) + + organizacion = request.user.organizacion + + if existing_documents.exists(): total_space_freed = sum(doc.size for doc in existing_documents) - - # Obtener la organización del usuario para actualizar el uso de almacenamiento - if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): - return Response( - {"error": "Usuario no autenticado o sin organización"}, - status=status.HTTP_400_BAD_REQUEST - ) - - organizacion = request.user.organizacion - - # Si es superusuario, puede eliminar documentos de cualquier organización + if request.user.is_superuser: - # Para superusuario, actualizar el uso de cada organización afectada organizaciones_afectadas = {} for doc in existing_documents: if doc.organizacion.id not in organizaciones_afectadas: @@ -1147,8 +833,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): 'espacio_liberado': 0 } organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size - - # Actualizar uso de almacenamiento para cada organización for org_data in organizaciones_afectadas.values(): try: uso = UsoAlmacenamiento.objects.select_for_update().get( @@ -1157,10 +841,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): uso.espacio_utilizado -= org_data['espacio_liberado'] uso.save() except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar pass else: - # Para usuarios normales, solo documentos de su organización try: uso = UsoAlmacenamiento.objects.select_for_update().get( organizacion=organizacion @@ -1168,48 +850,44 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): uso.espacio_utilizado -= total_space_freed uso.save() except UsoAlmacenamiento.DoesNotExist: - # Si no existe el registro, no hay nada que actualizar pass - - # Eliminar los documentos + archivos_eliminados = 0 for doc in existing_documents: try: if doc.archivo: - ruta = str(doc.archivo) - storage_service.delete_file(ruta) - + storage_service.delete_file(str(doc.archivo)) doc.delete() archivos_eliminados += 1 except Exception as e: errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}") failed_ids.append(str(doc.id)) - # deleted_count = existing_documents.count() deleted_count = archivos_eliminados - # existing_documents.delete() - - except Exception as e: - return Response( - {"error": f"Error al eliminar documentos: {str(e)}"}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - # Agregar errores para IDs no encontrados + + coves.delete() + + except Exception as e: + return Response( + {"error": f"Error al eliminar: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + if failed_ids: - errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids] - - # Convertir bytes a MB para la respuesta + errors.extend([ + f"No se encontró el documento con ID {i} o no pertenece a su organización" + for i in failed_ids + ]) + space_freed_mb = round(total_space_freed / (1024 * 1024), 2) - - # Preparar respuesta + response_data = { "deleted_count": deleted_count, "deleted_ids": existing_ids_str, "space_freed_mb": space_freed_mb } - - if failed_ids: + + if errors or failed_ids: response_data.update({ "message": "Algunos documentos no pudieron ser eliminados", "failed_ids": failed_ids, @@ -1217,9 +895,142 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): }) response_status = status.HTTP_207_MULTI_STATUS else: - response_data["message"] = "Documentos eliminados exitosamente" + response_data["message"] = "COVEs y documentos eliminados exitosamente" response_status = status.HTTP_200_OK - + + return Response(response_data, status=response_status) + + @action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu') + def bulk_delete_edocs_vu(self, request): + from ..customs.models import EDocument + + ids_edocs = request.data.get('ids', []) + + if not ids_edocs: + return Response( + {"error": "Se requiere una lista de IDs para eliminar"}, + status=status.HTTP_400_BAD_REQUEST + ) + + if not isinstance(ids_edocs, list): + return Response( + {"error": "El campo 'ids' debe ser una lista"}, + status=status.HTTP_400_BAD_REQUEST + ) + + edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento') + if not edocs.exists(): + return Response( + {"error": "No se encontraron EDocuments con los IDs proporcionados"}, + status=status.HTTP_404_NOT_FOUND + ) + + # Buscar documentos que contengan el numero_edocument en el nombre de archivo + doc_ids = [] + for edoc in edocs: + docs = Document.objects.filter( + pedimento_id=edoc.pedimento.id, + archivo__icontains=edoc.numero_edocument + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + existing_documents = queryset.filter(id__in=doc_ids) + existing_ids = list(existing_documents.values_list('id', flat=True)) + existing_ids_str = [str(i) for i in existing_ids] + + deleted_count = 0 + total_space_freed = 0 + errors = [] + failed_ids = [] + + try: + with transaction.atomic(): + if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): + return Response( + {"error": "Usuario no autenticado o sin organización"}, + status=status.HTTP_400_BAD_REQUEST + ) + + organizacion = request.user.organizacion + + if existing_documents.exists(): + total_space_freed = sum(doc.size for doc in existing_documents) + + if request.user.is_superuser: + organizaciones_afectadas = {} + for doc in existing_documents: + if doc.organizacion.id not in organizaciones_afectadas: + organizaciones_afectadas[doc.organizacion.id] = { + 'organizacion': doc.organizacion, + 'espacio_liberado': 0 + } + organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size + for org_data in organizaciones_afectadas.values(): + try: + uso = UsoAlmacenamiento.objects.select_for_update().get( + organizacion=org_data['organizacion'] + ) + uso.espacio_utilizado -= org_data['espacio_liberado'] + uso.save() + except UsoAlmacenamiento.DoesNotExist: + pass + else: + try: + uso = UsoAlmacenamiento.objects.select_for_update().get( + organizacion=organizacion + ) + uso.espacio_utilizado -= total_space_freed + uso.save() + except UsoAlmacenamiento.DoesNotExist: + pass + + archivos_eliminados = 0 + for doc in existing_documents: + try: + if doc.archivo: + storage_service.delete_file(str(doc.archivo)) + doc.delete() + archivos_eliminados += 1 + except Exception as e: + errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}") + failed_ids.append(str(doc.id)) + + deleted_count = archivos_eliminados + + edocs.delete() + + except Exception as e: + return Response( + {"error": f"Error al eliminar: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + if failed_ids: + errors.extend([ + f"No se encontró el documento con ID {i} o no pertenece a su organización" + for i in failed_ids + ]) + + space_freed_mb = round(total_space_freed / (1024 * 1024), 2) + + response_data = { + "deleted_count": deleted_count, + "deleted_ids": existing_ids_str, + "space_freed_mb": space_freed_mb + } + + if errors or failed_ids: + response_data.update({ + "message": "Algunos documentos no pudieron ser eliminados", + "failed_ids": failed_ids, + "errors": errors + }) + response_status = status.HTTP_207_MULTI_STATUS + else: + response_data["message"] = "EDocuments y documentos eliminados exitosamente" + response_status = status.HTTP_200_OK + return Response(response_data, status=response_status) @@ -2059,6 +1870,186 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): return Response(response_data, status=response_status) + @action(detail=False, methods=['post'], url_path='bulk-download-partidas-vu') + def bulk_download_partidas_vu(self, request): + from ..customs.models import Partida + import tempfile + + ids_partidas = request.data.get('ids', []) + if not ids_partidas: + return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(ids_partidas, list): + return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST) + + partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento') + if not partidas.exists(): + return Response({"error": "No se encontraron partidas"}, status=status.HTTP_404_NOT_FOUND) + + doc_ids = [] + for partida in partidas: + docs = Document.objects.filter( + pedimento_id=partida.pedimento.id, + archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_' + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + docs_qs = queryset.filter(id__in=doc_ids) + if not docs_qs.exists(): + return Response({"error": "No se encontraron documentos para las partidas seleccionadas"}, status=status.HTTP_404_NOT_FOUND) + + buffer = BytesIO() + temp_files = [] + try: + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for doc in docs_qs: + if not doc.archivo: + continue + ruta = str(doc.archivo) + if not storage_service.file_exists(ruta): + continue + with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp: + tmp_path = tmp.name + temp_files.append(tmp_path) + if not storage_service.download_file(ruta, tmp_path): + continue + nombre = ruta.rsplit('/', 1)[-1] + with open(tmp_path, 'rb') as f: + zip_file.writestr(nombre, f.read()) + buffer.seek(0) + response = HttpResponse(buffer, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename=partidas_vu_{len(ids_partidas)}.zip' + return response + except Exception as e: + return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + finally: + for tmp_path in temp_files: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + + @action(detail=False, methods=['post'], url_path='bulk-download-coves-vu') + def bulk_download_coves_vu(self, request): + from ..customs.models import Cove + import tempfile + + ids_coves = request.data.get('ids', []) + if not ids_coves: + return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(ids_coves, list): + return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST) + + coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento') + if not coves.exists(): + return Response({"error": "No se encontraron COVEs"}, status=status.HTTP_404_NOT_FOUND) + + doc_ids = [] + for cove in coves: + docs = Document.objects.filter( + pedimento_id=cove.pedimento.id, + archivo__icontains=cove.numero_cove + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + docs_qs = queryset.filter(id__in=doc_ids) + if not docs_qs.exists(): + return Response({"error": "No se encontraron documentos para los COVEs seleccionados"}, status=status.HTTP_404_NOT_FOUND) + + buffer = BytesIO() + temp_files = [] + try: + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for doc in docs_qs: + if not doc.archivo: + continue + ruta = str(doc.archivo) + if not storage_service.file_exists(ruta): + continue + with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp: + tmp_path = tmp.name + temp_files.append(tmp_path) + if not storage_service.download_file(ruta, tmp_path): + continue + nombre = ruta.rsplit('/', 1)[-1] + with open(tmp_path, 'rb') as f: + zip_file.writestr(nombre, f.read()) + buffer.seek(0) + response = HttpResponse(buffer, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename=coves_vu_{len(ids_coves)}.zip' + return response + except Exception as e: + return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + finally: + for tmp_path in temp_files: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + + @action(detail=False, methods=['post'], url_path='bulk-download-edocs-vu') + def bulk_download_edocs_vu(self, request): + from ..customs.models import EDocument + import tempfile + + ids_edocs = request.data.get('ids', []) + if not ids_edocs: + return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(ids_edocs, list): + return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST) + + edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento') + if not edocs.exists(): + return Response({"error": "No se encontraron EDocuments"}, status=status.HTTP_404_NOT_FOUND) + + doc_ids = [] + for edoc in edocs: + docs = Document.objects.filter( + pedimento_id=edoc.pedimento.id, + archivo__icontains=edoc.numero_edocument + ).values_list('id', flat=True) + doc_ids.extend(docs) + + queryset = self.get_queryset() + docs_qs = queryset.filter(id__in=doc_ids) + if not docs_qs.exists(): + return Response({"error": "No se encontraron documentos para los EDocuments seleccionados"}, status=status.HTTP_404_NOT_FOUND) + + buffer = BytesIO() + temp_files = [] + try: + with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for doc in docs_qs: + if not doc.archivo: + continue + ruta = str(doc.archivo) + if not storage_service.file_exists(ruta): + continue + with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp: + tmp_path = tmp.name + temp_files.append(tmp_path) + if not storage_service.download_file(ruta, tmp_path): + continue + nombre = ruta.rsplit('/', 1)[-1] + with open(tmp_path, 'rb') as f: + zip_file.writestr(nombre, f.read()) + buffer.seek(0) + response = HttpResponse(buffer, content_type='application/zip') + response['Content-Disposition'] = f'attachment; filename=edocs_vu_{len(ids_edocs)}.zip' + return response + except Exception as e: + return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + finally: + for tmp_path in temp_files: + try: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + except Exception: + pass + class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): permission_classes = [IsAuthenticated, require_permission('documentos.download')] diff --git a/api/reports/tasks/report_document.py b/api/reports/tasks/report_document.py index 1d762c6..bbbb3a3 100644 --- a/api/reports/tasks/report_document.py +++ b/api/reports/tasks/report_document.py @@ -1,128 +1,373 @@ -import tempfile - -from api.utils.storage_service import storage_service -from celery import shared_task -from api.organization.models import Organizacion -from django.utils import timezone -from api.reports.models import ReportDocument -from api.customs.models import Pedimento, Cove, EDocument, Partida -from django.db.models import Q, Exists, OuterRef -# from django.db.models import Q, -from api.record.models import Document -import csv +import io +import logging import os -from django.conf import settings -from django.core.files.uploadedfile import SimpleUploadedFile +import tempfile +import traceback +from collections import defaultdict -@shared_task -def generate_report_document(report_id): +import openpyxl +from openpyxl.styles import Alignment, Font, PatternFill + +from celery import shared_task +from celery.exceptions import SoftTimeLimitExceeded +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db.models import Q +from django.utils import timezone + +from api.customs.models import Cove, EDocument, Partida, Pedimento +from api.organization.models import Organizacion +from api.record.models import Document +from api.reports.models import ReportDocument +from api.utils.storage_service import storage_service +from core.redis_events import publish_task_event + +logger = logging.getLogger('api.reports.tasks') + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _estado(flag: bool) -> str: + return 'RECUPERADO' if flag else 'PENDIENTE' + + +def _build_pedimento_filters(filters: dict) -> Q: + q = Q() + if filters.get('organizacion_id'): + q &= Q(organizacion_id=filters['organizacion_id']) + if filters.get('fecha_pago__gte'): + q &= Q(fecha_pago__gte=filters['fecha_pago__gte']) + if filters.get('fecha_pago__lte'): + q &= Q(fecha_pago__lte=filters['fecha_pago__lte']) + if filters.get('patente'): + q &= Q(patente=filters['patente']) + if filters.get('aduana'): + q &= Q(aduana=filters['aduana']) + if filters.get('pedimento'): + q &= Q(pedimento=filters['pedimento']) + if filters.get('pedimento_app'): + q &= Q(pedimento_app=filters['pedimento_app']) + if filters.get('regimen'): + q &= Q(regimen=filters['regimen']) + if filters.get('tipo_operacion'): + q &= Q(tipo_operacion_id=filters['tipo_operacion']) + rfc_val = filters.get('contribuyente__rfc') + if rfc_val: + if rfc_val == 'SIN_RFC': + q &= Q(contribuyente__isnull=True) + else: + q &= Q(contribuyente__rfc=rfc_val) + return q + + +def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q: + """Restringe el queryset a los importadores visibles del usuario.""" + # SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True + if requested_rfc == 'SIN_RFC': + return q + user_rfcs = user.rfc.all() + if not user_rfcs.exists(): + if requested_rfc: + q &= Q(contribuyente__rfc=requested_rfc) + return q + if requested_rfc: + if user_rfcs.filter(rfc=requested_rfc).exists(): + q &= Q(contribuyente__rfc=requested_rfc) + else: + q &= Q(contribuyente__in=user_rfcs) + else: + q &= Q(contribuyente__in=user_rfcs) + return q + + +# ── tarea principal ─────────────────────────────────────────────────────────── + +@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660) +def generate_report_document(self, report_id): + task_id = self.request.id + report = None + + def _fail(msg, exc=None): + """Marca el reporte como error, notifica al frontend y loguea. Sin re-raise.""" + tb = traceback.format_exc() if exc else '' + full_msg = f"{msg}\n\n{tb}".strip() if tb else msg + logger.error('[reporte_cumplimiento] report=%s FALLO: %s', report_id, full_msg) + if report: + report.status = 'error' + report.error_message = full_msg + report.finished_at = timezone.now() + report.save(update_fields=['status', 'error_message', 'finished_at']) + publish_task_event(task_id, 'failed', msg, progress=0) + + # ── 1. Obtener reporte ──────────────────────────────────────────────────── try: report = ReportDocument.objects.get(id=report_id) - report.status = 'processing' - report.save(update_fields=['status']) - filters = report.filters or {} - pedimentos_filters = Q() - if filters.get('organizacion_id'): - pedimentos_filters &= Q(organizacion_id=filters['organizacion_id']) - if filters.get('fecha_pago__gte'): - pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte']) - if filters.get('fecha_pago__lte'): - pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte']) - if filters.get('contribuyente__rfc'): - pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc']) - if filters.get('patente'): - pedimentos_filters &= Q(patente=filters['patente']) - if filters.get('aduana'): - pedimentos_filters &= Q(aduana=filters['aduana']) - if filters.get('pedimento'): - pedimentos_filters &= Q(pedimento=filters['pedimento']) - if filters.get('pedimento_app'): - pedimentos_filters &= Q(pedimento_app=filters['pedimento_app']) - if filters.get('regimen'): - pedimentos_filters &= Q(regimen=filters['regimen']) - if filters.get('tipo_operacion'): - pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion']) - # Consulta asíncrona de los modelos - pedimentos = Pedimento.objects.filter(pedimentos_filters) - filename = filters.get('filename') - if filename: - filename = f"{filename}.csv" if not filename.endswith('.csv') else filename - else: - filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" + except ReportDocument.DoesNotExist: + logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id) + publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0) + return - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f: - tmp_path = f.name - - # Escribir CSV en archivo temporal - with open(tmp_path, 'w', newline='', encoding='utf-8') as f: - writer = csv.writer(f) - headers = [ - 'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento', - 'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado' - ] - writer.writerow(headers) - - for ped in pedimentos: - for cove in Cove.objects.filter(pedimento=ped): - writer.writerow([ - ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app, - ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id, - 'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado - ]) - for edoc in EDocument.objects.filter(pedimento=ped): - writer.writerow([ - ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app, - ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id, - 'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado - ]) - for partida in Partida.objects.filter(pedimento=ped): - writer.writerow([ - ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app, - ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id, - 'PARTIDA', partida.numero_partida, partida.descargado, '' - ]) - - # ============ NUEVO: Guardar en MinIO ============ - # Leer archivo temporal - with open(tmp_path, 'rb') as f: - file_content = f.read() - - # Crear UploadedFile - uploaded_file = SimpleUploadedFile( - name=filename, - content=file_content, - content_type='text/csv' + logger.info('[reporte_cumplimiento] Iniciando report=%s user=%s', report_id, report.user_id) + report.status = 'processing' + report.save(update_fields=['status']) + publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5) + + try: + filters = report.filters or {} + org_id = filters.get('organizacion_id') + + # ── 2. Filtros y organización ───────────────────────────────────────── + q = _build_pedimento_filters(filters) + q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc')) + + nombre_org = '' + if org_id: + try: + nombre_org = Organizacion.objects.get(id=org_id).nombre + except Organizacion.DoesNotExist: + pass + + logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters) + publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10) + + # ── 3. Listar RFCs (consulta liviana) ──────────────────────────────── + rfcs_list = list( + Pedimento.objects.filter(q) + .exclude(contribuyente__isnull=True) + .values_list('contribuyente__rfc', flat=True) + .distinct() + .order_by('contribuyente__rfc') ) - - # Guardar en storage + if Pedimento.objects.filter(q, contribuyente__isnull=True).exists(): + rfcs_list.append('SIN_RFC') + + total_rfcs = len(rfcs_list) + total_pedimentos = Pedimento.objects.filter(q).count() + + logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d', + report_id, total_rfcs, total_pedimentos) + + if total_rfcs == 0: + logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id) + + publish_task_event( + task_id, 'processing', + f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15, + ) + + # ── 4. Crear workbook ───────────────────────────────────────────────── + wb = openpyxl.Workbook() + ws = wb.active + ws.title = 'Reporte Cumplimiento' + + title_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid') + title_font = Font(color='FFFFFF', bold=True, size=12) + sub_fill = PatternFill(start_color='2E75B6', end_color='2E75B6', fill_type='solid') + sub_font = Font(color='FFFFFF', bold=True, size=10) + col_h_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid') + col_h_font = Font(bold=True, size=10) + footer_fill = PatternFill(start_color='E2EFDA', end_color='E2EFDA', fill_type='solid') + center = Alignment(horizontal='center', vertical='center', wrap_text=True) + top_left = Alignment(horizontal='left', vertical='top', wrap_text=True) + + COL_HEADERS = [ + 'Año', 'Aduana', 'Patente', 'Pedimento', + 'Nomenclatura Completo Pedimento', 'Clav', 'Tipo Operación', + 'Expediente Sí', 'Documento', 'Estatus', + ] + TOTAL_COLS = len(COL_HEADERS) + current_row = 1 + safe_total = max(total_rfcs, 1) + + # ── 5. Procesar RFC por RFC ─────────────────────────────────────────── + for rfc_idx, rfc in enumerate(rfcs_list): + pct = 20 + int((rfc_idx / safe_total) * 65) + publish_task_event( + task_id, 'processing', + f'RFC {rfc_idx + 1}/{total_rfcs}: {rfc}', progress=pct, + ) + + rfc_q = ( + q & Q(contribuyente__isnull=True) if rfc == 'SIN_RFC' + else q & Q(contribuyente__rfc=rfc) + ) + + peds = list( + Pedimento.objects.filter(rfc_q) + .select_related('contribuyente', 'tipo_operacion') + .order_by('fecha_pago') + ) + if not peds: + logger.warning('[reporte_cumplimiento] report=%s rfc=%s sin pedimentos, omitido', report_id, rfc) + continue + + ped_ids = [p.id for p in peds] + razon_social = nombre_org or 'Desconocido' + + logger.info('[reporte_cumplimiento] report=%s rfc=%s pedimentos=%d', + report_id, rfc, len(peds)) + + # documentos de este RFC solamente + coves_map: dict = defaultdict(list) + for c in Cove.objects.filter(pedimento_id__in=ped_ids): + coves_map[c.pedimento_id].append(c) + + edocs_map: dict = defaultdict(list) + for e in EDocument.objects.filter(pedimento_id__in=ped_ids): + edocs_map[e.pedimento_id].append(e) + + partidas_map: dict = defaultdict(list) + for p in Partida.objects.filter(pedimento_id__in=ped_ids).order_by('numero_partida'): + partidas_map[p.pedimento_id].append(p) + + remesa_ped_ids: set = set( + Document.objects.filter(pedimento_id__in=ped_ids, document_type_id=15) + .values_list('pedimento_id', flat=True) + ) + + total_coves = sum(len(v) for v in coves_map.values()) + total_edocs = sum(len(v) for v in edocs_map.values()) + total_partidas = sum(len(v) for v in partidas_map.values()) + est_rows = len(peds) + total_partidas + total_coves * 2 + total_edocs * 2 + len(remesa_ped_ids) + logger.info('[reporte_cumplimiento] report=%s rfc=%s docs coves=%d edocs=%d partidas=%d remesas=%d filas_estimadas=%d', + report_id, rfc, total_coves, total_edocs, total_partidas, len(remesa_ped_ids), est_rows) + + # encabezado sección + ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS) + cell = ws.cell(row=current_row, column=1, value='Reporte Integración de Expedientes.') + cell.fill, cell.font, cell.alignment = title_fill, title_font, center + current_row += 1 + + ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS) + cell = ws.cell(row=current_row, column=1, value=f'Razón Social Importador: {razon_social}') + cell.fill, cell.font = sub_fill, sub_font + current_row += 1 + + ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS) + cell = ws.cell(row=current_row, column=1, value=f'RFC: {rfc}') + cell.fill, cell.font = sub_fill, sub_font + current_row += 1 + + for col_i, header in enumerate(COL_HEADERS, 1): + cell = ws.cell(row=current_row, column=col_i, value=header) + cell.fill, cell.font, cell.alignment = col_h_fill, col_h_font, center + current_row += 1 + + total_exp = len(peds) + exp_con_docs = exp_completos = 0 + + for ped in peds: + doc_rows = [('PEDIMENTO COMPLETO', _estado(ped.existe_expediente))] + + for partida in partidas_map[ped.id]: + doc_rows.append((f'PARTIDA{partida.numero_partida}', _estado(partida.descargado))) + if ped.remesas: + doc_rows.append(('REMESA', _estado(ped.id in remesa_ped_ids))) + for cove in coves_map[ped.id]: + doc_rows.append((f'COVE{cove.numero_cove}', _estado(cove.cove_descargado))) + doc_rows.append((f'ACUSE COVE{cove.numero_cove}', _estado(cove.acuse_cove_descargado))) + for edoc in edocs_map[ped.id]: + doc_rows.append((f'EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.edocument_descargado))) + doc_rows.append((f'ACUSE EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.acuse_descargado))) + + if len(doc_rows) > 1: + exp_con_docs += 1 + if all(e == 'RECUPERADO' for _, e in doc_rows): + exp_completos += 1 + + n_rows = len(doc_rows) + start_row = current_row + anio = ped.fecha_pago.year % 100 if ped.fecha_pago else '' + base_vals = [ + anio, ped.aduana or '', ped.patente or '', ped.pedimento or '', + ped.pedimento_app or '', ped.clave_pedimento or '', + ped.tipo_operacion.tipo if ped.tipo_operacion else '', + 'SI' if ped.existe_expediente else 'NO', + ] + + # Sin merge_cells — para datasets grandes merge es O(n^2) y cuelga el proceso. + # Los datos base solo se escriben en la primera fila; el resto queda vacío, + # visualmente equivalente al merge pero sin el costo de memoria/CPU. + for offset, (doc_nombre, doc_est) in enumerate(doc_rows): + r = start_row + offset + if offset == 0: + for col, val in enumerate(base_vals, 1): + ws.cell(row=r, column=col, value=val) + ws.cell(row=r, column=9, value=doc_nombre) + ws.cell(row=r, column=10, value=doc_est) + + current_row += n_rows + + ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS) + cell = ws.cell( + row=current_row, column=1, + value=(f'Total de Expedientes= {total_exp} ' + f'Total De Expedientes Con Documentos= {exp_con_docs} ' + f'Total De Expedientes Completos= {exp_completos}'), + ) + cell.fill = footer_fill + cell.font = Font(bold=True) + current_row += 2 + + del peds, ped_ids, coves_map, edocs_map, partidas_map, remesa_ped_ids + + for i, w in enumerate([6, 8, 8, 12, 32, 8, 16, 12, 32, 14], 1): + ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w + + # ── 6. Serializar y subir ───────────────────────────────────────────── + logger.info('[reporte_cumplimiento] report=%s serializando Excel...', report_id) + publish_task_event(task_id, 'processing', 'Serializando Excel...', progress=88) + + filename = f"reporte_cumplimiento_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.xlsx" + buf = io.BytesIO() + wb.save(buf) + excel_bytes = buf.getvalue() + logger.info('[reporte_cumplimiento] report=%s Excel size=%.1fKB', report_id, len(excel_bytes) / 1024) + + publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93) + ruta = storage_service.save_report( - file=uploaded_file, - organizacion_id=filters.get('organizacion_id'), + file=SimpleUploadedFile( + name=filename, + content=excel_bytes, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + organizacion_id=org_id, metadata={ 'report_id': str(report.id), 'report_type': 'cumplimiento', - 'user_id': str(report.user.id) if report.user else None - } + 'user_id': str(report.user.id) if report.user else None, + }, ) - + if ruta: - report.file = ruta + logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta) + report.file = ruta report.status = 'ready' else: - report.status = 'error' - report.error_message = 'Error al guardar el archivo en storage' - - # Limpiar temporal - os.unlink(tmp_path) - + _fail('Error al guardar el archivo en almacenamiento (storage retornó None)') + return + report.finished_at = timezone.now() report.save(update_fields=['status', 'file', 'finished_at', 'error_message']) - - except Exception as e: - report.status = 'error' - report.error_message = str(e) - report.finished_at = timezone.now() - report.save(update_fields=['status', 'error_message', 'finished_at']) + + resultado = { + 'report_id': str(report.id), + 'total_rfcs': total_rfcs, + 'total_pedimentos': total_pedimentos, + } + publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado) + logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d', + report_id, total_rfcs, total_pedimentos) + return resultado + + except SoftTimeLimitExceeded: + _fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.') + + except Exception as exc: + _fail(str(exc), exc=exc) + + +# ── reporte de control de pedimentos (sin cambios) ──────────────────────────── @shared_task def generate_report_control_pedimento(report_id): @@ -133,8 +378,6 @@ def generate_report_control_pedimento(report_id): report.save(update_fields=['status']) filters = report.filters or {} - - # Construir filtros pedimentos_filters = {} if filters.get('organizacion_id'): pedimentos_filters['organizacion_id'] = filters['organizacion_id'] @@ -145,15 +388,12 @@ def generate_report_control_pedimento(report_id): if filters.get('pedimento_app'): pedimentos_filters['pedimento_app'] = filters['pedimento_app'] - # pedimentos por organizacion pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters) pedimentos_total = pedimentos_qs.count() - pedimento_ids = list(pedimentos_qs.values_list('id', flat=True)) rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True)) - # inicializar totales pedimentos_completos = 0 total_documentos = 0 documentos_sin_descargar = 0 @@ -161,17 +401,15 @@ def generate_report_control_pedimento(report_id): nombre_organizacion = '' if filters.get('organizacion_id'): try: - # Asumo que tienes un modelo Organizacion - ajusta según tu modelo real organizacion = Organizacion.objects.get(id=filters['organizacion_id']) - nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo + nombre_organizacion = organizacion.nombre except Organizacion.DoesNotExist: nombre_organizacion = f"ID: {filters['organizacion_id']}" except Exception as e: nombre_organizacion = f"Error: {str(e)}" - - # lista de rfc + rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc]))) - + fecha_inicio = '' fecha_fin = '' @@ -179,109 +417,78 @@ def generate_report_control_pedimento(report_id): primer_pedimento = pedimentos_qs.order_by('fecha_pago').first() if primer_pedimento and primer_pedimento.fecha_pago: fecha_inicio = primer_pedimento.fecha_pago.strftime('%Y-%m-%d') - + ultimo_pedimento = pedimentos_qs.order_by('-fecha_pago').first() if ultimo_pedimento and ultimo_pedimento.fecha_pago: fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d') - # Para cada pedimento, verificar si está completo for pedimento in pedimentos_qs: - # Contar documentos de este pedimento docs_pedimento = 0 docs_pendientes_pedimento = 0 - - # COVES + coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count() coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count() docs_pedimento += coves_count docs_pendientes_pedimento += coves_pendientes - - # PARTIDAS + partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count() partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count() docs_pedimento += partidas_count docs_pendientes_pedimento += partidas_pendientes - - # EDOCUMENTS + edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count() edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count() docs_pedimento += edocs_count docs_pendientes_pedimento += edocs_pendientes - - # Acumular totales + total_documentos += docs_pedimento documentos_sin_descargar += docs_pendientes_pedimento - - # Si no tiene documentos pendientes, está completo + if docs_pendientes_pedimento == 0 and docs_pedimento > 0: pedimentos_completos += 1 - # 3. PORCENTAJE porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0 - # 4. GENERAR CSV CON DETALLES filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp: tmp_path = tmp.name - + todas_las_filas = [] - - # Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO + for pedimento in pedimentos_qs: - # DATOS BASE DEL PEDIMENTO (se repiten en cada fila) datos_base_pedimento = [ pedimento.aduana or '', pedimento.patente or '', pedimento.regimen or '', - pedimento.pedimento or '', # No. Pedimento (7 dígitos) - pedimento.pedimento_app or '', # No. Pedimento App completo + pedimento.pedimento or '', + pedimento.pedimento_app or '', pedimento.clave_pedimento or '', pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '', str(pedimento.contribuyente_id) if pedimento.contribuyente_id else '' ] - - # COVES - Una fila por cada COVE + coves = Cove.objects.filter(pedimento_id=pedimento.id) for cove in coves: estado = 'VERDADERO' if cove.cove_descargado else 'FALSO' - fila = datos_base_pedimento + [ - # str(cove.id), # Identificador de documento - cove.numero_cove, - 'COVE', # Tipo de documento - estado - ] + fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado] todas_las_filas.append(fila) - - # PARTIDAS - Una fila por cada Partida + partidas = Partida.objects.filter(pedimento_id=pedimento.id) for partida in partidas: estado = 'VERDADERO' if partida.descargado else 'FALSO' - fila = datos_base_pedimento + [ - # str(partida.id), - partida.numero_partida, - 'PARTIDA', # Tipo de documento - estado - ] + fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado] todas_las_filas.append(fila) - - # EDOCUMENTS - Una fila por cada EDocument + edocuments = EDocument.objects.filter(pedimento_id=pedimento.id) for edoc in edocuments: estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO' - fila = datos_base_pedimento + [ - # str(edoc.id), - edoc.numero_edocument, - 'EDOCUMENT', # Tipo de documento - estado - ] + fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado] todas_las_filas.append(fila) - # 5. ESCRIBIR ARCHIVO CSV + import csv with open(tmp_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) - - # SECCIÓN DE TOTALES writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS']) writer.writerow(['ORGANIZACION:', nombre_organizacion]) writer.writerow([]) @@ -294,20 +501,15 @@ def generate_report_control_pedimento(report_id): writer.writerow(['LISTA RFC:', rfc_list]) writer.writerow([]) writer.writerow([]) - - # ENCABEZADOS DE DATOS (según requerimiento) headers = [ - 'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP', - 'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID', + 'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP', + 'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID', 'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO' ] writer.writerow(headers) - - # DATOS DETALLADOS for fila in todas_las_filas: writer.writerow(fila) - with open(tmp_path, 'rb') as f: file_content = f.read() @@ -344,4 +546,4 @@ def generate_report_control_pedimento(report_id): report.status = 'error' report.error_message = str(e) report.finished_at = timezone.now() - report.save(update_fields=['status', 'error_message', 'finished_at']) \ No newline at end of file + report.save(update_fields=['status', 'error_message', 'finished_at']) diff --git a/api/reports/tests.py b/api/reports/tests.py index 7ce503c..3494c5e 100644 --- a/api/reports/tests.py +++ b/api/reports/tests.py @@ -1,3 +1,446 @@ +""" +Tests para generate_report_document (T2026-04-001). + +Ejecución: + python manage.py test api.reports.tests + python manage.py test api.reports.tests.TestEstadoHelper +""" +import io +import uuid +from unittest.mock import MagicMock, call, patch + +import openpyxl +from django.contrib.auth import get_user_model +from django.db.models import Q from django.test import TestCase -# Create your tests here. +from api.customs.models import Cove, EDocument, Importador, Partida, Pedimento +from api.licence.models import Licencia +from api.organization.models import Organizacion +from api.reports.models import ReportDocument +from api.reports.tasks.report_document import ( + _apply_user_rfc_filter, + _estado, + generate_report_document, +) + +User = get_user_model() + +FAKE_PATH = 'reports/test/reporte.xlsx' + +# ── fixtures ────────────────────────────────────────────────────────────────── + +def _licencia(nombre='Plan Test'): + return Licencia.objects.create(nombre=nombre, almacenamiento=10) + + +def _org(nombre='Org Test'): + lic = _licencia(f'Lic {nombre}') + return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic) + + +def _user(org, username='tuser', rfcs=None): + u = User.objects.create_user(username=username, password='pass', organizacion=org) + if rfcs: + u.rfc.set(rfcs) + return u + + +def _imp(org, rfc='RFC000000001', nombre='Importador Test'): + return Importador.objects.create(rfc=rfc, nombre=nombre, organizacion=org) + + +def _ped(org, imp=None, num='0000001'): + return Pedimento.objects.create( + pedimento=num, + pedimento_app=f'25-160-3910-{num}', + organizacion=org, + contribuyente=imp, + aduana='160', + patente='3910', + regimen='ITE', + clave_pedimento='A1', + ) + + +def _reporte(user, org_id, extra=None): + filtros = {'organizacion_id': str(org_id)} + if extra: + filtros.update(extra) + return ReportDocument.objects.create( + user=user, filters=filtros, status='pending', report_type='cumplimiento' + ) + + +def _excel_desde_mock(mock_save): + """Parsea el workbook que recibió storage_service.save_report.""" + uf = mock_save.call_args[1]['file'] + return openpyxl.load_workbook(io.BytesIO(uf.read())) + + +def _docs_col(ws): + """Devuelve {documento: estatus} leyendo columnas 9 y 10 del worksheet.""" + return { + ws.cell(row=r, column=9).value: ws.cell(row=r, column=10).value + for r in range(1, ws.max_row + 1) + if ws.cell(row=r, column=9).value + } + + +def _col1_values(ws): + """Devuelve todos los valores no vacíos de la columna 1.""" + return [ + str(ws.cell(row=r, column=1).value) + for r in range(1, ws.max_row + 1) + if ws.cell(row=r, column=1).value + ] + + +# ── 1. Helpers ──────────────────────────────────────────────────────────────── + +class TestEstadoHelper(TestCase): + def test_true_retorna_recuperado(self): + self.assertEqual(_estado(True), 'RECUPERADO') + + def test_false_retorna_pendiente(self): + self.assertEqual(_estado(False), 'PENDIENTE') + + +# ── 2. Filtro de RFC por usuario ────────────────────────────────────────────── + +class TestApplyUserRfcFilter(TestCase): + @classmethod + def setUpTestData(cls): + cls.org = _org() + cls.imp1 = _imp(cls.org, rfc='RFC000000001') + cls.imp2 = _imp(cls.org, rfc='RFC000000002') + + def test_sin_rfcs_asignados_sin_filtro_retorna_q_vacio(self): + user = _user(self.org, username='u_admin') + q = _apply_user_rfc_filter(Q(), user, None) + self.assertEqual(str(q), str(Q())) + + def test_sin_rfcs_asignados_con_filtro_explicito_aplica_filtro(self): + user = _user(self.org, username='u_admin2') + q = _apply_user_rfc_filter(Q(), user, 'RFC000000001') + self.assertIn('RFC000000001', str(q)) + + def test_con_rfcs_sin_filtro_restringe_a_sus_importadores(self): + user = _user(self.org, username='u_imp1', rfcs=[self.imp1]) + q = _apply_user_rfc_filter(Q(), user, None) + self.assertIn('contribuyente', str(q)) + + def test_con_rfcs_pide_el_suyo_se_filtra_por_ese_rfc(self): + user = _user(self.org, username='u_imp2', rfcs=[self.imp1]) + q = _apply_user_rfc_filter(Q(), user, 'RFC000000001') + self.assertIn('RFC000000001', str(q)) + + def test_con_rfcs_pide_ajeno_se_usa_el_suyo_no_el_solicitado(self): + user = _user(self.org, username='u_imp3', rfcs=[self.imp1]) + q = _apply_user_rfc_filter(Q(), user, 'RFC000000002') + self.assertNotIn('RFC000000002', str(q)) + self.assertIn('contribuyente', str(q)) + + +# ── 3. Tarea completa ───────────────────────────────────────────────────────── +# Todos los tests en esta clase mockean Redis (publish_task_event) y MinIO +# (storage_service.save_report) para no depender de infraestructura externa. + +@patch('api.reports.tasks.report_document.publish_task_event') +@patch('api.reports.tasks.report_document.storage_service.save_report', + return_value=FAKE_PATH) +class TestGenerateReportDocument(TestCase): + @classmethod + def setUpTestData(cls): + cls.org = _org('Org Reporte') + cls.imp = _imp(cls.org, rfc='MTK8610143000', nombre='Servicios TETAKAWI') + cls.user = _user(cls.org, username='rep_user') + + def _run(self, report): + generate_report_document.apply(args=[str(report.id)]) + report.refresh_from_db() + + # ── 3.1 Sin pedimentos ──────────────────────────────────────────────────── + + def test_sin_pedimentos_genera_excel_vacio_y_status_ready(self, mock_save, mock_pub): + report = _reporte(self.user, self.org.id) + self._run(report) + + self.assertEqual(report.status, 'ready') + self.assertEqual(report.file, FAKE_PATH) + mock_save.assert_called_once() + + # El workbook no debe tener datos de RFCs + wb = _excel_desde_mock(mock_save) + ws = wb.active + col1 = _col1_values(ws) + self.assertFalse(col1, 'Excel vacío no debe tener contenido en col 1') + + # ── 3.2 RFC aparece en encabezado ───────────────────────────────────────── + + def test_rfc_del_importador_aparece_en_excel(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000001') + report = _reporte(self.user, self.org.id) + self._run(report) + + self.assertEqual(report.status, 'ready') + wb = _excel_desde_mock(mock_save) + ws = wb.active + col1 = ' '.join(_col1_values(ws)) + self.assertIn('MTK8610143000', col1) + + # ── 3.3 PEDIMENTO COMPLETO ──────────────────────────────────────────────── + + def test_pedimento_completo_recuperado_cuando_existe_expediente(self, mock_save, mock_pub): + ped = _ped(self.org, self.imp, '1000002') + ped.existe_expediente = True + ped.save(update_fields=['existe_expediente']) + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'RECUPERADO') + + def test_pedimento_completo_pendiente_cuando_no_tiene_expediente(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000003') # existe_expediente=False por default + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'PENDIENTE') + + # ── 3.4 Partidas ────────────────────────────────────────────────────────── + + def test_partidas_con_estado_correcto(self, mock_save, mock_pub): + ped = _ped(self.org, self.imp, '1000004') + Partida.objects.create( + pedimento=ped, organizacion=self.org, numero_partida=1, descargado=True + ) + Partida.objects.create( + pedimento=ped, organizacion=self.org, numero_partida=2, descargado=False + ) + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('PARTIDA1'), 'RECUPERADO') + self.assertEqual(docs.get('PARTIDA2'), 'PENDIENTE') + + # ── 3.5 COVEs y acuses ──────────────────────────────────────────────────── + + def test_cove_y_acuse_con_estados_distintos(self, mock_save, mock_pub): + ped = _ped(self.org, self.imp, '1000005') + Cove.objects.create( + pedimento=ped, organizacion=self.org, + numero_cove='654001', + cove_descargado=True, + acuse_cove_descargado=False, + ) + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('COVE654001'), 'RECUPERADO') + self.assertEqual(docs.get('ACUSE COVE654001'), 'PENDIENTE') + + # ── 3.6 EDocumentos y acuses ────────────────────────────────────────────── + + def test_edocumento_y_acuse_con_estados_distintos(self, mock_save, mock_pub): + ped = _ped(self.org, self.imp, '1000006') + EDocument.objects.create( + pedimento=ped, organizacion=self.org, + numero_edocument='EDOC001', + edocument_descargado=False, + acuse_descargado=True, + ) + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('EDOCUMENTOEDOC001'), 'PENDIENTE') + self.assertEqual(docs.get('ACUSE EDOCUMENTOEDOC001'), 'RECUPERADO') + + # ── 3.7 Remesa ──────────────────────────────────────────────────────────── + + def test_remesa_recuperada_cuando_document_tipo_15_existe(self, mock_save, mock_pub): + """Pedimento.remesas=True y el query de Document devuelve el pedimento_id.""" + ped = Pedimento.objects.create( + pedimento='1000007', pedimento_app='25-160-3910-1000007', + organizacion=self.org, contribuyente=self.imp, + aduana='160', patente='3910', remesas=True, + ) + + report = _reporte(self.user, self.org.id) + # Patch solo el query de Document dentro del task + with patch('api.reports.tasks.report_document.Document') as MockDoc: + mock_qs = MagicMock() + mock_qs.values_list.return_value = [ped.id] + MockDoc.objects.filter.return_value = mock_qs + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('REMESA'), 'RECUPERADO') + + def test_remesa_pendiente_cuando_no_hay_document(self, mock_save, mock_pub): + """Pedimento.remesas=True pero el query de Document devuelve lista vacía.""" + Pedimento.objects.create( + pedimento='1000008', pedimento_app='25-160-3910-1000008', + organizacion=self.org, contribuyente=self.imp, + aduana='160', patente='3910', remesas=True, + ) + + report = _reporte(self.user, self.org.id) + with patch('api.reports.tasks.report_document.Document') as MockDoc: + mock_qs = MagicMock() + mock_qs.values_list.return_value = [] + MockDoc.objects.filter.return_value = mock_qs + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertEqual(docs.get('REMESA'), 'PENDIENTE') + + def test_sin_remesa_no_aparece_fila_remesa(self, mock_save, mock_pub): + """Pedimento.remesas=False → no debe aparecer fila REMESA.""" + _ped(self.org, self.imp, '1000009') # remesas=False por default + + report = _reporte(self.user, self.org.id) + self._run(report) + + docs = _docs_col(_excel_desde_mock(mock_save).active) + self.assertNotIn('REMESA', docs) + + # ── 3.8 Múltiples RFCs ─────────────────────────────────────────────────── + + def test_multiples_rfcs_generan_secciones_separadas(self, mock_save, mock_pub): + imp2 = _imp(self.org, rfc='TEC140624802', nombre='TEC Importaciones') + _ped(self.org, self.imp, '1000010') + _ped(self.org, imp2, '1000011') + + report = _reporte(self.user, self.org.id) + self._run(report) + + self.assertEqual(report.status, 'ready') + contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active)) + self.assertIn('MTK8610143000', contenido) + self.assertIn('TEC140624802', contenido) + + # ── 3.9 Restricción por RFC de usuario ─────────────────────────────────── + + def test_importador_solo_ve_sus_pedimentos(self, mock_save, mock_pub): + imp2 = _imp(self.org, rfc='XYZ999999999', nombre='Externo') + _ped(self.org, self.imp, '1000012') + _ped(self.org, imp2, '1000013') + + user_restr = _user(self.org, username='u_restr', rfcs=[self.imp]) + report = _reporte(user_restr, self.org.id) + self._run(report) + + self.assertEqual(report.status, 'ready') + contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active)) + self.assertIn('MTK8610143000', contenido) + self.assertNotIn('XYZ999999999', contenido) + + # ── 3.10 Formato del archivo ────────────────────────────────────────────── + + def test_archivo_descargado_es_xlsx_valido(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000014') + report = _reporte(self.user, self.org.id) + self._run(report) + + uf = mock_save.call_args[1]['file'] + self.assertTrue(uf.name.endswith('.xlsx'), f'Esperado .xlsx, recibido: {uf.name}') + try: + wb = openpyxl.load_workbook(io.BytesIO(uf.read())) + self.assertIsNotNone(wb) + except Exception as exc: + self.fail(f'Excel no es válido: {exc}') + + def test_cabeceras_de_columna_presentes(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000015') + report = _reporte(self.user, self.org.id) + self._run(report) + + ws = _excel_desde_mock(mock_save).active + cabeceras = None + for r in range(1, ws.max_row + 1): + if ws.cell(row=r, column=1).value == 'Año': + cabeceras = [ws.cell(row=r, column=c).value for c in range(1, 11)] + break + + self.assertIsNotNone(cabeceras, 'No se encontró la fila de cabeceras') + for col in ('Año', 'Aduana', 'Patente', 'Pedimento', 'Documento', 'Estatus'): + self.assertIn(col, cabeceras, f'Cabecera "{col}" no encontrada') + + # ── 3.11 Progreso en Redis ──────────────────────────────────────────────── + + def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000016') + report = _reporte(self.user, self.org.id) + self._run(report) + + self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos') + + def test_ultimo_evento_es_completed_con_100(self, mock_save, mock_pub): + _ped(self.org, self.imp, '1000017') + report = _reporte(self.user, self.org.id) + self._run(report) + + ultimo = mock_pub.call_args_list[-1] + self.assertEqual(ultimo[0][1], 'completed') + self.assertEqual(ultimo[1].get('progress'), 100) + + # ── 3.12 Manejo de errores ──────────────────────────────────────────────── + + def test_storage_none_deja_status_error(self, mock_save, mock_pub): + """storage_service.save_report retorna None → report queda en error.""" + mock_save.return_value = None + _ped(self.org, self.imp, '1000018') + report = _reporte(self.user, self.org.id) + self._run(report) + + self.assertEqual(report.status, 'error') + self.assertIn('almacenamiento', report.error_message) + + def test_storage_none_publica_evento_failed(self, mock_save, mock_pub): + mock_save.return_value = None + _ped(self.org, self.imp, '1000019') + report = _reporte(self.user, self.org.id) + self._run(report) + + statuses = [c[0][1] for c in mock_pub.call_args_list] + self.assertIn('failed', statuses) + self.assertNotIn('completed', statuses) + + def test_excepcion_guarda_traceback_en_error_message(self, mock_save, mock_pub): + """Una excepción inesperada debe incluir traceback en error_message.""" + mock_save.side_effect = RuntimeError('Fallo simulado de MinIO') + _ped(self.org, self.imp, '1000020') + report = _reporte(self.user, self.org.id) + + try: + generate_report_document.apply(args=[str(report.id)]) + except RuntimeError: + pass # apply() re-raise la excepción + + report.refresh_from_db() + self.assertEqual(report.status, 'error') + self.assertIn('Fallo simulado de MinIO', report.error_message) + self.assertIn('Traceback', report.error_message) + + def test_excepcion_publica_evento_failed(self, mock_save, mock_pub): + mock_save.side_effect = RuntimeError('Error MinIO') + _ped(self.org, self.imp, '1000021') + report = _reporte(self.user, self.org.id) + + try: + generate_report_document.apply(args=[str(report.id)]) + except RuntimeError: + pass + + statuses = [c[0][1] for c in mock_pub.call_args_list] + self.assertIn('failed', statuses) diff --git a/api/reports/views_table.py b/api/reports/views_table.py index ddc43f3..78ee5d9 100644 --- a/api/reports/views_table.py +++ b/api/reports/views_table.py @@ -70,14 +70,13 @@ def table_summary(request): status='pending', report_type='cumplimiento' ) - generate_report_document.delay(report.id) + task = generate_report_document.delay(report.id) return Response({ "report_id": report.id, + "task_id": task.id, "status": report.status, "created_at": report.created_at, - # "download_url": report.file.url if report.file else None - "download_url": storage_service.get_file_url(report.file) if report.file else None - + "download_url": storage_service.get_file_url(report.file) if report.file else None, }, status=202) @api_view(['GET']) @@ -127,7 +126,7 @@ def report_document_download(request, report_id): return Response({"error": "El archivo aún no está disponible"}, status=404) ruta = str(report.file) - with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp: + with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') as tmp: tmp_path = tmp.name success = storage_service.download_file(ruta, tmp_path) diff --git a/config/settings.py b/config/settings.py index 2f21fd3..7ddbd8f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -68,9 +68,25 @@ ALLOWED_HOSTS = [ ,'192.168.1.79' ] -SITE_URL = os.getenv('SITE_URL') +SITE_URL = os.getenv('SITE_URL') SERVICE_API_URL = os.getenv('SERVICE_API_URL') SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2') + +# Hub / SSO +HUB_URL = os.getenv('HUB_URL', 'https://workspace.aduanasoft.com') +HUB_PRODUCT_SLUG = os.getenv('HUB_PRODUCT_SLUG', 'efc') +HUB_TENANT_SLUG = os.getenv('HUB_TENANT_SLUG', '') +HUB_PROVISION_SECRET = os.getenv('HUB_PROVISION_SECRET', '') +HUB_TENANT_ID = int(os.getenv('HUB_TENANT_ID', '1')) +COOKIE_SECURE = os.getenv('COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes') + +# Keycloak admin (para auto-provisión de usuarios en migración) +KC_URL = os.getenv('KC_URL', 'http://hub-keycloak:8080') +KC_REALM = os.getenv('KC_REALM', 'master') +KC_ADMIN_USER = os.getenv('KC_ADMIN_USER', 'admin') +KC_ADMIN_PASSWORD = os.getenv('KC_ADMIN_PASSWORD', 'admin') +KC_EFC_CLIENT_ID = os.getenv('KC_EFC_CLIENT_ID', 'efc-backend') +KC_EFC_CLIENT_SECRET = os.getenv('KC_EFC_CLIENT_SECRET', 'efc-backend-secret-dev') # Application definition BASE_APPS = [ 'django.contrib.admin', @@ -174,11 +190,14 @@ CORS_ALLOW_HEADERS = list(default_headers) + [ 'access-control-allow-credentials', ] +CORS_EXPOSE_HEADERS = ['Content-Disposition'] + # # JWT Authentication settings REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'rest_framework.authentication.TokenAuthentication', # Añade esta línea + 'api.cuser.hub_auth.HubAuthBackend', # Hub SSO (local + KC) + 'rest_framework_simplejwt.authentication.JWTAuthentication', # legacy + 'rest_framework.authentication.TokenAuthentication', ], 'DEFAULT_PERMISSION_CLASSES': [ 'rest_framework.permissions.IsAuthenticated', @@ -223,7 +242,9 @@ REDOC_SETTINGS = { CSRF_TRUSTED_ORIGINS = [ "https://api.efc-aduanasoft.com", "http://192.168.1.195", - "http://192.168.1.195:8000" + "http://192.168.1.195:8000", + "http://localhost:5173", + "http://localhost:8000", ] # URL Configuration @@ -319,10 +340,10 @@ CELERY_TIMEZONE = 'America/Denver' ASGI_APPLICATION = 'config.asgi.application' SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Tokens de acceso cortos por seguridad - 'REFRESH_TOKEN_LIFETIME': timedelta(days=5), # Refresh token de 5 días - 'ROTATE_REFRESH_TOKENS': True, # Rotar refresh tokens para mayor seguridad - 'BLACKLIST_AFTER_ROTATION': True, + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=59), # 1 hora — reduce frecuencia de refresh + 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7 días — sesión larga + 'ROTATE_REFRESH_TOKENS': False, # OFF — evita blacklist en múltiples tabs + 'BLACKLIST_AFTER_ROTATION': False, # OFF — sin blacklist, múltiples tabs coexisten 'AUTH_HEADER_TYPES': ('Bearer',), } diff --git a/config/urls.py b/config/urls.py index 352bc89..05adaee 100644 --- a/config/urls.py +++ b/config/urls.py @@ -32,11 +32,14 @@ urlpatterns = [ path('admin/', admin.site.urls), path('api/v1/', include('api.licence.urls')), - # JWT Authentication + # JWT Authentication (legacy — mantener durante transición) path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/v1/user/', include(('api.cuser.urls', 'cuser'), namespace='cuser')), # Custom user app + # Hub SSO + path('api/v1/auth/', include('api.cuser.sso_urls')), + #path('api-auth/', include('rest_framework.urls')), path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), diff --git a/script/migrate_users_to_keycloak.py b/script/migrate_users_to_keycloak.py new file mode 100644 index 0000000..12e78c6 --- /dev/null +++ b/script/migrate_users_to_keycloak.py @@ -0,0 +1,251 @@ +""" +Migración one-shot: crea usuarios EFC en Keycloak (realm master) y los +vincula al tenant en el Hub via BD directa. + +Estrategia: + - Crear usuario en Keycloak via admin REST API + - Insertar registro en hub.user_tenants + - Guardar keycloak_user_id en cuser_customuser + +CÓMO USAR: + Dry-run (no toca nada): + docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py --dry-run + + Migración real: + docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py + +CASOS ESPECIALES: + - Sin email: se omiten (no se pueden crear en KC sin email único) + - Email duplicado: se crea una sola vez en KC; el UUID se asigna a todos + los registros EFC con ese email +""" + +import argparse +import logging +import os +import sys +import time +from collections import defaultdict + +import django + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") +django.setup() + +import requests # noqa: E402 +from django.db import transaction # noqa: E402 + +from api.cuser.models import CustomUser # noqa: E402 + +_hub_token_cache: dict = {} + + +def get_hub_admin_token(session: requests.Session) -> str: + """Obtiene Bearer token del hubadmin. Acepta token pre-generado via HUB_BEARER_TOKEN.""" + preset = os.getenv("HUB_BEARER_TOKEN", "") + if preset: + return preset + r = session.post( + f"{HUB_API_URL.rstrip('/')}/api/v1/auth/login", + json={"username": HUB_ADMIN_USER, "password": HUB_ADMIN_PASS}, + timeout=10, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def register_user_in_hub(session: requests.Session, hub_token: str, + keycloak_user_id: str) -> bool: + """Llama a POST /hub/user-tenants/add para vincular el usuario al tenant.""" + r = session.post( + f"{HUB_API_URL.rstrip('/')}/api/v1/hub/user-tenants/add", + json={"keycloak_user_id": keycloak_user_id, "tenant_id": HUB_TENANT_ID, "role": "user"}, + headers={"Authorization": f"Bearer {hub_token}"}, + timeout=10, + ) + if r.status_code in (200, 201): + return True + if r.status_code == 409: + return True # ya registrado + logger.error("Hub user-tenants/add %s: %s", r.status_code, r.text[:200]) + return False + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + datefmt="%H:%M:%S", +) +logger = logging.getLogger("migrate") + +# ── Configuración ────────────────────────────────────────────────────────────── +KC_URL = os.getenv("KEYCLOAK_URL", "http://hub-keycloak:8080") +KC_REALM = "master" +KC_ADMIN_USER = os.getenv("KEYCLOAK_ADMIN_USERNAME", "admin") +KC_ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") + +HUB_API_URL = os.getenv("HUB_URL", "http://hub-backend:8000") +HUB_ADMIN_USER = os.getenv("HUB_ADMIN_USERNAME", "hubadmin") +HUB_ADMIN_PASS = os.getenv("HUB_ADMIN_PASSWORD", "Localhost1234!") +HUB_TENANT_ID = int(os.getenv("HUB_TENANT_ID", "1")) + +TENANT_SLUG = os.getenv("HUB_TENANT_SLUG", "efc") +TEMP_PASSWORD = "ChangeMe!Temp2025" +BATCH_SIZE = 20 + + +def get_kc_admin_token(session: requests.Session) -> str: + """Obtiene token de admin de Keycloak.""" + r = session.post( + f"{KC_URL}/kcauth/realms/master/protocol/openid-connect/token", + data={ + "client_id": "admin-cli", + "grant_type": "password", + "username": KC_ADMIN_USER, + "password": KC_ADMIN_PASS, + }, + timeout=10, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def create_kc_user(session: requests.Session, token: str, user: CustomUser) -> str | None: + """Crea usuario en Keycloak. Retorna UUID o None si falla.""" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + body = { + "username": user.username, + "email": user.email, + "firstName": user.first_name or "", + "lastName": user.last_name or "", + "enabled": True, + "credentials": [{"type": "password", "value": TEMP_PASSWORD, "temporary": True}], + } + r = session.post( + f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users", + json=body, + headers=headers, + timeout=15, + ) + + if r.status_code == 201: + # El ID viene en el header Location: .../users/ + location = r.headers.get("Location", "") + kc_id = location.split("/")[-1] + return kc_id if kc_id else None + + if r.status_code == 409: + # Ya existe — buscar por email + search = session.get( + f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users?email={user.email}&exact=true", + headers=headers, + timeout=10, + ) + users = search.json() + if users: + return users[0]["id"] + logger.warning("[%s] Conflicto 409 pero no se encontró por email", user.username) + return None + + logger.error("[%s] KC %s: %s", user.username, r.status_code, r.text[:200]) + return None + + + +def run(dry_run: bool): + pending = list( + CustomUser.objects.filter(is_active=True, keycloak_user_id__isnull=True) + .order_by("date_joined") + ) + + sin_email = [u for u in pending if not (u.email or "").strip()] + con_email = [u for u in pending if (u.email or "").strip()] + + by_email: dict[str, list] = defaultdict(list) + for u in con_email: + by_email[u.email.lower()].append(u) + + dup_emails = {e: us for e, us in by_email.items() if len(us) > 1} + + logger.info("═══════════════════════════════════════════") + logger.info("Pendientes : %d", len(pending)) + logger.info("Sin email (omiten): %d", len(sin_email)) + logger.info("Provisiones KC : %d emails únicos", len(by_email)) + logger.info("Emails duplicados : %d grupos", len(dup_emails)) + logger.info("Tenant Hub : %s", TENANT_SLUG) + logger.info("Keycloak URL : %s", KC_URL) + logger.info("Hub API URL : %s", HUB_API_URL) + logger.info("Hub Tenant ID : %d", HUB_TENANT_ID) + logger.info("═══════════════════════════════════════════") + + if sin_email: + logger.warning("Omitidos sin email: %s", [u.username for u in sin_email]) + + if dup_emails: + for email, users in dup_emails.items(): + logger.info("Duplicado %s → %s", email, [u.username for u in users]) + + if dry_run: + logger.info("=== DRY-RUN completado — no se hizo ningún cambio ===") + return + + ok = 0; failed = 0; i = 0 + with requests.Session() as session: + kc_token = get_kc_admin_token(session) + hub_token = get_hub_admin_token(session) + + for email, users in by_email.items(): + i += 1 + primary = users[0] + + # Refrescar tokens cada 50 usuarios + if i % 50 == 0: + try: + kc_token = get_kc_admin_token(session) + hub_token = get_hub_admin_token(session) + except Exception as exc: + logger.error("No se pudo refrescar tokens: %s", exc) + sys.exit(1) + + kc_id = create_kc_user(session, kc_token, primary) + if not kc_id: + failed += 1 + continue + + # Registrar en Hub via API + if not register_user_in_hub(session, hub_token, kc_id): + failed += 1 + continue + + # Guardar en EFC DB — solo el usuario principal (UNIQUE constraint) + # Los duplicados quedan con keycloak_user_id=NULL; comparten identidad KC + with transaction.atomic(): + CustomUser.objects.filter(pk=primary.pk).update(keycloak_user_id=kc_id) + + ok += 1 + extra = f" (+{len(users)-1} dups)" if len(users) > 1 else "" + logger.info("[%d/%d] %-40s → %s%s", i, len(by_email), email, kc_id[:8] + "...", extra) + + if i % BATCH_SIZE == 0: + time.sleep(0.3) + + logger.info("═══════════════════════════════════════════") + logger.info("Exitosos : %d", ok) + logger.info("Fallidos : %d", failed) + logger.info("Sin email: %d (omitidos)", len(sin_email)) + + if failed: + logger.warning("Hay fallidos — vuelve a correr el script, es idempotente.") + sys.exit(1) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--tenant-slug", default=TENANT_SLUG) + args = parser.parse_args() + + if args.tenant_slug != TENANT_SLUG: + TENANT_SLUG = args.tenant_slug + + run(args.dry_run)