Compare commits

...

9 Commits

46 changed files with 6233 additions and 917 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") is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario") rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
# Identidad Keycloak — se llena con el script de migración masiva
keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub")
def __str__(self): def __str__(self):
return self.username return self.username

View File

@@ -9,7 +9,7 @@ class CustomUserSerializer(serializers.ModelSerializer):
Serializer for the CustomUser model. Serializer for the CustomUser model.
""" """
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True, required=False)
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False) groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
rfc = serializers.PrimaryKeyRelatedField( rfc = serializers.PrimaryKeyRelatedField(
queryset=Importador.objects.all(), queryset=Importador.objects.all(),
@@ -23,6 +23,17 @@ class CustomUserSerializer(serializers.ModelSerializer):
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups'] fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups']
read_only_fields = ['id', 'organizacion', 'is_superuser'] read_only_fields = ['id', 'organizacion', 'is_superuser']
def validate_password(self, value):
if not value or not value.strip():
raise serializers.ValidationError("La contraseña no puede estar vacía o contener solo espacios.")
return value
def validate(self, attrs):
# En create, la contraseña es obligatoria
if self.instance is None and not attrs.get('password'):
raise serializers.ValidationError({"password": "Este campo es requerido."})
return attrs
def create(self, validated_data): def create(self, validated_data):
groups = validated_data.pop('groups', []) groups = validated_data.pop('groups', [])
rfcs = validated_data.pop('rfc', []) rfcs = validated_data.pop('rfc', [])

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

View File

@@ -184,7 +184,7 @@ class EDocumentSerializer(serializers.ModelSerializer):
numero = str(obj.numero_edocument).strip() numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip() # id_pedimento = str(obj.pedimento_id).strip()
# excluir e documents de tipo request y de tipo error # excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
@@ -240,15 +240,11 @@ class CoveSerializer(serializers.ModelSerializer):
try: try:
numero = str(obj.numero_cove).strip() numero = str(obj.numero_cove).strip()
# Excluir los tipo de documento 20, 24, 23 y 19 # Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
# 20 = error solicitud cove
# 24 = error solicitud acuse cove
# 23 = request acuse cove
# 19 = request cove
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
).exclude(document_type_id__in=[20, 24, 23, 19]) ).exclude(document_type_id__in=[19, 23])
# Filtro por organización si aplica # Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion: if hasattr(obj, 'organizacion') and obj.organizacion:

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 celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller 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 ( from api.customs.tasks.microservice import (
procesar_cove_individual, procesar_cove_individual,
procesar_acuse_individual, procesar_acuse_individual,
@@ -180,51 +183,88 @@ def crear_servicios(organizacion_id):
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)]) crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)]) crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
@shared_task @shared_task(bind=True)
def auditar_pedimentos(organizacion_id): 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) 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() pc = pedimento.documents.filter(document_type__id=2).first()
if pc: if pc:
with open(f'./media/{pc.archivo}', 'r') as f:
xml_content = f.read()
xml_data = xml_controller.extract_data(xml_content)
pedimento.numero_operacion = xml_data.get('numero_operacion')
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
pedimento.numero_partidas = xml_data.get('numero_partidas')
pedimento.remesas = xml_data.get('remesas')
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
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', []):
EDocument.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
clave=edoc.get('clave'),
descripcion=edoc.get('descripcion'),
numero_edocument=edoc.get('complemento1')
)
from django.db import IntegrityError
try: try:
for cove in xml_data.get('coves', []): with open(f'./media/{pc.archivo}', 'r') as f:
try: xml_content = f.read()
Cove.objects.get_or_create(
pedimento=pedimento, xml_data = xml_controller.extract_data(xml_content)
organizacion=pedimento.organizacion,
numero_cove=cove pedimento.numero_operacion = xml_data.get('numero_operacion')
) pedimento.curp_apoderado = xml_data.get('curp_apoderado')
except IntegrityError: pedimento.agente_aduanal = xml_data.get('agente_aduanal')
# Si ya existe por unique, recupera el objeto existente pedimento.numero_partidas = xml_data.get('numero_partidas')
Cove.objects.get(numero_cove=cove) pedimento.remesas = xml_data.get('remesas')
except: pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
# Si ya existe por unique, recupera el objeto existente pedimento.fecha_pago = xml_data.get('fecha_pago')
pass pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
for edoc in xml_data.get('identificadores_ed', []):
EDocument.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
clave=edoc.get('clave'),
descripcion=edoc.get('descripcion'),
numero_edocument=edoc.get('complemento1')
)
from django.db import IntegrityError
try:
for cove in xml_data.get('coves', []):
try:
Cove.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
numero_cove=cove
)
except IntegrityError:
# Si ya existe por unique, recupera el objeto existente
Cove.objects.get(numero_cove=cove)
except:
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 @shared_task
def crear_todos_los_servicios(): def crear_todos_los_servicios():

View File

