Compare commits

...

5 Commits

44 changed files with 5655 additions and 734 deletions

239
api/cuser/hub_auth.py Normal file
View 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")

View File

@@ -0,0 +1,57 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
def copiar_rfc_a_m2m(apps, schema_editor):
"""Copia el RFC singular (FK) al lado M2M antes de eliminar el FK."""
CustomUser = apps.get_model('cuser', 'CustomUser')
db_alias = schema_editor.connection.alias
for user in CustomUser.objects.using(db_alias).filter(rfc_old__isnull=False):
user.rfc.add(user.rfc_old)
def revertir_m2m_a_fk(apps, schema_editor):
"""En reversa: toma el primer RFC del M2M y lo pone de vuelta en el FK temporal."""
CustomUser = apps.get_model('cuser', 'CustomUser')
db_alias = schema_editor.connection.alias
for user in CustomUser.objects.using(db_alias).prefetch_related('rfc'):
primer_rfc = user.rfc.first()
if primer_rfc:
user.rfc_old = primer_rfc
user.save(update_fields=['rfc_old'])
class Migration(migrations.Migration):
dependencies = [
('cuser', '0004_alter_customuser_rfc'),
('customs', '0015_partida_updated_at'),
]
operations = [
# 1. Renombrar el FK actual a rfc_old para preservar los datos
migrations.RenameField(
model_name='customuser',
old_name='rfc',
new_name='rfc_old',
),
# 2. Crear el nuevo campo M2M
migrations.AddField(
model_name='customuser',
name='rfc',
field=models.ManyToManyField(
blank=True,
help_text='RFCs de importadores asociados al usuario',
related_name='users',
to='customs.importador',
),
),
# 3. Copiar datos del FK al M2M
migrations.RunPython(copiar_rfc_a_m2m, revertir_m2m_a_fk),
# 4. Eliminar el FK temporal
migrations.RemoveField(
model_name='customuser',
name='rfc_old',
),
]

View File

@@ -0,0 +1,25 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cuser', '0005_customuser_rfc_fk_to_m2m'),
('organization', '0003_organizacion_apply_auto_download'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='active_organization',
field=models.ForeignKey(
blank=True,
help_text='Solo superusuarios: organización activa para contexto de trabajo',
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='superusers_activos',
to='organization.organizacion',
),
),
]

View 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),
),
]

View File

@@ -25,6 +25,9 @@ class CustomUser(AbstractUser):
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
# Identidad Keycloak — se llena con el script de migración masiva
keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub")
def __str__(self):
return self.username

11
api/cuser/sso_urls.py Normal file
View 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
View 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

View File

@@ -0,0 +1,117 @@
"""
Corrige el mismatch de case entre el campo `archivo` en BD y los nombres
reales de los objetos en MinIO.
Causa habitual: transferencia de archivos de producción a local lowercaseó
los filenames, pero la BD conserva los nombres originales con mayúsculas.
Estrategia: para cada Document cuyo `archivo` no exista en MinIO con el
nombre exacto, intenta el filename en minúsculas. Si lo encuentra, actualiza
el campo en BD. Los archivos que ya coinciden no se tocan.
Uso:
python manage.py fix_archivo_case --pedimento <UUID> --dry-run
python manage.py fix_archivo_case --pedimento <UUID>
python manage.py fix_archivo_case --organizacion <UUID> --dry-run
python manage.py fix_archivo_case --organizacion <UUID>
"""
import posixpath
from django.core.management.base import BaseCommand, CommandError
from api.customs.models import Pedimento
from api.record.models import Document
from api.utils.minio_client import minio_client
class Command(BaseCommand):
help = "Corrige mismatch de case entre campo archivo en BD y MinIO."
def add_arguments(self, parser):
parser.add_argument(
"--pedimento", metavar="UUID",
help="UUID del pedimento a corregir.",
)
parser.add_argument(
"--organizacion", metavar="UUID",
help="UUID de la organización.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Solo diagnóstico, sin aplicar cambios.",
)
def handle(self, *args, **options):
ped_id = options.get("pedimento")
org_id = options.get("organizacion")
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING(
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ===\n"
))
qs = Document.objects.all()
if ped_id:
try:
ped = Pedimento.objects.get(id=ped_id)
except Pedimento.DoesNotExist:
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
qs = qs.filter(pedimento=ped)
self.stdout.write(f"Pedimento: {ped.pedimento_app}\n")
elif org_id:
qs = qs.filter(organizacion_id=org_id)
total = qs.count()
self.stdout.write(f"Documentos a revisar: {total}\n")
ok = mismatch = not_found = 0
for doc in qs.iterator(chunk_size=500):
name = doc.archivo.name if doc.archivo else None
if not name:
continue
if minio_client.file_exists(name):
ok += 1
continue
lower_name = self._lower_filename(name)
if lower_name == name:
not_found += 1
continue
if minio_client.file_exists(lower_name):
mismatch += 1
self.stdout.write(
f" {'[DRY]' if dry_run else '[FIX]'} doc {doc.id}:\n"
f" BD : {name}\n"
f" MinIO : {lower_name}\n"
)
if not dry_run:
doc.archivo.name = lower_name
doc.save(update_fields=["archivo"])
else:
not_found += 1
self.stdout.write(
f"\n{'' * 60}\nRESUMEN\n"
f" Coinciden exacto : {ok}\n"
f" Mismatch de case : {mismatch}\n"
f" No encontrados : {not_found}\n"
)
if dry_run and mismatch:
self.stdout.write(self.style.WARNING(
"\nEjecuta sin --dry-run para aplicar los cambios."
))
elif not dry_run and mismatch:
self.stdout.write(self.style.SUCCESS(
f"\n{mismatch} registros actualizados en BD."
))
def _lower_filename(self, name):
"""Lowercase solo el filename, preserva el path del directorio."""
dir_part = posixpath.dirname(name)
filename = posixpath.basename(name)
return posixpath.join(dir_part, filename.lower())

View File

@@ -0,0 +1,382 @@
"""
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
de respuesta VUCEM contienen <tieneError>true</tieneError>.
Convenciones de nomenclatura del microservicio:
- REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml
- ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml
- Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml
Acciones por cada documento con error VUCEM encontrado:
- document_type_id: actual → 18 (PT ERROR)
- archivo: renombrado a vu_PT_{pedimento_app}_{partida}_ERROR.xml
- Partida.descargado: True → False
Criterio de pedimento malformado (cualquiera de):
- aduana: nulo/vacío o len < 3
- numero_operacion: nulo o vacío
- patente: nulo/vacío o len < 4
- pedimento (campo): nulo/vacío o len < 7
Uso:
python manage.py fix_partidas_error --pedimento <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID>
python manage.py fix_partidas_error --dry-run # todas las orgs
"""
import io
import posixpath
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Length
from api.customs.models import Partida, Pedimento
from api.record.models import Document
from api.utils.minio_client import minio_client
_PT_REQUEST = 17
_PT_ERROR = 18
class Command(BaseCommand):
help = "Corrección de partidas descargado=True con respuestas de error VUCEM."
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(
"--pedimento", metavar="UUID",
help="UUID del pedimento a diagnosticar/corregir.",
)
# Filtros de fecha (aplican sobre fecha_pago del pedimento)
parser.add_argument(
"--fecha-desde", metavar="YYYY-MM-DD",
help="Procesar pedimentos con fecha_pago >= esta fecha.",
)
parser.add_argument(
"--fecha-hasta", metavar="YYYY-MM-DD",
help="Procesar pedimentos con fecha_pago <= esta fecha.",
)
# Control de lote
parser.add_argument(
"--offset", type=int, default=0,
help="Saltar los primeros N pedimentos malformados (default: 0).",
)
parser.add_argument(
"--limit", type=int, default=0,
help="Procesar máximo N pedimentos (default: 0 = todos).",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Solo diagnóstico, sin aplicar cambios.",
)
# ------------------------------------------------------------------ #
# Entry point
# ------------------------------------------------------------------ #
def handle(self, *args, **options):
org_id = options.get("organizacion")
ped_id = options.get("pedimento")
fecha_desde = options.get("fecha_desde")
fecha_hasta = options.get("fecha_hasta")
offset = options["offset"]
limit = options["limit"]
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING(
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ni storage ===\n"
))
if ped_id:
self._handle_single(ped_id, dry_run)
return
ped_qs = self._malformed_qs()
if org_id:
ped_qs = ped_qs.filter(organizacion_id=org_id)
if fecha_desde:
ped_qs = ped_qs.filter(fecha_pago__gte=fecha_desde)
if fecha_hasta:
ped_qs = ped_qs.filter(fecha_pago__lte=fecha_hasta)
ped_qs = ped_qs.select_related("organizacion").order_by("fecha_pago", "pedimento_app")
total_sin_filtro = ped_qs.count()
if offset:
ped_qs = ped_qs[offset:]
if limit:
ped_qs = ped_qs[:limit]
total = ped_qs.count() if not (offset or limit) else min(
limit or total_sin_filtro, max(0, total_sin_filtro - offset)
)
self.stdout.write(
f"Pedimentos malformados (total): {total_sin_filtro}\n"
f"Procesando este lote : {total}"
+ (f" [offset={offset}]" if offset else "")
+ (f" [limit={limit}]" if limit else "")
+ "\n"
)
if total == 0:
self.stdout.write(self.style.SUCCESS("Nada que corregir en este lote."))
return
total_partidas = total_docs = 0
for ped in ped_qs:
p, d = self._process_pedimento(ped, dry_run)
total_partidas += p
total_docs += d
self._print_summary(total, total_partidas, total_docs, dry_run)
# ------------------------------------------------------------------ #
# Flujo --pedimento
# ------------------------------------------------------------------ #
def _handle_single(self, ped_id, dry_run):
try:
ped = Pedimento.objects.get(id=ped_id)
except Pedimento.DoesNotExist:
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
checks = self._field_checks(ped)
self._print_ped_diagnosis(ped, checks)
if not any(checks.values()):
return
self._process_pedimento(ped, dry_run)
# ------------------------------------------------------------------ #
# Queryset de pedimentos malformados
# ------------------------------------------------------------------ #
def _malformed_qs(self):
return Pedimento.objects.annotate(
aduana_len=Length("aduana"),
patente_len=Length("patente"),
pedimento_len=Length("pedimento"),
).filter(
Q(aduana__isnull=True) | Q(aduana="") | Q(aduana_len__lt=3)
| Q(numero_operacion__isnull=True) | Q(numero_operacion="")
| Q(patente__isnull=True) | Q(patente="") | Q(patente_len__lt=4)
| Q(pedimento__isnull=True) | Q(pedimento="") | Q(pedimento_len__lt=7)
)
# ------------------------------------------------------------------ #
# Diagnóstico de un pedimento
# ------------------------------------------------------------------ #
def _field_checks(self, ped):
return {
"aduana (debe tener 3 dígitos)": not ped.aduana or len(ped.aduana.strip()) < 3,
"numero_operacion (obligatorio)": not ped.numero_operacion or not ped.numero_operacion.strip(),
"patente (debe tener 4 dígitos)": not ped.patente or len(ped.patente.strip()) < 4,
"pedimento_fld (debe tener 7 dígitos)": not ped.pedimento or len(ped.pedimento.strip()) < 7,
}
def _print_ped_diagnosis(self, ped, checks):
es_malo = any(checks.values())
estado = self.style.ERROR("MALFORMADO") if es_malo else self.style.SUCCESS("VÁLIDO")
self.stdout.write(
f"Pedimento {ped.pedimento_app} (id={ped.id}) → {estado}\n"
f" aduana = {ped.aduana!r} (len={len(ped.aduana or '')})\n"
f" patente = {ped.patente!r} (len={len(ped.patente or '')})\n"
f" numero_op = {ped.numero_operacion!r}\n"
f" pedimento_fld = {ped.pedimento!r} (len={len(ped.pedimento or '')})\n"
)
for campo, malo in checks.items():
marca = self.style.ERROR("") if malo else self.style.SUCCESS("")
self.stdout.write(f" {marca} {campo}")
self.stdout.write("")
# ------------------------------------------------------------------ #
# Procesamiento de un pedimento malformado
# ------------------------------------------------------------------ #
def _process_pedimento(self, ped, dry_run):
self.stdout.write(
f"Pedimento: {ped.pedimento_app} | "
f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}"
)
partidas = Partida.objects.filter(pedimento=ped, descargado=True)
n_partidas = partidas.count()
if n_partidas == 0:
self.stdout.write(" → Sin partidas con descargado=True\n")
return 0, 0
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
total_docs_error = 0
for partida in partidas:
# Documentos de respuesta: excluir REQUEST (17) y los ya marcados ERROR (18)
patron = f"vu_PT_{ped.pedimento_app}_{partida.numero_partida}_"
candidatos = list(
Document.objects.filter(
pedimento=ped,
archivo__icontains=patron,
).exclude(document_type_id__in=[_PT_REQUEST, _PT_ERROR])
)
self.stdout.write(
f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) candidatos a revisar"
)
docs_con_error = []
for doc in candidatos:
# estado: "error" | "ok" | "no_verificable"
estado, motivo = self._check_vucem_error(doc)
if estado == "error":
icono = self.style.ERROR("✗ ERROR VUCEM")
elif estado == "ok":
icono = self.style.SUCCESS("✓ ok")
else:
icono = self.style.WARNING("⚠ sin archivo en storage")
self.stdout.write(f" [{icono}] type={doc.document_type_id} | {doc.archivo.name}")
if estado == "error":
self.stdout.write(f" motivo : {motivo}")
new_name = self._build_error_filename(
doc.archivo.name, ped.pedimento_app, partida.numero_partida, len(docs_con_error)
)
self.stdout.write(f"{new_name}")
docs_con_error.append(doc)
elif estado == "no_verificable":
self.stdout.write(f" {motivo} — ejecuta en producción para verificar")
total_docs_error += len(docs_con_error)
if not dry_run and docs_con_error:
self._apply_fix(partida, docs_con_error, ped.pedimento_app)
self.stdout.write("")
return n_partidas, total_docs_error
# ------------------------------------------------------------------ #
# Detección de error VUCEM en el XML
# ------------------------------------------------------------------ #
def _check_vucem_error(self, doc):
"""
Lee el XML desde MinIO y verifica si VUCEM devolvió un error.
Retorna ("error" | "ok" | "no_verificable", motivo: str | None).
"""
try:
name = doc.archivo.name
if not minio_client.file_exists(name):
return "no_verificable", "archivo no encontrado en storage"
response = minio_client._client.get_object(minio_client._bucket_name, name)
try:
content = response.read()
finally:
response.close()
response.release_conn()
text = content.decode("utf-8", errors="replace")
if "tieneError>true<" in text:
return "error", "tieneError=true detectado en XML"
return "ok", None
except Exception as e:
return "no_verificable", f"excepción al leer archivo: {e}"
# ------------------------------------------------------------------ #
# Construcción del nombre de archivo de error
# ------------------------------------------------------------------ #
def _build_error_filename(self, old_name, pedimento_app, numero_partida, index=0):
"""
Retorna la ruta con nomenclatura de error:
index=0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR.xml
index>0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR_{index}.xml
El índice evita colisión cuando una partida tiene más de un doc con error.
"""
dir_part = posixpath.dirname(old_name)
suffix = f"_{index}" if index > 0 else ""
new_filename = f"vu_PT_{pedimento_app}_{numero_partida}_ERROR{suffix}.xml"
return posixpath.join(dir_part, new_filename)
# ------------------------------------------------------------------ #
# Aplicación de correcciones
# ------------------------------------------------------------------ #
@transaction.atomic
def _apply_fix(self, partida, docs, pedimento_app):
"""
Renombra archivos en storage y actualiza BD dentro de una transacción.
Nota: si la transacción revierte, los cambios en storage NO se deshacen.
"""
for idx, doc in enumerate(docs):
new_name = self._build_error_filename(
doc.archivo.name, pedimento_app, partida.numero_partida, idx
)
final_name = self._rename_in_storage(doc.archivo.name, new_name)
doc.archivo = final_name
doc.document_type_id = _PT_ERROR
doc.vu = True
doc.save(update_fields=["archivo", "document_type_id", "vu"])
self.stdout.write(self.style.SUCCESS(
f" ✓ Doc {doc.id}: type=18 | {final_name}"
))
partida.descargado = False
partida.save(update_fields=["descargado"])
self.stdout.write(self.style.SUCCESS(
f" ✓ Partida {partida.numero_partida}: descargado=False"
))
def _rename_in_storage(self, old_name, new_name):
if old_name == new_name:
return old_name
if minio_client.file_exists(new_name):
# Rename ya ocurrió en ejecución previa parcial
self.stderr.write(self.style.WARNING(
f" ⚠ ERROR ya existe en storage, usando: {new_name}"
))
if minio_client.file_exists(old_name):
minio_client.delete_file(old_name)
return new_name
if not minio_client.file_exists(old_name):
self.stderr.write(self.style.WARNING(
f" ⚠ Archivo no encontrado en storage: {old_name}"
))
return old_name
response = minio_client._client.get_object(minio_client._bucket_name, old_name)
try:
content = response.read()
finally:
response.close()
response.release_conn()
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type="application/xml")
minio_client.delete_file(old_name)
return new_name
# ------------------------------------------------------------------ #
# Resumen final
# ------------------------------------------------------------------ #
def _print_summary(self, total_peds, total_partidas, total_docs, dry_run):
self.stdout.write(
f"\n{'' * 60}\nRESUMEN\n"
f" Pedimentos malformados : {total_peds}\n"
f" Partidas con descargado=True : {total_partidas}\n"
f" Documentos con error VUCEM : {total_docs}\n"
)
if dry_run:
self.stdout.write(self.style.WARNING(
"\nMODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
))
else:
self.stdout.write(self.style.SUCCESS("\nCorrección completada."))

