feature/implementacion de hub en EFC #33
239
api/cuser/hub_auth.py
Normal file
239
api/cuser/hub_auth.py
Normal file
@@ -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")
|
||||||
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
18
api/cuser/migrations/0007_customuser_keycloak_user_id.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -25,6 +25,9 @@ class CustomUser(AbstractUser):
|
|||||||
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
|
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")
|
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):
|
def __str__(self):
|
||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|||||||
11
api/cuser/sso_urls.py
Normal file
11
api/cuser/sso_urls.py
Normal file
@@ -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
|
||||||
|
]
|
||||||
395
api/cuser/sso_views.py
Normal file
395
api/cuser/sso_views.py
Normal file
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from celery import shared_task, group
|
from celery import shared_task, group
|
||||||
@@ -7,6 +8,7 @@ from core.utils import xml_controller
|
|||||||
import requests
|
import requests
|
||||||
from core.utils import xml_remesas_controller
|
from core.utils import xml_remesas_controller
|
||||||
from core.redis_events import publish_task_event
|
from core.redis_events import publish_task_event
|
||||||
|
from api.utils.storage_service import storage_service
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -144,7 +146,7 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
|
|||||||
xml_data = extraer_coves(pedimento)
|
xml_data = extraer_coves(pedimento)
|
||||||
if xml_data:
|
if xml_data:
|
||||||
for remesa in xml_data:
|
for remesa in xml_data:
|
||||||
numero_cove = remesa.get('remesaSA')
|
numero_cove = remesa.get('comprobanteVE')
|
||||||
cove, creado = Cove.objects.get_or_create(
|
cove, creado = Cove.objects.get_or_create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
numero_cove=numero_cove,
|
numero_cove=numero_cove,
|
||||||
@@ -533,6 +535,903 @@ def auditar_acuse_por_pedimento(pedimento_id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'success': False, 'error': str(e), 'pedimento_id': str(pedimento_id)}
|
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
|
@shared_task
|
||||||
def auditar_pedimento_por_id(pedimento_id):
|
def auditar_pedimento_por_id(pedimento_id):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ def auditar_pedimentos(self, organizacion_id, user_id=None):
|
|||||||
pedimento.fecha_pago = xml_data.get('fecha_pago')
|
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
|
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(
|
EDocument.objects.get_or_create(
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
organizacion=pedimento.organizacion,
|
organizacion=pedimento.organizacion,
|
||||||
|
|||||||
@@ -67,6 +67,22 @@ from .views_auditor import (
|
|||||||
auditar_pedamentos_incompletos_endpoint,
|
auditar_pedamentos_incompletos_endpoint,
|
||||||
auditar_pedamento_incompleto_endpoint,
|
auditar_pedamento_incompleto_endpoint,
|
||||||
auto_corregir_pedamento_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 = [
|
urlpatterns = [
|
||||||
@@ -111,4 +127,22 @@ urlpatterns = [
|
|||||||
|
|
||||||
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
|
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'),
|
||||||
|
|
||||||
]
|
]
|
||||||
@@ -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
|
# 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_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte')
|
||||||
fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte')
|
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:
|
class Meta:
|
||||||
model = Pedimento
|
model = Pedimento
|
||||||
fields = [
|
fields = [
|
||||||
'patente', 'aduana', 'tipo_operacion', 'clave_pedimento',
|
'patente', 'aduana', 'tipo_operacion', 'clave_pedimento',
|
||||||
'pedimento', 'existe_expediente', 'contribuyente',
|
'pedimento', 'existe_expediente',
|
||||||
'curp_apoderado', 'fecha_pago', 'pedimento_app',
|
'curp_apoderado', 'fecha_pago', 'pedimento_app',
|
||||||
]
|
]
|
||||||
class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion
|
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
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validar organización del usuario
|
# Validar organización del usuario (superuser usa active_organization)
|
||||||
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"tieneError": True,
|
"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
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
organizacion = request.user.organizacion
|
|
||||||
|
|
||||||
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
|
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
|
||||||
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
|
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})$')
|
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
|
||||||
@@ -1744,18 +1748,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
|||||||
partidas_input = request.data.get('partidas')
|
partidas_input = request.data.get('partidas')
|
||||||
fuente_archivos = request.data.get('partidas')
|
fuente_archivos = request.data.get('partidas')
|
||||||
|
|
||||||
# Validar organización del usuario
|
# Validar organización del usuario (superuser usa active_organization)
|
||||||
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"tieneError": True,
|
"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
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
organizacion = request.user.organizacion
|
|
||||||
|
|
||||||
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
|
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
|
||||||
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
|
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})$')
|
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
|
||||||
@@ -2210,16 +2214,16 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validar organización del usuario
|
# Validar organización del usuario (superuser usa active_organization)
|
||||||
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
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(
|
return Response(
|
||||||
{'tieneError': True,
|
{'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
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
organizacion = request.user.organizacion
|
|
||||||
|
|
||||||
# Preparar parámetros
|
# Preparar parámetros
|
||||||
parametros = {
|
parametros = {
|
||||||
'contribuyente': request.data.get('contribuyente'),
|
'contribuyente': request.data.get('contribuyente'),
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ from .tasks.auditoria import (
|
|||||||
auditar_edocuments,
|
auditar_edocuments,
|
||||||
auditar_acuse,
|
auditar_acuse,
|
||||||
auditar_remesas,
|
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.internal_services import auditar_pedimentos
|
||||||
from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual
|
from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual
|
||||||
@@ -2317,3 +2333,379 @@ def auto_corregir_pedamento_endpoint(request):
|
|||||||
},
|
},
|
||||||
status=status.HTTP_202_ACCEPTED,
|
status=status.HTTP_202_ACCEPTED,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 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)
|
||||||
0
api/datastage/management/__init__.py
Normal file
0
api/datastage/management/__init__.py
Normal file
0
api/datastage/management/commands/__init__.py
Normal file
0
api/datastage/management/commands/__init__.py
Normal file
195
api/datastage/management/commands/reprocesar_datastages.py
Normal file
195
api/datastage/management/commands/reprocesar_datastages.py
Normal file
@@ -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 <UUID> # solo una org
|
||||||
|
python manage.py reprocesar_datastages --datastage 4 7 12 # IDs específicos
|
||||||
|
python manage.py reprocesar_datastages --organizacion <UUID> --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."))
|
||||||
@@ -23,7 +23,6 @@ from django.http import HttpResponse
|
|||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Q
|
|
||||||
from api.utils.storage_service import storage_service
|
from api.utils.storage_service import storage_service
|
||||||
|
|
||||||
from rest_framework.authentication import TokenAuthentication
|
from rest_framework.authentication import TokenAuthentication
|
||||||
@@ -39,6 +38,7 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import tempfile
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import requests
|
import requests
|
||||||
@@ -171,6 +171,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
'bulk_upload': 'documentos.upload',
|
'bulk_upload': 'documentos.upload',
|
||||||
'bulk_upload_vu': 'documentos.upload',
|
'bulk_upload_vu': 'documentos.upload',
|
||||||
'create_vu_record': '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')
|
codename = perms.get(self.action, 'documentos.view')
|
||||||
return [IsAuthenticated(), require_permission(codename)()]
|
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')
|
@action(detail=False, methods=['post'], url_path='bulk-delete-partidas-vu')
|
||||||
def bulk_delete_partidas_vu(self, request):
|
def bulk_delete_partidas_vu(self, request):
|
||||||
"""
|
from ..customs.models import Partida
|
||||||
Endpoint para eliminar múltiples archivos xlm de partidas de vu de manera masiva.
|
|
||||||
|
|
||||||
Payload esperado:
|
ids_partidas = request.data.get('ids', [])
|
||||||
{
|
|
||||||
"ids": ["uuid1", "uuid2", "uuid3", ...]
|
|
||||||
}
|
|
||||||
|
|
||||||
Respuesta exitosa:
|
if not ids_partidas:
|
||||||
{
|
|
||||||
"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(
|
return Response(
|
||||||
{"error": "Se requiere una lista de IDs para eliminar"},
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(ids_vu, list):
|
if not isinstance(ids_partidas, list):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "El campo 'ids' debe ser una lista"},
|
{"error": "El campo 'ids' debe ser una lista"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Obtener el queryset filtrado por organización
|
partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento')
|
||||||
queryset = self.get_queryset()
|
|
||||||
|
|
||||||
from ..customs.models import Partida
|
|
||||||
|
|
||||||
partidas = Partida.objects.filter(id__in=ids_vu)
|
|
||||||
if not partidas.exists():
|
if not partidas.exists():
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "No se encontraron Partidas"},
|
{"error": "No se encontraron partidas con los IDs proporcionados"},
|
||||||
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"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
|
# Buscar documentos vu_PT_ asociados a cada partida por pedimento + numero_partida
|
||||||
existing_documents = queryset.filter(id__in=ids)
|
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))
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
||||||
|
existing_ids_str = [str(i) for i in existing_ids]
|
||||||
# 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
|
deleted_count = 0
|
||||||
total_space_freed = 0
|
total_space_freed = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
failed_ids = []
|
||||||
|
|
||||||
if existing_documents.exists():
|
try:
|
||||||
try:
|
with transaction.atomic():
|
||||||
# Usar transacción atómica para consistencia
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
||||||
with transaction.atomic():
|
return Response(
|
||||||
# Calcular el espacio total a liberar
|
{"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)
|
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:
|
if request.user.is_superuser:
|
||||||
# Para superusuario, actualizar el uso de cada organización afectada
|
|
||||||
organizaciones_afectadas = {}
|
organizaciones_afectadas = {}
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
if doc.organizacion.id not in organizaciones_afectadas:
|
if doc.organizacion.id not in organizaciones_afectadas:
|
||||||
@@ -750,8 +698,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
'espacio_liberado': 0
|
'espacio_liberado': 0
|
||||||
}
|
}
|
||||||
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
||||||
|
|
||||||
# Actualizar uso de almacenamiento para cada organización
|
|
||||||
for org_data in organizaciones_afectadas.values():
|
for org_data in organizaciones_afectadas.values():
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
@@ -760,10 +706,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= org_data['espacio_liberado']
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Para usuarios normales, solo documentos de su organización
|
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
organizacion=organizacion
|
organizacion=organizacion
|
||||||
@@ -771,49 +715,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= total_space_freed
|
uso.espacio_utilizado -= total_space_freed
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Eliminar los documentos
|
|
||||||
archivos_eliminados = 0
|
archivos_eliminados = 0
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
try:
|
try:
|
||||||
if doc.archivo:
|
if doc.archivo:
|
||||||
ruta = str(doc.archivo)
|
storage_service.delete_file(str(doc.archivo))
|
||||||
storage_service.delete_file(ruta)
|
|
||||||
|
|
||||||
# Eliminar registro de la base de datos
|
|
||||||
doc.delete()
|
doc.delete()
|
||||||
archivos_eliminados += 1
|
archivos_eliminados += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
||||||
failed_ids.append(str(doc.id))
|
failed_ids.append(str(doc.id))
|
||||||
|
|
||||||
# deleted_count = existing_documents.count()
|
|
||||||
deleted_count = archivos_eliminados
|
deleted_count = archivos_eliminados
|
||||||
# existing_documents.delete()
|
|
||||||
|
|
||||||
except Exception as e:
|
# Eliminar los registros de Partida
|
||||||
return Response(
|
partidas.delete()
|
||||||
{"error": f"Error al eliminar documentos: {str(e)}"},
|
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
except Exception as e:
|
||||||
)
|
return Response(
|
||||||
|
{"error": f"Error al eliminar: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
# Agregar errores para IDs no encontrados
|
|
||||||
if failed_ids:
|
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]
|
errors.extend([
|
||||||
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
||||||
|
for i in failed_ids
|
||||||
|
])
|
||||||
|
|
||||||
# Convertir bytes a MB para la respuesta
|
|
||||||
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
||||||
|
|
||||||
# Preparar respuesta
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"deleted_count": deleted_count,
|
"deleted_count": deleted_count,
|
||||||
"deleted_ids": existing_ids_str,
|
"deleted_ids": existing_ids_str,
|
||||||
"space_freed_mb": space_freed_mb
|
"space_freed_mb": space_freed_mb
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed_ids:
|
if errors or failed_ids:
|
||||||
response_data.update({
|
response_data.update({
|
||||||
"message": "Algunos documentos no pudieron ser eliminados",
|
"message": "Algunos documentos no pudieron ser eliminados",
|
||||||
"failed_ids": failed_ids,
|
"failed_ids": failed_ids,
|
||||||
@@ -821,7 +761,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
})
|
})
|
||||||
response_status = status.HTTP_207_MULTI_STATUS
|
response_status = status.HTTP_207_MULTI_STATUS
|
||||||
else:
|
else:
|
||||||
response_data["message"] = "Documentos eliminados exitosamente"
|
response_data["message"] = "Partidas y documentos eliminados exitosamente"
|
||||||
response_status = status.HTTP_200_OK
|
response_status = status.HTTP_200_OK
|
||||||
|
|
||||||
return Response(response_data, status=response_status)
|
return Response(response_data, status=response_status)
|
||||||
@@ -829,118 +769,62 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='bulk-delete-coves-vu')
|
@action(detail=False, methods=['post'], url_path='bulk-delete-coves-vu')
|
||||||
def bulk_delete_coves_vu(self, request):
|
def bulk_delete_coves_vu(self, request):
|
||||||
"""
|
from ..customs.models import Cove
|
||||||
Endpoint para eliminar múltiples archivos xlm de coves de vu de manera masiva.
|
|
||||||
|
|
||||||
Payload esperado:
|
ids_coves = request.data.get('ids', [])
|
||||||
{
|
|
||||||
"ids": ["uuid1", "uuid2", "uuid3", ...]
|
|
||||||
}
|
|
||||||
|
|
||||||
Respuesta exitosa:
|
if not ids_coves:
|
||||||
{
|
|
||||||
"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(
|
return Response(
|
||||||
{"error": "Se requiere una lista de IDs para eliminar"},
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(ids_vu, list):
|
if not isinstance(ids_coves, list):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "El campo 'ids' debe ser una lista"},
|
{"error": "El campo 'ids' debe ser una lista"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Obtener el queryset filtrado por organización
|
coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento')
|
||||||
queryset = self.get_queryset()
|
|
||||||
|
|
||||||
from ..customs.models import Cove
|
|
||||||
|
|
||||||
coves = Cove.objects.filter(id__in=ids_vu)
|
|
||||||
if not coves.exists():
|
if not coves.exists():
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "No se encontraron COVEs"},
|
{"error": "No se encontraron COVEs con los IDs proporcionados"},
|
||||||
status=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
ids = []
|
|
||||||
for cove in coves:
|
|
||||||
|
|
||||||
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
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
|
# Buscar documentos que contengan el numero_cove en el nombre de archivo
|
||||||
existing_documents = queryset.filter(id__in=ids)
|
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))
|
existing_ids = list(existing_documents.values_list('id', flat=True))
|
||||||
|
existing_ids_str = [str(i) for i in existing_ids]
|
||||||
# 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
|
deleted_count = 0
|
||||||
total_space_freed = 0
|
total_space_freed = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
failed_ids = []
|
||||||
|
|
||||||
if existing_documents.exists():
|
try:
|
||||||
try:
|
with transaction.atomic():
|
||||||
# Usar transacción atómica para consistencia
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
||||||
with transaction.atomic():
|
return Response(
|
||||||
# Calcular el espacio total a liberar
|
{"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)
|
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:
|
if request.user.is_superuser:
|
||||||
# Para superusuario, actualizar el uso de cada organización afectada
|
|
||||||
organizaciones_afectadas = {}
|
organizaciones_afectadas = {}
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
if doc.organizacion.id not in organizaciones_afectadas:
|
if doc.organizacion.id not in organizaciones_afectadas:
|
||||||
@@ -949,8 +833,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
'espacio_liberado': 0
|
'espacio_liberado': 0
|
||||||
}
|
}
|
||||||
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
||||||
|
|
||||||
# Actualizar uso de almacenamiento para cada organización
|
|
||||||
for org_data in organizaciones_afectadas.values():
|
for org_data in organizaciones_afectadas.values():
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
@@ -959,10 +841,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= org_data['espacio_liberado']
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Para usuarios normales, solo documentos de su organización
|
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
organizacion=organizacion
|
organizacion=organizacion
|
||||||
@@ -970,49 +850,44 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= total_space_freed
|
uso.espacio_utilizado -= total_space_freed
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Eliminar los documentos
|
|
||||||
archivos_eliminados = 0
|
archivos_eliminados = 0
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
try:
|
try:
|
||||||
if doc.archivo:
|
if doc.archivo:
|
||||||
ruta = str(doc.archivo)
|
storage_service.delete_file(str(doc.archivo))
|
||||||
storage_service.delete_file(ruta)
|
|
||||||
|
|
||||||
# Eliminar registro de la base de datos
|
|
||||||
doc.delete()
|
doc.delete()
|
||||||
archivos_eliminados += 1
|
archivos_eliminados += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
||||||
failed_ids.append(str(doc.id))
|
failed_ids.append(str(doc.id))
|
||||||
|
|
||||||
# deleted_count = existing_documents.count()
|
|
||||||
deleted_count = archivos_eliminados
|
deleted_count = archivos_eliminados
|
||||||
# existing_documents.delete()
|
|
||||||
|
|
||||||
except Exception as e:
|
coves.delete()
|
||||||
return Response(
|
|
||||||
{"error": f"Error al eliminar documentos: {str(e)}"},
|
except Exception as e:
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
return Response(
|
||||||
)
|
{"error": f"Error al eliminar: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
# Agregar errores para IDs no encontrados
|
|
||||||
if failed_ids:
|
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]
|
errors.extend([
|
||||||
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
||||||
|
for i in failed_ids
|
||||||
|
])
|
||||||
|
|
||||||
# Convertir bytes a MB para la respuesta
|
|
||||||
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
||||||
|
|
||||||
# Preparar respuesta
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"deleted_count": deleted_count,
|
"deleted_count": deleted_count,
|
||||||
"deleted_ids": existing_ids_str,
|
"deleted_ids": existing_ids_str,
|
||||||
"space_freed_mb": space_freed_mb
|
"space_freed_mb": space_freed_mb
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed_ids:
|
if errors or failed_ids:
|
||||||
response_data.update({
|
response_data.update({
|
||||||
"message": "Algunos documentos no pudieron ser eliminados",
|
"message": "Algunos documentos no pudieron ser eliminados",
|
||||||
"failed_ids": failed_ids,
|
"failed_ids": failed_ids,
|
||||||
@@ -1020,125 +895,69 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
})
|
})
|
||||||
response_status = status.HTTP_207_MULTI_STATUS
|
response_status = status.HTTP_207_MULTI_STATUS
|
||||||
else:
|
else:
|
||||||
response_data["message"] = "Documentos eliminados exitosamente"
|
response_data["message"] = "COVEs y documentos eliminados exitosamente"
|
||||||
response_status = status.HTTP_200_OK
|
response_status = status.HTTP_200_OK
|
||||||
|
|
||||||
return Response(response_data, status=response_status)
|
return Response(response_data, status=response_status)
|
||||||
|
|
||||||
@action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu')
|
@action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu')
|
||||||
def bulk_delete_edocs_vu(self, request):
|
def bulk_delete_edocs_vu(self, request):
|
||||||
"""
|
from ..customs.models import EDocument
|
||||||
Endpoint para eliminar múltiples archivos xlm de edocs de vu de manera masiva.
|
|
||||||
|
|
||||||
Payload esperado:
|
ids_edocs = request.data.get('ids', [])
|
||||||
{
|
|
||||||
"ids": ["uuid1", "uuid2", "uuid3", ...]
|
|
||||||
}
|
|
||||||
|
|
||||||
Respuesta exitosa:
|
if not ids_edocs:
|
||||||
{
|
|
||||||
"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(
|
return Response(
|
||||||
{"error": "Se requiere una lista de IDs para eliminar"},
|
{"error": "Se requiere una lista de IDs para eliminar"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(ids_vu, list):
|
if not isinstance(ids_edocs, list):
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "El campo 'ids' debe ser una lista"},
|
{"error": "El campo 'ids' debe ser una lista"},
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
# Obtener el queryset filtrado por organización
|
edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento')
|
||||||
queryset = self.get_queryset()
|
|
||||||
|
|
||||||
from ..customs.models import EDocument
|
|
||||||
|
|
||||||
edocs = EDocument.objects.filter(id__in=ids_vu)
|
|
||||||
if not edocs.exists():
|
if not edocs.exists():
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "No se encontraron COVEs"},
|
{"error": "No se encontraron EDocuments con los IDs proporcionados"},
|
||||||
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"},
|
|
||||||
status=status.HTTP_404_NOT_FOUND
|
status=status.HTTP_404_NOT_FOUND
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
|
# Buscar documentos que contengan el numero_edocument en el nombre de archivo
|
||||||
existing_documents = queryset.filter(id__in=ids)
|
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 = list(existing_documents.values_list('id', flat=True))
|
||||||
|
existing_ids_str = [str(i) for i in existing_ids]
|
||||||
# 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
|
deleted_count = 0
|
||||||
total_space_freed = 0
|
total_space_freed = 0
|
||||||
errors = []
|
errors = []
|
||||||
|
failed_ids = []
|
||||||
|
|
||||||
if existing_documents.exists():
|
try:
|
||||||
try:
|
with transaction.atomic():
|
||||||
# Usar transacción atómica para consistencia
|
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
|
||||||
with transaction.atomic():
|
return Response(
|
||||||
# Calcular el espacio total a liberar
|
{"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)
|
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:
|
if request.user.is_superuser:
|
||||||
# Para superusuario, actualizar el uso de cada organización afectada
|
|
||||||
organizaciones_afectadas = {}
|
organizaciones_afectadas = {}
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
if doc.organizacion.id not in organizaciones_afectadas:
|
if doc.organizacion.id not in organizaciones_afectadas:
|
||||||
@@ -1147,8 +966,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
'espacio_liberado': 0
|
'espacio_liberado': 0
|
||||||
}
|
}
|
||||||
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
|
||||||
|
|
||||||
# Actualizar uso de almacenamiento para cada organización
|
|
||||||
for org_data in organizaciones_afectadas.values():
|
for org_data in organizaciones_afectadas.values():
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
@@ -1157,10 +974,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= org_data['espacio_liberado']
|
uso.espacio_utilizado -= org_data['espacio_liberado']
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Para usuarios normales, solo documentos de su organización
|
|
||||||
try:
|
try:
|
||||||
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
uso = UsoAlmacenamiento.objects.select_for_update().get(
|
||||||
organizacion=organizacion
|
organizacion=organizacion
|
||||||
@@ -1168,48 +983,44 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
uso.espacio_utilizado -= total_space_freed
|
uso.espacio_utilizado -= total_space_freed
|
||||||
uso.save()
|
uso.save()
|
||||||
except UsoAlmacenamiento.DoesNotExist:
|
except UsoAlmacenamiento.DoesNotExist:
|
||||||
# Si no existe el registro, no hay nada que actualizar
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Eliminar los documentos
|
|
||||||
archivos_eliminados = 0
|
archivos_eliminados = 0
|
||||||
for doc in existing_documents:
|
for doc in existing_documents:
|
||||||
try:
|
try:
|
||||||
if doc.archivo:
|
if doc.archivo:
|
||||||
ruta = str(doc.archivo)
|
storage_service.delete_file(str(doc.archivo))
|
||||||
storage_service.delete_file(ruta)
|
|
||||||
|
|
||||||
doc.delete()
|
doc.delete()
|
||||||
archivos_eliminados += 1
|
archivos_eliminados += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
|
||||||
failed_ids.append(str(doc.id))
|
failed_ids.append(str(doc.id))
|
||||||
|
|
||||||
# deleted_count = existing_documents.count()
|
|
||||||
deleted_count = archivos_eliminados
|
deleted_count = archivos_eliminados
|
||||||
# existing_documents.delete()
|
|
||||||
|
|
||||||
except Exception as e:
|
edocs.delete()
|
||||||
return Response(
|
|
||||||
{"error": f"Error al eliminar documentos: {str(e)}"},
|
except Exception as e:
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
return Response(
|
||||||
)
|
{"error": f"Error al eliminar: {str(e)}"},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
# Agregar errores para IDs no encontrados
|
|
||||||
if failed_ids:
|
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]
|
errors.extend([
|
||||||
|
f"No se encontró el documento con ID {i} o no pertenece a su organización"
|
||||||
|
for i in failed_ids
|
||||||
|
])
|
||||||
|
|
||||||
# Convertir bytes a MB para la respuesta
|
|
||||||
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
space_freed_mb = round(total_space_freed / (1024 * 1024), 2)
|
||||||
|
|
||||||
# Preparar respuesta
|
|
||||||
response_data = {
|
response_data = {
|
||||||
"deleted_count": deleted_count,
|
"deleted_count": deleted_count,
|
||||||
"deleted_ids": existing_ids_str,
|
"deleted_ids": existing_ids_str,
|
||||||
"space_freed_mb": space_freed_mb
|
"space_freed_mb": space_freed_mb
|
||||||
}
|
}
|
||||||
|
|
||||||
if failed_ids:
|
if errors or failed_ids:
|
||||||
response_data.update({
|
response_data.update({
|
||||||
"message": "Algunos documentos no pudieron ser eliminados",
|
"message": "Algunos documentos no pudieron ser eliminados",
|
||||||
"failed_ids": failed_ids,
|
"failed_ids": failed_ids,
|
||||||
@@ -1217,7 +1028,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
})
|
})
|
||||||
response_status = status.HTTP_207_MULTI_STATUS
|
response_status = status.HTTP_207_MULTI_STATUS
|
||||||
else:
|
else:
|
||||||
response_data["message"] = "Documentos eliminados exitosamente"
|
response_data["message"] = "EDocuments y documentos eliminados exitosamente"
|
||||||
response_status = status.HTTP_200_OK
|
response_status = status.HTTP_200_OK
|
||||||
|
|
||||||
return Response(response_data, status=response_status)
|
return Response(response_data, status=response_status)
|
||||||
@@ -2059,6 +1870,186 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
|
|||||||
|
|
||||||
return Response(response_data, status=response_status)
|
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):
|
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
|
||||||
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
|
||||||
|
|||||||
@@ -1,128 +1,373 @@
|
|||||||
import tempfile
|
import io
|
||||||
|
import logging
|
||||||
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 os
|
import os
|
||||||
from django.conf import settings
|
import tempfile
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
import traceback
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
@shared_task
|
import openpyxl
|
||||||
def generate_report_document(report_id):
|
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:
|
try:
|
||||||
report = ReportDocument.objects.get(id=report_id)
|
report = ReportDocument.objects.get(id=report_id)
|
||||||
report.status = 'processing'
|
except ReportDocument.DoesNotExist:
|
||||||
report.save(update_fields=['status'])
|
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
|
||||||
|
|
||||||
|
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 {}
|
filters = report.filters or {}
|
||||||
pedimentos_filters = Q()
|
org_id = filters.get('organizacion_id')
|
||||||
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"
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
|
# ── 2. Filtros y organización ─────────────────────────────────────────
|
||||||
tmp_path = f.name
|
q = _build_pedimento_filters(filters)
|
||||||
|
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
|
||||||
|
|
||||||
# Escribir CSV en archivo temporal
|
nombre_org = ''
|
||||||
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
|
if org_id:
|
||||||
writer = csv.writer(f)
|
try:
|
||||||
headers = [
|
nombre_org = Organizacion.objects.get(id=org_id).nombre
|
||||||
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
|
except Organizacion.DoesNotExist:
|
||||||
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
|
pass
|
||||||
]
|
|
||||||
writer.writerow(headers)
|
|
||||||
|
|
||||||
for ped in pedimentos:
|
logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
|
||||||
for cove in Cove.objects.filter(pedimento=ped):
|
publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
|
||||||
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 ============
|
# ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
|
||||||
# Leer archivo temporal
|
rfcs_list = list(
|
||||||
with open(tmp_path, 'rb') as f:
|
Pedimento.objects.filter(q)
|
||||||
file_content = f.read()
|
.exclude(contribuyente__isnull=True)
|
||||||
|
.values_list('contribuyente__rfc', flat=True)
|
||||||
|
.distinct()
|
||||||
|
.order_by('contribuyente__rfc')
|
||||||
|
)
|
||||||
|
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
|
||||||
|
rfcs_list.append('SIN_RFC')
|
||||||
|
|
||||||
# Crear UploadedFile
|
total_rfcs = len(rfcs_list)
|
||||||
uploaded_file = SimpleUploadedFile(
|
total_pedimentos = Pedimento.objects.filter(q).count()
|
||||||
name=filename,
|
|
||||||
content=file_content,
|
logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
|
||||||
content_type='text/csv'
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Guardar en storage
|
# ── 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(
|
ruta = storage_service.save_report(
|
||||||
file=uploaded_file,
|
file=SimpleUploadedFile(
|
||||||
organizacion_id=filters.get('organizacion_id'),
|
name=filename,
|
||||||
|
content=excel_bytes,
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
),
|
||||||
|
organizacion_id=org_id,
|
||||||
metadata={
|
metadata={
|
||||||
'report_id': str(report.id),
|
'report_id': str(report.id),
|
||||||
'report_type': 'cumplimiento',
|
'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:
|
if ruta:
|
||||||
report.file = ruta
|
logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
|
||||||
|
report.file = ruta
|
||||||
report.status = 'ready'
|
report.status = 'ready'
|
||||||
else:
|
else:
|
||||||
report.status = 'error'
|
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
|
||||||
report.error_message = 'Error al guardar el archivo en storage'
|
return
|
||||||
|
|
||||||
# Limpiar temporal
|
|
||||||
os.unlink(tmp_path)
|
|
||||||
|
|
||||||
report.finished_at = timezone.now()
|
report.finished_at = timezone.now()
|
||||||
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||||
|
|
||||||
except Exception as e:
|
resultado = {
|
||||||
report.status = 'error'
|
'report_id': str(report.id),
|
||||||
report.error_message = str(e)
|
'total_rfcs': total_rfcs,
|
||||||
report.finished_at = timezone.now()
|
'total_pedimentos': total_pedimentos,
|
||||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
}
|
||||||
|
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
|
@shared_task
|
||||||
def generate_report_control_pedimento(report_id):
|
def generate_report_control_pedimento(report_id):
|
||||||
@@ -133,8 +378,6 @@ def generate_report_control_pedimento(report_id):
|
|||||||
report.save(update_fields=['status'])
|
report.save(update_fields=['status'])
|
||||||
filters = report.filters or {}
|
filters = report.filters or {}
|
||||||
|
|
||||||
|
|
||||||
# Construir filtros
|
|
||||||
pedimentos_filters = {}
|
pedimentos_filters = {}
|
||||||
if filters.get('organizacion_id'):
|
if filters.get('organizacion_id'):
|
||||||
pedimentos_filters['organizacion_id'] = filters['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'):
|
if filters.get('pedimento_app'):
|
||||||
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
|
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
|
||||||
|
|
||||||
# pedimentos por organizacion
|
|
||||||
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
|
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
|
||||||
pedimentos_total = pedimentos_qs.count()
|
pedimentos_total = pedimentos_qs.count()
|
||||||
|
|
||||||
|
|
||||||
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
|
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
|
||||||
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
|
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
|
||||||
|
|
||||||
# inicializar totales
|
|
||||||
pedimentos_completos = 0
|
pedimentos_completos = 0
|
||||||
total_documentos = 0
|
total_documentos = 0
|
||||||
documentos_sin_descargar = 0
|
documentos_sin_descargar = 0
|
||||||
@@ -161,15 +401,13 @@ def generate_report_control_pedimento(report_id):
|
|||||||
nombre_organizacion = ''
|
nombre_organizacion = ''
|
||||||
if filters.get('organizacion_id'):
|
if filters.get('organizacion_id'):
|
||||||
try:
|
try:
|
||||||
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
|
|
||||||
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
|
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:
|
except Organizacion.DoesNotExist:
|
||||||
nombre_organizacion = f"ID: {filters['organizacion_id']}"
|
nombre_organizacion = f"ID: {filters['organizacion_id']}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
nombre_organizacion = f"Error: {str(e)}"
|
nombre_organizacion = f"Error: {str(e)}"
|
||||||
|
|
||||||
# lista de rfc
|
|
||||||
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
|
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
|
||||||
|
|
||||||
fecha_inicio = ''
|
fecha_inicio = ''
|
||||||
@@ -184,42 +422,33 @@ def generate_report_control_pedimento(report_id):
|
|||||||
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
|
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
|
||||||
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
|
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
# Para cada pedimento, verificar si está completo
|
|
||||||
for pedimento in pedimentos_qs:
|
for pedimento in pedimentos_qs:
|
||||||
# Contar documentos de este pedimento
|
|
||||||
docs_pedimento = 0
|
docs_pedimento = 0
|
||||||
docs_pendientes_pedimento = 0
|
docs_pendientes_pedimento = 0
|
||||||
|
|
||||||
# COVES
|
|
||||||
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
|
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
|
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
|
||||||
docs_pedimento += coves_count
|
docs_pedimento += coves_count
|
||||||
docs_pendientes_pedimento += coves_pendientes
|
docs_pendientes_pedimento += coves_pendientes
|
||||||
|
|
||||||
# PARTIDAS
|
|
||||||
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
|
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
|
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
|
||||||
docs_pedimento += partidas_count
|
docs_pedimento += partidas_count
|
||||||
docs_pendientes_pedimento += partidas_pendientes
|
docs_pendientes_pedimento += partidas_pendientes
|
||||||
|
|
||||||
# EDOCUMENTS
|
|
||||||
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
|
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
|
||||||
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
|
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
|
||||||
docs_pedimento += edocs_count
|
docs_pedimento += edocs_count
|
||||||
docs_pendientes_pedimento += edocs_pendientes
|
docs_pendientes_pedimento += edocs_pendientes
|
||||||
|
|
||||||
# Acumular totales
|
|
||||||
total_documentos += docs_pedimento
|
total_documentos += docs_pedimento
|
||||||
documentos_sin_descargar += docs_pendientes_pedimento
|
documentos_sin_descargar += docs_pendientes_pedimento
|
||||||
|
|
||||||
# Si no tiene documentos pendientes, está completo
|
|
||||||
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
|
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
|
||||||
pedimentos_completos += 1
|
pedimentos_completos += 1
|
||||||
|
|
||||||
# 3. PORCENTAJE
|
|
||||||
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
|
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"
|
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:
|
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
|
||||||
@@ -227,61 +456,39 @@ def generate_report_control_pedimento(report_id):
|
|||||||
|
|
||||||
todas_las_filas = []
|
todas_las_filas = []
|
||||||
|
|
||||||
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
|
|
||||||
for pedimento in pedimentos_qs:
|
for pedimento in pedimentos_qs:
|
||||||
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
|
|
||||||
datos_base_pedimento = [
|
datos_base_pedimento = [
|
||||||
pedimento.aduana or '',
|
pedimento.aduana or '',
|
||||||
pedimento.patente or '',
|
pedimento.patente or '',
|
||||||
pedimento.regimen or '',
|
pedimento.regimen or '',
|
||||||
pedimento.pedimento or '', # No. Pedimento (7 dígitos)
|
pedimento.pedimento or '',
|
||||||
pedimento.pedimento_app or '', # No. Pedimento App completo
|
pedimento.pedimento_app or '',
|
||||||
pedimento.clave_pedimento or '',
|
pedimento.clave_pedimento or '',
|
||||||
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
|
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
|
||||||
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
|
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
|
||||||
]
|
]
|
||||||
|
|
||||||
# COVES - Una fila por cada COVE
|
|
||||||
coves = Cove.objects.filter(pedimento_id=pedimento.id)
|
coves = Cove.objects.filter(pedimento_id=pedimento.id)
|
||||||
for cove in coves:
|
for cove in coves:
|
||||||
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
|
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
|
||||||
# str(cove.id), # Identificador de documento
|
|
||||||
cove.numero_cove,
|
|
||||||
'COVE', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# PARTIDAS - Una fila por cada Partida
|
|
||||||
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
|
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
|
||||||
for partida in partidas:
|
for partida in partidas:
|
||||||
estado = 'VERDADERO' if partida.descargado else 'FALSO'
|
estado = 'VERDADERO' if partida.descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
|
||||||
# str(partida.id),
|
|
||||||
partida.numero_partida,
|
|
||||||
'PARTIDA', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# EDOCUMENTS - Una fila por cada EDocument
|
|
||||||
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
|
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
|
||||||
for edoc in edocuments:
|
for edoc in edocuments:
|
||||||
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
|
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
|
||||||
fila = datos_base_pedimento + [
|
fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
|
||||||
# str(edoc.id),
|
|
||||||
edoc.numero_edocument,
|
|
||||||
'EDOCUMENT', # Tipo de documento
|
|
||||||
estado
|
|
||||||
]
|
|
||||||
todas_las_filas.append(fila)
|
todas_las_filas.append(fila)
|
||||||
|
|
||||||
# 5. ESCRIBIR ARCHIVO CSV
|
import csv
|
||||||
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
|
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
|
|
||||||
# SECCIÓN DE TOTALES
|
|
||||||
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
|
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
|
||||||
writer.writerow(['ORGANIZACION:', nombre_organizacion])
|
writer.writerow(['ORGANIZACION:', nombre_organizacion])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
@@ -294,20 +501,15 @@ def generate_report_control_pedimento(report_id):
|
|||||||
writer.writerow(['LISTA RFC:', rfc_list])
|
writer.writerow(['LISTA RFC:', rfc_list])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
|
|
||||||
# ENCABEZADOS DE DATOS (según requerimiento)
|
|
||||||
headers = [
|
headers = [
|
||||||
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
|
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
|
||||||
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
|
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
|
||||||
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
|
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
|
||||||
]
|
]
|
||||||
writer.writerow(headers)
|
writer.writerow(headers)
|
||||||
|
|
||||||
# DATOS DETALLADOS
|
|
||||||
for fila in todas_las_filas:
|
for fila in todas_las_filas:
|
||||||
writer.writerow(fila)
|
writer.writerow(fila)
|
||||||
|
|
||||||
|
|
||||||
with open(tmp_path, 'rb') as f:
|
with open(tmp_path, 'rb') as f:
|
||||||
file_content = f.read()
|
file_content = f.read()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|||||||
@@ -70,14 +70,13 @@ def table_summary(request):
|
|||||||
status='pending',
|
status='pending',
|
||||||
report_type='cumplimiento'
|
report_type='cumplimiento'
|
||||||
)
|
)
|
||||||
generate_report_document.delay(report.id)
|
task = generate_report_document.delay(report.id)
|
||||||
return Response({
|
return Response({
|
||||||
"report_id": report.id,
|
"report_id": report.id,
|
||||||
|
"task_id": task.id,
|
||||||
"status": report.status,
|
"status": report.status,
|
||||||
"created_at": report.created_at,
|
"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)
|
}, status=202)
|
||||||
|
|
||||||
@api_view(['GET'])
|
@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)
|
return Response({"error": "El archivo aún no está disponible"}, status=404)
|
||||||
|
|
||||||
ruta = str(report.file)
|
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
|
tmp_path = tmp.name
|
||||||
|
|
||||||
success = storage_service.download_file(ruta, tmp_path)
|
success = storage_service.download_file(ruta, tmp_path)
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ ALLOWED_HOSTS = [
|
|||||||
SITE_URL = os.getenv('SITE_URL')
|
SITE_URL = os.getenv('SITE_URL')
|
||||||
SERVICE_API_URL = os.getenv('SERVICE_API_URL')
|
SERVICE_API_URL = os.getenv('SERVICE_API_URL')
|
||||||
SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2')
|
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
|
# Application definition
|
||||||
BASE_APPS = [
|
BASE_APPS = [
|
||||||
'django.contrib.admin',
|
'django.contrib.admin',
|
||||||
@@ -174,11 +190,14 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
|
|||||||
'access-control-allow-credentials',
|
'access-control-allow-credentials',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
CORS_EXPOSE_HEADERS = ['Content-Disposition']
|
||||||
|
|
||||||
# # JWT Authentication settings
|
# # JWT Authentication settings
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'api.cuser.hub_auth.HubAuthBackend', # Hub SSO (local + KC)
|
||||||
'rest_framework.authentication.TokenAuthentication', # Añade esta línea
|
'rest_framework_simplejwt.authentication.JWTAuthentication', # legacy
|
||||||
|
'rest_framework.authentication.TokenAuthentication',
|
||||||
],
|
],
|
||||||
'DEFAULT_PERMISSION_CLASSES': [
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
@@ -223,7 +242,9 @@ REDOC_SETTINGS = {
|
|||||||
CSRF_TRUSTED_ORIGINS = [
|
CSRF_TRUSTED_ORIGINS = [
|
||||||
"https://api.efc-aduanasoft.com",
|
"https://api.efc-aduanasoft.com",
|
||||||
"http://192.168.1.195",
|
"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
|
# URL Configuration
|
||||||
@@ -319,10 +340,10 @@ CELERY_TIMEZONE = 'America/Denver'
|
|||||||
ASGI_APPLICATION = 'config.asgi.application'
|
ASGI_APPLICATION = 'config.asgi.application'
|
||||||
|
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Tokens de acceso cortos por seguridad
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=59), # 1 hora — reduce frecuencia de refresh
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=5), # Refresh token de 5 días
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7 días — sesión larga
|
||||||
'ROTATE_REFRESH_TOKENS': True, # Rotar refresh tokens para mayor seguridad
|
'ROTATE_REFRESH_TOKENS': False, # OFF — evita blacklist en múltiples tabs
|
||||||
'BLACKLIST_AFTER_ROTATION': True,
|
'BLACKLIST_AFTER_ROTATION': False, # OFF — sin blacklist, múltiples tabs coexisten
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ urlpatterns = [
|
|||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('api/v1/', include('api.licence.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/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
|
||||||
path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
|
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
|
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-auth/', include('rest_framework.urls')),
|
||||||
path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
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'),
|
path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||||
|
|||||||
251
script/migrate_users_to_keycloak.py
Normal file
251
script/migrate_users_to_keycloak.py
Normal file
@@ -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/<uuid>
|
||||||
|
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)
|
||||||
Reference in New Issue
Block a user