@@ -91,12 +91,18 @@ def procesar_coves_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/coves", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/coves",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuse_coves_pedimento(pedimento_id): def procesar_acuse_coves_pedimento(pedimento_id):
@@ -114,12 +120,18 @@ def procesar_acuse_coves_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_edocs_pedimento(pedimento_id): def procesar_edocs_pedimento(pedimento_id):
@@ -137,12 +149,18 @@ def procesar_edocs_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/download/all/edocs/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuses_pedimento(pedimento_id): def procesar_acuses_pedimento(pedimento_id):
@@ -160,12 +178,18 @@ def procesar_acuses_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_partidas_pedimento(pedimento_id): def procesar_partidas_pedimento(pedimento_id):
@@ -177,18 +201,31 @@ def procesar_partidas_pedimento(pedimento_id):
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
payload = { payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], "partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/partidas/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/partidas/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
result = response.json()
logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
raise
@shared_task @shared_task
def procesar_remesas_pedimento(pedimento_id): def procesar_remesas_pedimento(pedimento_id):
@@ -205,17 +242,23 @@ def procesar_remesas_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/remesas", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/remesas",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @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) 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) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -225,13 +268,19 @@ def procesar_pedimento_completo_individual(pedimento_id):
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/pedimento_completo", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/pedimento_completo",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio enviado para pedimento {pedimento.pedimento}") timeout=60
return response )
response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
return response
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_pedimentos_completos(organizacion_id): def procesar_pedimentos_completos(organizacion_id):
@@ -270,13 +319,18 @@ def procesar_pedimentos_completos(organizacion_id):
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo" url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
dataJson = json.dumps(payload) dataJson = json.dumps(payload)
response = requests.post( try:
url, response = requests.post(
data=dataJson, url,
headers={"Content-Type": "application/json"} data=dataJson,
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
print(f"Servicio enviado para pedimento {pedimento.pedimento}") )
response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_remesas(organizacion_id): def procesar_remesas(organizacion_id):
@@ -311,9 +365,11 @@ def procesar_remesas(organizacion_id):
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas/", f"{SERVICE_API_URL_V2}/services/remesas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}") response.raise_for_status()
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
except Exception as e: except Exception as e:
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True) logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
@@ -339,14 +395,18 @@ def procesar_coves(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/coves", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/coves",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuse_coves(organizacion_id): def procesar_acuse_coves(organizacion_id):
@@ -370,14 +430,18 @@ def procesar_acuse_coves(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuses(organizacion_id): def procesar_acuses(organizacion_id):
@@ -401,14 +465,18 @@ def procesar_acuses(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_edocs(organizacion_id): def procesar_edocs(organizacion_id):
@@ -432,14 +500,18 @@ def procesar_edocs(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/download/all/edocs/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_partidas(organizacion_id): def procesar_partidas(organizacion_id):
@@ -449,27 +521,40 @@ def procesar_partidas(organizacion_id):
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
# Convertir el pedimento a JSON usando el serializer if not partidas_pendientes:
pedimento_dict = pedimento_to_dict(pedimento) continue
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credenciales_dict = credenciales_to_dict(credenciales) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first()
credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], "partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/", f"{SERVICE_API_URL_V2}/services/all/partidas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
result = response.json()
print(f"Servicio enviado para pedimento {pedimento.pedimento}") logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
continue
@shared_task @shared_task
def documentos_con_errores(organizacion_id): def documentos_con_errores(organizacion_id):

View File

@@ -62,6 +62,27 @@ from .views_auditor import (
auditor_obtener_peticion_edocument_vu, auditor_obtener_peticion_edocument_vu,
auditor_obtener_respuesta_edocument_vu, auditor_obtener_respuesta_edocument_vu,
auditar_pedimento_endpoint, 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 = [ urlpatterns = [
@@ -80,6 +101,11 @@ urlpatterns = [
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'), path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'), 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/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/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'), path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
@@ -101,4 +127,22 @@ urlpatterns = [
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'), path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
path('auditor/auditar-integridad-partidas/', auditar_integridad_partidas_endpoint, name='auditar-integridad-partidas'),
path('auditor/auditar-integridad-partidas/pedimento/', auditar_integridad_partidas_pedimento_endpoint, name='auditar-integridad-partidas-pedimento'),
path('auditor/auditar-integridad-edocuments/', auditar_integridad_edocuments_endpoint, name='auditar-integridad-edocuments'),
path('auditor/auditar-integridad-edocuments/pedimento/', auditar_integridad_edocuments_pedimento_endpoint, name='auditar-integridad-edocuments-pedimento'),
path('auditor/auditar-integridad-coves/', auditar_integridad_coves_endpoint, name='auditar-integridad-coves'),
path('auditor/auditar-integridad-coves/pedimento/', auditar_integridad_coves_pedimento_endpoint, name='auditar-integridad-coves-pedimento'),
path('auditor/auditar-integridad-remesa/', auditar_integridad_remesa_endpoint, name='auditar-integridad-remesa'),
path('auditor/auditar-integridad-remesa/pedimento/', auditar_integridad_remesa_pedimento_endpoint, name='auditar-integridad-remesa-pedimento'),
path('auditor/corregir-integridad-partidas/', corregir_integridad_partidas_endpoint, name='corregir-integridad-partidas'),
path('auditor/corregir-integridad-partidas/pedimento/', corregir_integridad_partidas_pedimento_endpoint, name='corregir-integridad-partidas-pedimento'),
path('auditor/corregir-integridad-edocuments/', corregir_integridad_edocuments_endpoint, name='corregir-integridad-edocuments'),
path('auditor/corregir-integridad-edocuments/pedimento/', corregir_integridad_edocuments_pedimento_endpoint, name='corregir-integridad-edocuments-pedimento'),
path('auditor/corregir-integridad-coves/', corregir_integridad_coves_endpoint, name='corregir-integridad-coves'),
path('auditor/corregir-integridad-coves/pedimento/', corregir_integridad_coves_pedimento_endpoint, name='corregir-integridad-coves-pedimento'),
path('auditor/corregir-integridad-remesa/', corregir_integridad_remesa_endpoint, name='corregir-integridad-remesa'),
path('auditor/corregir-integridad-remesa/pedimento/', corregir_integridad_remesa_pedimento_endpoint, name='corregir-integridad-remesa-pedimento'),
] ]

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 # Rango de fecha de pago: ?fecha_pago_desde=YYYY-MM-DD&fecha_pago_hasta=YYYY-MM-DD
fecha_pago_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte') fecha_pago_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte')
fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte') fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte')
# CharFilter directo sobre contribuyente_id (RFC). ModelChoiceFilter silenciosamente
# omite el filtro cuando el RFC no existe, lo que causa fuga de pedimentos de otros
# importadores. Con CharFilter+exact: RFC inválido → cero resultados, nunca fuga.
contribuyente = django_filters.CharFilter(field_name='contribuyente', lookup_expr='exact')
class Meta: class Meta:
model = Pedimento model = Pedimento
fields = [ fields = [
'patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'patente', 'aduana', 'tipo_operacion', 'clave_pedimento',
'pedimento', 'existe_expediente', 'contribuyente', 'pedimento', 'existe_expediente',
'curp_apoderado', 'fecha_pago', 'pedimento_app', 'curp_apoderado', 'fecha_pago', 'pedimento_app',
] ]
class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion
@@ -1198,18 +1202,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Validar organización del usuario # Validar organización del usuario (superuser usa active_organization)
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response( return Response(
{ {
"tieneError": True, "tieneError": True,
"error": "Usuario no autenticado o sin organización" "error": "Usuario no autenticado o sin organización asignada"
}, },
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
organizacion = request.user.organizacion
# Regex para validar nomenclatura: anio-aduana-patente-pedimento # Regex para validar nomenclatura: anio-aduana-patente-pedimento
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$') nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$') nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
@@ -1744,18 +1748,18 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
partidas_input = request.data.get('partidas') partidas_input = request.data.get('partidas')
fuente_archivos = request.data.get('partidas') fuente_archivos = request.data.get('partidas')
# Validar organización del usuario # Validar organización del usuario (superuser usa active_organization)
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response( return Response(
{ {
"tieneError": True, "tieneError": True,
"error": "Usuario no autenticado o sin organización" "error": "Usuario no autenticado o sin organización asignada"
}, },
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
organizacion = request.user.organizacion
# Regex para validar nomenclatura: anio-aduana-patente-pedimento # Regex para validar nomenclatura: anio-aduana-patente-pedimento
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$') nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$') nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
@@ -2210,16 +2214,16 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
# Validar organización del usuario # Validar organización del usuario (superuser usa active_organization)
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'): from core.permissions import get_org_context
organizacion = get_org_context(request.user) if request.user.is_authenticated else None
if not organizacion:
return Response( return Response(
{'tieneError': True, {'tieneError': True,
"mensaje": "Usuario no autenticado o sin organización"}, "mensaje": "Usuario no autenticado o sin organización asignada"},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
organizacion = request.user.organizacion
# Preparar parámetros # Preparar parámetros
parametros = { parametros = {
'contribuyente': request.data.get('contribuyente'), 'contribuyente': request.data.get('contribuyente'),
@@ -2505,6 +2509,7 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
'partial_update': 'edocuments.edit', 'partial_update': 'edocuments.edit',
'destroy': 'edocuments.delete', 'destroy': 'edocuments.delete',
'bulk_delete_edocs_vu': 'edocuments.delete', 'bulk_delete_edocs_vu': 'edocuments.delete',
'reset_acuse': 'edocuments.edit',
} }
codename = perms.get(self.action, 'edocuments.view') codename = perms.get(self.action, 'edocuments.view')
return [IsAuthenticated(), require_permission(codename)()] return [IsAuthenticated(), require_permission(codename)()]
@@ -2531,6 +2536,88 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
def perform_destroy(self, instance): def perform_destroy(self, instance):
instance.delete() instance.delete()
@action(detail=True, methods=['post'], url_path='reset-acuse')
def reset_acuse(self, request, pk=None):
"""
Detecta inconsistencia cuando acuse_descargado=True pero no existe el documento
de acuse (tipo 4). Crea un registro de error tipo 26 para Errores VU y
restablece acuse_descargado=False para permitir reintentar.
"""
from api.record.models import Document, DocumentType
import logging
logger = logging.getLogger('api.customs.views')
edoc = self.get_object()
if not edoc.acuse_descargado:
return Response(
{"error": "El acuse no está marcado como descargado"},
status=status.HTTP_400_BAD_REQUEST
)
# Verificar si el acuse PDF (tipo 4 = Pedimento Acuse) existe realmente
acuse_disponible = Document.objects.filter(
pedimento=edoc.pedimento,
archivo__icontains=edoc.numero_edocument,
document_type_id=4
).exists()
if acuse_disponible:
return Response(
{"status": "El acuse está disponible correctamente", "acuse_disponible": True},
status=status.HTTP_200_OK
)
# Inconsistencia confirmada: crear documento de error tipo 26 para Errores VU
doc_type_error = DocumentType.objects.filter(id=26).first()
if doc_type_error:
error_content = (
f"Inconsistencia detectada: el acuse del EDocument {edoc.numero_edocument} "
f"fue marcado como descargado pero el documento no se encuentra disponible. "
f"El estado fue restablecido para permitir reprocesamiento."
).encode('utf-8')
try:
with tempfile.NamedTemporaryFile(
mode='wb', suffix='.txt', delete=False
) as f:
f.write(error_content)
tmp_path = f.name
pedimento_app = getattr(edoc.pedimento, 'pedimento_app', str(edoc.pedimento.pedimento))
file_name = f"error_acuse_{edoc.numero_edocument}.txt"
saved_path = storage_service.save_document_from_path(
file_path=tmp_path,
file_name=file_name,
organizacion_id=edoc.organizacion_id,
pedimento_app=pedimento_app
)
if saved_path:
Document.objects.create(
organizacion=edoc.organizacion,
pedimento=edoc.pedimento,
archivo=saved_path,
document_type=doc_type_error,
extension='TXT',
size=len(error_content),
fuente=None,
)
except Exception as e:
logger.error(
f"Error creando documento de error para acuse {edoc.numero_edocument}: {e}"
)
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
edoc.acuse_descargado = False
edoc.save()
serializer = self.get_serializer(edoc)
return Response(serializer.data, status=status.HTTP_200_OK)
class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
""" """
ViewSet for Cove model. ViewSet for Cove model.

File diff suppressed because it is too large Load Diff

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

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 import io
import logging
from api.utils.storage_service import storage_service
from celery import shared_task
from api.organization.models import Organizacion
from django.utils import timezone
from api.reports.models import ReportDocument
from api.customs.models import Pedimento, Cove, EDocument, Partida
from django.db.models import Q, Exists, OuterRef
# from django.db.models import Q,
from api.record.models import Document
import csv
import os import os
from django.conf import settings import tempfile
from django.core.files.uploadedfile import SimpleUploadedFile import traceback
from collections import defaultdict
@shared_task import openpyxl
def generate_report_document(report_id): from openpyxl.styles import Alignment, Font, PatternFill
from celery import shared_task
from celery.exceptions import SoftTimeLimitExceeded
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.utils import timezone
from api.customs.models import Cove, EDocument, Partida, Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
from api.reports.models import ReportDocument
from api.utils.storage_service import storage_service
from core.redis_events import publish_task_event
logger = logging.getLogger('api.reports.tasks')
# ── helpers ───────────────────────────────────────────────────────────────────
def _estado(flag: bool) -> str:
return 'RECUPERADO' if flag else 'PENDIENTE'
def _build_pedimento_filters(filters: dict) -> Q:
q = Q()
if filters.get('organizacion_id'):
q &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
q &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
q &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('patente'):
q &= Q(patente=filters['patente'])
if filters.get('aduana'):
q &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
q &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
q &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
q &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
q &= Q(tipo_operacion_id=filters['tipo_operacion'])
rfc_val = filters.get('contribuyente__rfc')
if rfc_val:
if rfc_val == 'SIN_RFC':
q &= Q(contribuyente__isnull=True)
else:
q &= Q(contribuyente__rfc=rfc_val)
return q
def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q:
"""Restringe el queryset a los importadores visibles del usuario."""
# SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True
if requested_rfc == 'SIN_RFC':
return q
user_rfcs = user.rfc.all()
if not user_rfcs.exists():
if requested_rfc:
q &= Q(contribuyente__rfc=requested_rfc)
return q
if requested_rfc:
if user_rfcs.filter(rfc=requested_rfc).exists():
q &= Q(contribuyente__rfc=requested_rfc)
else:
q &= Q(contribuyente__in=user_rfcs)
else:
q &= Q(contribuyente__in=user_rfcs)
return q
# ── tarea principal ───────────────────────────────────────────────────────────
@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660)
def generate_report_document(self, report_id):
task_id = self.request.id
report = None
def _fail(msg, exc=None):
"""Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
tb = traceback.format_exc() if exc else ''
full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
logger.error('[reporte_cumplimiento] report=%s FALLO: %s', report_id, full_msg)
if report:
report.status = 'error'
report.error_message = full_msg
report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])
publish_task_event(task_id, 'failed', msg, progress=0)
# ── 1. Obtener reporte ────────────────────────────────────────────────────
try: try:
report = ReportDocument.objects.get(id=report_id) report = ReportDocument.objects.get(id=report_id)
report.status = 'processing' except ReportDocument.DoesNotExist:
report.save(update_fields=['status']) logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id)
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
return
logger.info('[reporte_cumplimiento] Iniciando report=%s user=%s', report_id, report.user_id)
report.status = 'processing'
report.save(update_fields=['status'])
publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5)
try:
filters = report.filters or {} filters = report.filters or {}
pedimentos_filters = Q() org_id = filters.get('organizacion_id')
if filters.get('organizacion_id'):
pedimentos_filters &= Q(organizacion_id=filters['organizacion_id'])
if filters.get('fecha_pago__gte'):
pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
if filters.get('fecha_pago__lte'):
pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
if filters.get('contribuyente__rfc'):
pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc'])
if filters.get('patente'):
pedimentos_filters &= Q(patente=filters['patente'])
if filters.get('aduana'):
pedimentos_filters &= Q(aduana=filters['aduana'])
if filters.get('pedimento'):
pedimentos_filters &= Q(pedimento=filters['pedimento'])
if filters.get('pedimento_app'):
pedimentos_filters &= Q(pedimento_app=filters['pedimento_app'])
if filters.get('regimen'):
pedimentos_filters &= Q(regimen=filters['regimen'])
if filters.get('tipo_operacion'):
pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion'])
# Consulta asíncrona de los modelos
pedimentos = Pedimento.objects.filter(pedimentos_filters)
filename = filters.get('filename')
if filename:
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else:
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f: # ── 2. Filtros y organización ─────────────────────────────────────────
tmp_path = f.name q = _build_pedimento_filters(filters)
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
# Escribir CSV en archivo temporal nombre_org = ''
with open(tmp_path, 'w', newline='', encoding='utf-8') as f: if org_id:
writer = csv.writer(f) try:
headers = [ nombre_org = Organizacion.objects.get(id=org_id).nombre
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento', except Organizacion.DoesNotExist:
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado' pass
]
writer.writerow(headers)
for ped in pedimentos: logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
for cove in Cove.objects.filter(pedimento=ped): publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado
])
for edoc in EDocument.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado
])
for partida in Partida.objects.filter(pedimento=ped):
writer.writerow([
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, ''
])
# ============ NUEVO: Guardar en MinIO ============ # ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
# Leer archivo temporal rfcs_list = list(
with open(tmp_path, 'rb') as f: Pedimento.objects.filter(q)
file_content = f.read() .exclude(contribuyente__isnull=True)
.values_list('contribuyente__rfc', flat=True)
.distinct()
.order_by('contribuyente__rfc')
)
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
rfcs_list.append('SIN_RFC')
# Crear UploadedFile total_rfcs = len(rfcs_list)
uploaded_file = SimpleUploadedFile( total_pedimentos = Pedimento.objects.filter(q).count()
name=filename,
content=file_content, logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
content_type='text/csv' report_id, total_rfcs, total_pedimentos)
if total_rfcs == 0:
logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id)
publish_task_event(
task_id, 'processing',
f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15,
) )
# Guardar en storage # ── 4. Crear workbook ─────────────────────────────────────────────────
wb = openpyxl.Workbook()
ws = wb.active
ws.title = 'Reporte Cumplimiento'
title_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
title_font = Font(color='FFFFFF', bold=True, size=12)
sub_fill = PatternFill(start_color='2E75B6', end_color='2E75B6', fill_type='solid')
sub_font = Font(color='FFFFFF', bold=True, size=10)
col_h_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid')
col_h_font = Font(bold=True, size=10)
footer_fill = PatternFill(start_color='E2EFDA', end_color='E2EFDA', fill_type='solid')
center = Alignment(horizontal='center', vertical='center', wrap_text=True)
top_left = Alignment(horizontal='left', vertical='top', wrap_text=True)
COL_HEADERS = [
'Año', 'Aduana', 'Patente', 'Pedimento',
'Nomenclatura Completo Pedimento', 'Clav', 'Tipo Operación',
'Expediente Sí', 'Documento', 'Estatus',
]
TOTAL_COLS = len(COL_HEADERS)
current_row = 1
safe_total = max(total_rfcs, 1)
# ── 5. Procesar RFC por RFC ───────────────────────────────────────────
for rfc_idx, rfc in enumerate(rfcs_list):
pct = 20 + int((rfc_idx / safe_total) * 65)
publish_task_event(
task_id, 'processing',
f'RFC {rfc_idx + 1}/{total_rfcs}: {rfc}', progress=pct,
)
rfc_q = (
q & Q(contribuyente__isnull=True) if rfc == 'SIN_RFC'
else q & Q(contribuyente__rfc=rfc)
)
peds = list(
Pedimento.objects.filter(rfc_q)
.select_related('contribuyente', 'tipo_operacion')
.order_by('fecha_pago')
)
if not peds:
logger.warning('[reporte_cumplimiento] report=%s rfc=%s sin pedimentos, omitido', report_id, rfc)
continue
ped_ids = [p.id for p in peds]
razon_social = nombre_org or 'Desconocido'
logger.info('[reporte_cumplimiento] report=%s rfc=%s pedimentos=%d',
report_id, rfc, len(peds))
# documentos de este RFC solamente
coves_map: dict = defaultdict(list)
for c in Cove.objects.filter(pedimento_id__in=ped_ids):
coves_map[c.pedimento_id].append(c)
edocs_map: dict = defaultdict(list)
for e in EDocument.objects.filter(pedimento_id__in=ped_ids):
edocs_map[e.pedimento_id].append(e)
partidas_map: dict = defaultdict(list)
for p in Partida.objects.filter(pedimento_id__in=ped_ids).order_by('numero_partida'):
partidas_map[p.pedimento_id].append(p)
remesa_ped_ids: set = set(
Document.objects.filter(pedimento_id__in=ped_ids, document_type_id=15)
.values_list('pedimento_id', flat=True)
)
total_coves = sum(len(v) for v in coves_map.values())
total_edocs = sum(len(v) for v in edocs_map.values())
total_partidas = sum(len(v) for v in partidas_map.values())
est_rows = len(peds) + total_partidas + total_coves * 2 + total_edocs * 2 + len(remesa_ped_ids)
logger.info('[reporte_cumplimiento] report=%s rfc=%s docs coves=%d edocs=%d partidas=%d remesas=%d filas_estimadas=%d',
report_id, rfc, total_coves, total_edocs, total_partidas, len(remesa_ped_ids), est_rows)
# encabezado sección
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value='Reporte Integración de Expedientes.')
cell.fill, cell.font, cell.alignment = title_fill, title_font, center
current_row += 1
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value=f'Razón Social Importador: {razon_social}')
cell.fill, cell.font = sub_fill, sub_font
current_row += 1
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(row=current_row, column=1, value=f'RFC: {rfc}')
cell.fill, cell.font = sub_fill, sub_font
current_row += 1
for col_i, header in enumerate(COL_HEADERS, 1):
cell = ws.cell(row=current_row, column=col_i, value=header)
cell.fill, cell.font, cell.alignment = col_h_fill, col_h_font, center
current_row += 1
total_exp = len(peds)
exp_con_docs = exp_completos = 0
for ped in peds:
doc_rows = [('PEDIMENTO COMPLETO', _estado(ped.existe_expediente))]
for partida in partidas_map[ped.id]:
doc_rows.append((f'PARTIDA{partida.numero_partida}', _estado(partida.descargado)))
if ped.remesas:
doc_rows.append(('REMESA', _estado(ped.id in remesa_ped_ids)))
for cove in coves_map[ped.id]:
doc_rows.append((f'COVE{cove.numero_cove}', _estado(cove.cove_descargado)))
doc_rows.append((f'ACUSE COVE{cove.numero_cove}', _estado(cove.acuse_cove_descargado)))
for edoc in edocs_map[ped.id]:
doc_rows.append((f'EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.edocument_descargado)))
doc_rows.append((f'ACUSE EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.acuse_descargado)))
if len(doc_rows) > 1:
exp_con_docs += 1
if all(e == 'RECUPERADO' for _, e in doc_rows):
exp_completos += 1
n_rows = len(doc_rows)
start_row = current_row
anio = ped.fecha_pago.year % 100 if ped.fecha_pago else ''
base_vals = [
anio, ped.aduana or '', ped.patente or '', ped.pedimento or '',
ped.pedimento_app or '', ped.clave_pedimento or '',
ped.tipo_operacion.tipo if ped.tipo_operacion else '',
'SI' if ped.existe_expediente else 'NO',
]
# Sin merge_cells — para datasets grandes merge es O(n^2) y cuelga el proceso.
# Los datos base solo se escriben en la primera fila; el resto queda vacío,
# visualmente equivalente al merge pero sin el costo de memoria/CPU.
for offset, (doc_nombre, doc_est) in enumerate(doc_rows):
r = start_row + offset
if offset == 0:
for col, val in enumerate(base_vals, 1):
ws.cell(row=r, column=col, value=val)
ws.cell(row=r, column=9, value=doc_nombre)
ws.cell(row=r, column=10, value=doc_est)
current_row += n_rows
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
cell = ws.cell(
row=current_row, column=1,
value=(f'Total de Expedientes= {total_exp} '
f'Total De Expedientes Con Documentos= {exp_con_docs} '
f'Total De Expedientes Completos= {exp_completos}'),
)
cell.fill = footer_fill
cell.font = Font(bold=True)
current_row += 2
del peds, ped_ids, coves_map, edocs_map, partidas_map, remesa_ped_ids
for i, w in enumerate([6, 8, 8, 12, 32, 8, 16, 12, 32, 14], 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
# ── 6. Serializar y subir ─────────────────────────────────────────────
logger.info('[reporte_cumplimiento] report=%s serializando Excel...', report_id)
publish_task_event(task_id, 'processing', 'Serializando Excel...', progress=88)
filename = f"reporte_cumplimiento_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.xlsx"
buf = io.BytesIO()
wb.save(buf)
excel_bytes = buf.getvalue()
logger.info('[reporte_cumplimiento] report=%s Excel size=%.1fKB', report_id, len(excel_bytes) / 1024)
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
ruta = storage_service.save_report( ruta = storage_service.save_report(
file=uploaded_file, file=SimpleUploadedFile(
organizacion_id=filters.get('organizacion_id'), name=filename,
content=excel_bytes,
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
),
organizacion_id=org_id,
metadata={ metadata={
'report_id': str(report.id), 'report_id': str(report.id),
'report_type': 'cumplimiento', 'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None 'user_id': str(report.user.id) if report.user else None,
} },
) )
if ruta: if ruta:
report.file = ruta logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
report.file = ruta
report.status = 'ready' report.status = 'ready'
else: else:
report.status = 'error' _fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
report.error_message = 'Error al guardar el archivo en storage' return
# Limpiar temporal
os.unlink(tmp_path)
report.finished_at = timezone.now() report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at', 'error_message']) report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e: resultado = {
report.status = 'error' 'report_id': str(report.id),
report.error_message = str(e) 'total_rfcs': total_rfcs,
report.finished_at = timezone.now() 'total_pedimentos': total_pedimentos,
report.save(update_fields=['status', 'error_message', 'finished_at']) }
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d',
report_id, total_rfcs, total_pedimentos)
return resultado
except SoftTimeLimitExceeded:
_fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.')
except Exception as exc:
_fail(str(exc), exc=exc)
# ── reporte de control de pedimentos (sin cambios) ────────────────────────────
@shared_task @shared_task
def generate_report_control_pedimento(report_id): def generate_report_control_pedimento(report_id):
@@ -133,8 +378,6 @@ def generate_report_control_pedimento(report_id):
report.save(update_fields=['status']) report.save(update_fields=['status'])
filters = report.filters or {} filters = report.filters or {}
# Construir filtros
pedimentos_filters = {} pedimentos_filters = {}
if filters.get('organizacion_id'): if filters.get('organizacion_id'):
pedimentos_filters['organizacion_id'] = filters['organizacion_id'] pedimentos_filters['organizacion_id'] = filters['organizacion_id']
@@ -145,15 +388,12 @@ def generate_report_control_pedimento(report_id):
if filters.get('pedimento_app'): if filters.get('pedimento_app'):
pedimentos_filters['pedimento_app'] = filters['pedimento_app'] pedimentos_filters['pedimento_app'] = filters['pedimento_app']
# pedimentos por organizacion
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters) pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
pedimentos_total = pedimentos_qs.count() pedimentos_total = pedimentos_qs.count()
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True)) pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True)) rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
# inicializar totales
pedimentos_completos = 0 pedimentos_completos = 0
total_documentos = 0 total_documentos = 0
documentos_sin_descargar = 0 documentos_sin_descargar = 0
@@ -161,15 +401,13 @@ def generate_report_control_pedimento(report_id):
nombre_organizacion = '' nombre_organizacion = ''
if filters.get('organizacion_id'): if filters.get('organizacion_id'):
try: try:
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
organizacion = Organizacion.objects.get(id=filters['organizacion_id']) organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo nombre_organizacion = organizacion.nombre
except Organizacion.DoesNotExist: except Organizacion.DoesNotExist:
nombre_organizacion = f"ID: {filters['organizacion_id']}" nombre_organizacion = f"ID: {filters['organizacion_id']}"
except Exception as e: except Exception as e:
nombre_organizacion = f"Error: {str(e)}" nombre_organizacion = f"Error: {str(e)}"
# lista de rfc
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc]))) rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
fecha_inicio = '' fecha_inicio = ''
@@ -184,42 +422,33 @@ def generate_report_control_pedimento(report_id):
if ultimo_pedimento and ultimo_pedimento.fecha_pago: if ultimo_pedimento and ultimo_pedimento.fecha_pago:
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d') fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
# Para cada pedimento, verificar si está completo
for pedimento in pedimentos_qs: for pedimento in pedimentos_qs:
# Contar documentos de este pedimento
docs_pedimento = 0 docs_pedimento = 0
docs_pendientes_pedimento = 0 docs_pendientes_pedimento = 0
# COVES
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count() coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count() coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
docs_pedimento += coves_count docs_pedimento += coves_count
docs_pendientes_pedimento += coves_pendientes docs_pendientes_pedimento += coves_pendientes
# PARTIDAS
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count() partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count() partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
docs_pedimento += partidas_count docs_pedimento += partidas_count
docs_pendientes_pedimento += partidas_pendientes docs_pendientes_pedimento += partidas_pendientes
# EDOCUMENTS
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count() edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count() edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
docs_pedimento += edocs_count docs_pedimento += edocs_count
docs_pendientes_pedimento += edocs_pendientes docs_pendientes_pedimento += edocs_pendientes
# Acumular totales
total_documentos += docs_pedimento total_documentos += docs_pedimento
documentos_sin_descargar += docs_pendientes_pedimento documentos_sin_descargar += docs_pendientes_pedimento
# Si no tiene documentos pendientes, está completo
if docs_pendientes_pedimento == 0 and docs_pedimento > 0: if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
pedimentos_completos += 1 pedimentos_completos += 1
# 3. PORCENTAJE
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0 porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
# 4. GENERAR CSV CON DETALLES
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp: with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
@@ -227,61 +456,39 @@ def generate_report_control_pedimento(report_id):
todas_las_filas = [] todas_las_filas = []
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
for pedimento in pedimentos_qs: for pedimento in pedimentos_qs:
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
datos_base_pedimento = [ datos_base_pedimento = [
pedimento.aduana or '', pedimento.aduana or '',
pedimento.patente or '', pedimento.patente or '',
pedimento.regimen or '', pedimento.regimen or '',
pedimento.pedimento or '', # No. Pedimento (7 dígitos) pedimento.pedimento or '',
pedimento.pedimento_app or '', # No. Pedimento App completo pedimento.pedimento_app or '',
pedimento.clave_pedimento or '', pedimento.clave_pedimento or '',
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '', pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else '' str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
] ]
# COVES - Una fila por cada COVE
coves = Cove.objects.filter(pedimento_id=pedimento.id) coves = Cove.objects.filter(pedimento_id=pedimento.id)
for cove in coves: for cove in coves:
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO' estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
fila = datos_base_pedimento + [ fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
# str(cove.id), # Identificador de documento
cove.numero_cove,
'COVE', # Tipo de documento
estado
]
todas_las_filas.append(fila) todas_las_filas.append(fila)
# PARTIDAS - Una fila por cada Partida
partidas = Partida.objects.filter(pedimento_id=pedimento.id) partidas = Partida.objects.filter(pedimento_id=pedimento.id)
for partida in partidas: for partida in partidas:
estado = 'VERDADERO' if partida.descargado else 'FALSO' estado = 'VERDADERO' if partida.descargado else 'FALSO'
fila = datos_base_pedimento + [ fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
# str(partida.id),
partida.numero_partida,
'PARTIDA', # Tipo de documento
estado
]
todas_las_filas.append(fila) todas_las_filas.append(fila)
# EDOCUMENTS - Una fila por cada EDocument
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id) edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
for edoc in edocuments: for edoc in edocuments:
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO' estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
fila = datos_base_pedimento + [ fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
# str(edoc.id),
edoc.numero_edocument,
'EDOCUMENT', # Tipo de documento
estado
]
todas_las_filas.append(fila) todas_las_filas.append(fila)
# 5. ESCRIBIR ARCHIVO CSV import csv
with open(tmp_path, 'w', newline='', encoding='utf-8') as f: with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f) writer = csv.writer(f)
# SECCIÓN DE TOTALES
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS']) writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
writer.writerow(['ORGANIZACION:', nombre_organizacion]) writer.writerow(['ORGANIZACION:', nombre_organizacion])
writer.writerow([]) writer.writerow([])
@@ -294,20 +501,15 @@ def generate_report_control_pedimento(report_id):
writer.writerow(['LISTA RFC:', rfc_list]) writer.writerow(['LISTA RFC:', rfc_list])
writer.writerow([]) writer.writerow([])
writer.writerow([]) writer.writerow([])
# ENCABEZADOS DE DATOS (según requerimiento)
headers = [ headers = [
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP', 'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID', 'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO' 'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
] ]
writer.writerow(headers) writer.writerow(headers)
# DATOS DETALLADOS
for fila in todas_las_filas: for fila in todas_las_filas:
writer.writerow(fila) writer.writerow(fila)
with open(tmp_path, 'rb') as f: with open(tmp_path, 'rb') as f:
file_content = f.read() file_content = f.read()

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

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

View File

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

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)