View File

@@ -0,0 +1,50 @@
# Generated by Django 5.2.3 on 2026-01-16 00:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customs', '0016_alter_pedimento_unique_together'),
('organization', '0002_remove_organizacion_membretado_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BulkUploadTask',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('contribuyente', models.CharField(blank=True, max_length=255, null=True)),
('status', models.CharField(choices=[('pending', 'Pendiente'), ('processing', 'Procesando'), ('completed', 'Completado'), ('failed', 'Fallido'), ('partial', 'Parcialmente completado')], default='pending', max_length=20)),
('task_type', models.CharField(default='bulk_create', max_length=50)),
('total_files', models.IntegerField(default=0)),
('processed_files', models.IntegerField(default=0)),
('created_pedimentos', models.IntegerField(default=0)),
('created_documents', models.IntegerField(default=0)),
('result', models.JSONField(blank=True, default=dict)),
('failed_files', models.JSONField(blank=True, default=list)),
('error_message', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('started_at', models.DateTimeField(blank=True, null=True)),
('finished_at', models.DateTimeField(blank=True, null=True)),
('fecha_pago', models.DateField(blank=True, null=True)),
('clave_pedimento', models.CharField(blank=True, max_length=50, null=True)),
('tipo_operacion_id', models.IntegerField(blank=True, null=True)),
('curp_apoderado', models.CharField(blank=True, max_length=50, null=True)),
('partidas', models.IntegerField(default=0)),
('celery_task_id', models.CharField(blank=True, max_length=255, null=True)),
('organizacion', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='organization.organizacion')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bulk_upload_tasks', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Tarea de Carga Masiva',
'verbose_name_plural': 'Tareas de Carga Masiva',
'db_table': 'bulk_upload_task',
'ordering': ['-created_at'],
},
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.3 on 2026-03-06 19:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('customs', '0017_bulkuploadtask'),
('organization', '0002_remove_organizacion_membretado_and_more'),
]
operations = [
migrations.AlterUniqueTogether(
name='pedimento',
unique_together={('organizacion', 'pedimento_app')},
),
migrations.DeleteModel(
name='BulkUploadTask',
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-05-19 14:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customs', '0018_alter_pedimento_unique_together_and_more'),
]
operations = [
migrations.AddField(
model_name='pedimento',
name='consultar_vucem',
field=models.BooleanField(default=False, help_text='Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente'),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,477 @@
"""
Tarea Celery: auto-corrección de pedimentos incompletos a partir de sus XMLs.
Busca pedimentos con consultar_vucem=False, analiza su documento XML más reciente
en busca de una respuesta consultarPedimentoCompleto de VUCEM, y si el número de
pedimento coincide, auto-corrige los campos faltantes en BD y reclasifica el documento.
Campos corregidos (solo si están vacíos/nulos en BD):
numero_operacion, aduana, clave_pedimento, regimen, contribuyente (por RFC).
Acciones sobre el documento si el tipo no es 2 (Pedimento Completo):
- Renombra el archivo en MinIO: vu_PC_{pedimento_app}.xml
- Actualiza document_type_id → 2
- Actualiza vu → False (tipo 2 no es VUCEM directo)
Al finalizar activa consultar_vucem=True en el pedimento.
"""
import io
import logging
import posixpath
import xml.etree.ElementTree as ET
from celery import shared_task
from django.db import transaction
from api.customs.models import Importador, Pedimento, Regimen
from api.record.models import Document
from api.utils.minio_client import minio_client
from core.redis_events import publish_task_event
logger = logging.getLogger('api.customs.tasks.auto_corregir')
_DOC_TYPE_PC = 2 # Pedimento Completo (ya procesado — no volver a procesar)
_PROGRESS_INTERVAL = 10 # Emitir progreso cada N pedimentos
# Tipos excluidos de la búsqueda:
# 1 = Pedimento Partida (no contiene respuesta PC)
# 2 = Pedimento Completo (ya procesado)
# 1326 = Tipos VUCEM: requests, errors de VU (peticiones salientes, no respuestas de contenido)
_EXCLUDE_DOC_TYPES = frozenset(range(13, 27)) | {1, _DOC_TYPE_PC}
# ──────────────────────────────────────────────
# Helpers XML (namespace-agnostic)
# ──────────────────────────────────────────────
def _local(tag):
return tag.split('}')[-1] if '}' in tag else tag
def _find_text(root, local_name):
"""Primer elemento con ese nombre local; retorna su texto o None."""
for el in root.iter():
if _local(el.tag) == local_name:
text = (el.text or '').strip()
return text or None
return None
def _find_child_text(root, parent_name, child_name):
"""Texto del hijo directo child_name dentro del primer parent_name encontrado."""
for el in root.iter():
if _local(el.tag) == parent_name:
for child in el:
if _local(child.tag) == child_name:
text = (child.text or '').strip()
return text or None
return None
def _find_pedimento_number(root):
"""
Extrae el número de pedimento de la estructura anidada:
<ns2:pedimento> ← contenedor
<ns2:pedimento>XXXX</ns2:pedimento> ← número
"""
for el in root.iter():
if _local(el.tag) == 'pedimento':
for child in el:
if _local(child.tag) == 'pedimento':
text = (child.text or '').strip()
return text or None
return None
# ──────────────────────────────────────────────
# Helpers MinIO
# ──────────────────────────────────────────────
def _read_from_minio(object_name):
if not minio_client.file_exists(object_name):
return None
response = minio_client._client.get_object(minio_client._bucket_name, object_name)
try:
return response.read()
finally:
response.close()
response.release_conn()
def _rename_in_minio(old_name, new_name, content):
if old_name == new_name:
return old_name
# Si ya existe en destino (ejecución previa parcial): limpiar origen
if minio_client.file_exists(new_name):
if minio_client.file_exists(old_name):
minio_client.delete_file(old_name)
return new_name
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type='application/xml')
minio_client.delete_file(old_name)
return new_name
def _resolve_regimen(clave_pedimento, tipo_operacion_raw):
"""
Convierte clave_documento + tipo_operacion del XML al código de régimen,
replicando la lógica de carga de datastage:
Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).regimenped
"""
if not clave_pedimento or not tipo_operacion_raw:
return None
try:
tipo_int = int(tipo_operacion_raw)
except (ValueError, TypeError):
return None
regimen_obj = Regimen.objects.filter(claveped=clave_pedimento, tipo=tipo_int).first()
return regimen_obj.regimenped if regimen_obj else None
def _find_pc_document(pedimento):
"""
Busca entre los XMLs del pedimento el primero que contenga una respuesta
consultarPedimentoCompleto de VUCEM.
Tipos incluidos: 312 (documentos de contenido: pedimento, remesas, acuse,
edocument, estado, cove, digitalizacion, error, general).
Tipos excluidos: 1 (partida), 2 (ya procesado), 1326 (peticiones/errores VU).
Retorna (doc, content_bytes, object_name, hay_candidatos):
- hay_candidatos=False → ningún XML candidato en BD
- hay_candidatos=True, doc=None → hay XMLs pero ninguno es respuesta PC
- doc!=None → encontrado
"""
qs = (
Document.objects.filter(
pedimento=pedimento,
archivo__iendswith='.xml',
)
.exclude(document_type_id__in=_EXCLUDE_DOC_TYPES)
.order_by('-created_at')
)
hay_candidatos = False
for doc in qs:
if not doc.archivo:
continue
hay_candidatos = True
object_name = doc.archivo.name
try:
content = _read_from_minio(object_name)
except Exception as exc:
logger.debug(f"[find_pc] {pedimento.pedimento_app} — error MinIO {object_name}: {exc}")
continue
if not content:
continue
if b'consultarPedimentoCompletoRespuesta' in content:
return doc, content, object_name, True
return None, None, None, hay_candidatos
# ──────────────────────────────────────────────
# Tarea principal
# ──────────────────────────────────────────────
@shared_task(bind=True, name='auto_corregir_pedamentos')
def auto_corregir_pedamentos_task(self, organizacion_id, pedimento_id=None):
"""
Itera pedimentos con consultar_vucem=False de la organización.
Si se proporciona pedimento_id, procesa solo ese pedimento.
Por cada uno verifica si tiene un XML de pedimento completo válido
y corrige BD + storage.
"""
task_id = self.request.id
revisados = 0
corregidos = 0
ignorados = 0
detalles = []
qs = Pedimento.objects.filter(consultar_vucem=False).order_by('pedimento_app')
if pedimento_id:
qs = qs.filter(id=pedimento_id)
else:
qs = qs.filter(organizacion_id=organizacion_id)
total = qs.count()
logger.info(f"[auto_corregir] org={organizacion_id}{total} pedimentos a revisar")
publish_task_event(task_id, 'processing', f'Iniciando: {total} pedimentos a revisar', progress=0)
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
revisados += 1
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
pct = int(((idx + 1) / total) * 95)
publish_task_event(
task_id, 'processing',
f'Revisando {idx + 1}/{total}: {pedimento.pedimento_app}',
progress=pct,
)
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
try:
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
except Exception as exc:
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — error buscando PC: {exc}")
ignorados += 1
continue
if not candidato:
ignorados += 1
continue
try:
root = ET.fromstring(content)
except ET.ParseError as exc:
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — XML inválido: {exc}")
ignorados += 1
continue
tiene_error = _find_text(root, 'tieneError')
if tiene_error and tiene_error.lower() == 'true':
ignorados += 1
continue
pedimento_xml = _find_pedimento_number(root)
pedimento_bd = (pedimento.pedimento or '').strip()
if not pedimento_xml or pedimento_xml != pedimento_bd:
logger.info(
f"[auto_corregir] {pedimento.pedimento_app} — número no coincide "
f"(XML={pedimento_xml!r}, BD={pedimento_bd!r})"
)
ignorados += 1
continue
# ── Extracción de campos ──────────────────
numero_operacion = _find_text(root, 'numeroOperacion')
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
ped_fields = []
if numero_operacion and not pedimento.numero_operacion:
pedimento.numero_operacion = numero_operacion
ped_fields.append('numero_operacion')
if aduana and aduana != (pedimento.aduana or '').strip():
pedimento.aduana = aduana
ped_fields.append('aduana')
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
pedimento.clave_pedimento = clave_pedimento
ped_fields.append('clave_pedimento')
if regimen and not pedimento.regimen:
pedimento.regimen = regimen
ped_fields.append('regimen')
if rfc:
try:
importador = Importador.objects.get(rfc=rfc)
if pedimento.contribuyente_id != importador.rfc:
pedimento.contribuyente_id = importador.rfc
ped_fields.append('contribuyente')
except Importador.DoesNotExist:
pass
pedimento.consultar_vucem = True
ped_fields.append('consultar_vucem')
# ── Renombrado de documento si no es tipo 2 ──
doc_fields = ['document_type_id', 'vu']
final_object_name = object_name
if candidato.document_type_id != _DOC_TYPE_PC:
dir_part = posixpath.dirname(object_name)
new_filename = f"vu_PC_{pedimento.pedimento_app}.xml"
new_object_name = posixpath.join(dir_part, new_filename)
try:
final_object_name = _rename_in_minio(object_name, new_object_name, content)
doc_fields.append('archivo')
except Exception as exc:
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error renombrando en MinIO: {exc}")
# ── Persistir cambios en BD ───────────────
try:
with transaction.atomic():
pedimento.save(update_fields=ped_fields)
candidato.document_type_id = _DOC_TYPE_PC
candidato.vu = False
if 'archivo' in doc_fields:
candidato.archivo = final_object_name
candidato.save(update_fields=doc_fields)
except Exception as exc:
logger.error(f"[auto_corregir] {pedimento.pedimento_app} — error guardando en BD: {exc}")
ignorados += 1
continue
corregidos += 1
detalles.append({
'pedimento': pedimento.pedimento_app,
'accion': 'corregido',
'campos_pedimento': ped_fields,
'documento_final': final_object_name,
})
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — corregido: {ped_fields}")
# Modo individual: encolar el procesamiento completo (remesas, partidas,
# coves, edocs) forzando aunque ya exista el documento tipo 2.
if pedimento_id:
try:
from .microservice_v2 import procesar_pedimento_completo_individual
procesar_pedimento_completo_individual.delay(str(pedimento.id), force=True)
logger.info(f"[auto_corregir] {pedimento.pedimento_app} — PC completo encolado (force)")
except Exception as exc:
logger.warning(f"[auto_corregir] {pedimento.pedimento_app} — no se pudo encolar PC: {exc}")
resultado = {
'total_revisados': revisados,
'corregidos': corregidos,
'ignorados': ignorados,
'detalles': detalles,
}
logger.info(f"[auto_corregir] org={organizacion_id} finalizado — {resultado}")
publish_task_event(task_id, 'completed', 'Auto-corrección finalizada', resultado=resultado, progress=100)
return resultado
# ──────────────────────────────────────────────
# Tarea de análisis (sin modificar nada)
# ──────────────────────────────────────────────
def _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc):
"""Retorna la lista de campos que se corregirían y los valores que se asignarían."""
campos = []
if numero_operacion and not pedimento.numero_operacion:
campos.append({'campo': 'numero_operacion', 'valor_actual': None, 'valor_nuevo': numero_operacion})
if aduana and aduana != (pedimento.aduana or '').strip():
campos.append({'campo': 'aduana', 'valor_actual': pedimento.aduana, 'valor_nuevo': aduana})
if clave_pedimento and clave_pedimento != (pedimento.clave_pedimento or '').strip():
campos.append({'campo': 'clave_pedimento', 'valor_actual': pedimento.clave_pedimento, 'valor_nuevo': clave_pedimento})
if regimen and not pedimento.regimen:
campos.append({'campo': 'regimen', 'valor_actual': None, 'valor_nuevo': regimen})
if rfc:
try:
importador = Importador.objects.get(rfc=rfc)
if pedimento.contribuyente_id != importador.rfc:
campos.append({
'campo': 'contribuyente',
'valor_actual': pedimento.contribuyente_id,
'valor_nuevo': rfc,
})
except Importador.DoesNotExist:
pass
return campos
@shared_task(bind=True, name='auditar_pedamentos_incompletos')
def auditar_pedamentos_incompletos_task(self, organizacion_id, pedimento_id=None):
"""
Análisis de solo lectura: reporta qué pedimentos serían corregidos y qué
cambios se aplicarían, sin modificar BD ni storage.
Si se proporciona pedimento_id, analiza solo ese pedimento.
"""
task_id = self.request.id
revisados = 0
corregibles = []
sin_xml = 0
xml_sin_pc = 0
num_no_coincide = 0
con_error_vucem = 0
# Individual: analiza el pedimento específico sin importar su estado de corrección.
# Masivo: solo los pendientes (consultar_vucem=False).
if pedimento_id:
qs = Pedimento.objects.filter(id=pedimento_id).order_by('pedimento_app')
else:
qs = Pedimento.objects.filter(
organizacion_id=organizacion_id, consultar_vucem=False
).order_by('pedimento_app')
total = qs.count()
logger.info(f"[auditar_incompletos] org={organizacion_id}{total} pedimentos a analizar")
publish_task_event(task_id, 'processing', f'Iniciando análisis: {total} pedimentos', progress=0)
for idx, pedimento in enumerate(qs.iterator(chunk_size=100)):
revisados += 1
if total > 0 and (idx % _PROGRESS_INTERVAL == 0 or idx == total - 1):
pct = int(((idx + 1) / total) * 95)
publish_task_event(
task_id, 'processing',
f'Analizando {idx + 1}/{total}: {pedimento.pedimento_app}',
progress=pct,
)
# Buscar XML con respuesta de pedimento completo (evalúa todos, VUCEM primero)
try:
candidato, content, object_name, hay_candidatos = _find_pc_document(pedimento)
except Exception as exc:
logger.warning(f"[auditar_incompletos] {pedimento.pedimento_app} — error buscando PC: {exc}")
sin_xml += 1
continue
if not candidato:
if hay_candidatos:
xml_sin_pc += 1
else:
sin_xml += 1
continue
try:
root = ET.fromstring(content)
except ET.ParseError:
xml_sin_pc += 1
continue
tiene_error = _find_text(root, 'tieneError')
if tiene_error and tiene_error.lower() == 'true':
con_error_vucem += 1
continue
pedimento_xml = _find_pedimento_number(root)
pedimento_bd = (pedimento.pedimento or '').strip()
if not pedimento_xml or pedimento_xml != pedimento_bd:
num_no_coincide += 1
continue
numero_operacion = _find_text(root, 'numeroOperacion')
aduana = _find_child_text(root, 'aduanaEntradaSalida', 'clave')
clave_pedimento = _find_child_text(root, 'claveDocumento', 'clave')
tipo_operacion_raw = _find_child_text(root, 'tipoOperacion', 'clave')
regimen = _resolve_regimen(clave_pedimento, tipo_operacion_raw)
rfc = _find_child_text(root, 'importadorExportador', 'rfc')
campos = _campos_a_corregir(pedimento, numero_operacion, aduana, clave_pedimento, regimen, rfc)
dir_part = posixpath.dirname(object_name)
nombre_pc = posixpath.join(dir_part, f"vu_PC_{pedimento.pedimento_app}.xml")
corregibles.append({
'pedimento_app': pedimento.pedimento_app,
'pedimento_id': str(pedimento.id),
'documento_actual': {
'id': str(candidato.id),
'archivo': object_name,
'document_type_id': candidato.document_type_id,
},
'documento_nuevo_nombre': nombre_pc if candidato.document_type_id != _DOC_TYPE_PC else None,
'campos_a_corregir': campos,
'consultar_vucem': True,
})
resultado = {
'total_revisados': revisados,
'corregibles': len(corregibles),
'sin_xml_o_ilegible': sin_xml,
'xml_no_es_pedimento_completo': xml_sin_pc,
'numero_pedimento_no_coincide': num_no_coincide,
'con_error_vucem': con_error_vucem,
'pedimentos': corregibles,
}
logger.info(f"[auditar_incompletos] org={organizacion_id} finalizado — {resultado}")
publish_task_event(task_id, 'completed', 'Análisis finalizado', resultado=resultado, progress=100)
return resultado

View File

@@ -1,6 +1,9 @@
import logging
from celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller
from core.redis_events import publish_task_event
from api.customs.tasks.auditoria import _crear_notificacion_auditoria
from api.customs.tasks.microservice import (
procesar_cove_individual,
procesar_acuse_individual,
@@ -180,13 +183,24 @@ def crear_servicios(organizacion_id):
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
@shared_task
def auditar_pedimentos(organizacion_id):
@shared_task(bind=True)
def auditar_pedimentos(self, organizacion_id, user_id=None):
_logger = logging.getLogger('api.customs.async_operations')
task_id = self.request.id
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos:
total_pedimentos = pedimentos.count()
publish_task_event(task_id, "processing", f"Auditando pedimentos: {total_pedimentos} pedimentos", progress=0)
procesados = 0
sin_xml = 0
errores = []
for idx, pedimento in enumerate(pedimentos):
pc = pedimento.documents.filter(document_type__id=2).first()
if pc:
try:
with open(f'./media/{pc.archivo}', 'r') as f:
xml_content = f.read()
@@ -201,7 +215,7 @@ def auditar_pedimentos(organizacion_id):
pedimento.fecha_pago = xml_data.get('fecha_pago')
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
for edoc in xml_data.get('edocuments', []):
for edoc in xml_data.get('identificadores_ed', []):
EDocument.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
@@ -223,9 +237,35 @@ def auditar_pedimentos(organizacion_id):
# Si ya existe por unique, recupera el objeto existente
Cove.objects.get(numero_cove=cove)
except:
# Si ya existe por unique, recupera el objeto existente
pass
procesados += 1
except Exception as e:
errores.append({'pedimento_id': str(pedimento.id), 'error': str(e)})
_logger.error(f"Error auditando pedimento {pedimento.id}: {e}")
else:
sin_xml += 1
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
pct = int(((idx + 1) / total_pedimentos) * 100)
publish_task_event(task_id, "processing", f"Auditando pedimentos: {idx + 1}/{total_pedimentos}", progress=pct)
resultado = {
'organizacion_id': str(organizacion_id),
'auditoria': 'pedimentos',
'total_pedimentos': total_pedimentos,
'procesados': procesados,
'sin_xml': sin_xml,
'con_errores': len(errores),
'detalle_errores': errores,
}
publish_task_event(task_id, "completed", "Auditoría de pedimentos completada", resultado=resultado, progress=100)
if user_id:
_crear_notificacion_auditoria(user_id, task_id, "Pedimentos", resultado)
return resultado
@shared_task
def crear_todos_los_servicios():
from organization.models import Organizacion

View File

@@ -256,9 +256,9 @@ def procesar_remesas_pedimento(pedimento_id):
raise
@shared_task
def procesar_pedimento_completo_individual(pedimento_id):
def procesar_pedimento_completo_individual(pedimento_id, force=False):
pedimento = Pedimento.objects.get(id=pedimento_id)
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id

View File

@@ -63,6 +63,26 @@ from .views_auditor import (
auditor_obtener_respuesta_edocument_vu,
auditar_pedimento_endpoint,
procesar_pedimento_completo_endpoint,
auto_corregir_pedamentos_endpoint,
auditar_pedamentos_incompletos_endpoint,
auditar_pedamento_incompleto_endpoint,
auto_corregir_pedamento_endpoint,
auditar_integridad_partidas_endpoint,
auditar_integridad_partidas_pedimento_endpoint,
auditar_integridad_edocuments_endpoint,
auditar_integridad_edocuments_pedimento_endpoint,
auditar_integridad_coves_endpoint,
auditar_integridad_coves_pedimento_endpoint,
auditar_integridad_remesa_endpoint,
auditar_integridad_remesa_pedimento_endpoint,
corregir_integridad_partidas_endpoint,
corregir_integridad_partidas_pedimento_endpoint,
corregir_integridad_edocuments_endpoint,
corregir_integridad_edocuments_pedimento_endpoint,
corregir_integridad_coves_endpoint,
corregir_integridad_coves_pedimento_endpoint,
corregir_integridad_remesa_endpoint,
corregir_integridad_remesa_pedimento_endpoint,
)
urlpatterns = [
@@ -82,6 +102,10 @@ urlpatterns = [
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
path('auditor/auto-corregir-pedamentos/', auto_corregir_pedamentos_endpoint, name='auto-corregir-pedamentos'),
path('auditor/auditar-pedamentos-incompletos/', auditar_pedamentos_incompletos_endpoint, name='auditar-pedamentos-incompletos'),
path('auditor/auto-corregir-pedamento/', auto_corregir_pedamento_endpoint, name='auto-corregir-pedamento'),
path('auditor/auditar-pedamento-incompleto/', auditar_pedamento_incompleto_endpoint, name='auditar-pedamento-incompleto'),
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
@@ -103,4 +127,22 @@ urlpatterns = [
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
path('auditor/auditar-integridad-partidas/', auditar_integridad_partidas_endpoint, name='auditar-integridad-partidas'),
path('auditor/auditar-integridad-partidas/pedimento/', auditar_integridad_partidas_pedimento_endpoint, name='auditar-integridad-partidas-pedimento'),
path('auditor/auditar-integridad-edocuments/', auditar_integridad_edocuments_endpoint, name='auditar-integridad-edocuments'),
path('auditor/auditar-integridad-edocuments/pedimento/', auditar_integridad_edocuments_pedimento_endpoint, name='auditar-integridad-edocuments-pedimento'),
path('auditor/auditar-integridad-coves/', auditar_integridad_coves_endpoint, name='auditar-integridad-coves'),
path('auditor/auditar-integridad-coves/pedimento/', auditar_integridad_coves_pedimento_endpoint, name='auditar-integridad-coves-pedimento'),
path('auditor/auditar-integridad-remesa/', auditar_integridad_remesa_endpoint, name='auditar-integridad-remesa'),
path('auditor/auditar-integridad-remesa/pedimento/', auditar_integridad_remesa_pedimento_endpoint, name='auditar-integridad-remesa-pedimento'),
path('auditor/corregir-integridad-partidas/', corregir_integridad_partidas_endpoint, name='corregir-integridad-partidas'),
path('auditor/corregir-integridad-partidas/pedimento/', corregir_integridad_partidas_pedimento_endpoint, name='corregir-integridad-partidas-pedimento'),
path('auditor/corregir-integridad-edocuments/', corregir_integridad_edocuments_endpoint, name='corregir-integridad-edocuments'),
path('auditor/corregir-integridad-edocuments/pedimento/', corregir_integridad_edocuments_pedimento_endpoint, name='corregir-integridad-edocuments-pedimento'),
path('auditor/corregir-integridad-coves/', corregir_integridad_coves_endpoint, name='corregir-integridad-coves'),
path('auditor/corregir-integridad-coves/pedimento/', corregir_integridad_coves_pedimento_endpoint, name='corregir-integridad-coves-pedimento'),
path('auditor/corregir-integridad-remesa/', corregir_integridad_remesa_endpoint, name='corregir-integridad-remesa'),
path('auditor/corregir-integridad-remesa/pedimento/', corregir_integridad_remesa_pedimento_endpoint, name='corregir-integridad-remesa-pedimento'),
]

View File

@@ -257,12 +257,16 @@ class PedimentoFilter(django_filters.FilterSet):
# Rango de fecha de pago: ?fecha_pago_desde=YYYY-MM-DD&fecha_pago_hasta=YYYY-MM-DD
fecha_pago_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte')
fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte')
# CharFilter directo sobre contribuyente_id (RFC). ModelChoiceFilter silenciosamente
# omite el filtro cuando el RFC no existe, lo que causa fuga de pedimentos de otros
# importadores. Con CharFilter+exact: RFC inválido → cero resultados, nunca fuga.
contribuyente = django_filters.CharFilter(field_name='contribuyente', lookup_expr='exact')
class Meta:
model = Pedimento
fields = [
'patente', 'aduana', 'tipo_operacion', 'clave_pedimento',
'pedimento', 'existe_expediente', 'contribuyente',
'pedimento', 'existe_expediente',
'curp_apoderado', 'fecha_pago', 'pedimento_app',
]
class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion
@@ -1198,18 +1202,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
status=status.HTTP_400_BAD_REQUEST
)
# Validar organización del usuario
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
# Validar organización del usuario (superuser usa active_organization)
from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response(
{
"tieneError": True,
"error": "Usuario no autenticado o sin organización"
"error": "Usuario no autenticado o sin organización asignada"
},
status=status.HTTP_400_BAD_REQUEST
)
organizacion = request.user.organizacion
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
@@ -1744,18 +1748,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
partidas_input = request.data.get('partidas')
fuente_archivos = request.data.get('partidas')
# Validar organización del usuario
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
# Validar organización del usuario (superuser usa active_organization)
from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response(
{
"tieneError": True,
"error": "Usuario no autenticado o sin organización"
"error": "Usuario no autenticado o sin organización asignada"
},
status=status.HTTP_400_BAD_REQUEST
)
organizacion = request.user.organizacion
# Regex para validar nomenclatura: anio-aduana-patente-pedimento
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
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
)
# Validar organización del usuario
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
# Validar organización del usuario (superuser usa active_organization)
from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response(
{'tieneError': True,
"mensaje": "Usuario no autenticado o sin organización"},
"mensaje": "Usuario no autenticado o sin organización asignada"},
status=status.HTTP_400_BAD_REQUEST
)
organizacion = request.user.organizacion
# Preparar parámetros
parametros = {
'contribuyente': request.data.get('contribuyente'),

View File

@@ -13,9 +13,26 @@ from .tasks.auditoria import (
auditar_edocuments,
auditar_acuse,
auditar_remesas,
auditar_integridad_partidas,
auditar_integridad_partidas_por_pedimento,
auditar_integridad_edocuments,
auditar_integridad_edocuments_por_pedimento,
auditar_integridad_coves,
auditar_integridad_coves_por_pedimento,
auditar_integridad_remesa,
auditar_integridad_remesa_por_pedimento,
corregir_integridad_partidas,
corregir_integridad_edocuments,
corregir_integridad_coves,
corregir_integridad_remesa,
_corregir_integridad_partidas_pedimento,
_corregir_integridad_edocuments_pedimento,
_corregir_integridad_coves_pedimento,
_corregir_integridad_remesa_pedimento,
)
from .tasks.internal_services import auditar_pedimentos
from .tasks.microservice_v2 import procesar_pedimentos_completos, procesar_pedimento_completo_individual
from .tasks.auto_corregir import auto_corregir_pedamentos_task, auditar_pedamentos_incompletos_task
from api.customs.models import Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
@@ -98,7 +115,7 @@ def crear_partidas_organizacion(request):
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
task = crear_partidas.delay(organizacion_id)
task = crear_partidas.delay(organizacion_id, user_id=str(user.id))
return Response({
'organizacion_id': organizacion_id,
@@ -228,7 +245,7 @@ def auditar_pedimentos_endpoint(request):
)
# Ejecutar la tarea de auditoría
task = auditar_pedimentos.delay(organizacion_id)
task = auditar_pedimentos.delay(organizacion_id, user_id=str(user.id))
message = f"Auditoría iniciada para la organización {organizacion_id}"
return Response({
@@ -309,7 +326,7 @@ def auditar_procesamiento_remesa_pedimento_endpoint(request):
def _lanzar_auditoria_organizacion(request, task_fn, label):
"""Helper compartido para los 4 endpoints de auditoría masiva por organización."""
"""Helper compartido para los endpoints de auditoría masiva por organización."""
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response({'error': 'Debe proporcionar organizacion_id'}, status=status.HTTP_400_BAD_REQUEST)
@@ -318,12 +335,12 @@ def _lanzar_auditoria_organizacion(request, task_fn, label):
if not user.is_superuser and str(user.organizacion.id) != organizacion_id:
return Response({'error': 'No tiene permisos para esta organización'}, status=status.HTTP_403_FORBIDDEN)
task = task_fn.delay(organizacion_id)
task = task_fn.delay(organizacion_id, user_id=str(user.id))
return Response({
'organizacion_id': organizacion_id,
'auditoria': label,
'task_id': task.id,
'mensaje': f'Auditoría de {label} iniciada. Consulta el resultado en GET /api/tasks/status/{task.id}/',
'mensaje': f'Auditoría de {label} iniciada. Usa el stream SSE para seguimiento en tiempo real.',
}, status=status.HTTP_202_ACCEPTED)
@@ -1973,12 +1990,14 @@ def procesar_pedimento_completo_endpoint(request):
'pc_descargado': pc_descargado,
}
# Ya descargado — devolver diagnóstico sin encolar
if pc_descargado:
force = bool(request.data.get('force', False))
# Ya descargado — solo bloquear si no es forzado
if pc_descargado and not force:
return Response({
**base_response,
'estado': 'ya_descargado',
'mensaje': 'El pedimento completo ya fue descargado',
'mensaje': 'El pedimento completo ya fue descargado. Usa force=true para reprocesar remesas, partidas y documentos derivados.',
}, status=status.HTTP_200_OK)
# No puede procesar — devolver diagnóstico con razones
@@ -1990,7 +2009,7 @@ def procesar_pedimento_completo_endpoint(request):
}, status=status.HTTP_200_OK)
# Todo en orden — encolar
task = procesar_pedimento_completo_individual.delay(str(pedimento_id))
task = procesar_pedimento_completo_individual.delay(str(pedimento_id), force=force)
logger.info(f"Procesamiento PC encolado: {pedimento.pedimento} (task={task.id})")
return Response({
@@ -2096,3 +2115,597 @@ def actualizar_info_pedimento(pedimento, info_xml):
except Exception:
return False
# ──────────────────────────────────────────────────────────────────────────────
# Auto-corrección de pedimentos incompletos
# ──────────────────────────────────────────────────────────────────────────────
@swagger_auto_schema(
method='post',
operation_description=(
"Encola una tarea Celery que analiza los XMLs de pedimentos con "
"consultar_vucem=False, extrae datos del pedimento completo VUCEM y "
"auto-corrige los campos faltantes (numero_operacion, aduana, "
"clave_pedimento, regimen, contribuyente). El documento se reclasifica "
"a tipo 2 (Pedimento Completo) y se activa consultar_vucem=True."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['organizacion_id'],
properties={
'organizacion_id': openapi.Schema(
type=openapi.TYPE_STRING,
description='UUID de la organización a procesar',
),
},
),
responses={
202: openapi.Response('Tarea encolada correctamente'),
400: openapi.Response('organizacion_id faltante'),
404: openapi.Response('Organización no encontrada'),
},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auto_corregir_pedamentos_endpoint(request):
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'organizacion_id es requerido'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
Organizacion.objects.get(id=organizacion_id)
except Organizacion.DoesNotExist:
return Response(
{'error': 'Organización no encontrada'},
status=status.HTTP_404_NOT_FOUND,
)
task = auto_corregir_pedamentos_task.delay(str(organizacion_id))
logger.info(
f"[auto_corregir] tarea encolada — org={organizacion_id} task={task.id}"
)
return Response(
{
'task_id': task.id,
'organizacion': str(organizacion_id),
'mensaje': (
'Tarea encolada. Se analizarán los pedimentos con '
'consultar_vucem=False de la organización.'
),
},
status=status.HTTP_202_ACCEPTED,
)
@swagger_auto_schema(
method='post',
operation_description=(
"Análisis de solo lectura: detecta pedimentos con consultar_vucem=False "
"que podrían corregirse automáticamente. No modifica BD ni storage. "
"Retorna el listado de pedimentos corregibles, los campos que cambiarían "
"y el nuevo nombre de documento que se asignaría."
),
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['organizacion_id'],
properties={
'organizacion_id': openapi.Schema(
type=openapi.TYPE_STRING,
description='UUID de la organización a analizar',
),
},
),
responses={
202: openapi.Response('Tarea de análisis encolada correctamente'),
400: openapi.Response('organizacion_id faltante'),
404: openapi.Response('Organización no encontrada'),
},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_pedamentos_incompletos_endpoint(request):
organizacion_id = request.data.get('organizacion_id')
if not organizacion_id:
return Response(
{'error': 'organizacion_id es requerido'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
Organizacion.objects.get(id=organizacion_id)
except Organizacion.DoesNotExist:
return Response(
{'error': 'Organización no encontrada'},
status=status.HTTP_404_NOT_FOUND,
)
task = auditar_pedamentos_incompletos_task.delay(str(organizacion_id))
logger.info(
f"[auditar_incompletos] tarea encolada — org={organizacion_id} task={task.id}"
)
return Response(
{
'task_id': task.id,
'organizacion': str(organizacion_id),
'mensaje': (
'Tarea de análisis encolada. Se reportarán los pedimentos con '
'consultar_vucem=False que podrían corregirse automáticamente, '
'sin modificar nada.'
),
},
status=status.HTTP_202_ACCEPTED,
)
@swagger_auto_schema(
method='post',
operation_description="Analiza un pedimento específico para auto-corrección (solo lectura, sin modificar BD). Acepta pedimento_id (UUID) o pedimento_app.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='UUID del pedimento'),
'pedimento_app': openapi.Schema(type=openapi.TYPE_STRING, description='Número de pedimento (ej: 21-80-3452-1004463)'),
},
),
responses={
202: openapi.Response('Tarea de análisis encolada'),
400: openapi.Response('Parámetro faltante'),
404: openapi.Response('Pedimento no encontrado'),
},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_pedamento_incompleto_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
pedimento_app = request.data.get('pedimento_app')
if not pedimento_id and not pedimento_app:
return Response({'error': 'pedimento_id o pedimento_app es requerido'}, status=status.HTTP_400_BAD_REQUEST)
try:
if pedimento_id:
pedimento = Pedimento.objects.get(id=pedimento_id)
else:
pedimento = Pedimento.objects.get(pedimento_app=pedimento_app)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auditar_pedamentos_incompletos_task.delay(str(pedimento.organizacion_id), str(pedimento.id))
logger.info(f"[auditar_incompletos] individual — ped={pedimento.pedimento_app} task={task.id}")
return Response(
{
'task_id': task.id,
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento_app,
'mensaje': 'Análisis individual encolado. Sin modificar nada.',
},
status=status.HTTP_202_ACCEPTED,
)
@swagger_auto_schema(
method='post',
operation_description="Auto-corrige un pedimento específico: extrae campos del XML y actualiza BD + storage. Acepta pedimento_id (UUID) o pedimento_app.",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'pedimento_id': openapi.Schema(type=openapi.TYPE_STRING, description='UUID del pedimento'),
'pedimento_app': openapi.Schema(type=openapi.TYPE_STRING, description='Número de pedimento (ej: 21-80-3452-1004463)'),
},
),
responses={
202: openapi.Response('Tarea de corrección encolada'),
400: openapi.Response('Parámetro faltante'),
404: openapi.Response('Pedimento no encontrado'),
},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auto_corregir_pedamento_endpoint(request):
pedimento_id = request.data.get('pedimento_id')
pedimento_app = request.data.get('pedimento_app')
if not pedimento_id and not pedimento_app:
return Response({'error': 'pedimento_id o pedimento_app es requerido'}, status=status.HTTP_400_BAD_REQUEST)
try:
if pedimento_id:
pedimento = Pedimento.objects.get(id=pedimento_id)
else:
pedimento = Pedimento.objects.get(pedimento_app=pedimento_app)
except Pedimento.DoesNotExist:
return Response({'error': 'Pedimento no encontrado'}, status=status.HTTP_404_NOT_FOUND)
task = auto_corregir_pedamentos_task.delay(str(pedimento.organizacion_id), str(pedimento.id))
logger.info(f"[auto_corregir] individual — ped={pedimento.pedimento_app} task={task.id}")
return Response(
{
'task_id': task.id,
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento_app,
'mensaje': 'Corrección individual encolada.',
},
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)

View File

View 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."))

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-04-20 16:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('datastage', '0011_alter_registro502_fecha_pago_real_and_more'),
]
operations = [
migrations.AlterField(
model_name='datastage',
name='archivo',
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View File

@@ -0,0 +1,26 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('datastage', '0012_alter_datastage_archivo'),
]
operations = [
# La columna created_at ya existe en la BD (NOT NULL, sin DEFAULT).
# Solo actualizamos el estado interno de Django para que auto_now_add
# inserte el valor al hacer bulk_create.
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddField(
model_name='registro501',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
],
database_operations=[],
),
]

View File

@@ -0,0 +1,44 @@
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
"""
Las columnas created_at ya existen en la BD como NOT NULL sin DEFAULT.
Solo actualizamos el estado interno de Django para que auto_now_add
inserte el timestamp al hacer bulk_create.
"""
dependencies = [
('datastage', '0013_registro501_add_timestamps'),
]
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.AddField(model_name='registro502', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro503', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro504', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro505', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro506', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro507', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro508', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro509', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro510', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro511', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro512', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro551', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro552', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro553', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro554', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro555', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro556', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro557', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro558', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registrosel', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro701', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
migrations.AddField(model_name='registro702', name='created_at', field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), preserve_default=False),
],
database_operations=[],
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-05-26 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notificaciones', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='notificacion',
name='datos',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -21,6 +21,7 @@ class Notificacion(models.Model):
mensaje = models.TextField(help_text="Mensaje de la notificación")
datos = models.JSONField(null=True, blank=True)
fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación")
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación")
visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista")

View File

@@ -16,10 +16,11 @@ class NotificacionSerializer(serializers.ModelSerializer):
'tipo',
'dirigido',
'mensaje',
'datos',
'fecha_envio',
'created_at',
'visto'
]
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje']
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje', 'datos']

View File

@@ -1,6 +1,8 @@
from rest_framework import viewsets
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from .models import Notificacion, TipoNotificacion
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
@@ -45,3 +47,11 @@ class NotificacionViewSet(viewsets.ModelViewSet):
serializer.save()
return
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
@action(detail=False, methods=['get'], url_path=r'by-task/(?P<task_id>[^/.]+)')
def by_task(self, request, task_id=None):
"""Recupera la notificación de una tarea de auditoría por su task_id (Celery)."""
notif = self.get_queryset().filter(datos__task_id=task_id).first()
if not notif:
return Response({'detail': 'No encontrada.'}, status=status.HTTP_404_NOT_FOUND)
return Response(self.get_serializer(notif).data)

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-05-19 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organization', '0002_remove_organizacion_membretado_and_more'),
]
operations = [
migrations.AddField(
model_name='organizacion',
name='apply_auto_download',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,25 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organization', '0003_organizacion_apply_auto_download'),
('cuser', '0005_customuser_rfc_fk_to_m2m'),
]
operations = [
migrations.AddField(
model_name='organizacion',
name='owner',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='organizaciones_que_administra',
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-05-26 13:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('rbac', '0004_auditoria_permissions'),
]
operations = [
migrations.AlterField(
model_name='rolepermission',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-03-06 19:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('record', '0002_fuente_document_fuente'),
]
operations = [
migrations.AddField(
model_name='document',
name='vu',
field=models.BooleanField(default=False),
),
]

View File

@@ -23,7 +23,6 @@ from django.http import HttpResponse
from rest_framework.decorators import action
from datetime import timedelta
from django.utils import timezone
from django.db.models import Q
from api.utils.storage_service import storage_service
from rest_framework.authentication import TokenAuthentication
@@ -39,6 +38,7 @@ import logging
logger = logging.getLogger(__name__)
import os
import tempfile
from django.core.files.storage import default_storage
from django.conf import settings
import requests
@@ -171,6 +171,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
'bulk_upload': 'documentos.upload',
'bulk_upload_vu': 'documentos.upload',
'create_vu_record': 'documentos.upload',
'bulk_download_partidas_vu': 'documentos.view',
'bulk_download_coves_vu': 'documentos.view',
'bulk_download_edocs_vu': 'documentos.view',
}
codename = perms.get(self.action, 'documentos.view')
return [IsAuthenticated(), require_permission(codename)()]
@@ -631,106 +634,50 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
@action(detail=False, methods=['post'], url_path='bulk-delete-partidas-vu')
def bulk_delete_partidas_vu(self, request):
"""
Endpoint para eliminar múltiples archivos xlm de partidas de vu de manera masiva.
from ..customs.models import Partida
Payload esperado:
{
"ids": ["uuid1", "uuid2", "uuid3", ...]
}
ids_partidas = request.data.get('ids', [])
Respuesta exitosa:
{
"message": "Documentos eliminados exitosamente",
"deleted_count": 3,
"deleted_ids": ["uuid1", "uuid2", "uuid3"],
"space_freed_mb": 25.6
}
Respuesta con errores:
{
"message": "Algunos documentos no pudieron ser eliminados",
"deleted_count": 2,
"deleted_ids": ["uuid1", "uuid2"],
"failed_ids": ["uuid3"],
"errors": ["No se encontró el documento con ID uuid3"],
"space_freed_mb": 15.2
}
"""
# Obtener los IDs del payload
ids_vu = request.data.get('ids', [])
if not ids_vu:
if not ids_partidas:
return Response(
{"error": "Se requiere una lista de IDs para eliminar"},
status=status.HTTP_400_BAD_REQUEST
)
if not isinstance(ids_vu, list):
if not isinstance(ids_partidas, list):
return Response(
{"error": "El campo 'ids' debe ser una lista"},
status=status.HTTP_400_BAD_REQUEST
)
# Obtener el queryset filtrado por organización
queryset = self.get_queryset()
from ..customs.models import Partida
partidas = Partida.objects.filter(id__in=ids_vu)
partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento')
if not partidas.exists():
return Response(
{"error": "No se encontraron Partidas"},
{"error": "No se encontraron partidas con los IDs proporcionados"},
status=status.HTTP_404_NOT_FOUND
)
ids = []
# Buscar documentos vu_PT_ asociados a cada partida por pedimento + numero_partida
doc_ids = []
for partida in partidas:
docs = Document.objects.filter(
pedimento_id=partida.pedimento.id,
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
).values_list('id', flat=True)
doc_ids.extend(docs)
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
)
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
existing_documents = queryset.filter(id__in=ids)
queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids)
existing_ids = list(existing_documents.values_list('id', flat=True))
# Convertir UUIDs a strings para comparación
existing_ids_str = [str(id) for id in existing_ids]
requested_ids_str = [str(id) for id in ids]
# Identificar IDs que no existen o no pertenecen a la organización
failed_ids = [id for id in requested_ids_str if id not in existing_ids_str]
existing_ids_str = [str(i) for i in existing_ids]
deleted_count = 0
total_space_freed = 0
errors = []
failed_ids = []
if existing_documents.exists():
try:
# Usar transacción atómica para consistencia
with transaction.atomic():
# Calcular el espacio total a liberar
total_space_freed = sum(doc.size for doc in existing_documents)
# Obtener la organización del usuario para actualizar el uso de almacenamiento
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response(
{"error": "Usuario no autenticado o sin organización"},
@@ -739,9 +686,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion = request.user.organizacion
# Si es superusuario, puede eliminar documentos de cualquier organización
if existing_documents.exists():
total_space_freed = sum(doc.size for doc in existing_documents)
if request.user.is_superuser:
# Para superusuario, actualizar el uso de cada organización afectada
organizaciones_afectadas = {}
for doc in existing_documents:
if doc.organizacion.id not in organizaciones_afectadas:
@@ -750,8 +698,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
'espacio_liberado': 0
}
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
# Actualizar uso de almacenamiento para cada organización
for org_data in organizaciones_afectadas.values():
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
@@ -760,10 +706,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= org_data['espacio_liberado']
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
else:
# Para usuarios normales, solo documentos de su organización
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
organizacion=organizacion
@@ -771,49 +715,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= total_space_freed
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
storage_service.delete_file(str(doc.archivo))
doc.delete()
archivos_eliminados += 1
except Exception as e:
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
failed_ids.append(str(doc.id))
# deleted_count = existing_documents.count()
deleted_count = archivos_eliminados
# existing_documents.delete()
# Eliminar los registros de Partida
partidas.delete()
except Exception as e:
return Response(
{"error": f"Error al eliminar documentos: {str(e)}"},
{"error": f"Error al eliminar: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Agregar errores para IDs no encontrados
if failed_ids:
errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids]
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)
# Preparar respuesta
response_data = {
"deleted_count": deleted_count,
"deleted_ids": existing_ids_str,
"space_freed_mb": space_freed_mb
}
if failed_ids:
if errors or failed_ids:
response_data.update({
"message": "Algunos documentos no pudieron ser eliminados",
"failed_ids": failed_ids,
@@ -821,7 +761,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = "Documentos eliminados exitosamente"
response_data["message"] = "Partidas y documentos eliminados exitosamente"
response_status = status.HTTP_200_OK
return Response(response_data, status=response_status)
@@ -829,107 +769,50 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
@action(detail=False, methods=['post'], url_path='bulk-delete-coves-vu')
def bulk_delete_coves_vu(self, request):
"""
Endpoint para eliminar múltiples archivos xlm de coves de vu de manera masiva.
from ..customs.models import Cove
Payload esperado:
{
"ids": ["uuid1", "uuid2", "uuid3", ...]
}
ids_coves = request.data.get('ids', [])
Respuesta exitosa:
{
"message": "Documentos eliminados exitosamente",
"deleted_count": 3,
"deleted_ids": ["uuid1", "uuid2", "uuid3"],
"space_freed_mb": 25.6
}
Respuesta con errores:
{
"message": "Algunos documentos no pudieron ser eliminados",
"deleted_count": 2,
"deleted_ids": ["uuid1", "uuid2"],
"failed_ids": ["uuid3"],
"errors": ["No se encontró el documento con ID uuid3"],
"space_freed_mb": 15.2
}
"""
# Obtener los IDs del payload
ids_vu = request.data.get('ids', [])
if not ids_vu:
if not ids_coves:
return Response(
{"error": "Se requiere una lista de IDs para eliminar"},
status=status.HTTP_400_BAD_REQUEST
)
if not isinstance(ids_vu, list):
if not isinstance(ids_coves, list):
return Response(
{"error": "El campo 'ids' debe ser una lista"},
status=status.HTTP_400_BAD_REQUEST
)
# Obtener el queryset filtrado por organización
queryset = self.get_queryset()
from ..customs.models import Cove
coves = Cove.objects.filter(id__in=ids_vu)
coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento')
if not coves.exists():
return Response(
{"error": "No se encontraron COVEs"},
{"error": "No se encontraron COVEs con los IDs proporcionados"},
status=status.HTTP_404_NOT_FOUND
)
ids = []
# Buscar documentos que contengan el numero_cove en el nombre de archivo
doc_ids = []
for cove in coves:
docs = Document.objects.filter(
pedimento_id=cove.pedimento.id,
archivo__icontains=cove.numero_cove
).values_list('id', flat=True)
doc_ids.extend(docs)
pedimento_cove = cove.pedimento
pedimento_app = pedimento_cove.pedimento_app
pedimento_id=pedimento_cove.id
numero_cove = cove.numero_cove
documents = Document.objects.filter(
Q(archivo__startswith=f'documents/vu_COVE_{pedimento_app}_{numero_cove}') |
Q(archivo__startswith=f'documents/vu_AC_COVE_{pedimento_app}_{numero_cove}'),
pedimento_id=pedimento_id
).values_list('id', flat=True) # <-- solo los IDs
if documents.exists():
# agregar los IDs a la lista
ids.extend(documents)
if len(ids) <= 0:
return Response(
{"error": "No se encontraron docuemntos para eliminar"},
status=status.HTTP_404_NOT_FOUND
)
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
existing_documents = queryset.filter(id__in=ids)
queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids)
existing_ids = list(existing_documents.values_list('id', flat=True))
# Convertir UUIDs a strings para comparación
existing_ids_str = [str(id) for id in existing_ids]
requested_ids_str = [str(id) for id in ids]
# Identificar IDs que no existen o no pertenecen a la organización
failed_ids = [id for id in requested_ids_str if id not in existing_ids_str]
existing_ids_str = [str(i) for i in existing_ids]
deleted_count = 0
total_space_freed = 0
errors = []
failed_ids = []
if existing_documents.exists():
try:
# Usar transacción atómica para consistencia
with transaction.atomic():
# Calcular el espacio total a liberar
total_space_freed = sum(doc.size for doc in existing_documents)
# Obtener la organización del usuario para actualizar el uso de almacenamiento
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response(
{"error": "Usuario no autenticado o sin organización"},
@@ -938,9 +821,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion = request.user.organizacion
# Si es superusuario, puede eliminar documentos de cualquier organización
if existing_documents.exists():
total_space_freed = sum(doc.size for doc in existing_documents)
if request.user.is_superuser:
# Para superusuario, actualizar el uso de cada organización afectada
organizaciones_afectadas = {}
for doc in existing_documents:
if doc.organizacion.id not in organizaciones_afectadas:
@@ -949,8 +833,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
'espacio_liberado': 0
}
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
# Actualizar uso de almacenamiento para cada organización
for org_data in organizaciones_afectadas.values():
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
@@ -959,10 +841,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= org_data['espacio_liberado']
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
else:
# Para usuarios normales, solo documentos de su organización
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
organizacion=organizacion
@@ -970,49 +850,44 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= total_space_freed
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
storage_service.delete_file(str(doc.archivo))
doc.delete()
archivos_eliminados += 1
except Exception as e:
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
failed_ids.append(str(doc.id))
# deleted_count = existing_documents.count()
deleted_count = archivos_eliminados
# existing_documents.delete()
coves.delete()
except Exception as e:
return Response(
{"error": f"Error al eliminar documentos: {str(e)}"},
{"error": f"Error al eliminar: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Agregar errores para IDs no encontrados
if failed_ids:
errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids]
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)
# Preparar respuesta
response_data = {
"deleted_count": deleted_count,
"deleted_ids": existing_ids_str,
"space_freed_mb": space_freed_mb
}
if failed_ids:
if errors or failed_ids:
response_data.update({
"message": "Algunos documentos no pudieron ser eliminados",
"failed_ids": failed_ids,
@@ -1020,114 +895,57 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = "Documentos eliminados exitosamente"
response_data["message"] = "COVEs y documentos eliminados exitosamente"
response_status = status.HTTP_200_OK
return Response(response_data, status=response_status)
@action(detail=False, methods=['post'], url_path='bulk-delete-edocs-vu')
def bulk_delete_edocs_vu(self, request):
"""
Endpoint para eliminar múltiples archivos xlm de edocs de vu de manera masiva.
from ..customs.models import EDocument
Payload esperado:
{
"ids": ["uuid1", "uuid2", "uuid3", ...]
}
ids_edocs = request.data.get('ids', [])
Respuesta exitosa:
{
"message": "Documentos eliminados exitosamente",
"deleted_count": 3,
"deleted_ids": ["uuid1", "uuid2", "uuid3"],
"space_freed_mb": 25.6
}
Respuesta con errores:
{
"message": "Algunos documentos no pudieron ser eliminados",
"deleted_count": 2,
"deleted_ids": ["uuid1", "uuid2"],
"failed_ids": ["uuid3"],
"errors": ["No se encontró el documento con ID uuid3"],
"space_freed_mb": 15.2
}
"""
# Obtener los IDs del payload
ids_vu = request.data.get('ids', [])
if not ids_vu:
if not ids_edocs:
return Response(
{"error": "Se requiere una lista de IDs para eliminar"},
status=status.HTTP_400_BAD_REQUEST
)
if not isinstance(ids_vu, list):
if not isinstance(ids_edocs, list):
return Response(
{"error": "El campo 'ids' debe ser una lista"},
status=status.HTTP_400_BAD_REQUEST
)
# Obtener el queryset filtrado por organización
queryset = self.get_queryset()
from ..customs.models import EDocument
edocs = EDocument.objects.filter(id__in=ids_vu)
edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento')
if not edocs.exists():
return Response(
{"error": "No se encontraron COVEs"},
{"error": "No se encontraron EDocuments con los IDs proporcionados"},
status=status.HTTP_404_NOT_FOUND
)
ids = []
# Buscar documentos que contengan el numero_edocument en el nombre de archivo
doc_ids = []
for edoc in edocs:
docs = Document.objects.filter(
pedimento_id=edoc.pedimento.id,
archivo__icontains=edoc.numero_edocument
).values_list('id', flat=True)
doc_ids.extend(docs)
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
)
# Filtrar solo los documentos que existen y pertenecen a la organización del usuario
existing_documents = queryset.filter(id__in=ids)
queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids)
existing_ids = list(existing_documents.values_list('id', flat=True))
# Convertir UUIDs a strings para comparación
existing_ids_str = [str(id) for id in existing_ids]
requested_ids_str = [str(id) for id in ids]
# Identificar IDs que no existen o no pertenecen a la organización
failed_ids = [id for id in requested_ids_str if id not in existing_ids_str]
existing_ids_str = [str(i) for i in existing_ids]
deleted_count = 0
total_space_freed = 0
errors = []
failed_ids = []
if existing_documents.exists():
try:
# Usar transacción atómica para consistencia
with transaction.atomic():
# Calcular el espacio total a liberar
total_space_freed = sum(doc.size for doc in existing_documents)
# Obtener la organización del usuario para actualizar el uso de almacenamiento
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response(
{"error": "Usuario no autenticado o sin organización"},
@@ -1136,9 +954,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion = request.user.organizacion
# Si es superusuario, puede eliminar documentos de cualquier organización
if existing_documents.exists():
total_space_freed = sum(doc.size for doc in existing_documents)
if request.user.is_superuser:
# Para superusuario, actualizar el uso de cada organización afectada
organizaciones_afectadas = {}
for doc in existing_documents:
if doc.organizacion.id not in organizaciones_afectadas:
@@ -1147,8 +966,6 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
'espacio_liberado': 0
}
organizaciones_afectadas[doc.organizacion.id]['espacio_liberado'] += doc.size
# Actualizar uso de almacenamiento para cada organización
for org_data in organizaciones_afectadas.values():
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
@@ -1157,10 +974,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= org_data['espacio_liberado']
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
else:
# Para usuarios normales, solo documentos de su organización
try:
uso = UsoAlmacenamiento.objects.select_for_update().get(
organizacion=organizacion
@@ -1168,48 +983,44 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
uso.espacio_utilizado -= total_space_freed
uso.save()
except UsoAlmacenamiento.DoesNotExist:
# Si no existe el registro, no hay nada que actualizar
pass
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
storage_service.delete_file(str(doc.archivo))
doc.delete()
archivos_eliminados += 1
except Exception as e:
errors.append(f"No se pudo eliminar el documento {doc.id}: {str(e)}")
failed_ids.append(str(doc.id))
# deleted_count = existing_documents.count()
deleted_count = archivos_eliminados
# existing_documents.delete()
edocs.delete()
except Exception as e:
return Response(
{"error": f"Error al eliminar documentos: {str(e)}"},
{"error": f"Error al eliminar: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
# Agregar errores para IDs no encontrados
if failed_ids:
errors = [f"No se encontró el documento con ID {id} o no pertenece a su organización" for id in failed_ids]
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)
# Preparar respuesta
response_data = {
"deleted_count": deleted_count,
"deleted_ids": existing_ids_str,
"space_freed_mb": space_freed_mb
}
if failed_ids:
if errors or failed_ids:
response_data.update({
"message": "Algunos documentos no pudieron ser eliminados",
"failed_ids": failed_ids,
@@ -1217,7 +1028,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = "Documentos eliminados exitosamente"
response_data["message"] = "EDocuments y documentos eliminados exitosamente"
response_status = status.HTTP_200_OK
return Response(response_data, status=response_status)
@@ -1326,6 +1137,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
failed_files = []
errors = []
total_space_used = 0
created_count = 0
replaced_count = 0
try:
with transaction.atomic():
@@ -1410,6 +1223,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
else:
raise Exception(f"Error al guardar archivo: {file.name}")
document = existing_doc
replaced_count += 1
was_replaced = True
else:
# Crear nuevo documento
document = Document.objects.create(
@@ -1431,6 +1246,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
created_count += 1
was_replaced = False
# Actualizar espacio usado
espacio_usado_temp += file.size
@@ -1441,7 +1258,8 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"filename": file.name,
"size": file.size,
"extension": extension,
"document_type": document.document_type.nombre if document.document_type else None
"document_type": document.document_type.nombre if document.document_type else None,
"replaced": was_replaced,
})
except Exception as e:
@@ -1463,23 +1281,32 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
space_used_mb = round(total_space_used / (1024 * 1024), 2)
# Preparar respuesta
partes = []
if created_count:
partes.append(f"{created_count} documento(s) creado(s) exitosamente")
if replaced_count:
partes.append(f"{replaced_count} documento(s) reemplazado(s) exitosamente")
mensaje_exito = " y ".join(partes) if partes else "Sin cambios"
response_data = {
"uploaded_count": len(uploaded_documents),
"created_count": created_count,
"replaced_count": replaced_count,
"uploaded_documents": uploaded_documents,
"space_used_mb": space_used_mb,
"pedimento_id": str(pedimento_id),
"document_type": document_type.nombre
"document_type": document_type.nombre,
}
if failed_files:
response_data.update({
"message": "Algunos documentos no pudieron ser subidos",
"message": f"Algunos documentos no pudieron ser subidos. {mensaje_exito}",
"failed_files": failed_files,
"errors": errors
"errors": errors,
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = "Documentos subidos exitosamente"
response_data["message"] = mensaje_exito
response_status = status.HTTP_201_CREATED
return Response(response_data, status=response_status)
@@ -2043,6 +1870,186 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
return Response(response_data, status=response_status)
@action(detail=False, methods=['post'], url_path='bulk-download-partidas-vu')
def bulk_download_partidas_vu(self, request):
from ..customs.models import Partida
import tempfile
ids_partidas = request.data.get('ids', [])
if not ids_partidas:
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(ids_partidas, list):
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
partidas = Partida.objects.filter(id__in=ids_partidas).select_related('pedimento')
if not partidas.exists():
return Response({"error": "No se encontraron partidas"}, status=status.HTTP_404_NOT_FOUND)
doc_ids = []
for partida in partidas:
docs = Document.objects.filter(
pedimento_id=partida.pedimento.id,
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids)
if not docs_qs.exists():
return Response({"error": "No se encontraron documentos para las partidas seleccionadas"}, status=status.HTTP_404_NOT_FOUND)
buffer = BytesIO()
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs_qs:
if not doc.archivo:
continue
ruta = str(doc.archivo)
if not storage_service.file_exists(ruta):
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
if not storage_service.download_file(ruta, tmp_path):
continue
nombre = ruta.rsplit('/', 1)[-1]
with open(tmp_path, 'rb') as f:
zip_file.writestr(nombre, f.read())
buffer.seek(0)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename=partidas_vu_{len(ids_partidas)}.zip'
return response
except Exception as e:
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception:
pass
@action(detail=False, methods=['post'], url_path='bulk-download-coves-vu')
def bulk_download_coves_vu(self, request):
from ..customs.models import Cove
import tempfile
ids_coves = request.data.get('ids', [])
if not ids_coves:
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(ids_coves, list):
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
coves = Cove.objects.filter(id__in=ids_coves).select_related('pedimento')
if not coves.exists():
return Response({"error": "No se encontraron COVEs"}, status=status.HTTP_404_NOT_FOUND)
doc_ids = []
for cove in coves:
docs = Document.objects.filter(
pedimento_id=cove.pedimento.id,
archivo__icontains=cove.numero_cove
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids)
if not docs_qs.exists():
return Response({"error": "No se encontraron documentos para los COVEs seleccionados"}, status=status.HTTP_404_NOT_FOUND)
buffer = BytesIO()
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs_qs:
if not doc.archivo:
continue
ruta = str(doc.archivo)
if not storage_service.file_exists(ruta):
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
if not storage_service.download_file(ruta, tmp_path):
continue
nombre = ruta.rsplit('/', 1)[-1]
with open(tmp_path, 'rb') as f:
zip_file.writestr(nombre, f.read())
buffer.seek(0)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename=coves_vu_{len(ids_coves)}.zip'
return response
except Exception as e:
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception:
pass
@action(detail=False, methods=['post'], url_path='bulk-download-edocs-vu')
def bulk_download_edocs_vu(self, request):
from ..customs.models import EDocument
import tempfile
ids_edocs = request.data.get('ids', [])
if not ids_edocs:
return Response({"error": "Se requiere una lista de IDs"}, status=status.HTTP_400_BAD_REQUEST)
if not isinstance(ids_edocs, list):
return Response({"error": "El campo 'ids' debe ser una lista"}, status=status.HTTP_400_BAD_REQUEST)
edocs = EDocument.objects.filter(id__in=ids_edocs).select_related('pedimento')
if not edocs.exists():
return Response({"error": "No se encontraron EDocuments"}, status=status.HTTP_404_NOT_FOUND)
doc_ids = []
for edoc in edocs:
docs = Document.objects.filter(
pedimento_id=edoc.pedimento.id,
archivo__icontains=edoc.numero_edocument
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids)
if not docs_qs.exists():
return Response({"error": "No se encontraron documentos para los EDocuments seleccionados"}, status=status.HTTP_404_NOT_FOUND)
buffer = BytesIO()
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs_qs:
if not doc.archivo:
continue
ruta = str(doc.archivo)
if not storage_service.file_exists(ruta):
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
if not storage_service.download_file(ruta, tmp_path):
continue
nombre = ruta.rsplit('/', 1)[-1]
with open(tmp_path, 'rb') as f:
zip_file.writestr(nombre, f.read())
buffer.seek(0)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename=edocs_vu_{len(ids_edocs)}.zip'
return response
except Exception as e:
return Response({"error": f"Error al crear ZIP: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception:
pass
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated, require_permission('documentos.download')]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2025-11-21 14:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reports', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='reportdocument',
name='report_type',
field=models.CharField(choices=[('cumplimiento', 'cumplimiento'), ('control_pedimento', 'control_pedimento')], default='cumplimiento', max_length=30),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-04-21 22:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('reports', '0002_reportdocument_report_type'),
]
operations = [
migrations.AlterField(
model_name='reportdocument',
name='file',
field=models.CharField(blank=True, max_length=500, null=True),
),
]

View File

@@ -1,128 +1,373 @@
import tempfile
from api.utils.storage_service import storage_service
from celery import shared_task
from api.organization.models import Organizacion
from django.utils import timezone
from api.reports.models import ReportDocument
from api.customs.models import Pedimento, Cove, EDocument, Partida
from django.db.models import Q, Exists, OuterRef
# from django.db.models import Q,
from api.record.models import Document
import csv
import io
import logging
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
import tempfile
import traceback
from collections import defaultdict
@shared_task
def generate_report_document(report_id):
import openpyxl
from openpyxl.styles import Alignment, Font, PatternFill
from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.utils import timezone
from api.customs.models import Cove, EDocument, Partida, Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
from api.reports.models import ReportDocument
from api.utils.storage_service import storage_service
from core.redis_events import publish_task_event
logger = logging.getLogger('api.reports.tasks')
# ── helpers ───────────────────────────────────────────────────────────────────
def _estado(flag: bool) -> str:
return 'RECUPERADO' if flag else 'PENDIENTE'
def _build_pedimento_filters(filters: dict) -> Q:
q = Q()
if filters.get('organizacion_id'):
q &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
q &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
q &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('patente'):
q &= Q(patente=filters['patente'])
if filters.get('aduana'):
q &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
q &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
q &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
q &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
q &= Q(tipo_operacion_id=filters['tipo_operacion'])
rfc_val = filters.get('contribuyente__rfc')
if rfc_val:
if rfc_val == 'SIN_RFC':
q &= Q(contribuyente__isnull=True)
else:
q &= Q(contribuyente__rfc=rfc_val)
return q
def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q:
"""Restringe el queryset a los importadores visibles del usuario."""
# SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True
if requested_rfc == 'SIN_RFC':
return q
user_rfcs = user.rfc.all()
if not user_rfcs.exists():
if requested_rfc:
q &= Q(contribuyente__rfc=requested_rfc)
return q
if requested_rfc:
if user_rfcs.filter(rfc=requested_rfc).exists():
q &= Q(contribuyente__rfc=requested_rfc)
else:
q &= Q(contribuyente__in=user_rfcs)
else:
q &= Q(contribuyente__in=user_rfcs)
return q
# ── tarea principal ───────────────────────────────────────────────────────────
@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660)
def generate_report_document(self, report_id):
task_id = self.request.id
report = None
def _fail(msg, exc=None):
"""Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
tb = traceback.format_exc() if exc else ''
full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
logger.error('[reporte_cumplimiento] report=%s FALLO: %s', report_id, full_msg)
if report:
report.status = 'error'
report.error_message = full_msg
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
publish_task_event(task_id, 'failed', msg, progress=0)
# ── 1. Obtener reporte ────────────────────────────────────────────────────
try:
report = ReportDocument.objects.get(id=report_id)
except ReportDocument.DoesNotExist:
logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id)
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
return
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 {}
pedimentos_filters = Q()
if filters.get('organizacion_id'):
pedimentos_filters &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('contribuyente__rfc'):
pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc'])
if filters.get('patente'):
pedimentos_filters &= Q(patente=filters['patente'])
if filters.get('aduana'):
pedimentos_filters &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
pedimentos_filters &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
pedimentos_filters &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
pedimentos_filters &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion'])
# Consulta asíncrona de los modelos
pedimentos = Pedimento.objects.filter(pedimentos_filters)
filename = filters.get('filename')
if filename:
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else:
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
org_id = filters.get('organizacion_id')
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
tmp_path = f.name
# ── 2. Filtros y organización ─────────────────────────────────────────
q = _build_pedimento_filters(filters)
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
# Escribir CSV en archivo temporal
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
headers = [
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
]
writer.writerow(headers)
nombre_org = ''
if org_id:
try:
nombre_org = Organizacion.objects.get(id=org_id).nombre
except Organizacion.DoesNotExist:
pass
for ped in pedimentos:
for cove in Cove.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado
])
for edoc in EDocument.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado
])
for partida in Partida.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, ''
])
logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
# ============ NUEVO: Guardar en MinIO ============
# Leer archivo temporal
with open(tmp_path, 'rb') as f:
file_content = f.read()
# ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
rfcs_list = list(
Pedimento.objects.filter(q)
.exclude(contribuyente__isnull=True)
.values_list('contribuyente__rfc', flat=True)
.distinct()
.order_by('contribuyente__rfc')
)
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
rfcs_list.append('SIN_RFC')
# Crear UploadedFile
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
total_rfcs = len(rfcs_list)
total_pedimentos = Pedimento.objects.filter(q).count()
logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
report_id, total_rfcs, total_pedimentos)
if total_rfcs == 0:
logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id)
publish_task_event(
task_id, 'processing',
f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15,
)
# 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(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
file=SimpleUploadedFile(
name=filename,
content=excel_bytes,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
organizacion_id=org_id,
metadata={
'report_id': str(report.id),
'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None
}
'user_id': str(report.user.id) if report.user else None,
},
)
if ruta:
logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
report.file = ruta
report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
# Limpiar temporal
os.unlink(tmp_path)
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
return
report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e:
report.status = 'error'
report.error_message = str(e)
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
resultado = {
'report_id': str(report.id),
'total_rfcs': total_rfcs,
'total_pedimentos': total_pedimentos,
}
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d',
report_id, total_rfcs, total_pedimentos)
return resultado
except SoftTimeLimitExceeded:
_fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.')
except Exception as exc:
_fail(str(exc), exc=exc)
# ── reporte de control de pedimentos (sin cambios) ────────────────────────────
@shared_task
def generate_report_control_pedimento(report_id):
@@ -133,8 +378,6 @@ def generate_report_control_pedimento(report_id):
report.save(update_fields=['status'])
filters = report.filters or {}
# Construir filtros
pedimentos_filters = {}
if filters.get('organizacion_id'):
pedimentos_filters['organizacion_id'] = filters['organizacion_id']
@@ -145,15 +388,12 @@ def generate_report_control_pedimento(report_id):
if filters.get('pedimento_app'):
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
# pedimentos por organizacion
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
pedimentos_total = pedimentos_qs.count()
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
# inicializar totales
pedimentos_completos = 0
total_documentos = 0
documentos_sin_descargar = 0
@@ -161,15 +401,13 @@ def generate_report_control_pedimento(report_id):
nombre_organizacion = ''
if filters.get('organizacion_id'):
try:
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo
nombre_organizacion = organizacion.nombre
except Organizacion.DoesNotExist:
nombre_organizacion = f"ID: {filters['organizacion_id']}"
except Exception as e:
nombre_organizacion = f"Error: {str(e)}"
# lista de rfc
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
fecha_inicio = ''
@@ -184,42 +422,33 @@ def generate_report_control_pedimento(report_id):
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
# Para cada pedimento, verificar si está completo
for pedimento in pedimentos_qs:
# Contar documentos de este pedimento
docs_pedimento = 0
docs_pendientes_pedimento = 0
# COVES
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
docs_pedimento += coves_count
docs_pendientes_pedimento += coves_pendientes
# PARTIDAS
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
docs_pedimento += partidas_count
docs_pendientes_pedimento += partidas_pendientes
# EDOCUMENTS
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
docs_pedimento += edocs_count
docs_pendientes_pedimento += edocs_pendientes
# Acumular totales
total_documentos += docs_pedimento
documentos_sin_descargar += docs_pendientes_pedimento
# Si no tiene documentos pendientes, está completo
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
pedimentos_completos += 1
# 3. PORCENTAJE
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
# 4. GENERAR CSV CON DETALLES
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
@@ -227,61 +456,39 @@ def generate_report_control_pedimento(report_id):
todas_las_filas = []
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
for pedimento in pedimentos_qs:
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
datos_base_pedimento = [
pedimento.aduana or '',
pedimento.patente or '',
pedimento.regimen or '',
pedimento.pedimento or '', # No. Pedimento (7 dígitos)
pedimento.pedimento_app or '', # No. Pedimento App completo
pedimento.pedimento or '',
pedimento.pedimento_app or '',
pedimento.clave_pedimento or '',
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
]
# COVES - Una fila por cada COVE
coves = Cove.objects.filter(pedimento_id=pedimento.id)
for cove in coves:
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(cove.id), # Identificador de documento
cove.numero_cove,
'COVE', # Tipo de documento
estado
]
fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
todas_las_filas.append(fila)
# PARTIDAS - Una fila por cada Partida
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
for partida in partidas:
estado = 'VERDADERO' if partida.descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(partida.id),
partida.numero_partida,
'PARTIDA', # Tipo de documento
estado
]
fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
todas_las_filas.append(fila)
# EDOCUMENTS - Una fila por cada EDocument
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
for edoc in edocuments:
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
fila = datos_base_pedimento + [
# str(edoc.id),
edoc.numero_edocument,
'EDOCUMENT', # Tipo de documento
estado
]
fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
todas_las_filas.append(fila)
# 5. ESCRIBIR ARCHIVO CSV
import csv
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
# SECCIÓN DE TOTALES
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
writer.writerow(['ORGANIZACION:', nombre_organizacion])
writer.writerow([])
@@ -294,20 +501,15 @@ def generate_report_control_pedimento(report_id):
writer.writerow(['LISTA RFC:', rfc_list])
writer.writerow([])
writer.writerow([])
# ENCABEZADOS DE DATOS (según requerimiento)
headers = [
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
]
writer.writerow(headers)
# DATOS DETALLADOS
for fila in todas_las_filas:
writer.writerow(fila)
with open(tmp_path, 'rb') as f:
file_content = f.read()

View File

@@ -1,3 +1,446 @@
"""
Tests para generate_report_document (T2026-04-001).
Ejecución:
python manage.py test api.reports.tests
python manage.py test api.reports.tests.TestEstadoHelper
"""
import io
import uuid
from unittest.mock import MagicMock, call, patch
import openpyxl
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.test import TestCase
# Create your tests here.
from api.customs.models import Cove, EDocument, Importador, Partida, Pedimento
from api.licence.models import Licencia
from api.organization.models import Organizacion
from api.reports.models import ReportDocument
from api.reports.tasks.report_document import (
_apply_user_rfc_filter,
_estado,
generate_report_document,
)
User = get_user_model()
FAKE_PATH = 'reports/test/reporte.xlsx'
# ── fixtures ──────────────────────────────────────────────────────────────────
def _licencia(nombre='Plan Test'):
return Licencia.objects.create(nombre=nombre, almacenamiento=10)
def _org(nombre='Org Test'):
lic = _licencia(f'Lic {nombre}')
return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
def _user(org, username='tuser', rfcs=None):
u = User.objects.create_user(username=username, password='pass', organizacion=org)
if rfcs:
u.rfc.set(rfcs)
return u
def _imp(org, rfc='RFC000000001', nombre='Importador Test'):
return Importador.objects.create(rfc=rfc, nombre=nombre, organizacion=org)
def _ped(org, imp=None, num='0000001'):
return Pedimento.objects.create(
pedimento=num,
pedimento_app=f'25-160-3910-{num}',
organizacion=org,
contribuyente=imp,
aduana='160',
patente='3910',
regimen='ITE',
clave_pedimento='A1',
)
def _reporte(user, org_id, extra=None):
filtros = {'organizacion_id': str(org_id)}
if extra:
filtros.update(extra)
return ReportDocument.objects.create(
user=user, filters=filtros, status='pending', report_type='cumplimiento'
)
def _excel_desde_mock(mock_save):
"""Parsea el workbook que recibió storage_service.save_report."""
uf = mock_save.call_args[1]['file']
return openpyxl.load_workbook(io.BytesIO(uf.read()))
def _docs_col(ws):
"""Devuelve {documento: estatus} leyendo columnas 9 y 10 del worksheet."""
return {
ws.cell(row=r, column=9).value: ws.cell(row=r, column=10).value
for r in range(1, ws.max_row + 1)
if ws.cell(row=r, column=9).value
}
def _col1_values(ws):
"""Devuelve todos los valores no vacíos de la columna 1."""
return [
str(ws.cell(row=r, column=1).value)
for r in range(1, ws.max_row + 1)
if ws.cell(row=r, column=1).value
]
# ── 1. Helpers ────────────────────────────────────────────────────────────────
class TestEstadoHelper(TestCase):
def test_true_retorna_recuperado(self):
self.assertEqual(_estado(True), 'RECUPERADO')
def test_false_retorna_pendiente(self):
self.assertEqual(_estado(False), 'PENDIENTE')
# ── 2. Filtro de RFC por usuario ──────────────────────────────────────────────
class TestApplyUserRfcFilter(TestCase):
@classmethod
def setUpTestData(cls):
cls.org = _org()
cls.imp1 = _imp(cls.org, rfc='RFC000000001')
cls.imp2 = _imp(cls.org, rfc='RFC000000002')
def test_sin_rfcs_asignados_sin_filtro_retorna_q_vacio(self):
user = _user(self.org, username='u_admin')
q = _apply_user_rfc_filter(Q(), user, None)
self.assertEqual(str(q), str(Q()))
def test_sin_rfcs_asignados_con_filtro_explicito_aplica_filtro(self):
user = _user(self.org, username='u_admin2')
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
self.assertIn('RFC000000001', str(q))
def test_con_rfcs_sin_filtro_restringe_a_sus_importadores(self):
user = _user(self.org, username='u_imp1', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, None)
self.assertIn('contribuyente', str(q))
def test_con_rfcs_pide_el_suyo_se_filtra_por_ese_rfc(self):
user = _user(self.org, username='u_imp2', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, 'RFC000000001')
self.assertIn('RFC000000001', str(q))
def test_con_rfcs_pide_ajeno_se_usa_el_suyo_no_el_solicitado(self):
user = _user(self.org, username='u_imp3', rfcs=[self.imp1])
q = _apply_user_rfc_filter(Q(), user, 'RFC000000002')
self.assertNotIn('RFC000000002', str(q))
self.assertIn('contribuyente', str(q))
# ── 3. Tarea completa ─────────────────────────────────────────────────────────
# Todos los tests en esta clase mockean Redis (publish_task_event) y MinIO
# (storage_service.save_report) para no depender de infraestructura externa.
@patch('api.reports.tasks.report_document.publish_task_event')
@patch('api.reports.tasks.report_document.storage_service.save_report',
return_value=FAKE_PATH)
class TestGenerateReportDocument(TestCase):
@classmethod
def setUpTestData(cls):
cls.org = _org('Org Reporte')
cls.imp = _imp(cls.org, rfc='MTK8610143000', nombre='Servicios TETAKAWI')
cls.user = _user(cls.org, username='rep_user')
def _run(self, report):
generate_report_document.apply(args=[str(report.id)])
report.refresh_from_db()
# ── 3.1 Sin pedimentos ────────────────────────────────────────────────────
def test_sin_pedimentos_genera_excel_vacio_y_status_ready(self, mock_save, mock_pub):
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
self.assertEqual(report.file, FAKE_PATH)
mock_save.assert_called_once()
# El workbook no debe tener datos de RFCs
wb = _excel_desde_mock(mock_save)
ws = wb.active
col1 = _col1_values(ws)
self.assertFalse(col1, 'Excel vacío no debe tener contenido en col 1')
# ── 3.2 RFC aparece en encabezado ─────────────────────────────────────────
def test_rfc_del_importador_aparece_en_excel(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000001')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
wb = _excel_desde_mock(mock_save)
ws = wb.active
col1 = ' '.join(_col1_values(ws))
self.assertIn('MTK8610143000', col1)
# ── 3.3 PEDIMENTO COMPLETO ────────────────────────────────────────────────
def test_pedimento_completo_recuperado_cuando_existe_expediente(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000002')
ped.existe_expediente = True
ped.save(update_fields=['existe_expediente'])
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'RECUPERADO')
def test_pedimento_completo_pendiente_cuando_no_tiene_expediente(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000003') # existe_expediente=False por default
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PEDIMENTO COMPLETO'), 'PENDIENTE')
# ── 3.4 Partidas ──────────────────────────────────────────────────────────
def test_partidas_con_estado_correcto(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000004')
Partida.objects.create(
pedimento=ped, organizacion=self.org, numero_partida=1, descargado=True
)
Partida.objects.create(
pedimento=ped, organizacion=self.org, numero_partida=2, descargado=False
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('PARTIDA1'), 'RECUPERADO')
self.assertEqual(docs.get('PARTIDA2'), 'PENDIENTE')
# ── 3.5 COVEs y acuses ────────────────────────────────────────────────────
def test_cove_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000005')
Cove.objects.create(
pedimento=ped, organizacion=self.org,
numero_cove='654001',
cove_descargado=True,
acuse_cove_descargado=False,
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('COVE654001'), 'RECUPERADO')
self.assertEqual(docs.get('ACUSE COVE654001'), 'PENDIENTE')
# ── 3.6 EDocumentos y acuses ──────────────────────────────────────────────
def test_edocumento_y_acuse_con_estados_distintos(self, mock_save, mock_pub):
ped = _ped(self.org, self.imp, '1000006')
EDocument.objects.create(
pedimento=ped, organizacion=self.org,
numero_edocument='EDOC001',
edocument_descargado=False,
acuse_descargado=True,
)
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('EDOCUMENTOEDOC001'), 'PENDIENTE')
self.assertEqual(docs.get('ACUSE EDOCUMENTOEDOC001'), 'RECUPERADO')
# ── 3.7 Remesa ────────────────────────────────────────────────────────────
def test_remesa_recuperada_cuando_document_tipo_15_existe(self, mock_save, mock_pub):
"""Pedimento.remesas=True y el query de Document devuelve el pedimento_id."""
ped = Pedimento.objects.create(
pedimento='1000007', pedimento_app='25-160-3910-1000007',
organizacion=self.org, contribuyente=self.imp,
aduana='160', patente='3910', remesas=True,
)
report = _reporte(self.user, self.org.id)
# Patch solo el query de Document dentro del task
with patch('api.reports.tasks.report_document.Document') as MockDoc:
mock_qs = MagicMock()
mock_qs.values_list.return_value = [ped.id]
MockDoc.objects.filter.return_value = mock_qs
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('REMESA'), 'RECUPERADO')
def test_remesa_pendiente_cuando_no_hay_document(self, mock_save, mock_pub):
"""Pedimento.remesas=True pero el query de Document devuelve lista vacía."""
Pedimento.objects.create(
pedimento='1000008', pedimento_app='25-160-3910-1000008',
organizacion=self.org, contribuyente=self.imp,
aduana='160', patente='3910', remesas=True,
)
report = _reporte(self.user, self.org.id)
with patch('api.reports.tasks.report_document.Document') as MockDoc:
mock_qs = MagicMock()
mock_qs.values_list.return_value = []
MockDoc.objects.filter.return_value = mock_qs
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertEqual(docs.get('REMESA'), 'PENDIENTE')
def test_sin_remesa_no_aparece_fila_remesa(self, mock_save, mock_pub):
"""Pedimento.remesas=False → no debe aparecer fila REMESA."""
_ped(self.org, self.imp, '1000009') # remesas=False por default
report = _reporte(self.user, self.org.id)
self._run(report)
docs = _docs_col(_excel_desde_mock(mock_save).active)
self.assertNotIn('REMESA', docs)
# ── 3.8 Múltiples RFCs ───────────────────────────────────────────────────
def test_multiples_rfcs_generan_secciones_separadas(self, mock_save, mock_pub):
imp2 = _imp(self.org, rfc='TEC140624802', nombre='TEC Importaciones')
_ped(self.org, self.imp, '1000010')
_ped(self.org, imp2, '1000011')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
self.assertIn('MTK8610143000', contenido)
self.assertIn('TEC140624802', contenido)
# ── 3.9 Restricción por RFC de usuario ───────────────────────────────────
def test_importador_solo_ve_sus_pedimentos(self, mock_save, mock_pub):
imp2 = _imp(self.org, rfc='XYZ999999999', nombre='Externo')
_ped(self.org, self.imp, '1000012')
_ped(self.org, imp2, '1000013')
user_restr = _user(self.org, username='u_restr', rfcs=[self.imp])
report = _reporte(user_restr, self.org.id)
self._run(report)
self.assertEqual(report.status, 'ready')
contenido = ' '.join(_col1_values(_excel_desde_mock(mock_save).active))
self.assertIn('MTK8610143000', contenido)
self.assertNotIn('XYZ999999999', contenido)
# ── 3.10 Formato del archivo ──────────────────────────────────────────────
def test_archivo_descargado_es_xlsx_valido(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000014')
report = _reporte(self.user, self.org.id)
self._run(report)
uf = mock_save.call_args[1]['file']
self.assertTrue(uf.name.endswith('.xlsx'), f'Esperado .xlsx, recibido: {uf.name}')
try:
wb = openpyxl.load_workbook(io.BytesIO(uf.read()))
self.assertIsNotNone(wb)
except Exception as exc:
self.fail(f'Excel no es válido: {exc}')
def test_cabeceras_de_columna_presentes(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000015')
report = _reporte(self.user, self.org.id)
self._run(report)
ws = _excel_desde_mock(mock_save).active
cabeceras = None
for r in range(1, ws.max_row + 1):
if ws.cell(row=r, column=1).value == 'Año':
cabeceras = [ws.cell(row=r, column=c).value for c in range(1, 11)]
break
self.assertIsNotNone(cabeceras, 'No se encontró la fila de cabeceras')
for col in ('Año', 'Aduana', 'Patente', 'Pedimento', 'Documento', 'Estatus'):
self.assertIn(col, cabeceras, f'Cabecera "{col}" no encontrada')
# ── 3.11 Progreso en Redis ────────────────────────────────────────────────
def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000016')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
def test_ultimo_evento_es_completed_con_100(self, mock_save, mock_pub):
_ped(self.org, self.imp, '1000017')
report = _reporte(self.user, self.org.id)
self._run(report)
ultimo = mock_pub.call_args_list[-1]
self.assertEqual(ultimo[0][1], 'completed')
self.assertEqual(ultimo[1].get('progress'), 100)
# ── 3.12 Manejo de errores ────────────────────────────────────────────────
def test_storage_none_deja_status_error(self, mock_save, mock_pub):
"""storage_service.save_report retorna None → report queda en error."""
mock_save.return_value = None
_ped(self.org, self.imp, '1000018')
report = _reporte(self.user, self.org.id)
self._run(report)
self.assertEqual(report.status, 'error')
self.assertIn('almacenamiento', report.error_message)
def test_storage_none_publica_evento_failed(self, mock_save, mock_pub):
mock_save.return_value = None
_ped(self.org, self.imp, '1000019')
report = _reporte(self.user, self.org.id)
self._run(report)
statuses = [c[0][1] for c in mock_pub.call_args_list]
self.assertIn('failed', statuses)
self.assertNotIn('completed', statuses)
def test_excepcion_guarda_traceback_en_error_message(self, mock_save, mock_pub):
"""Una excepción inesperada debe incluir traceback en error_message."""
mock_save.side_effect = RuntimeError('Fallo simulado de MinIO')
_ped(self.org, self.imp, '1000020')
report = _reporte(self.user, self.org.id)
try:
generate_report_document.apply(args=[str(report.id)])
except RuntimeError:
pass # apply() re-raise la excepción
report.refresh_from_db()
self.assertEqual(report.status, 'error')
self.assertIn('Fallo simulado de MinIO', report.error_message)
self.assertIn('Traceback', report.error_message)
def test_excepcion_publica_evento_failed(self, mock_save, mock_pub):
mock_save.side_effect = RuntimeError('Error MinIO')
_ped(self.org, self.imp, '1000021')
report = _reporte(self.user, self.org.id)
try:
generate_report_document.apply(args=[str(report.id)])
except RuntimeError:
pass
statuses = [c[0][1] for c in mock_pub.call_args_list]
self.assertIn('failed', statuses)

View File

@@ -70,14 +70,13 @@ def table_summary(request):
status='pending',
report_type='cumplimiento'
)
generate_report_document.delay(report.id)
task = generate_report_document.delay(report.id)
return Response({
"report_id": report.id,
"task_id": task.id,
"status": report.status,
"created_at": report.created_at,
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None,
}, status=202)
@api_view(['GET'])
@@ -127,7 +126,7 @@ def report_document_download(request, report_id):
return Response({"error": "El archivo aún no está disponible"}, status=404)
ruta = str(report.file)
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.3 on 2026-04-21 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('vucem', '0011_alter_credencialesimportador_rfc'),
]
operations = [
migrations.AlterField(
model_name='vucem',
name='cer',
field=models.CharField(blank=True, help_text='Certificado de VUCEM', max_length=500, null=True),
),
migrations.AlterField(
model_name='vucem',
name='key',
field=models.CharField(blank=True, help_text='Llave privada de VUCEM', max_length=500, null=True),
),
]

View File

@@ -71,6 +71,22 @@ ALLOWED_HOSTS = [
SITE_URL = os.getenv('SITE_URL')
SERVICE_API_URL = os.getenv('SERVICE_API_URL')
SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2')
# Hub / SSO
HUB_URL = os.getenv('HUB_URL', 'https://workspace.aduanasoft.com')
HUB_PRODUCT_SLUG = os.getenv('HUB_PRODUCT_SLUG', 'efc')
HUB_TENANT_SLUG = os.getenv('HUB_TENANT_SLUG', '')
HUB_PROVISION_SECRET = os.getenv('HUB_PROVISION_SECRET', '')
HUB_TENANT_ID = int(os.getenv('HUB_TENANT_ID', '1'))
COOKIE_SECURE = os.getenv('COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes')
# Keycloak admin (para auto-provisión de usuarios en migración)
KC_URL = os.getenv('KC_URL', 'http://hub-keycloak:8080')
KC_REALM = os.getenv('KC_REALM', 'master')
KC_ADMIN_USER = os.getenv('KC_ADMIN_USER', 'admin')
KC_ADMIN_PASSWORD = os.getenv('KC_ADMIN_PASSWORD', 'admin')
KC_EFC_CLIENT_ID = os.getenv('KC_EFC_CLIENT_ID', 'efc-backend')
KC_EFC_CLIENT_SECRET = os.getenv('KC_EFC_CLIENT_SECRET', 'efc-backend-secret-dev')
# Application definition
BASE_APPS = [
'django.contrib.admin',
@@ -174,11 +190,14 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
'access-control-allow-credentials',
]
CORS_EXPOSE_HEADERS = ['Content-Disposition']
# # JWT Authentication settings
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.TokenAuthentication', # Añade esta línea
'api.cuser.hub_auth.HubAuthBackend', # Hub SSO (local + KC)
'rest_framework_simplejwt.authentication.JWTAuthentication', # legacy
'rest_framework.authentication.TokenAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
@@ -223,7 +242,9 @@ REDOC_SETTINGS = {
CSRF_TRUSTED_ORIGINS = [
"https://api.efc-aduanasoft.com",
"http://192.168.1.195",
"http://192.168.1.195:8000"
"http://192.168.1.195:8000",
"http://localhost:5173",
"http://localhost:8000",
]
# URL Configuration
@@ -319,10 +340,10 @@ CELERY_TIMEZONE = 'America/Denver'
ASGI_APPLICATION = 'config.asgi.application'
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Tokens de acceso cortos por seguridad
'REFRESH_TOKEN_LIFETIME': timedelta(days=5), # Refresh token de 5 días
'ROTATE_REFRESH_TOKENS': True, # Rotar refresh tokens para mayor seguridad
'BLACKLIST_AFTER_ROTATION': True,
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=59), # 1 hora — reduce frecuencia de refresh
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7 días — sesión larga
'ROTATE_REFRESH_TOKENS': False, # OFF — evita blacklist en múltiples tabs
'BLACKLIST_AFTER_ROTATION': False, # OFF — sin blacklist, múltiples tabs coexisten
'AUTH_HEADER_TYPES': ('Bearer',),
}

View File

@@ -32,11 +32,14 @@ urlpatterns = [
path('admin/', admin.site.urls),
path('api/v1/', include('api.licence.urls')),
# JWT Authentication
# JWT Authentication (legacy — mantener durante transición)
path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/v1/user/', include(('api.cuser.urls', 'cuser'), namespace='cuser')), # Custom user app
# Hub SSO
path('api/v1/auth/', include('api.cuser.sso_urls')),
#path('api-auth/', include('rest_framework.urls')),
path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),

42
core/redis_events.py Normal file
View File

@@ -0,0 +1,42 @@
import json
import os
import logging
logger = logging.getLogger(__name__)
CHANNEL_PREFIX = "audit_task:"
STATE_PREFIX = "audit_task_state:"
STATE_TTL = 7200 # 2 horas
def _get_client():
import redis
return redis.Redis(
host=os.getenv("REDIS_HOST", "localhost"),
port=int(os.getenv("REDIS_PORT", 6379)),
db=int(os.getenv("REDIS_DB", 0)),
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
def publish_task_event(task_id: str, status: str, message: str = "", resultado: dict = None, progress: int = None):
"""
Publica un evento de progreso de tarea en Redis Pub/Sub.
El microservicio SSE usa el mismo canal para streamear al frontend.
"""
payload: dict = {"task_id": task_id, "status": status, "message": message}
if resultado is not None:
payload["resultado"] = resultado
if progress is not None:
payload["progress"] = progress
try:
client = _get_client()
serialized = json.dumps(payload)
client.publish(f"{CHANNEL_PREFIX}{task_id}", serialized)
client.setex(f"{STATE_PREFIX}{task_id}", STATE_TTL, serialized)
client.close()
except Exception as exc:
logger.error(f"[redis_events] Error publicando evento para tarea {task_id}: {exc}")

View 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)