Compare commits
1 Commits
d732602775
...
celery-ped
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8379807f8 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -179,4 +179,3 @@ cython_debug/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/django
|
||||
*.bak
|
||||
.vscode/
|
||||
@@ -8,10 +8,10 @@ from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from core.permissions import (
|
||||
get_org_context,
|
||||
require_permission,
|
||||
user_has_permission,
|
||||
user_has_role,
|
||||
IsSameOrganization,
|
||||
IsSameOrganizationDeveloper,
|
||||
IsSameOrganizationAndAdmin,
|
||||
IsSuperUser
|
||||
)
|
||||
|
||||
from api.organization.models import UsoAlmacenamiento, Organizacion
|
||||
@@ -34,7 +34,7 @@ class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin)
|
||||
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
|
||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
model = Document
|
||||
|
||||
my_tags = ['Cards']
|
||||
@@ -100,7 +100,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
||||
View para obtener información de uso de servicios relacionados con pedimentos.
|
||||
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
model = Document
|
||||
my_tags = ['Cards']
|
||||
|
||||
@@ -137,28 +137,32 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_authenticated or not hasattr(user, 'organizacion'):
|
||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||
return None
|
||||
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return ProcesamientoPedimento.objects.none()
|
||||
# Si es super usuario, devuelve todos los procesos
|
||||
if self.request.user.is_superuser:
|
||||
return ProcesamientoPedimento.objects.all()
|
||||
|
||||
qs = ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
|
||||
# Misma precedencia que los mixins de filtrado: superuser y roles
|
||||
# operativos ven todo lo de su org; is_importador no los degrada.
|
||||
if (
|
||||
user.is_superuser or
|
||||
user_has_role(user, 'admin') or
|
||||
user_has_role(user, 'developer') or
|
||||
user_has_role(user, 'Agente Aduanal') or
|
||||
user_has_role(user, 'user')
|
||||
):
|
||||
return qs
|
||||
if user.is_importador:
|
||||
return qs.filter(pedimento__contribuyente__in=user.rfc.all())
|
||||
return ProcesamientoPedimento.objects.none()
|
||||
# Si es Administrador de la organizacion devuelve todos los servicios de la organizacion
|
||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
||||
|
||||
# Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion
|
||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
||||
|
||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
||||
|
||||
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos
|
||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists():
|
||||
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc)
|
||||
|
||||
|
||||
|
||||
# Si es parte de una organización, filtrar por esa organización
|
||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
||||
|
||||
def get(self, request):
|
||||
queryset = self.get_queryset()
|
||||
@@ -189,21 +193,12 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
||||
Endpoint para análisis de actividades de usuario.
|
||||
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
|
||||
model = UserActivity
|
||||
campo_organizacion = 'user__organizacion'
|
||||
|
||||
my_tags = ['Cards']
|
||||
|
||||
def get_queryset_importador(self):
|
||||
# Importadores solo ven sus propias actividades
|
||||
user = self.request.user
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return UserActivity.objects.none()
|
||||
return UserActivity.objects.filter(user__organizacion=org, user=user)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
|
||||
manual_parameters=[
|
||||
@@ -258,9 +253,7 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
||||
}
|
||||
)
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_importador:
|
||||
return self.get_queryset_importador()
|
||||
return self.get_queryset_filtrado()
|
||||
return self.get_queryset_filtrado()
|
||||
|
||||
def get(self, request):
|
||||
queryset = self.get_queryset()
|
||||
@@ -296,20 +289,11 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
||||
Endpoint para análisis de logs de peticiones.
|
||||
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
model = RequestLog
|
||||
campo_organizacion = 'user__organizacion'
|
||||
|
||||
my_tags = ['Cards']
|
||||
|
||||
def get_queryset_importador(self):
|
||||
# Importadores solo ven sus propios logs
|
||||
user = self.request.user
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return RequestLog.objects.none()
|
||||
return RequestLog.objects.filter(user__organizacion=org, user=user)
|
||||
|
||||
@swagger_auto_schema(
|
||||
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
|
||||
manual_parameters=[
|
||||
@@ -361,8 +345,6 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
||||
}
|
||||
)
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_importador:
|
||||
return self.get_queryset_importador()
|
||||
return self.get_queryset_filtrado()
|
||||
|
||||
def get(self, request):
|
||||
@@ -394,7 +376,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin):
|
||||
View que obtiene los ultimos 10 documentos agregados.
|
||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||
"""
|
||||
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
model = Document
|
||||
|
||||
my_tags = ['Cards']
|
||||
|
||||
@@ -13,7 +13,7 @@ class CustomUserCreationForm(UserCreationForm):
|
||||
class CustomUserChangeForm(UserChangeForm):
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture', 'is_importador', 'rfc')
|
||||
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture')
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
@@ -25,12 +25,11 @@ class CustomUserAdmin(UserAdmin):
|
||||
list_filter = ('is_staff', 'is_active', 'organizacion')
|
||||
search_fields = ('username', 'email', 'first_name', 'last_name')
|
||||
ordering = ('username',)
|
||||
filter_horizontal = ('rfc', 'groups', 'user_permissions')
|
||||
|
||||
# Fieldsets para editar un usuario
|
||||
fieldsets = (
|
||||
(None, {'fields': ('username', 'password')}),
|
||||
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'active_organization', 'profile_picture', 'is_importador', 'rfc')}),
|
||||
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'profile_picture', 'is_importador', 'rfc')}),
|
||||
('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Fechas importantes', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
"""
|
||||
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")
|
||||
@@ -1,57 +0,0 @@
|
||||
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',
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -11,22 +11,8 @@ class CustomUser(AbstractUser):
|
||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users')
|
||||
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
|
||||
|
||||
# Contexto de trabajo activo para superusuarios. Filtra datos igual que un usuario normal.
|
||||
# Sin este campo activo, el superuser no puede consultar datos — debe hacer switch primero.
|
||||
active_organization = models.ForeignKey(
|
||||
'organization.Organizacion',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='superusers_activos',
|
||||
help_text="Solo superusuarios: organización activa para contexto de trabajo",
|
||||
)
|
||||
|
||||
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
|
||||
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
|
||||
|
||||
# Identidad Keycloak — se llena con el script de migración masiva
|
||||
keycloak_user_id = models.CharField(max_length=36, null=True, blank=True, unique=True, help_text="UUID del usuario en Keycloak/Hub")
|
||||
rfc = models.ForeignKey('customs.Importador', on_delete=models.SET_NULL, null=True, blank=True, related_name='users', help_text="RFC associated with the user if they are an importer")
|
||||
|
||||
def __str__(self):
|
||||
return self.username
|
||||
|
||||
@@ -2,62 +2,28 @@
|
||||
from rest_framework import serializers
|
||||
from .models import CustomUser
|
||||
from django.contrib.auth.models import Group
|
||||
from api.customs.models import Importador
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Serializer for the CustomUser model.
|
||||
"""
|
||||
|
||||
password = serializers.CharField(write_only=True, required=False)
|
||||
password = serializers.CharField(write_only=True)
|
||||
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
|
||||
rfc = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Importador.objects.all(),
|
||||
many=True,
|
||||
required=False,
|
||||
pk_field=serializers.CharField(),
|
||||
)
|
||||
rfc = serializers.CharField(max_length=20, required=False, allow_blank=True)
|
||||
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
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']
|
||||
|
||||
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):
|
||||
groups = validated_data.pop('groups', [])
|
||||
rfcs = validated_data.pop('rfc', [])
|
||||
password = validated_data.pop('password')
|
||||
user = CustomUser(**validated_data)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
if groups:
|
||||
user.groups.set(groups)
|
||||
if rfcs:
|
||||
user.rfc.set(rfcs)
|
||||
return user
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
groups = validated_data.pop('groups', None)
|
||||
rfcs = validated_data.pop('rfc', None)
|
||||
password = validated_data.pop('password', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
if password:
|
||||
instance.set_password(password)
|
||||
instance.save()
|
||||
if groups is not None:
|
||||
instance.groups.set(groups)
|
||||
if rfcs is not None:
|
||||
instance.rfc.set(rfcs)
|
||||
return instance
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
]
|
||||
@@ -1,564 +0,0 @@
|
||||
"""
|
||||
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
|
||||
import re
|
||||
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
|
||||
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 _slug_from_nombre(nombre: str) -> str:
|
||||
"""Deriva un slug válido del nombre de la organización: "TEMEX S.A." → "temex"."""
|
||||
return re.sub(r'[^a-z0-9]+', '-', nombre.lower()).strip('-')[:100]
|
||||
|
||||
|
||||
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 el usuario no tiene keycloak_user_id (first login).
|
||||
|
||||
Envía new_tenant=True: el Hub crea el tenant (y su licencia por defecto) si
|
||||
aún no existe, usando el slug de la organización de EFC.
|
||||
Flujo:
|
||||
1. Obtener org del usuario → derivar/usar hub_tenant_slug
|
||||
2. Provisionar al usuario; el Hub resuelve/crea el tenant y le asigna acceso
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from api.cuser.models import CustomUser
|
||||
|
||||
user = CustomUser.objects.select_related('organizacion').filter(
|
||||
Q(username=username) | Q(email=username),
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
if not user:
|
||||
return False
|
||||
|
||||
org = user.organizacion
|
||||
if not org:
|
||||
logger.warning("[provision] Usuario %s sin organización asignada — omitiendo provisión", username)
|
||||
return False
|
||||
|
||||
# Determinar slug del tenant: usar el guardado o derivarlo del nombre
|
||||
tenant_slug = org.hub_tenant_slug
|
||||
if not tenant_slug:
|
||||
tenant_slug = _slug_from_nombre(org.nombre)
|
||||
# Persistir para no recalcular en futuros logins
|
||||
type(org).objects.filter(pk=org.pk).update(hub_tenant_slug=tenant_slug)
|
||||
logger.info("[provision] Slug derivado para org '%s' → '%s'", org.nombre, tenant_slug)
|
||||
|
||||
provision_secret = getattr(settings, "HUB_PROVISION_SECRET", "")
|
||||
|
||||
# Rol del usuario en el tenant: si tiene el rol admin de su organización lo
|
||||
# provisionamos como admin del tenant en Hub; de lo contrario, como operador.
|
||||
from api.rbac.models import UserRole
|
||||
is_org_admin = UserRole.objects.filter(user=user, role__is_admin_role=True).exists()
|
||||
role = "admin" if is_org_admin else "operador"
|
||||
|
||||
try:
|
||||
r = http.post(
|
||||
f"{HUB_URL()}/api/v1/auth/provision-user",
|
||||
# new_tenant=True → el Hub crea el tenant y su licencia si no existe.
|
||||
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,
|
||||
"tenant_name": org.nombre,
|
||||
"product_slug": "efc",
|
||||
"role": role,
|
||||
"new_tenant": True,
|
||||
},
|
||||
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 → tenant '%s' — KC id: %s",
|
||||
user.username, tenant_slug, 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 _verify_password_against_hub(username: str, password: str) -> bool:
|
||||
"""
|
||||
Verifica credenciales contra el Hub (KC vía /auth/login).
|
||||
Se usa cuando el login local falla para usuarios traídos del Hub vía SSO,
|
||||
que no tienen contraseña local usable. Retorna True solo si el Hub responde 200.
|
||||
"""
|
||||
try:
|
||||
r = http.post(
|
||||
f"{HUB_URL()}/api/v1/auth/login",
|
||||
json={"username": username, "password": password},
|
||||
timeout=15,
|
||||
)
|
||||
except http.exceptions.RequestException as exc:
|
||||
logger.error("[login] Error de red verificando credenciales en Hub para %s: %s", username, exc)
|
||||
return False
|
||||
# 200 = credenciales válidas (tokens o selector de tenant). 401 = inválidas.
|
||||
return r.status_code == 200
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers SSO: auto-provisión Hub → EFC
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_efc_organization(tenant_slug: str, tenant_name: str = None):
|
||||
"""
|
||||
Devuelve (org, created). Si no existe, la crea con datos mínimos.
|
||||
El nombre viene del Hub (tenant_name); si no llega, se deriva del slug.
|
||||
El admin completa RFC, etc. desde el panel de Django.
|
||||
"""
|
||||
from api.organization.models import Organizacion
|
||||
from api.licence.models import Licencia
|
||||
|
||||
org = Organizacion.objects.filter(hub_tenant_slug=tenant_slug).first()
|
||||
if org:
|
||||
return org, False
|
||||
|
||||
licencia, _ = Licencia.objects.get_or_create(
|
||||
nombre='Hub SSO Default',
|
||||
defaults={'almacenamiento': 0},
|
||||
)
|
||||
org = Organizacion.objects.create(
|
||||
hub_tenant_slug=tenant_slug,
|
||||
nombre=(tenant_name or '').strip() or tenant_slug.upper().replace('-', ' '),
|
||||
licencia=licencia,
|
||||
rfc='XAXX010101000',
|
||||
titular='',
|
||||
email='',
|
||||
telefono='',
|
||||
estado='',
|
||||
ciudad='',
|
||||
is_active=True,
|
||||
)
|
||||
logger.info("[sso] Organizacion creada para tenant Hub '%s'", tenant_slug)
|
||||
return org, True
|
||||
|
||||
|
||||
def _ensure_efc_user(hub_data: dict, org):
|
||||
"""
|
||||
Devuelve (user, created). Si no existe, lo crea vinculado a la organización.
|
||||
Si ya existe pero le falta el KC id o la org, los completa.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
from api.cuser.models import CustomUser
|
||||
|
||||
kc_id = hub_data.get('user_id')
|
||||
email = hub_data.get('email', '')
|
||||
username = (hub_data.get('preferred_username') or email or '').strip()
|
||||
|
||||
user = None
|
||||
if kc_id:
|
||||
user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
|
||||
if not user and (email or username):
|
||||
user = CustomUser.objects.filter(
|
||||
Q(email=email) | Q(username=username)
|
||||
).first()
|
||||
|
||||
if user:
|
||||
updates = {}
|
||||
if kc_id and not user.keycloak_user_id:
|
||||
updates['keycloak_user_id'] = kc_id
|
||||
if org and not user.organizacion_id:
|
||||
updates['organizacion'] = org
|
||||
if updates:
|
||||
CustomUser.objects.filter(pk=user.pk).update(**updates)
|
||||
return user, False
|
||||
|
||||
# Usuario nuevo — contraseña inutilizable (solo SSO)
|
||||
name = (hub_data.get('name') or '').strip()
|
||||
parts = name.split(' ', 1) if name else []
|
||||
first = parts[0] if parts else ''
|
||||
last = parts[1] if len(parts) > 1 else ''
|
||||
|
||||
user = CustomUser.objects.create_user(
|
||||
username=username,
|
||||
email=email,
|
||||
first_name=first,
|
||||
last_name=last,
|
||||
password=None,
|
||||
is_active=True,
|
||||
keycloak_user_id=kc_id,
|
||||
organizacion=org,
|
||||
)
|
||||
logger.info("[sso] Usuario '%s' creado desde Hub SSO → org '%s'",
|
||||
username, org.nombre if org else 'sin org')
|
||||
return user, True
|
||||
|
||||
|
||||
def _assign_admin_role(user, org):
|
||||
"""Asigna el rol admin de la org al usuario. No-op si ya lo tiene."""
|
||||
from api.rbac.models import OrganizationRole, UserRole
|
||||
try:
|
||||
admin_role = OrganizationRole.objects.get(organizacion=org, nombre='admin')
|
||||
_, assigned = UserRole.objects.get_or_create(user=user, role=admin_role)
|
||||
if assigned:
|
||||
logger.info("[sso] Rol admin asignado a '%s' en org '%s'", user.username, org.nombre)
|
||||
except OrganizationRole.DoesNotExist:
|
||||
logger.warning("[sso] Rol admin no encontrado para org '%s' — ¿signals ejecutados?", org.nombre)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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).
|
||||
"""
|
||||
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)
|
||||
|
||||
user = django_auth(request, username=username, password=password)
|
||||
|
||||
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)
|
||||
|
||||
# Fallback Hub: los usuarios traídos del Hub vía SSO se crean sin contraseña local
|
||||
# usable (set_unusable_password), así que django_auth falla. Si el usuario está
|
||||
# vinculado al Hub (keycloak_user_id), verificamos la contraseña contra el Hub y, si
|
||||
# es válida, la "localizamos" en EFC para que los próximos logins sean directos.
|
||||
if not user:
|
||||
hub_user = CustomUser.objects.filter(
|
||||
Q(username=username) | Q(email=username), is_active=True
|
||||
).first()
|
||||
if hub_user and hub_user.keycloak_user_id and _verify_password_against_hub(hub_user.username, password):
|
||||
hub_user.set_password(password)
|
||||
hub_user.save(update_fields=["password"])
|
||||
user = hub_user
|
||||
logger.info("[login] Contraseña localizada en EFC para usuario Hub '%s'", hub_user.username)
|
||||
|
||||
if not user or not user.is_active:
|
||||
return Response({"detail": "Credenciales inválidas"}, status=401)
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
Además de emitir tokens, auto-provisiona la organización y el usuario
|
||||
en la BD de EFC si aún no existen (flujo Hub → EFC).
|
||||
"""
|
||||
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()
|
||||
tenant_slug = data.get("tenant_slug")
|
||||
|
||||
try:
|
||||
org, org_created = _ensure_efc_organization(tenant_slug, data.get("tenant_name")) if tenant_slug else (None, False)
|
||||
user, user_created = _ensure_efc_user(data, org)
|
||||
# Primer usuario de una org nueva → admin automático
|
||||
if org_created and user_created and org and user:
|
||||
_assign_admin_role(user, org)
|
||||
except Exception as exc:
|
||||
logger.error("[sso] Error en auto-provisión EFC para tenant '%s': %s", tenant_slug, exc)
|
||||
|
||||
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": 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": 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"), 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,
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
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/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@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.
|
||||
"""
|
||||
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,
|
||||
})
|
||||
set_session_cookies(response, new_tokens)
|
||||
return response
|
||||
@@ -20,11 +20,7 @@ from core.permissions import (
|
||||
IsSameOrganization,
|
||||
IsSameOrganizationDeveloper,
|
||||
IsSameOrganizationAndAdmin,
|
||||
IsSuperUser,
|
||||
get_org_context,
|
||||
is_internal_service_request,
|
||||
user_has_permission,
|
||||
require_permission,
|
||||
IsSuperUser
|
||||
)
|
||||
|
||||
from .serializers import CustomUserSerializer
|
||||
@@ -78,62 +74,78 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
"""
|
||||
ViewSet for CustomUser model.
|
||||
"""
|
||||
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )]
|
||||
pagination_class = CustomPagination
|
||||
model = CustomUser
|
||||
serializer_class = CustomUserSerializer
|
||||
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
|
||||
my_tags = ['User Profile']
|
||||
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('me', 'change_password'):
|
||||
return [IsAuthenticated()]
|
||||
perms = {
|
||||
'list': 'usuarios.view',
|
||||
'retrieve': 'usuarios.view',
|
||||
'create': 'usuarios.create',
|
||||
'update': 'usuarios.edit',
|
||||
'partial_update': 'usuarios.edit',
|
||||
'destroy': 'usuarios.delete',
|
||||
}
|
||||
codename = perms.get(self.action, 'usuarios.view')
|
||||
return [IsAuthenticated(), require_permission(codename)()]
|
||||
# Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización
|
||||
if self.action == 'destroy':
|
||||
user = self.request.user
|
||||
if not (
|
||||
user.is_superuser or
|
||||
user.groups.filter(name='admin').exists() or
|
||||
user.groups.filter(name='Agente Aduanal').exists() or
|
||||
user.groups.filter(name='user').exists()
|
||||
):
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.")
|
||||
elif self.action in ['create', 'update', 'partial_update']:
|
||||
if not (self.request.user.is_superuser or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Importador').exists()) :
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Solo admin o superusuario pueden modificar usuarios.")
|
||||
return super().get_permissions()
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
user = self.request.user
|
||||
org = get_org_context(user)
|
||||
if user.is_superuser or instance.organizacion == org:
|
||||
# Solo permitir eliminar usuarios de la misma organización
|
||||
if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion:
|
||||
instance.delete()
|
||||
else:
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if is_internal_service_request(self.request):
|
||||
return CustomUser.objects.all()
|
||||
if not user_has_permission(user, 'usuarios.view'):
|
||||
return CustomUser.objects.none()
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return CustomUser.objects.none()
|
||||
return CustomUser.objects.filter(organizacion=org)
|
||||
# Si es importador, solo puede ver su propio usuario
|
||||
if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists():
|
||||
return CustomUser.objects.filter(pk=self.request.user.pk)
|
||||
|
||||
# Otros roles: filtrar por organización
|
||||
return self.get_queryset_filtrado_por_organizacion()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
creator = self.request.user
|
||||
|
||||
if creator.is_superuser:
|
||||
user = serializer.save(is_active=False)
|
||||
send_activation_email(user, self.request)
|
||||
# Always assign the creator's organization
|
||||
if self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
if not self.request.user.organizacion:
|
||||
raise PermissionDenied("Los administradores deben tener una organización asignada para crear usuarios.")
|
||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
||||
send_activation_email(user, self.request) # Usa template HTML
|
||||
return
|
||||
|
||||
if creator.is_importador:
|
||||
if self.request.user.is_superuser:
|
||||
# If superuser, allow creating users without organization
|
||||
user = serializer.save(is_active=False)
|
||||
send_activation_email(user, self.request) # Usa template HTML
|
||||
return
|
||||
|
||||
if self.request.user.groups.filter(name='developer').exists():
|
||||
# Developers can create users but must assign an organization
|
||||
if not self.request.user.organizacion:
|
||||
raise PermissionDenied("Los desarrolladores deben tener una organización asignada para crear usuarios.")
|
||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
||||
send_activation_email(user, self.request) # Usa template HTML
|
||||
return
|
||||
|
||||
if self.request.user.groups.filter(name='importador').exists():
|
||||
# No puedes crear un usuario si eres importador
|
||||
raise PermissionDenied("Los importadores no pueden crear usuarios.")
|
||||
|
||||
org = get_org_context(creator)
|
||||
if not org:
|
||||
raise PermissionDenied("Debes tener una organización asignada para crear usuarios.")
|
||||
|
||||
user = serializer.save(organizacion=org, is_active=False)
|
||||
send_activation_email(user, self.request)
|
||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
||||
send_activation_email(user, self.request) # Usa template HTML
|
||||
return
|
||||
|
||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||
def me(self, request):
|
||||
@@ -155,11 +167,8 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
"""
|
||||
user = self.get_object()
|
||||
current_user = request.user
|
||||
puede_cambiar_ajena = (
|
||||
current_user.is_superuser or
|
||||
user_has_permission(current_user, 'usuarios.change_password')
|
||||
)
|
||||
if not (puede_cambiar_ajena or user == current_user):
|
||||
# Solo el propio usuario, admin o superuser pueden cambiar la contraseña
|
||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user):
|
||||
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
|
||||
|
||||
old_password = request.data.get('old_password')
|
||||
@@ -167,7 +176,8 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
if not new_password:
|
||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||
|
||||
if not puede_cambiar_ajena:
|
||||
# Si no es admin/superuser, debe validar old_password
|
||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists()):
|
||||
if not old_password or not user.check_password(old_password):
|
||||
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
|
||||
|
||||
@@ -216,11 +226,11 @@ class ProfilePictureView(LoggingMixin, APIView):
|
||||
my_tags = ['User Profile']
|
||||
|
||||
def get(self, request, user_id):
|
||||
# Obtiene el usuario (automáticamente 404 si no existe)
|
||||
user = get_object_or_404(CustomUser, pk=user_id)
|
||||
|
||||
org = get_org_context(request.user)
|
||||
if not request.user.is_superuser and user.organizacion != org:
|
||||
raise Http404("No autorizado")
|
||||
# El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin
|
||||
# Así que no necesitas validar manualmente los permisos aquí.
|
||||
|
||||
if not user.profile_picture:
|
||||
raise Http404("El usuario no tiene imagen de perfil")
|
||||
@@ -257,8 +267,6 @@ class PasswordResetConfirmView(APIView):
|
||||
return Response({'detail': 'Enlace inválido.'}, status=400)
|
||||
if not default_token_generator.check_token(user, token):
|
||||
return Response({'detail': 'Token inválido o expirado.'}, status=400)
|
||||
if not user.is_active:
|
||||
return Response({'detail': 'La cuenta de usuario no está activa.'}, status=400)
|
||||
password = request.data.get('password')
|
||||
if not password:
|
||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||
|
||||
@@ -6,5 +6,4 @@ class CustomsConfig(AppConfig):
|
||||
name = 'api.customs'
|
||||
|
||||
def ready(self):
|
||||
# corregir el import aqui
|
||||
import api.customs.signals.procesamiento
|
||||
import api.customs.signals
|
||||
@@ -1,117 +0,0 @@
|
||||
"""
|
||||
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())
|
||||
@@ -1,541 +0,0 @@
|
||||
"""
|
||||
Diagnóstico y corrección de partidas con descargado=True que NO tienen un XML
|
||||
de respuesta de partida válido.
|
||||
|
||||
Una partida cuenta como realmente descargada solo si alguno de sus documentos
|
||||
contiene el nodo <consultarPartidaRespuesta> sin <tieneError>true</tieneError>.
|
||||
|
||||
Clasificación por contenido de cada documento candidato (excluye types 17/18,
|
||||
que ya están identificados como REQUEST/ERROR):
|
||||
- valida : consultarPartidaRespuesta sin tieneError=true
|
||||
- error : tieneError=true → renombra a _ERROR, type 18
|
||||
- request : consultarPartidaPeticion → renombra a _REQUEST, type 17
|
||||
(eco de la petición guardado como si fuera respuesta)
|
||||
- desconocido : contenido no identificable → solo reporte
|
||||
- ausente : registro en BD cuyo archivo no existe en storage
|
||||
- no_verificable : storage inaccesible (excepción al consultar/leer)
|
||||
|
||||
Veredicto por partida con descargado=True:
|
||||
- ≥1 valida → conserva descargado=True
|
||||
- 0 validas y ≥1 no_verificable → sin cambios (storage inaccesible)
|
||||
- 0 validas, ≥1 ausente y NINGÚN archivo del pedimento existe en storage
|
||||
→ sin cambios (canario: probablemente se
|
||||
está corriendo contra un storage que no
|
||||
es el de esta BD, p. ej. dev)
|
||||
- en cualquier otro caso → descargado=False (incluye partidas que
|
||||
solo tienen el REQUEST, ningún doc, o
|
||||
registros fantasma con el storage real)
|
||||
|
||||
Canario de storage: si al menos un archivo vu_PT_ del pedimento (REQUEST,
|
||||
ERROR o respuesta) sí existe en storage, el storage es el correcto y los
|
||||
documentos ausentes son registros fantasma reales (BD sin archivo).
|
||||
|
||||
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
|
||||
(el storage puede agregar sufijos de unicidad: vu_PT_{...}_{partida}_Ab12xQ.xml)
|
||||
- Legacy : vu_PT_..._{partida}.xml (número de partida al final)
|
||||
|
||||
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 --solo-malformados --dry-run
|
||||
python manage.py fix_partidas_error --dry-run # todas las orgs
|
||||
"""
|
||||
import io
|
||||
import posixpath
|
||||
import re
|
||||
|
||||
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
|
||||
|
||||
# Clasificaciones por contenido del XML
|
||||
_VALIDA = "valida"
|
||||
_ERROR_VU = "error"
|
||||
_REQUEST_ECO = "request"
|
||||
_DESCONOCIDO = "desconocido"
|
||||
_AUSENTE = "ausente"
|
||||
_NO_VERIFICABLE = "no_verificable"
|
||||
|
||||
# clase → (sufijo de archivo, document_type destino)
|
||||
_RECLASIFICACION = {
|
||||
_ERROR_VU: ("ERROR", _PT_ERROR),
|
||||
_REQUEST_ECO: ("REQUEST", _PT_REQUEST),
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Corrige partidas descargado=True sin XML de respuesta de partida válido."
|
||||
|
||||
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.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--solo-malformados", action="store_true",
|
||||
help="Limitar a pedimentos con aduana/patente/pedimento/numero_operacion inválidos (comportamiento anterior).",
|
||||
)
|
||||
# Control de lote
|
||||
parser.add_argument(
|
||||
"--offset", type=int, default=0,
|
||||
help="Saltar los primeros N pedimentos (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
|
||||
|
||||
# Universo: pedimentos con al menos una partida descargado=True
|
||||
ped_ids = Partida.objects.filter(descargado=True).values_list(
|
||||
"pedimento_id", flat=True
|
||||
).distinct()
|
||||
base_qs = self._malformed_qs() if options["solo_malformados"] else Pedimento.objects.all()
|
||||
ped_qs = base_qs.filter(id__in=ped_ids)
|
||||
|
||||
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 = total_sin_filtro if not (offset or limit) else min(
|
||||
limit or total_sin_filtro, max(0, total_sin_filtro - offset)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f"Pedimentos con partidas descargadas (total): {total_sin_filtro}\n"
|
||||
f"Procesando este lote : {total}"
|
||||
+ (f" [offset={offset}]" if offset else "")
|
||||
+ (f" [limit={limit}]" if limit else "")
|
||||
+ (f" [solo malformados]" if options["solo_malformados"] else "")
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
if total == 0:
|
||||
self.stdout.write(self.style.SUCCESS("Nada que revisar en este lote."))
|
||||
return
|
||||
|
||||
stats = self._stats_vacios()
|
||||
n_peds = 0
|
||||
for ped in ped_qs:
|
||||
parciales = self._process_pedimento(ped, dry_run)
|
||||
n_peds += 1
|
||||
for k in stats:
|
||||
stats[k] += parciales[k]
|
||||
|
||||
self._print_summary(n_peds, stats, 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.")
|
||||
|
||||
# Diagnóstico de campos: informativo, ya no excluye pedimentos válidos
|
||||
self._print_ped_diagnosis(ped, self._field_checks(ped))
|
||||
stats = self._process_pedimento(ped, dry_run)
|
||||
self._print_summary(1, stats, 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
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _stats_vacios(self):
|
||||
return {
|
||||
"partidas": 0, # partidas descargado=True revisadas
|
||||
"corregidas": 0, # partidas marcadas descargado=False
|
||||
"bloqueadas": 0, # partidas sin cambios (storage inaccesible/equivocado)
|
||||
"docs_error": 0, # docs renombrados a _ERROR (type 18)
|
||||
"docs_request": 0, # docs reclasificados a _REQUEST (type 17)
|
||||
"desconocidos": 0, # docs con contenido no identificable
|
||||
"fantasmas": 0, # registros en BD sin archivo en storage (no se borran)
|
||||
}
|
||||
|
||||
def _process_pedimento(self, ped, dry_run):
|
||||
es_malformado = any(self._field_checks(ped).values())
|
||||
self.stdout.write(
|
||||
f"Pedimento: {ped.pedimento_app} | "
|
||||
f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}"
|
||||
+ (" [MALFORMADO]" if es_malformado else "")
|
||||
)
|
||||
stats = self._stats_vacios()
|
||||
|
||||
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 stats
|
||||
|
||||
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
|
||||
|
||||
# Una sola consulta por pedimento; la asignación por partida es en memoria
|
||||
docs_pedimento = list(
|
||||
Document.objects.filter(pedimento=ped, archivo__icontains="vu_PT_")
|
||||
)
|
||||
|
||||
# Canario perezoso: ¿existe en storage al menos un archivo del pedimento?
|
||||
# Distingue "registro fantasma con storage real" de "storage equivocado".
|
||||
canario = {"valor": None}
|
||||
|
||||
def storage_es_correcto():
|
||||
if canario["valor"] is None:
|
||||
canario["valor"] = self._storage_tiene_archivos(docs_pedimento)
|
||||
return canario["valor"]
|
||||
|
||||
for partida in partidas:
|
||||
self._process_partida(ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats)
|
||||
|
||||
self.stdout.write("")
|
||||
return stats
|
||||
|
||||
def _storage_tiene_archivos(self, docs):
|
||||
"""True si al menos un archivo vu_PT_ del pedimento existe en storage."""
|
||||
for doc in docs:
|
||||
try:
|
||||
if minio_client.file_exists(doc.archivo.name):
|
||||
return True
|
||||
except Exception:
|
||||
return False # storage inaccesible: modo conservador
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Procesamiento de una partida
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _process_partida(self, ped, partida, docs_pedimento, storage_es_correcto, dry_run, stats):
|
||||
stats["partidas"] += 1
|
||||
docs = self._docs_de_partida(docs_pedimento, ped.pedimento_app, partida.numero_partida)
|
||||
candidatos = [d for d in docs if d.document_type_id not in (_PT_REQUEST, _PT_ERROR)]
|
||||
n_requests = sum(1 for d in docs if d.document_type_id == _PT_REQUEST)
|
||||
n_errores = sum(1 for d in docs if d.document_type_id == _PT_ERROR)
|
||||
|
||||
self.stdout.write(
|
||||
f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) de respuesta a revisar"
|
||||
f" (REQUEST: {n_requests}, ERROR previos: {n_errores})"
|
||||
)
|
||||
|
||||
clasificados = []
|
||||
for doc in candidatos:
|
||||
clase, motivo = self._classify_document(doc)
|
||||
iconos = {
|
||||
_VALIDA: self.style.SUCCESS("✓ partida válida"),
|
||||
_ERROR_VU: self.style.ERROR("✗ ERROR VUCEM"),
|
||||
_REQUEST_ECO: self.style.WARNING("↺ es REQUEST, no respuesta"),
|
||||
_DESCONOCIDO: self.style.WARNING("? contenido desconocido"),
|
||||
_AUSENTE: self.style.WARNING("✗ registro sin archivo en storage"),
|
||||
_NO_VERIFICABLE: self.style.WARNING("⚠ storage inaccesible"),
|
||||
}
|
||||
self.stdout.write(f" [{iconos[clase]}] type={doc.document_type_id} | {doc.archivo.name}")
|
||||
if motivo:
|
||||
self.stdout.write(f" motivo: {motivo}")
|
||||
clasificados.append((doc, clase))
|
||||
|
||||
validas = [d for d, c in clasificados if c == _VALIDA]
|
||||
no_verificables = [d for d, c in clasificados if c == _NO_VERIFICABLE]
|
||||
ausentes = [d for d, c in clasificados if c == _AUSENTE]
|
||||
corregibles = [(d, c) for d, c in clasificados if c in _RECLASIFICACION]
|
||||
stats["desconocidos"] += sum(1 for _, c in clasificados if c == _DESCONOCIDO)
|
||||
|
||||
# Veredicto: solo una consultarPartidaRespuesta sin error mantiene la
|
||||
# partida como descargada. Storage inaccesible bloquea el cambio; un
|
||||
# archivo ausente solo bloquea cuando NINGÚN archivo del pedimento
|
||||
# existe en storage (canario: posible storage equivocado, p. ej. dev).
|
||||
if validas:
|
||||
marcar_no_descargada = False
|
||||
veredicto = self.style.SUCCESS("OK: tiene respuesta de partida válida")
|
||||
elif no_verificables:
|
||||
marcar_no_descargada = False
|
||||
stats["bloqueadas"] += 1
|
||||
veredicto = self.style.WARNING(
|
||||
"SIN CAMBIOS: storage inaccesible — ejecutar donde el storage sea accesible"
|
||||
)
|
||||
elif ausentes and not storage_es_correcto():
|
||||
marcar_no_descargada = False
|
||||
stats["bloqueadas"] += 1
|
||||
veredicto = self.style.WARNING(
|
||||
"SIN CAMBIOS: ningún archivo del pedimento existe en storage — "
|
||||
"¿se está corriendo contra el storage correcto?"
|
||||
)
|
||||
else:
|
||||
marcar_no_descargada = True
|
||||
stats["corregidas"] += 1
|
||||
stats["fantasmas"] += len(ausentes)
|
||||
veredicto = self.style.ERROR("descargado → False (sin XML de partida válido)")
|
||||
self.stdout.write(f" Veredicto: {veredicto}")
|
||||
|
||||
for _, clase in corregibles:
|
||||
clave = "docs_error" if clase == _ERROR_VU else "docs_request"
|
||||
stats[clave] += 1
|
||||
|
||||
if not dry_run and (corregibles or marcar_no_descargada):
|
||||
self._apply_fix(partida, corregibles, marcar_no_descargada, ped.pedimento_app)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Asignación de documentos a una partida por nombre de archivo
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _docs_de_partida(self, docs, pedimento_app, numero_partida):
|
||||
"""
|
||||
Naming actual : vu_PT_{pedimento_app}_{numero} seguido de "_" o "."
|
||||
(cubre éxito canónico, sufijos de unicidad del storage,
|
||||
REQUEST y ERROR; "_" evita confundir partida 1 con 11)
|
||||
Naming legacy : vu_PT_..._{numero}.xml (número de partida al final)
|
||||
"""
|
||||
prefijo = f"vu_pt_{pedimento_app}_{numero_partida}".lower()
|
||||
legacy_re = re.compile(
|
||||
rf"^vu_pt_.+_{re.escape(str(numero_partida))}\.xml$", re.IGNORECASE
|
||||
)
|
||||
asignados = {}
|
||||
for doc in docs:
|
||||
base = posixpath.basename(doc.archivo.name or "").lower()
|
||||
es_actual = (
|
||||
base.startswith(prefijo)
|
||||
and len(base) > len(prefijo)
|
||||
and base[len(prefijo)] in "_."
|
||||
)
|
||||
if es_actual or legacy_re.match(base):
|
||||
asignados[doc.id] = doc
|
||||
return list(asignados.values())
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Clasificación del contenido XML
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _classify_document(self, doc):
|
||||
"""
|
||||
Lee el XML desde MinIO y clasifica su contenido.
|
||||
Retorna (clase, motivo: str | None).
|
||||
"""
|
||||
name = doc.archivo.name
|
||||
try:
|
||||
if not minio_client.file_exists(name):
|
||||
return _AUSENTE, "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").lower()
|
||||
except Exception as e:
|
||||
return _NO_VERIFICABLE, f"excepción al leer archivo: {e}"
|
||||
|
||||
if "tieneerror>true<" in text:
|
||||
return _ERROR_VU, "tieneError=true detectado en XML"
|
||||
if "consultarpartidarespuesta" in text:
|
||||
return _VALIDA, None
|
||||
if "consultarpartidapeticion" in text:
|
||||
return _REQUEST_ECO, "es la petición SOAP, no la respuesta"
|
||||
return _DESCONOCIDO, "sin consultarPartidaRespuesta, sin consultarPartidaPeticion y sin tieneError"
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Aplicación de correcciones
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@transaction.atomic
|
||||
def _apply_fix(self, partida, corregibles, marcar_no_descargada, pedimento_app):
|
||||
"""
|
||||
Renombra/reclasifica documentos y actualiza la partida en una transacción.
|
||||
Nota: si la transacción revierte, los cambios en storage NO se deshacen;
|
||||
re-ejecutar el comando converge (ver _rename_in_storage).
|
||||
"""
|
||||
for doc, clase in corregibles:
|
||||
suffix, doc_type = _RECLASIFICACION[clase]
|
||||
new_name = self._pick_target_name(doc, pedimento_app, partida.numero_partida, suffix)
|
||||
final_name = self._rename_in_storage(doc.archivo.name, new_name)
|
||||
doc.archivo = final_name
|
||||
doc.document_type_id = doc_type
|
||||
doc.vu = True
|
||||
doc.save(update_fields=["archivo", "document_type_id", "vu"])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ Doc {doc.id}: type={doc_type} | {final_name}"
|
||||
))
|
||||
|
||||
if marcar_no_descargada:
|
||||
partida.descargado = False
|
||||
partida.save(update_fields=["descargado"])
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f" ✓ Partida {partida.numero_partida}: descargado=False"
|
||||
))
|
||||
|
||||
def _pick_target_name(self, doc, pedimento_app, numero_partida, suffix):
|
||||
"""
|
||||
Primer nombre libre con nomenclatura
|
||||
{dir}/vu_PT_{pedimento_app}_{numero_partida}_{SUFFIX}[_{n}].xml
|
||||
verificado contra BD (excluyendo el propio doc) para que dos Documents
|
||||
nunca terminen apuntando al mismo archivo (p. ej. contra el REQUEST
|
||||
real type 17 que ya usa el nombre sin índice).
|
||||
"""
|
||||
dir_part = posixpath.dirname(doc.archivo.name)
|
||||
index = 0
|
||||
while True:
|
||||
tail = f"_{index}" if index else ""
|
||||
candidate = posixpath.join(
|
||||
dir_part, f"vu_PT_{pedimento_app}_{numero_partida}_{suffix}{tail}.xml"
|
||||
)
|
||||
if candidate == doc.archivo.name:
|
||||
return candidate
|
||||
if not Document.objects.filter(archivo=candidate).exclude(id=doc.id).exists():
|
||||
return candidate
|
||||
index += 1
|
||||
|
||||
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" ⚠ Destino 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, stats, dry_run):
|
||||
self.stdout.write(
|
||||
f"\n{'─' * 60}\nRESUMEN\n"
|
||||
f" Pedimentos procesados : {total_peds}\n"
|
||||
f" Partidas revisadas (descargado=True) : {stats['partidas']}\n"
|
||||
f" Partidas corregidas (descargado=False) : {stats['corregidas']}\n"
|
||||
f" Partidas sin cambios (no verificables) : {stats['bloqueadas']}\n"
|
||||
f" Docs renombrados a ERROR (type 18) : {stats['docs_error']}\n"
|
||||
f" Docs reclasificados a REQUEST (type 17): {stats['docs_request']}\n"
|
||||
f" Docs con contenido desconocido : {stats['desconocidos']}\n"
|
||||
f" Registros en BD sin archivo en storage : {stats['fantasmas']} (no se borran)\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."))
|
||||
@@ -36,8 +36,7 @@ class Command(BaseCommand):
|
||||
|
||||
if organizacion_id:
|
||||
if procesamiento:
|
||||
# microservice_v2.ejecutar_procesamiento_por_organizacion(organizacion_id, procesamiento)
|
||||
microservice_v2.ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento)
|
||||
microservice_v2.ejecutar_procesamiento_por_organizacion(organizacion_id, procesamiento)
|
||||
self.stdout.write(self.style.SUCCESS(f'Se ejecutó el procesamiento {procesamiento} para la organización {organizacion_id}.'))
|
||||
else:
|
||||
microservice_v2.ejecutar_todos_por_organizacion(organizacion_id)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from api.customs.models import EDocument, Cove, EstadoDescarga
|
||||
from api.record.models import Document
|
||||
from api.utils.storage_service import storage_service
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Reconciliación de estatus de descarga VUCEM (T2026-05-027).
|
||||
|
||||
Detecta registros marcados como 'descargado' cuyo documento no existe en BD
|
||||
o cuyo archivo falta físicamente en storage (MinIO), y los transiciona a
|
||||
estado 'error' para que sean visibles y reprocesables. Sin --apply solo
|
||||
reporta (dry-run).
|
||||
|
||||
Uso:
|
||||
python manage.py reconciliar_descargas # reporte
|
||||
python manage.py reconciliar_descargas --apply # corrige
|
||||
python manage.py reconciliar_descargas --organizacion <uuid>
|
||||
"""
|
||||
|
||||
help = "Reconcilia estatus de descarga de EDocs/COVEs contra documentos reales (BD + storage)"
|
||||
|
||||
# Catálogo confirmado de document_type:
|
||||
# 4 = acuse EDoc, 7 = acuse COVE, 19/23 = request COVE, 21/25 = request EDoc,
|
||||
# 20 = error COVE, 22 = error EDoc, 24 = error acuse COVE, 26 = error acuse EDoc
|
||||
EXCLUIR_EDOC_GENERAL = [4, 21, 22, 25, 26]
|
||||
EXCLUIR_COVE_GENERAL = [7, 19, 20, 23, 24]
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--apply', action='store_true',
|
||||
help='Aplica las correcciones; sin esta bandera solo reporta (dry-run)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--organizacion', type=str, default=None,
|
||||
help='Limitar la reconciliación a una organización (UUID)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pedimento', type=str, default=None,
|
||||
help='Limitar la reconciliación a un pedimento (UUID)'
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
apply_changes = opts['apply']
|
||||
detectados = []
|
||||
|
||||
flujos = [
|
||||
# (modelo, campo_estado, campo_intentos, etiqueta, fn_documentos)
|
||||
(EDocument, 'acuse_estado', 'acuse_intentos', 'edoc.acuse',
|
||||
lambda r: Document.objects.filter(
|
||||
pedimento=r.pedimento,
|
||||
archivo__icontains=r.numero_edocument,
|
||||
document_type_id=4)),
|
||||
(EDocument, 'edocument_estado', 'edocument_intentos', 'edoc.general',
|
||||
lambda r: Document.objects.filter(
|
||||
pedimento=r.pedimento,
|
||||
archivo__icontains=r.numero_edocument,
|
||||
).exclude(document_type_id__in=self.EXCLUIR_EDOC_GENERAL)),
|
||||
(Cove, 'acuse_cove_estado', 'acuse_cove_intentos', 'cove.acuse',
|
||||
lambda r: Document.objects.filter(
|
||||
pedimento=r.pedimento,
|
||||
archivo__icontains=r.numero_cove,
|
||||
document_type_id=7)),
|
||||
(Cove, 'cove_estado', 'cove_intentos', 'cove.general',
|
||||
lambda r: Document.objects.filter(
|
||||
pedimento=r.pedimento,
|
||||
archivo__icontains=r.numero_cove,
|
||||
).exclude(document_type_id__in=self.EXCLUIR_COVE_GENERAL)),
|
||||
]
|
||||
|
||||
for modelo, campo_estado, campo_intentos, etiqueta, fn_documentos in flujos:
|
||||
qs = modelo.objects.filter(**{campo_estado: EstadoDescarga.DESCARGADO})
|
||||
if opts['organizacion']:
|
||||
qs = qs.filter(organizacion_id=opts['organizacion'])
|
||||
if opts['pedimento']:
|
||||
qs = qs.filter(pedimento_id=opts['pedimento'])
|
||||
|
||||
for registro in qs.select_related('pedimento').iterator():
|
||||
numero = getattr(registro, 'numero_edocument', None) or registro.numero_cove
|
||||
docs = fn_documentos(registro)
|
||||
# Disponible = al menos un documento con fila en BD, tamaño > 0
|
||||
# y archivo físicamente presente en storage
|
||||
disponible = any(
|
||||
doc.size and storage_service.file_exists(doc.archivo.name)
|
||||
for doc in docs
|
||||
)
|
||||
if disponible:
|
||||
continue
|
||||
|
||||
detectados.append((etiqueta, str(registro.id), numero, str(registro.pedimento_id)))
|
||||
if apply_changes:
|
||||
with transaction.atomic():
|
||||
setattr(registro, campo_estado, EstadoDescarga.ERROR)
|
||||
registro.ultimo_error = (
|
||||
f"Reconciliación: {etiqueta} marcado como descargado "
|
||||
f"sin archivo disponible en BD/storage"
|
||||
)
|
||||
# save() del modelo sincroniza el booleano legado
|
||||
registro.save(update_fields=[campo_estado, 'ultimo_error'])
|
||||
|
||||
modo = 'CORREGIDOS' if apply_changes else 'DETECTADOS (dry-run, usa --apply para corregir)'
|
||||
self.stdout.write(self.style.WARNING(f"{modo}: {len(detectados)}"))
|
||||
for etiqueta, registro_id, numero, pedimento_id in detectados:
|
||||
self.stdout.write(f" [{etiqueta}] id={registro_id} numero={numero} pedimento={pedimento_id}")
|
||||
|
||||
if not detectados:
|
||||
self.stdout.write(self.style.SUCCESS("Sin inconsistencias: todos los 'descargado' tienen archivo disponible"))
|
||||
@@ -1,50 +0,0 @@
|
||||
# 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,21 +0,0 @@
|
||||
# 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',
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,99 +0,0 @@
|
||||
# Migración T2026-05-027: estados de descarga de 3 valores (pendiente/descargado/error)
|
||||
# y contador de intentos automáticos para EDocument y Cove.
|
||||
#
|
||||
# NO aplicar en automático. Después de aplicarla, ejecutar el backfill:
|
||||
# backend/scripts/t2026_05_027/02_backfill_estados.sql
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('customs', '0019_pedimento_consultar_vucem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# --- EDocument ---
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='edocument_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del e-documento: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='acuse_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='edocument_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='acuse_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='ultimo_intento_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='edocument',
|
||||
name='ultimo_error',
|
||||
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='edocument',
|
||||
name='edocument_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='edocument',
|
||||
name='acuse_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)'),
|
||||
),
|
||||
# --- Cove ---
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='cove_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga de la cove: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_estado',
|
||||
field=models.CharField(choices=[('pendiente', 'Pendiente'), ('descargado', 'Descargado'), ('error', 'Error')], default='pendiente', help_text='Estado de descarga del acuse de la cove: pendiente, descargado o error', max_length=12),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='cove_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_intentos',
|
||||
field=models.PositiveSmallIntegerField(default=0, help_text='Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='ultimo_intento_at',
|
||||
field=models.DateTimeField(blank=True, help_text='Fecha del último intento automático de descarga', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='cove',
|
||||
name='ultimo_error',
|
||||
field=models.TextField(blank=True, help_text='Detalle del último error de descarga', null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cove',
|
||||
name='cove_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si la cove ha sido descargada (legado, derivado de cove_estado)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='cove',
|
||||
name='acuse_cove_descargado',
|
||||
field=models.BooleanField(default=False, help_text='Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)'),
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
# Create your models here.
|
||||
|
||||
@@ -34,7 +35,6 @@ class Pedimento(models.Model):
|
||||
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
|
||||
|
||||
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
|
||||
consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente")
|
||||
|
||||
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
|
||||
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
|
||||
@@ -62,17 +62,10 @@ class Pedimento(models.Model):
|
||||
db_table = 'pedimento'
|
||||
ordering = ['pedimento']
|
||||
unique_together = [
|
||||
# ['organizacion', 'pedimento'],
|
||||
['organizacion', 'pedimento'],
|
||||
['organizacion', 'pedimento_app']
|
||||
]
|
||||
|
||||
class EstadoDescarga(models.TextChoices):
|
||||
"""Estado de descarga de documentos VUCEM (requerimiento T2026-05-027):
|
||||
'error' indica que la descarga no pudo completarse y requiere atención."""
|
||||
PENDIENTE = 'pendiente', 'Pendiente'
|
||||
DESCARGADO = 'descargado', 'Descargado'
|
||||
ERROR = 'error', 'Error'
|
||||
|
||||
class Partida(models.Model):
|
||||
pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida")
|
||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida")
|
||||
@@ -101,28 +94,8 @@ class EDocument(models.Model):
|
||||
descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del documento")
|
||||
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del documento")
|
||||
edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado (legado, derivado de edocument_estado)")
|
||||
acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado (legado, derivado de acuse_estado)")
|
||||
edocument_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del e-documento: pendiente, descargado o error")
|
||||
acuse_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse: pendiente, descargado o error")
|
||||
edocument_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del e-documento (un ciclo de orquestación = un intento)")
|
||||
acuse_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse (un ciclo de orquestación = un intento)")
|
||||
ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
|
||||
ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
|
||||
self.edocument_descargado = self.edocument_estado == EstadoDescarga.DESCARGADO
|
||||
self.acuse_descargado = self.acuse_estado == EstadoDescarga.DESCARGADO
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if update_fields is not None:
|
||||
update_fields = set(update_fields)
|
||||
if 'edocument_estado' in update_fields:
|
||||
update_fields.add('edocument_descargado')
|
||||
if 'acuse_estado' in update_fields:
|
||||
update_fields.add('acuse_descargado')
|
||||
kwargs['update_fields'] = list(update_fields)
|
||||
super().save(*args, **kwargs)
|
||||
edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado")
|
||||
acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.descripcion} - {self.pedimento.pedimento}"
|
||||
@@ -139,28 +112,8 @@ class Cove(models.Model):
|
||||
numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la cove")
|
||||
updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización de la cove")
|
||||
cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada (legado, derivado de cove_estado)")
|
||||
acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado (legado, derivado de acuse_cove_estado)")
|
||||
cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga de la cove: pendiente, descargado o error")
|
||||
acuse_cove_estado = models.CharField(max_length=12, choices=EstadoDescarga.choices, default=EstadoDescarga.PENDIENTE, help_text="Estado de descarga del acuse de la cove: pendiente, descargado o error")
|
||||
cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga de la cove (un ciclo de orquestación = un intento)")
|
||||
acuse_cove_intentos = models.PositiveSmallIntegerField(default=0, help_text="Intentos automáticos de descarga del acuse de la cove (un ciclo de orquestación = un intento)")
|
||||
ultimo_intento_at = models.DateTimeField(null=True, blank=True, help_text="Fecha del último intento automático de descarga")
|
||||
ultimo_error = models.TextField(null=True, blank=True, help_text="Detalle del último error de descarga")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# El estado de 3 valores es la fuente de verdad; los booleanos legados se derivan
|
||||
self.cove_descargado = self.cove_estado == EstadoDescarga.DESCARGADO
|
||||
self.acuse_cove_descargado = self.acuse_cove_estado == EstadoDescarga.DESCARGADO
|
||||
update_fields = kwargs.get('update_fields')
|
||||
if update_fields is not None:
|
||||
update_fields = set(update_fields)
|
||||
if 'cove_estado' in update_fields:
|
||||
update_fields.add('cove_descargado')
|
||||
if 'acuse_cove_estado' in update_fields:
|
||||
update_fields.add('acuse_cove_descargado')
|
||||
kwargs['update_fields'] = list(update_fields)
|
||||
super().save(*args, **kwargs)
|
||||
cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada")
|
||||
acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.numero_cove} - {self.pedimento.pedimento}"
|
||||
@@ -259,3 +212,44 @@ class Importador(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.rfc} - {self.nombre}"
|
||||
|
||||
# bulk de datos
|
||||
class BulkUploadTask(models.Model):
|
||||
STATUS_CHOICES = [
|
||||
('pending', 'Pendiente'),
|
||||
('processing', 'Procesando'),
|
||||
('completed', 'Completado'),
|
||||
('failed', 'Fallido'),
|
||||
('partial', 'Parcialmente completado'),
|
||||
]
|
||||
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='bulk_upload_tasks')
|
||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE)
|
||||
contribuyente = models.CharField(max_length=255, blank=True, null=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
task_type = models.CharField(max_length=50, default='bulk_create')
|
||||
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(default=dict, blank=True)
|
||||
failed_files = models.JSONField(default=list, blank=True)
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
finished_at = models.DateTimeField(null=True, blank=True)
|
||||
fecha_pago = models.DateField(null=True, blank=True)
|
||||
clave_pedimento = models.CharField(max_length=50, blank=True, null=True)
|
||||
tipo_operacion_id = models.IntegerField(null=True, blank=True)
|
||||
curp_apoderado = models.CharField(max_length=50, blank=True, null=True)
|
||||
partidas = models.IntegerField(default=0)
|
||||
celery_task_id = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"BulkUpload {self.id} - {self.status}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Tarea de Carga Masiva"
|
||||
verbose_name_plural = "Tareas de Carga Masiva"
|
||||
db_table = 'bulk_upload_task'
|
||||
ordering = ['-created_at']
|
||||
@@ -6,8 +6,7 @@ from api.customs.models import (
|
||||
EDocument,
|
||||
Cove,
|
||||
Importador,
|
||||
Partida,
|
||||
EstadoDescarga
|
||||
Partida
|
||||
)
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
@@ -48,31 +47,55 @@ class PartidaSerializer(serializers.ModelSerializer):
|
||||
documentos = serializers.SerializerMethodField()
|
||||
|
||||
def get_documentos(self, obj):
|
||||
"""
|
||||
Busca documentos en la tabla `document` que coincidan EXACTAMENTE con:
|
||||
'documents/vu_PT_{pedimentoApp}_{numero}' al inicio del nombre del archivo.
|
||||
"""
|
||||
|
||||
if not obj or not getattr(obj, 'pedimento', None):
|
||||
return []
|
||||
|
||||
if not obj or not getattr(obj, 'numero_partida', None):
|
||||
return []
|
||||
|
||||
try:
|
||||
pedimento_app = str(obj.pedimento.pedimento_app).strip()
|
||||
pedimentoApp = str(obj.pedimento.pedimento_app).strip()
|
||||
numero = str(obj.numero_partida).strip()
|
||||
# Incluir pedimento_app en el patrón para evitar falsos positivos
|
||||
# entre partidas con números cortos (1 matchearía 10, 100, etc.)
|
||||
patron = f"vu_PT_{pedimento_app}_{numero}_"
|
||||
|
||||
# 17 = REQUEST partida, 18 = ERROR partida
|
||||
# Construir el patrón exacto de búsqueda
|
||||
patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml'
|
||||
|
||||
# Buscar documentos que empiecen EXACTAMENTE con ese patrón
|
||||
qs = Document.objects.filter(
|
||||
pedimento=obj.pedimento,
|
||||
archivo__icontains=patron,
|
||||
).exclude(document_type_id__in=[17, 18])
|
||||
archivo=patron_exacto
|
||||
)
|
||||
|
||||
# Opción 2: Si puede tener diferentes extensiones
|
||||
# patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}'
|
||||
# qs = Document.objects.filter(
|
||||
# archivo__startswith=patron_base
|
||||
# ).filter(
|
||||
# archivo__in=[
|
||||
# f'{patron_base}.xml',
|
||||
# f'{patron_base}.pdf',
|
||||
# f'{patron_base}.zip'
|
||||
# ]
|
||||
# )
|
||||
|
||||
# Filtro adicional por pedimento si el modelo Document tiene este campo
|
||||
if hasattr(Document, 'pedimento'):
|
||||
qs = qs.filter(pedimento=obj.pedimento)
|
||||
|
||||
# Filtro por organización
|
||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
||||
qs = qs.filter(organizacion=obj.organizacion)
|
||||
|
||||
serializer = DocumentSerializer(qs, many=True, context=self.context)
|
||||
return serializer.data
|
||||
|
||||
#return []
|
||||
except Exception:
|
||||
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
|
||||
return []
|
||||
class Meta:
|
||||
model = Partida
|
||||
@@ -185,11 +208,10 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
||||
numero = str(obj.numero_edocument).strip()
|
||||
# id_pedimento = str(obj.pedimento_id).strip()
|
||||
|
||||
# excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
|
||||
qs = Document.objects.filter(
|
||||
pedimento=obj.pedimento,
|
||||
archivo__icontains=numero,
|
||||
).exclude(document_type_id__in=[21, 25])
|
||||
)
|
||||
|
||||
# Filtro por organización si aplica
|
||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
||||
@@ -207,22 +229,6 @@ class EDocumentSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
def validate(self, attrs):
|
||||
# Compatibilidad: payloads legados que solo mandan los booleanos se traducen
|
||||
# al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
|
||||
# degrada un estado 'error' ya asignado.
|
||||
if 'edocument_descargado' in attrs and 'edocument_estado' not in attrs:
|
||||
if attrs['edocument_descargado']:
|
||||
attrs['edocument_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.edocument_estado == EstadoDescarga.ERROR):
|
||||
attrs['edocument_estado'] = EstadoDescarga.PENDIENTE
|
||||
if 'acuse_descargado' in attrs and 'acuse_estado' not in attrs:
|
||||
if attrs['acuse_descargado']:
|
||||
attrs['acuse_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.acuse_estado == EstadoDescarga.ERROR):
|
||||
attrs['acuse_estado'] = EstadoDescarga.PENDIENTE
|
||||
return attrs
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Si no es superusuario, hacer organizacion read_only
|
||||
@@ -238,22 +244,6 @@ class CoveSerializer(serializers.ModelSerializer):
|
||||
fields = '__all__'
|
||||
read_only_fields = ('created_at', 'updated_at')
|
||||
|
||||
def validate(self, attrs):
|
||||
# Compatibilidad: payloads legados que solo mandan los booleanos se traducen
|
||||
# al estado de 3 valores (fuente de verdad en el modelo). Un False legado no
|
||||
# degrada un estado 'error' ya asignado.
|
||||
if 'cove_descargado' in attrs and 'cove_estado' not in attrs:
|
||||
if attrs['cove_descargado']:
|
||||
attrs['cove_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.cove_estado == EstadoDescarga.ERROR):
|
||||
attrs['cove_estado'] = EstadoDescarga.PENDIENTE
|
||||
if 'acuse_cove_descargado' in attrs and 'acuse_cove_estado' not in attrs:
|
||||
if attrs['acuse_cove_descargado']:
|
||||
attrs['acuse_cove_estado'] = EstadoDescarga.DESCARGADO
|
||||
elif not (self.instance and self.instance.acuse_cove_estado == EstadoDescarga.ERROR):
|
||||
attrs['acuse_cove_estado'] = EstadoDescarga.PENDIENTE
|
||||
return attrs
|
||||
|
||||
def get_documentos(self, obj):
|
||||
"""
|
||||
Busca documentos en la tabla `document` que coincidan con el
|
||||
@@ -273,11 +263,10 @@ class CoveSerializer(serializers.ModelSerializer):
|
||||
try:
|
||||
numero = str(obj.numero_cove).strip()
|
||||
|
||||
# Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
|
||||
qs = Document.objects.filter(
|
||||
pedimento=obj.pedimento,
|
||||
archivo__icontains=numero,
|
||||
).exclude(document_type_id__in=[19, 23])
|
||||
)
|
||||
|
||||
# Filtro por organización si aplica
|
||||
if hasattr(obj, 'organizacion') and obj.organizacion:
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.dispatch import receiver
|
||||
from django.db import transaction
|
||||
from time import sleep
|
||||
|
||||
from api.customs.models import EstadoDeProcesamiento, Pedimento, ProcesamientoPedimento, Cove, EDocument
|
||||
from api.customs.models import Pedimento, ProcesamientoPedimento, Cove, EDocument
|
||||
from api.customs.tasks.internal_services import (
|
||||
crear_procesamiento_remesa,
|
||||
crear_procesamiento_partida,
|
||||
@@ -20,52 +20,8 @@ from api.customs.tasks.microservice import (
|
||||
|
||||
@receiver(post_save, sender=Pedimento)
|
||||
def trigger_celery_task_on_create(sender, instance, created, **kwargs):
|
||||
|
||||
if not created:
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
logger.info("NO es creación de pedimento, no se crea procesamiento.")
|
||||
return
|
||||
|
||||
if not instance.consultar_vucem:
|
||||
return
|
||||
|
||||
def crear_procesamiento():
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
logger.info(f"Pedimento confirmado en BD: {instance.id}, creando procesamiento...")
|
||||
|
||||
try:
|
||||
estado, _ = EstadoDeProcesamiento.objects.get_or_create(
|
||||
estado='En Espera'
|
||||
)
|
||||
except Exception:
|
||||
estado = EstadoDeProcesamiento.objects.first()
|
||||
|
||||
try:
|
||||
ProcesamientoPedimento.objects.get_or_create(
|
||||
pedimento=instance,
|
||||
organizacion=instance.organizacion,
|
||||
defaults={
|
||||
'estado': estado,
|
||||
'servicio_id': 3,
|
||||
'tipo_procesamiento_id': 2,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"No se pudo crear ProcesamientoPedimento "
|
||||
f"para pedimento {instance.id}: {e}"
|
||||
)
|
||||
|
||||
# Disparar la tarea asíncrona existente
|
||||
try:
|
||||
procesar_pedimento_completo_individual.apply_async(args=[instance.id, instance.organizacion.id])
|
||||
except Exception as e:
|
||||
logger.exception(f"Error al encolar procesar_pedimento_completo_individual: {e}")
|
||||
|
||||
transaction.on_commit(crear_procesamiento)
|
||||
|
||||
if created:
|
||||
procesar_pedimento_completo_individual.apply_async(args=[instance.id, instance.organizacion.id])
|
||||
|
||||
@receiver(post_save, sender=Pedimento)
|
||||
def trigger_celery_task_on_update(sender, instance, created,**kwargs):
|
||||
@@ -90,11 +46,8 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
|
||||
pedimento_id = str(instance.pedimento.id)
|
||||
def enqueue_cove_tasks():
|
||||
crear_procesamiento_cove.apply_async(args=[pedimento_id])
|
||||
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
|
||||
transaction.on_commit(enqueue_cove_tasks)
|
||||
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)])
|
||||
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)])
|
||||
|
||||
@receiver(post_save, sender=EDocument)
|
||||
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
|
||||
@@ -102,8 +55,5 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
|
||||
pedimento_id = str(instance.pedimento.id)
|
||||
def enqueue_edocument_tasks():
|
||||
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
|
||||
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
|
||||
transaction.on_commit(enqueue_edocument_tasks)
|
||||
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)])
|
||||
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)])
|
||||
@@ -1,4 +1,3 @@
|
||||
from .microservice import *
|
||||
from .internal_services import *
|
||||
from .bulk_upload import *
|
||||
from .microservice_v2 import *
|
||||
from .bulk_pedimentos import *
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,6 @@
|
||||
# auditoria_xml.py
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.auditoria_xml')
|
||||
|
||||
def extraer_info_pedimento_xml(xml_content):
|
||||
"""
|
||||
@@ -15,10 +13,8 @@ def extraer_info_pedimento_xml(xml_content):
|
||||
# Buscar el namespace (puede variar)
|
||||
namespaces = {
|
||||
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||
's': 'http://schemas.xmlsoap.org/soap/envelope/',
|
||||
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
|
||||
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta',
|
||||
|
||||
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta'
|
||||
}
|
||||
|
||||
resultado = {}
|
||||
@@ -185,37 +181,10 @@ def extraer_info_pedimento_xml(xml_content):
|
||||
if edocs_encontrados:
|
||||
resultado['edocuments_en_xml'] = edocs_encontrados
|
||||
|
||||
# Verificar si hay error en la respuesta — 3 variantes según el servicio VUCEM:
|
||||
# 1) Remesas/pedimentos: <ns3:tieneError> en namespace oxml/respuesta
|
||||
# 2) eDocuments: <TieneError> en namespace tempuri.org, mensaje en <Errores>
|
||||
# 3) Acuses: <error> sin namespace dentro de responseConsultaAcuses
|
||||
# Verificar si hay error en la respuesta
|
||||
tiene_error = root.find('.//ns3:tieneError', namespaces)
|
||||
if tiene_error is not None:
|
||||
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
|
||||
if resultado['tiene_error']:
|
||||
mensaje = root.find('.//ns3:error/ns3:mensaje', namespaces)
|
||||
if mensaje is not None and mensaje.text:
|
||||
resultado['error_mensaje'] = mensaje.text.strip()
|
||||
else:
|
||||
# Variante eDocuments (tempuri.org)
|
||||
tiene_error_edoc = root.find('.//{http://tempuri.org/}TieneError')
|
||||
if tiene_error_edoc is not None:
|
||||
resultado['tiene_error'] = tiene_error_edoc.text.lower() == 'true'
|
||||
if resultado['tiene_error']:
|
||||
errores_elem = root.find('.//{http://tempuri.org/}Errores')
|
||||
if errores_elem is not None and errores_elem.text:
|
||||
resultado['error_mensaje'] = errores_elem.text.strip()
|
||||
else:
|
||||
# Variante acuses: <error> sin namespace
|
||||
error_acuses = root.find('.//error')
|
||||
if error_acuses is not None and error_acuses.text is not None:
|
||||
resultado['tiene_error'] = error_acuses.text.lower() == 'true'
|
||||
if resultado['tiene_error']:
|
||||
descripciones = root.findall('.//mensajeErrores/descripcion')
|
||||
if descripciones:
|
||||
resultado['error_mensaje'] = ' | '.join(
|
||||
d.text.strip() for d in descripciones if d.text
|
||||
)
|
||||
|
||||
return resultado
|
||||
|
||||
|
||||
@@ -1,477 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
# 13–26 = 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: 3–12 (documentos de contenido: pedimento, remesas, acuse,
|
||||
edocument, estado, cove, digitalizacion, error, general).
|
||||
Tipos excluidos: 1 (partida), 2 (ya procesado), 13–26 (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
|
||||
421
api/customs/tasks/bulk_pedimentos.py
Normal file
421
api/customs/tasks/bulk_pedimentos.py
Normal file
@@ -0,0 +1,421 @@
|
||||
# tasks/bulk_pedimentos.py COMPLETO
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
import re
|
||||
from datetime import datetime
|
||||
from celery import shared_task
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
import traceback
|
||||
from rarfile import RarFile, RarCannotExec, Error as RarError
|
||||
import subprocess
|
||||
|
||||
from ..models import BulkUploadTask, Pedimento, Importador, TipoOperacion
|
||||
from ...record.models import DocumentType, Document, Fuente
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def extract_rar_to_dir(rar_path, dest_dir):
|
||||
"""
|
||||
Extrae archivos RAR con múltiples métodos de fallback
|
||||
"""
|
||||
try:
|
||||
with RarFile(rar_path, 'r') as rar_ref:
|
||||
rar_ref.extractall(dest_dir)
|
||||
return True
|
||||
except (RarCannotExec, RarError):
|
||||
try:
|
||||
subprocess.run(['7z', 'x', rar_path, f'-o{dest_dir}'],
|
||||
check=True, capture_output=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(['unrar', 'x', rar_path, dest_dir],
|
||||
check=True, capture_output=True)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def is_same_document(doc, file_name):
|
||||
"""
|
||||
Determina si un documento es el mismo basado en el nombre
|
||||
"""
|
||||
if not doc.archivo:
|
||||
return False
|
||||
|
||||
doc_name = os.path.basename(doc.archivo.name).lower()
|
||||
new_name = file_name.lower()
|
||||
|
||||
doc_base = os.path.splitext(doc_name)[0]
|
||||
new_base = os.path.splitext(new_name)[0]
|
||||
|
||||
return doc_base == new_base
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def process_bulk_upload(self, bulk_upload_id):
|
||||
"""
|
||||
Tarea principal para procesar bulk upload de forma asíncrona
|
||||
SOLO recibe el ID, obtiene todo de la base de datos
|
||||
"""
|
||||
try:
|
||||
|
||||
bulk_upload = BulkUploadTask.objects.get(id=bulk_upload_id)
|
||||
|
||||
bulk_upload.celery_task_id = self.request.id
|
||||
bulk_upload.status = 'processing'
|
||||
bulk_upload.started_at = timezone.now()
|
||||
bulk_upload.save(update_fields=['celery_task_id', 'status', 'started_at'])
|
||||
|
||||
if bulk_upload.task_type == 'bulk_create_pedimento_desk':
|
||||
result = _process_bulk_pedimento_desk(bulk_upload)
|
||||
else:
|
||||
result = _process_bulk_create(bulk_upload)
|
||||
|
||||
bulk_upload.status = 'completed' if not result['failed_files'] else 'partial'
|
||||
bulk_upload.finished_at = timezone.now()
|
||||
bulk_upload.result = result
|
||||
bulk_upload.created_pedimentos = result.get('created_count', 0)
|
||||
bulk_upload.created_documents = result.get('documents_created', 0)
|
||||
bulk_upload.failed_files = result.get('failed_files', [])
|
||||
bulk_upload.processed_files = result.get('processed_files', 0)
|
||||
bulk_upload.save()
|
||||
|
||||
temp_files_info = bulk_upload.result.get('temp_files', [])
|
||||
for file_info in temp_files_info:
|
||||
try:
|
||||
default_storage.delete(file_info['saved_path'])
|
||||
except Exception as e:
|
||||
print(f"Error al limpiar archivo temporal: {str(e)}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
if 'bulk_upload' in locals():
|
||||
bulk_upload.status = 'failed'
|
||||
bulk_upload.error_message = f"{str(e)}\n{traceback.format_exc()}"
|
||||
bulk_upload.finished_at = timezone.now()
|
||||
bulk_upload.save()
|
||||
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
|
||||
def _process_bulk_create(bulk_upload):
|
||||
"""
|
||||
Procesa bulk_create normal - VERSIÓN CORREGIDA
|
||||
"""
|
||||
|
||||
created_pedimentos = []
|
||||
failed_files = []
|
||||
documents_created = 0
|
||||
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})$')
|
||||
|
||||
try:
|
||||
organizacion = bulk_upload.organizacion
|
||||
contribuyente = bulk_upload.contribuyente
|
||||
temp_files_info = bulk_upload.result.get('temp_files', [])
|
||||
|
||||
try:
|
||||
document_type = DocumentType.objects.get(nombre="Pedimento")
|
||||
except DocumentType.DoesNotExist:
|
||||
document_type = DocumentType.objects.create(
|
||||
nombre="Pedimento",
|
||||
descripcion="Documento de pedimento"
|
||||
)
|
||||
|
||||
for idx, file_info in enumerate(temp_files_info):
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
temp_file_path = os.path.join(temp_dir, file_info['original_name'])
|
||||
with default_storage.open(file_info['saved_path'], 'rb') as src:
|
||||
with open(temp_file_path, 'wb') as dst:
|
||||
dst.write(src.read())
|
||||
|
||||
archivo_name_sin_extension = os.path.splitext(file_info['original_name'])[0]
|
||||
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
|
||||
os.makedirs(sub_dir, exist_ok=True)
|
||||
|
||||
archivo_name = file_info['original_name'].lower()
|
||||
|
||||
if archivo_name.endswith('.zip'):
|
||||
with zipfile.ZipFile(temp_file_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(sub_dir)
|
||||
|
||||
elif archivo_name.endswith('.rar'):
|
||||
if not extract_rar_to_dir(temp_file_path, sub_dir):
|
||||
failed_files.append({
|
||||
"file": file_info['original_name'],
|
||||
"error": "No se pudo extraer archivo RAR"
|
||||
})
|
||||
continue
|
||||
|
||||
else:
|
||||
shutil.move(temp_file_path, os.path.join(sub_dir, file_info['original_name']))
|
||||
|
||||
for root, dirs, files in os.walk(sub_dir):
|
||||
for file_name in files:
|
||||
file_path = os.path.join(root, file_name)
|
||||
relative_path = os.path.relpath(file_path, sub_dir)
|
||||
folder_name = archivo_name_sin_extension
|
||||
|
||||
match = nomenclatura_pattern.match(folder_name)
|
||||
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
|
||||
|
||||
if not match and not match_sin_anio:
|
||||
archivo_original = folder_name + os.path.splitext(file_info['original_name'])[1]
|
||||
failed_files.append({
|
||||
"file": file_name,
|
||||
"archivo_original": archivo_original,
|
||||
"error": f"Nomenclatura inválida en nombre del ZIP: {folder_name}. Esperado: anio-aduana-patente-pedimento"
|
||||
})
|
||||
continue
|
||||
|
||||
if match:
|
||||
anio, aduana, patente, pedimento_num = match.groups()
|
||||
|
||||
try:
|
||||
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
|
||||
fecha_pago = datetime(anio_completo, 1, 1).date()
|
||||
except ValueError:
|
||||
failed_files.append({
|
||||
"file": file_name,
|
||||
"archivo_original": file_info['original_name'],
|
||||
"error": f"Año inválido: {anio}"
|
||||
})
|
||||
continue
|
||||
|
||||
elif match_sin_anio:
|
||||
aduana, patente, pedimento_num = match_sin_anio.groups()
|
||||
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
|
||||
año_actual = datetime.now().year
|
||||
|
||||
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
|
||||
|
||||
if año_con_digito <= año_actual:
|
||||
año_final = año_con_digito
|
||||
else:
|
||||
año_final = año_con_digito - 10
|
||||
|
||||
anio = año_final % 100
|
||||
fecha_pago = datetime(año_final, 1, 1).date()
|
||||
|
||||
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
|
||||
|
||||
existing_pedimento = Pedimento.objects.filter(
|
||||
pedimento_app=pedimento_app,
|
||||
organizacion=organizacion
|
||||
).first()
|
||||
|
||||
if not existing_pedimento:
|
||||
|
||||
importador, created = Importador.objects.get_or_create(
|
||||
rfc=contribuyente,
|
||||
defaults={
|
||||
'nombre': f"Importador {contribuyente}",
|
||||
'organizacion': organizacion
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
pedimento = Pedimento.objects.create(
|
||||
organizacion=organizacion,
|
||||
contribuyente=importador,
|
||||
pedimento=int(pedimento_num),
|
||||
aduana=int(aduana),
|
||||
patente=int(patente),
|
||||
fecha_pago=fecha_pago,
|
||||
pedimento_app=pedimento_app,
|
||||
agente_aduanal=f"Agente {patente}",
|
||||
clave_pedimento="A1"
|
||||
)
|
||||
|
||||
created_pedimentos.append({
|
||||
"id": str(pedimento.id),
|
||||
"pedimento_app": pedimento_app,
|
||||
"contribuyente": importador.rfc,
|
||||
"contribuyente_nombre": importador.nombre
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append({
|
||||
"file": file_name,
|
||||
"archivo_original": file_info['original_name'],
|
||||
"error": f"Error al crear pedimento: {str(e)}"
|
||||
})
|
||||
continue
|
||||
|
||||
pedimento_obj = pedimento
|
||||
else:
|
||||
pedimento_obj = existing_pedimento
|
||||
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
extension = os.path.splitext(file_name)[1].lower().lstrip('.')
|
||||
|
||||
existing_documents = Document.objects.filter(
|
||||
pedimento_id=pedimento_obj.id,
|
||||
organizacion=organizacion
|
||||
)
|
||||
|
||||
existing_document = None
|
||||
for doc in existing_documents:
|
||||
if is_same_document(doc, file_name):
|
||||
existing_document = doc
|
||||
break
|
||||
|
||||
django_file = ContentFile(file_content, name=file_name)
|
||||
|
||||
if existing_document:
|
||||
try:
|
||||
if existing_document.archivo and os.path.exists(existing_document.archivo.path):
|
||||
os.remove(existing_document.archivo.path)
|
||||
except (ValueError, OSError) as e:
|
||||
print(f"No se pudo eliminar archivo físico anterior: {str(e)}")
|
||||
|
||||
existing_document.archivo = django_file
|
||||
existing_document.size = len(file_content)
|
||||
existing_document.extension = extension
|
||||
existing_document.updated_at = timezone.now()
|
||||
existing_document.save()
|
||||
|
||||
else:
|
||||
document = Document.objects.create(
|
||||
organizacion=organizacion,
|
||||
pedimento_id=pedimento_obj.id,
|
||||
document_type=document_type,
|
||||
fuente_id=4,
|
||||
archivo=django_file,
|
||||
size=len(file_content),
|
||||
extension=extension
|
||||
)
|
||||
|
||||
documents_created += 1
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append({
|
||||
"file": file_name,
|
||||
"archivo_original": file_info['original_name'],
|
||||
"error": f"Error al crear documento: {str(e)}"
|
||||
})
|
||||
continue
|
||||
|
||||
if os.path.exists(temp_file_path):
|
||||
os.remove(temp_file_path)
|
||||
|
||||
try:
|
||||
default_storage.delete(file_info['saved_path'])
|
||||
except:
|
||||
pass
|
||||
|
||||
bulk_upload.processed_files = idx + 1
|
||||
bulk_upload.save(update_fields=['processed_files'])
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append({
|
||||
"file": file_info['original_name'],
|
||||
"error": str(e)
|
||||
})
|
||||
continue
|
||||
finally:
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
print(f"Error al eliminar directorio temporal: {str(e)}")
|
||||
|
||||
result = {
|
||||
"created_count": len(created_pedimentos),
|
||||
"created_pedimentos": created_pedimentos,
|
||||
"documents_created": documents_created,
|
||||
"failed_files": failed_files,
|
||||
"processed_files": len(temp_files_info),
|
||||
"summary": f"Procesados {len(temp_files_info)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)"
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
failed_files.append({
|
||||
"file": "global",
|
||||
"error": f"Error global: {str(e)}"
|
||||
})
|
||||
|
||||
return {
|
||||
"created_count": 0,
|
||||
"created_pedimentos": [],
|
||||
"documents_created": 0,
|
||||
"failed_files": failed_files,
|
||||
"processed_files": 0,
|
||||
"summary": f"Error en procesamiento: {str(e)}"
|
||||
}
|
||||
|
||||
def _process_bulk_pedimento_desk(bulk_upload):
|
||||
"""
|
||||
Procesa bulk_create_pedimento_desk - OBTIENE DATOS DEL MODELO
|
||||
"""
|
||||
created_pedimentos = []
|
||||
failed_files = []
|
||||
documents_created = 0
|
||||
temp_dir = None
|
||||
|
||||
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})$')
|
||||
|
||||
try:
|
||||
organizacion = bulk_upload.organizacion
|
||||
contribuyente = bulk_upload.contribuyente
|
||||
temp_files_info = bulk_upload.result.get('temp_files', [])
|
||||
|
||||
fecha_pago_input = bulk_upload.fecha_pago
|
||||
clave_pedimento_input = bulk_upload.clave_pedimento
|
||||
tipo_operacion_id = bulk_upload.tipo_operacion_id
|
||||
curp_apoderado_input = bulk_upload.curp_apoderado
|
||||
partidas_input = bulk_upload.partidas
|
||||
|
||||
tipo_operacion_obj = None
|
||||
if tipo_operacion_id:
|
||||
try:
|
||||
tipo_operacion_obj = TipoOperacion.objects.get(id=tipo_operacion_id)
|
||||
except TipoOperacion.DoesNotExist:
|
||||
print(f"TipoOperacion ID {tipo_operacion_id} no encontrado")
|
||||
|
||||
result = {
|
||||
"created_count": len(created_pedimentos),
|
||||
"created_pedimentos": created_pedimentos,
|
||||
"documents_created": documents_created,
|
||||
"failed_files": failed_files,
|
||||
"processed_files": len(temp_files_info),
|
||||
"summary": f"Procesados {len(temp_files_info)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)"
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
|
||||
failed_files.append({
|
||||
"file": "global",
|
||||
"error": f"Error global: {str(e)}"
|
||||
})
|
||||
|
||||
return {
|
||||
"created_count": 0,
|
||||
"created_pedimentos": [],
|
||||
"documents_created": 0,
|
||||
"failed_files": failed_files,
|
||||
"processed_files": 0,
|
||||
"summary": f"Error en procesamiento: {str(e)}"
|
||||
}
|
||||
|
||||
finally:
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
except Exception as e:
|
||||
print(f"Error al eliminar directorio temporal: {str(e)}")
|
||||
@@ -1,710 +0,0 @@
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_filename(filename):
|
||||
"""
|
||||
Normaliza el nombre del archivo removiendo caracteres especiales,
|
||||
espacios y asegurando consistencia.
|
||||
"""
|
||||
from unicodedata import normalize
|
||||
filename = normalize('NFKD', filename).encode('ASCII', 'ignore').decode('ASCII')
|
||||
filename = re.sub(r'[^\w\s.-]', '_', filename)
|
||||
filename = re.sub(r'[\s()]+', '_', filename)
|
||||
filename = re.sub(r'_+', '_', filename)
|
||||
filename = filename.strip('_')
|
||||
return filename
|
||||
|
||||
|
||||
def extract_django_suffix(filename):
|
||||
"""
|
||||
Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos.
|
||||
"""
|
||||
name_without_ext = os.path.splitext(filename)[0]
|
||||
match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def get_clean_base_filename(filename):
|
||||
"""
|
||||
Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
|
||||
"""
|
||||
normalized = normalize_filename(filename)
|
||||
name_without_ext, ext = os.path.splitext(normalized)
|
||||
|
||||
django_suffix = extract_django_suffix(name_without_ext)
|
||||
if django_suffix:
|
||||
base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
|
||||
else:
|
||||
base_name = name_without_ext
|
||||
|
||||
base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name)
|
||||
|
||||
return base_name.lower().strip('_')
|
||||
|
||||
|
||||
def is_same_document(existing_doc, new_filename):
|
||||
"""
|
||||
Compara si un documento existente y un nuevo archivo son el mismo documento.
|
||||
"""
|
||||
existing_basename = os.path.basename(existing_doc.archivo.name)
|
||||
existing_base = get_clean_base_filename(existing_basename)
|
||||
|
||||
new_base = get_clean_base_filename(new_filename)
|
||||
|
||||
existing_ext = existing_doc.extension.lower()
|
||||
new_ext = os.path.splitext(new_filename)[1].lower().lstrip('.')
|
||||
|
||||
return existing_base == new_base and existing_ext == new_ext
|
||||
|
||||
|
||||
def procesar_archivo_m_con_nomenclatura(content, pedimento_instance):
|
||||
"""
|
||||
Procesa archivos con nomenclatura M8988852.300 (7 dígitos, punto, 3 dígitos)
|
||||
y extrae información de registros específicos para actualizar el pedimento.
|
||||
|
||||
Args:
|
||||
content: bytes del contenido del archivo
|
||||
pedimento_instance: instancia del modelo Pedimento
|
||||
|
||||
Returns:
|
||||
dict: Diccionario con información extraída
|
||||
"""
|
||||
try:
|
||||
content_text = content.decode('utf-8', errors='ignore')
|
||||
|
||||
registros = {}
|
||||
|
||||
for line in content_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split('|')
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
tipo_registro = parts[0]
|
||||
|
||||
if tipo_registro not in registros:
|
||||
registros[tipo_registro] = []
|
||||
registros[tipo_registro].append(parts)
|
||||
|
||||
info_extraida = {
|
||||
'tiene_nomenclatura_especial': False,
|
||||
'registros_encontrados': list(registros.keys()),
|
||||
'detalles_registro_500': [],
|
||||
'detalles_registro_506': [],
|
||||
'detalles_registro_501': [],
|
||||
'detalles_registro_551': [],
|
||||
'detalles_registro_800': [],
|
||||
'detalles_registro_801': [],
|
||||
'actualizaciones_aplicadas': []
|
||||
}
|
||||
|
||||
if '500' in registros:
|
||||
info_extraida['tiene_nomenclatura_especial'] = True
|
||||
|
||||
for reg_500 in registros['500']:
|
||||
if len(reg_500) >= 1:
|
||||
info_extraida['detalles_registro_500'].append({
|
||||
'tipo_movimiento': reg_500[1] if len(reg_500) > 1 else None,
|
||||
'patente': reg_500[2] if len(reg_500) > 1 else None,
|
||||
'numero_pedimento': reg_500[3] if len(reg_500) > 1 else None,
|
||||
'aduana_seccion': reg_500[4] if len(reg_500) > 1 else None,
|
||||
'acuse_electronico': reg_500[5] if len(reg_500) > 1 else None,
|
||||
})
|
||||
|
||||
for reg_506 in registros.get('506', []):
|
||||
if len(reg_506) >= 1:
|
||||
info_extraida['detalles_registro_506'].append({
|
||||
'numero_pedimento': reg_506[1] if len(reg_506) > 1 else None,
|
||||
'tipo_fecha': reg_506[2] if len(reg_506) > 1 else None,
|
||||
'fecha': reg_506[3] if len(reg_506) > 1 else None
|
||||
})
|
||||
|
||||
for reg_501 in registros.get('501', []):
|
||||
if len(reg_501) >= 1:
|
||||
info_extraida['detalles_registro_501'].append({
|
||||
'patente': reg_501[1] if len(reg_501) > 1 else None,
|
||||
'numero_pedimento': reg_501[2] if len(reg_501) > 1 else None,
|
||||
'aduana_seccion': reg_501[3] if len(reg_501) > 1 else None,
|
||||
'rfc': reg_501[8] if len(reg_501) > 1 else None,
|
||||
'curp': reg_501[9] if len(reg_501) > 1 else None
|
||||
})
|
||||
|
||||
for reg_551 in registros.get('551', []):
|
||||
if len(reg_551) >= 1:
|
||||
info_extraida['detalles_registro_551'].append({
|
||||
'numero_pedimento': reg_501[1] if len(reg_501) > 1 else None,
|
||||
'fraccion_arancelaria': reg_551[2] if len(reg_551) > 1 else None,
|
||||
'partida': reg_551[3] if len(reg_551) > 1 else None,
|
||||
'subfraccion': reg_551[4] if len(reg_551) > 1 else None
|
||||
})
|
||||
|
||||
for reg_801 in registros.get('800', []):
|
||||
if len(reg_801) >= 1:
|
||||
info_extraida['detalles_registro_800'].append({
|
||||
'numero_pedimento': reg_801[1] if len(reg_801) > 1 else None
|
||||
})
|
||||
|
||||
for reg_801 in registros.get('801', []):
|
||||
if len(reg_801) >= 1:
|
||||
info_extraida['detalles_registro_801'].append({
|
||||
'total_partidas': reg_801[1] if len(reg_801) > 1 else None
|
||||
})
|
||||
|
||||
actualizaciones = actualizar_pedimento_con_registros(pedimento_instance, registros)
|
||||
info_extraida['actualizaciones_aplicadas'] = actualizaciones
|
||||
|
||||
return info_extraida
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error al procesar archivo con nomenclatura especial: {str(e)}")
|
||||
return {
|
||||
'tiene_nomenclatura_especial': False,
|
||||
'error': str(e),
|
||||
'registros_encontrados': []
|
||||
}
|
||||
|
||||
|
||||
def actualizar_pedimento_con_registros(pedimento_instance, registros):
|
||||
"""
|
||||
Actualiza el pedimento con información extraída de los registros.
|
||||
|
||||
Args:
|
||||
pedimento_instance: Instancia del pedimento a actualizar
|
||||
registros: Diccionario con registros parseados
|
||||
|
||||
Returns:
|
||||
list: Lista de actualizaciones aplicadas
|
||||
"""
|
||||
actualizaciones = []
|
||||
|
||||
try:
|
||||
if '500' in registros and registros['500']:
|
||||
for reg_500 in registros['500']:
|
||||
if len(reg_500) >= 1:
|
||||
if pedimento_instance.pedimento == reg_500[3]:
|
||||
try:
|
||||
pedimento_instance.aduana = reg_500[4]
|
||||
actualizaciones.append(f"aduana actualizada a {reg_500[4]}")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if '501' in registros and registros['501']:
|
||||
for reg_501 in registros['501']:
|
||||
if len(reg_501) >= 1:
|
||||
rfc = reg_501[8] if len(reg_501) > 1 else None
|
||||
|
||||
if rfc and not pedimento_instance.contribuyente and pedimento_instance.pedimento == reg_501[2]:
|
||||
try:
|
||||
from api.customs.models import Importador
|
||||
importador, created = Importador.objects.get_or_create(
|
||||
rfc=rfc,
|
||||
defaults={
|
||||
'nombre': f"Importador {rfc}",
|
||||
'organizacion': pedimento_instance.organizacion
|
||||
}
|
||||
)
|
||||
pedimento_instance.contribuyente = importador
|
||||
if created:
|
||||
actualizaciones.append(f"importador creado con RFC {rfc}")
|
||||
else:
|
||||
actualizaciones.append(f"importador asociado con RFC {rfc}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error al crear/obtener importador: {str(e)}")
|
||||
|
||||
if '501' in registros and registros['501']:
|
||||
for reg_501 in registros['501']:
|
||||
if len(reg_501) >= 1:
|
||||
curp = reg_501[9] if len(reg_501) > 1 else None
|
||||
if curp and not pedimento_instance.curp_apoderado and pedimento_instance.pedimento == reg_501[2]:
|
||||
pedimento_instance.curp_apoderado = curp
|
||||
actualizaciones.append(f"curp_apoderado actualizado a {curp}")
|
||||
|
||||
if '501' in registros and registros['501']:
|
||||
for reg_501 in registros['501']:
|
||||
if len(reg_501) >= 1:
|
||||
tipo_operacion = reg_501[4] if len(reg_501) > 1 else None
|
||||
if tipo_operacion and pedimento_instance.pedimento == reg_501[2]:
|
||||
|
||||
if tipo_operacion=='1':
|
||||
nombre_tipo_op = "Importacion"
|
||||
elif tipo_operacion=='2':
|
||||
nombre_tipo_op = "Exportacion"
|
||||
else:
|
||||
nombre_tipo_op = f"Tipo {tipo_operacion}"
|
||||
|
||||
try:
|
||||
from api.customs.models import TipoOperacion
|
||||
tipo_op_obj, created = TipoOperacion.objects.get_or_create(
|
||||
id=tipo_operacion,
|
||||
tipo=nombre_tipo_op,
|
||||
defaults={'descripcion': f"Tipo de Operación {tipo_operacion}"}
|
||||
)
|
||||
pedimento_instance.tipo_operacion = tipo_op_obj
|
||||
if created:
|
||||
actualizaciones.append(f"tipo_operacion creado con tipo {tipo_operacion}")
|
||||
else:
|
||||
actualizaciones.append(f"tipo_operacion asociado con tipo {tipo_operacion}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error al crear/obtener tipo de operación: {str(e)}")
|
||||
|
||||
if '501' in registros and registros['501']:
|
||||
for reg_501 in registros['501']:
|
||||
if len(reg_501) >= 1:
|
||||
clave = reg_501[5] if len(reg_501) > 1 else None
|
||||
if clave and pedimento_instance.pedimento == reg_501[2]:
|
||||
pedimento_instance.clave_pedimento = clave
|
||||
actualizaciones.append(f"clave pedimento actualizada a {clave}")
|
||||
|
||||
if '506' in registros and registros['506']:
|
||||
for reg_506 in registros['506']:
|
||||
|
||||
if not pedimento_instance.pedimento == reg_506[1]:
|
||||
continue
|
||||
|
||||
if len(reg_506) >= 1:
|
||||
tipo_fecha = reg_506[2] if len(reg_506) > 1 else None
|
||||
fecha_str = reg_506[3] if len(reg_506) > 1 else None
|
||||
|
||||
if not tipo_fecha == '2':
|
||||
continue
|
||||
|
||||
if fecha_str:
|
||||
try:
|
||||
if len(fecha_str) == 8:
|
||||
fecha = datetime.strptime(fecha_str, '%d%m%Y').date()
|
||||
elif len(fecha_str) == 6:
|
||||
fecha = datetime.strptime(fecha_str, '%d%m%y').date()
|
||||
else:
|
||||
continue
|
||||
|
||||
pedimento_instance.fecha_pago = fecha
|
||||
actualizaciones.append(f"fecha_pago actualizada a {fecha}")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
num_partidas = 0
|
||||
if '551' in registros and registros['551']:
|
||||
for reg_551 in registros['551']:
|
||||
if not pedimento_instance.pedimento == reg_551[1]:
|
||||
continue
|
||||
|
||||
num_partidas += 1
|
||||
pedimento_instance.numero_partidas = num_partidas
|
||||
actualizaciones.append(f"numero_partidas actualizado a {num_partidas}")
|
||||
|
||||
|
||||
if actualizaciones:
|
||||
pedimento_instance.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error al actualizar pedimento con registros: {str(e)}")
|
||||
actualizaciones.append(f"error: {str(e)}")
|
||||
|
||||
return actualizaciones
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, time_limit=600)
|
||||
def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
|
||||
"""
|
||||
Procesa archivos ZIP de pedimentos en segundo plano.
|
||||
|
||||
Args:
|
||||
organizacion_id: UUID de la organización
|
||||
parametros: dict con keys:
|
||||
- contribuyente
|
||||
- fecha_pago_input
|
||||
- clave_pedimento_input
|
||||
- patente_input
|
||||
- tipo_operacion_input
|
||||
- aduana_input
|
||||
- curp_apoderado_input
|
||||
- partidas_input
|
||||
archivo_paths: lista de rutas temporales de archivos ZIP
|
||||
"""
|
||||
from api.organization.models import Organizacion
|
||||
from api.customs.models import Pedimento, Importador, TipoOperacion
|
||||
from api.record.models import Document, DocumentType, Fuente
|
||||
|
||||
created_pedimentos = []
|
||||
updated_pedimentos = []
|
||||
failed_records = []
|
||||
documents_created = 0
|
||||
temp_dir = None
|
||||
|
||||
try:
|
||||
organizacion = Organizacion.objects.get(id=organizacion_id)
|
||||
|
||||
# Extraer parámetros
|
||||
contribuyente = parametros.get('contribuyente', None)
|
||||
fecha_pago_input = parametros.get('fecha_pago_input', None)
|
||||
clave_pedimento_input = parametros.get('clave_pedimento_input', None)
|
||||
patente_input = parametros.get('patente_input', None)
|
||||
tipo_operacion_input = parametros.get('tipo_operacion_input', None)
|
||||
aduana_input = parametros.get('aduana_input', None)
|
||||
curp_apoderado_input = parametros.get('curp_apoderado_input', None)
|
||||
partidas_input = parametros.get('partidas_input', None)
|
||||
|
||||
# Regex patterns
|
||||
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})$')
|
||||
|
||||
# Obtener DocumentType
|
||||
try:
|
||||
document_type = DocumentType.objects.get(nombre="Pedimento")
|
||||
except DocumentType.DoesNotExist:
|
||||
document_type = DocumentType.objects.create(
|
||||
nombre="Pedimento",
|
||||
descripcion="Documento de pedimento"
|
||||
)
|
||||
|
||||
# Fuente
|
||||
fuente, _ = Fuente.objects.get_or_create(
|
||||
nombre="APP-EFC",
|
||||
descripcion='Transmitido por la app de escritorio'
|
||||
)
|
||||
|
||||
# Usar el directorio donde están los archivos (ya guardado en MEDIA_ROOT)
|
||||
# El directorio base es el padre del primer archivo
|
||||
if archivo_paths:
|
||||
temp_dir = os.path.dirname(archivo_paths[0])
|
||||
else:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
# Patrón para nomenclatura especial M8988852.300
|
||||
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
|
||||
|
||||
existing_pedimento = None
|
||||
|
||||
for archivo_path in archivo_paths:
|
||||
archivo_name = os.path.basename(archivo_path).lower()
|
||||
archivo_name_sin_extension = os.path.splitext(os.path.basename(archivo_path))[0]
|
||||
|
||||
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
|
||||
os.makedirs(sub_dir, exist_ok=True)
|
||||
|
||||
print(f"Procesando archivo: {archivo_name} en ruta temporal: {archivo_path}")
|
||||
|
||||
if archivo_name.endswith('.zip'):
|
||||
try:
|
||||
with zipfile.ZipFile(archivo_path, 'r') as zip_ref:
|
||||
zip_ref.extractall(sub_dir)
|
||||
os.remove(archivo_path) # Eliminar el archivo ZIP después de extraerlo
|
||||
except zipfile.BadZipFile as e:
|
||||
failed_records.append({
|
||||
"file": archivo_path,
|
||||
"archivo_original": archivo_name,
|
||||
"error": f"Archivo ZIP corrupto o inválido: {str(e)}"
|
||||
})
|
||||
continue
|
||||
except Exception as e:
|
||||
failed_records.append({
|
||||
"file": archivo_path,
|
||||
"archivo_original": archivo_name,
|
||||
"error": f"Error al extraer ZIP: {str(e)}"
|
||||
})
|
||||
continue
|
||||
else:
|
||||
failed_records.append({
|
||||
"file": archivo_path,
|
||||
"archivo_original": archivo_name,
|
||||
"error": "Solo se admiten archivos ZIP"
|
||||
})
|
||||
continue
|
||||
|
||||
# Procesar archivos extraídos
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
for file_name in files:
|
||||
file_path = os.path.join(root, file_name)
|
||||
relative_path = os.path.relpath(file_path, temp_dir)
|
||||
|
||||
# Determinar folder_name
|
||||
folder_name = None
|
||||
if os.path.dirname(relative_path):
|
||||
folder_parts = relative_path.split(os.sep)
|
||||
folder_name = folder_parts[0]
|
||||
else:
|
||||
folder_name = os.path.splitext(file_name)[0]
|
||||
|
||||
# Validar nomenclatura
|
||||
match = nomenclatura_pattern.match(folder_name)
|
||||
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
|
||||
|
||||
if not match and not match_sin_anio:
|
||||
archivo_original = folder_name + '.zip'
|
||||
failed_records.append({
|
||||
"file": relative_path,
|
||||
"archivo_original": archivo_original,
|
||||
"error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento"
|
||||
})
|
||||
continue
|
||||
|
||||
if match:
|
||||
anio, aduana, patente, pedimento_num = match.groups()
|
||||
try:
|
||||
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
|
||||
fecha_pago = datetime(anio_completo, 1, 1).date()
|
||||
except ValueError:
|
||||
failed_records.append({
|
||||
"file": relative_path,
|
||||
"archivo_original": folder_name + '.zip',
|
||||
"error": f"Año inválido: {anio}"
|
||||
})
|
||||
continue
|
||||
|
||||
elif match_sin_anio:
|
||||
aduana, patente, pedimento_num = match_sin_anio.groups()
|
||||
|
||||
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
|
||||
año_actual = datetime.now().year
|
||||
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
|
||||
|
||||
if año_con_digito <= año_actual:
|
||||
año_final = año_con_digito
|
||||
else:
|
||||
año_final = año_con_digito - 10
|
||||
|
||||
anio = año_final % 100
|
||||
fecha_pago = datetime(año_final, 1, 1).date()
|
||||
|
||||
# Generar pedimento_app
|
||||
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
|
||||
|
||||
# Verificar si el pedimento ya existe
|
||||
existing_pedimento = Pedimento.objects.filter(
|
||||
pedimento_app=pedimento_app,
|
||||
organizacion=organizacion
|
||||
).first()
|
||||
|
||||
if not existing_pedimento:
|
||||
# Crear nuevo pedimento
|
||||
try:
|
||||
importador = None
|
||||
if contribuyente:
|
||||
importador, created = Importador.objects.get_or_create(
|
||||
rfc=contribuyente,
|
||||
defaults={
|
||||
'nombre': f"Importador {contribuyente}",
|
||||
'organizacion': organizacion
|
||||
}
|
||||
)
|
||||
|
||||
tipo_op = None
|
||||
if tipo_operacion_input:
|
||||
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
|
||||
|
||||
pedimento = Pedimento.objects.create(
|
||||
organizacion=organizacion,
|
||||
contribuyente=importador if importador else None,
|
||||
pedimento=str(pedimento_num),
|
||||
aduana=str(aduana),
|
||||
patente=str(patente),
|
||||
fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago,
|
||||
curp_apoderado=curp_apoderado_input if curp_apoderado_input else "",
|
||||
numero_partidas=partidas_input if partidas_input else 0,
|
||||
tipo_operacion=tipo_op if tipo_op else None,
|
||||
pedimento_app=pedimento_app,
|
||||
agente_aduanal=f"Agente {patente}",
|
||||
clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1"
|
||||
)
|
||||
|
||||
existing_pedimento = pedimento
|
||||
|
||||
created_pedimentos.append({
|
||||
"id": str(pedimento.id),
|
||||
"pedimento_app": pedimento_app,
|
||||
"contribuyente": getattr(importador, 'rfc', None),
|
||||
"contribuyente_nombre": getattr(importador, 'nombre', None)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
failed_records.append({
|
||||
"file": relative_path,
|
||||
"archivo_original": folder_name + '.zip',
|
||||
"error": f"Error al crear pedimento: {str(e)}"
|
||||
})
|
||||
continue
|
||||
else:
|
||||
# Actualizar pedimento existente
|
||||
if contribuyente:
|
||||
importador, created = Importador.objects.get_or_create(
|
||||
rfc=contribuyente,
|
||||
defaults={
|
||||
'nombre': f"Importador {contribuyente}",
|
||||
'organizacion': organizacion
|
||||
}
|
||||
)
|
||||
|
||||
importador_db = existing_pedimento.contribuyente
|
||||
if importador_db:
|
||||
if importador_db != importador:
|
||||
existing_pedimento.contribuyente = importador
|
||||
else:
|
||||
existing_pedimento.contribuyente = importador
|
||||
|
||||
existing_pedimento.save()
|
||||
|
||||
# Actualizar Tipo Operacion
|
||||
if tipo_operacion_input:
|
||||
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
|
||||
if tipo_op and not existing_pedimento.tipo_operacion:
|
||||
existing_pedimento.tipo_operacion = tipo_op
|
||||
existing_pedimento.save()
|
||||
|
||||
# Actualizar fecha de pago
|
||||
if fecha_pago_input:
|
||||
fecha_db = existing_pedimento.fecha_pago
|
||||
if fecha_db:
|
||||
if isinstance(fecha_db, datetime):
|
||||
fecha_db = fecha_db.date()
|
||||
if fecha_db.month == 1 and fecha_db.day == 1:
|
||||
existing_pedimento.fecha_pago = fecha_pago_input
|
||||
existing_pedimento.save()
|
||||
else:
|
||||
existing_pedimento.fecha_pago = fecha_pago_input
|
||||
existing_pedimento.save()
|
||||
|
||||
# Actualizar clave_pedimento
|
||||
if clave_pedimento_input:
|
||||
clave_pedimento = existing_pedimento.clave_pedimento
|
||||
if not clave_pedimento or clave_pedimento.strip() != clave_pedimento_input.strip():
|
||||
existing_pedimento.clave_pedimento = clave_pedimento_input
|
||||
existing_pedimento.save()
|
||||
|
||||
# Actualizar curp_apoderado
|
||||
if curp_apoderado_input:
|
||||
if not existing_pedimento.curp_apoderado:
|
||||
existing_pedimento.curp_apoderado = curp_apoderado_input
|
||||
existing_pedimento.save()
|
||||
|
||||
# Actualizar partidas
|
||||
if partidas_input:
|
||||
num_partidas = existing_pedimento.numero_partidas
|
||||
if not num_partidas or num_partidas <= 0:
|
||||
existing_pedimento.numero_partidas = partidas_input
|
||||
existing_pedimento.save()
|
||||
|
||||
# Crear documento asociado al pedimento
|
||||
try:
|
||||
with open(file_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
file_name_lower = file_name.lower()
|
||||
tiene_nomenclatura_especial = False
|
||||
info_extraida = {}
|
||||
|
||||
nombre_base, extension = os.path.splitext(file_name)
|
||||
|
||||
if patron_nomenclatura.match(file_name_lower):
|
||||
tiene_nomenclatura_especial = True
|
||||
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento)
|
||||
|
||||
# Buscar documento existente
|
||||
existing_documents = Document.objects.filter(
|
||||
pedimento_id=existing_pedimento.id,
|
||||
organizacion=organizacion
|
||||
)
|
||||
|
||||
existing_document = None
|
||||
for doc in existing_documents:
|
||||
if is_same_document(doc, file_name):
|
||||
existing_document = doc
|
||||
break
|
||||
|
||||
if existing_document:
|
||||
updated_pedimentos.append({
|
||||
"id": str(existing_pedimento.id),
|
||||
"pedimento_app": existing_pedimento.pedimento_app,
|
||||
"accion": "Documento ya existente, omitido",
|
||||
"documento": file_name
|
||||
})
|
||||
else:
|
||||
# Crear registro sin archivo primero
|
||||
document = Document.objects.create(
|
||||
organizacion=organizacion,
|
||||
pedimento_id=existing_pedimento.id,
|
||||
document_type=document_type,
|
||||
fuente_id=fuente.id,
|
||||
size=len(file_content),
|
||||
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
|
||||
)
|
||||
|
||||
from api.utils.storage_service import storage_service
|
||||
ruta = storage_service.save_document_from_path(
|
||||
file_path=file_path,
|
||||
file_name=file_name,
|
||||
organizacion_id=organizacion.id,
|
||||
pedimento_app=existing_pedimento.pedimento_app,
|
||||
metadata={
|
||||
'pedimento_id': str(existing_pedimento.id),
|
||||
'document_id': str(document.id),
|
||||
'source': 'bulk_upload_async'
|
||||
}
|
||||
)
|
||||
|
||||
if ruta:
|
||||
document.archivo = ruta
|
||||
document.save()
|
||||
documents_created += 1
|
||||
updated_pedimentos.append({
|
||||
"id": str(existing_pedimento.id),
|
||||
"pedimento_app": existing_pedimento.pedimento_app,
|
||||
"accion": "Documento creado",
|
||||
"documento": file_name
|
||||
})
|
||||
else:
|
||||
document.delete()
|
||||
failed_records.append({
|
||||
"file": relative_path,
|
||||
"archivo_original": folder_name + '.zip',
|
||||
"error": f"Error al guardar {file_name} en almacenamiento"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
failed_records.append({
|
||||
"file": relative_path,
|
||||
"archivo_original": folder_name + '.zip',
|
||||
"error": f"Error al crear documento: {str(e)}"
|
||||
})
|
||||
continue
|
||||
|
||||
# Actualizar estado de expediente
|
||||
if documents_created > 0 and existing_pedimento:
|
||||
existing_pedimento.existe_expediente = True
|
||||
existing_pedimento.save()
|
||||
|
||||
return {
|
||||
'status': 'completed',
|
||||
'created_pedimentos': created_pedimentos,
|
||||
'updated_pedimentos': updated_pedimentos,
|
||||
'failed_records': failed_records,
|
||||
'documents_created': documents_created,
|
||||
'tieneError': len(failed_records) > 0
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error en bulk_upload_record_task: {str(e)}")
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
|
||||
finally:
|
||||
# Limpiar directorio temporal
|
||||
if temp_dir and os.path.exists(temp_dir):
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error al limpiar directorio temporal: {e}")
|
||||
@@ -1,17 +1,6 @@
|
||||
import logging
|
||||
from celery import shared_task, group
|
||||
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
|
||||
from core.utils import xml_controller
|
||||
from core.redis_events import publish_task_event
|
||||
from api.customs.tasks.auditoria import _crear_notificacion_auditoria
|
||||
from api.customs.tasks.microservice import (
|
||||
procesar_cove_individual,
|
||||
procesar_acuse_individual,
|
||||
procesar_acuse_cove_individual,
|
||||
procesar_edoc_individual,
|
||||
procesar_partida_individual,
|
||||
procesar_remesa_individual,
|
||||
)
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_remesa(pedimento_id):
|
||||
@@ -22,7 +11,7 @@ def crear_procesamiento_remesa(pedimento_id):
|
||||
if pedimento.remesas:
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=5,
|
||||
servicio_id=5, # ID del servicio de remesas
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -30,11 +19,10 @@ def crear_procesamiento_remesa(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=5,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_partida(pedimento_id):
|
||||
@@ -44,7 +32,7 @@ def crear_procesamiento_partida(pedimento_id):
|
||||
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=4,
|
||||
servicio_id=4, # ID del servicio de partidas
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -52,11 +40,10 @@ def crear_procesamiento_partida(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=4,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_cove(pedimento_id):
|
||||
@@ -67,7 +54,7 @@ def crear_procesamiento_cove(pedimento_id):
|
||||
if pedimento.coves.exists():
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=8,
|
||||
servicio_id=8, # ID del servicio de Coves
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -75,11 +62,10 @@ def crear_procesamiento_cove(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=8,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_acuse(pedimento_id):
|
||||
@@ -87,10 +73,10 @@ def crear_procesamiento_acuse(pedimento_id):
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
|
||||
if pedimento.documentos.exists():
|
||||
if pedimento.coves.exists():
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=6,
|
||||
servicio_id=6, # ID del servicio de Acuse Cove
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -98,11 +84,10 @@ def crear_procesamiento_acuse(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=6,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_acuse_cove(pedimento_id):
|
||||
@@ -113,7 +98,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
|
||||
if pedimento.coves.exists():
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=9,
|
||||
servicio_id=9, # ID del servicio de Acuse Cove
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -121,11 +106,10 @@ def crear_procesamiento_acuse_cove(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=9,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_edocument(pedimento_id):
|
||||
@@ -136,7 +120,7 @@ def crear_procesamiento_edocument(pedimento_id):
|
||||
if pedimento.documentos.exists():
|
||||
existe = ProcesamientoPedimento.objects.filter(
|
||||
pedimento=pedimento,
|
||||
servicio_id=7,
|
||||
servicio_id=7, # ID del servicio de EDocument
|
||||
organizacion=pedimento.organizacion,
|
||||
estado_id__in=[1, 2, 3, 4]
|
||||
).exists()
|
||||
@@ -144,11 +128,10 @@ def crear_procesamiento_edocument(pedimento_id):
|
||||
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento=pedimento,
|
||||
estado_id=1,
|
||||
estado_id=1, # Estado "pendiente"
|
||||
servicio_id=7,
|
||||
organizacion=pedimento.organizacion
|
||||
)
|
||||
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
|
||||
|
||||
@shared_task
|
||||
def crear_procesamiento_pedimento_completo(organizacion_id):
|
||||
@@ -183,88 +166,51 @@ def crear_servicios(organizacion_id):
|
||||
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
|
||||
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
|
||||
|
||||
@shared_task(bind=True)
|
||||
def auditar_pedimentos(self, organizacion_id, user_id=None):
|
||||
_logger = logging.getLogger('api.customs.async_operations')
|
||||
task_id = self.request.id
|
||||
@shared_task
|
||||
def auditar_pedimentos(organizacion_id):
|
||||
|
||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
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):
|
||||
for pedimento in pedimentos:
|
||||
pc = pedimento.documents.filter(document_type__id=2).first()
|
||||
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:
|
||||
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('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
|
||||
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:
|
||||
# Si ya existe por unique, recupera el objeto existente
|
||||
pass
|
||||
|
||||
@shared_task
|
||||
def crear_todos_los_servicios():
|
||||
|
||||
@@ -11,9 +11,6 @@ from datetime import datetime
|
||||
# ===================
|
||||
@shared_task
|
||||
def procesar_pedimento_completo_individual(pedimento_id, organizacion_id):
|
||||
import logging
|
||||
logger = logging.getLogger('api.customs.async_operations')
|
||||
logger.info(f"Pedimento a monitorear: {pedimento_id}, org:: {organizacion_id}, verificando servicios a crear...")
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL}/async/services/pedimento_completo",
|
||||
json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)}
|
||||
|
||||
@@ -1,47 +1,24 @@
|
||||
from api.organization.models import Organizacion
|
||||
from celery import group
|
||||
from celery import shared_task, group
|
||||
from api.customs.models import *
|
||||
from api.record.models import *
|
||||
from api.customs.serializers import PedimentoSerializer
|
||||
from api.vucem.models import *
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
import requests
|
||||
from config.settings import SERVICE_API_URL_V2, MAX_INTENTOS_AUTO
|
||||
from config.settings import SERVICE_API_URL_V2
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero
|
||||
TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23')
|
||||
logger = logging.getLogger('api.customs.microservice_v2')
|
||||
|
||||
def credenciales_to_dict(credenciales):
|
||||
if not credenciales:
|
||||
return {}
|
||||
|
||||
key_value = None
|
||||
if credenciales.key:
|
||||
if hasattr(credenciales.key, 'url'):
|
||||
key_value = credenciales.key.url
|
||||
else:
|
||||
key_value = str(credenciales.key)
|
||||
|
||||
cer_value = None
|
||||
if credenciales.cer:
|
||||
if hasattr(credenciales.cer, 'url'):
|
||||
cer_value = credenciales.cer.url
|
||||
else:
|
||||
cer_value = str(credenciales.cer)
|
||||
|
||||
return {
|
||||
"id": str(credenciales.id),
|
||||
"user": credenciales.usuario,
|
||||
"password": credenciales.password,
|
||||
"efirma": credenciales.efirma,
|
||||
"key": key_value,
|
||||
"cer": cer_value,
|
||||
"key": credenciales.key.url if credenciales.key else None,
|
||||
"cer": credenciales.cer.url if credenciales.cer else None,
|
||||
"is_active": credenciales.is_active,
|
||||
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
|
||||
}
|
||||
@@ -79,10 +56,8 @@ def partida_to_dict(partida):
|
||||
|
||||
@shared_task
|
||||
def procesar_coves_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.coves.filter(cove_estado__in=estados_reprocesables).exists():
|
||||
if pedimento.coves.filter(cove_descargado=False).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -90,30 +65,22 @@ def procesar_coves_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_estado__in=estados_reprocesables)],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables).exists():
|
||||
if pedimento.coves.filter(acuse_cove_descargado=False).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -121,30 +88,22 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables)],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_edocs_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.documentos.filter(edocument_estado__in=estados_reprocesables).exists():
|
||||
if pedimento.documentos.filter(edocument_descargado=False).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -152,30 +111,22 @@ def procesar_edocs_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_estado__in=estados_reprocesables)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/edoc/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_acuses_pedimento(pedimento_id):
|
||||
# Flujo manual: incluye registros en 'error' y no aplica tope ni contador de intentos
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
|
||||
if pedimento.documentos.filter(acuse_estado__in=estados_reprocesables).exists():
|
||||
if pedimento.documentos.filter(acuse_descargado=False).exists():
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -183,23 +134,17 @@ def procesar_acuses_pedimento(pedimento_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_estado__in=estados_reprocesables)],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_partidas_pedimento(pedimento_id):
|
||||
@@ -211,31 +156,18 @@ def procesar_partidas_pedimento(pedimento_id):
|
||||
).first()
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||
payload = {
|
||||
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_remesas_pedimento(pedimento_id):
|
||||
@@ -252,23 +184,17 @@ def procesar_remesas_pedimento(pedimento_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_pedimento_completo_individual(pedimento_id, force=False):
|
||||
def procesar_pedimento_completo_individual(pedimento_id):
|
||||
pedimento = Pedimento.objects.get(id=pedimento_id)
|
||||
if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(
|
||||
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||
@@ -278,92 +204,49 @@ def procesar_pedimento_completo_individual(pedimento_id, force=False):
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
return response
|
||||
|
||||
@shared_task
|
||||
def procesar_pedimentos_completos(organizacion_id):
|
||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
respuestas = []
|
||||
for pedimento in pedimentos:
|
||||
|
||||
if not pedimento.contribuyente:
|
||||
print(f"Pedimento {pedimento.pedimento} no tiene contribuyente")
|
||||
continue
|
||||
|
||||
credencial_importador = CredencialesImportador.objects.filter(
|
||||
rfc=pedimento.contribuyente
|
||||
).first()
|
||||
|
||||
if not credencial_importador:
|
||||
print(f"No credencial para RFC {pedimento.contribuyente.rfc}")
|
||||
continue
|
||||
|
||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
# credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
|
||||
|
||||
if not credenciales:
|
||||
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
|
||||
continue
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
|
||||
dataJson = json.dumps(payload)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
data=dataJson,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
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
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_remesas(organizacion_id):
|
||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
|
||||
for pedimento in pedimentos:
|
||||
logger.info(f"pedimento >>>> {pedimento}")
|
||||
try:
|
||||
# if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada
|
||||
# logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.")
|
||||
# continue
|
||||
|
||||
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
|
||||
credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
|
||||
if not credencial_importador:
|
||||
logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.")
|
||||
continue
|
||||
|
||||
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
|
||||
if not credenciales:
|
||||
logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.")
|
||||
continue
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
@@ -372,17 +255,15 @@ def procesar_remesas(organizacion_id):
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_coves(organizacion_id):
|
||||
@@ -391,14 +272,7 @@ def procesar_coves(organizacion_id):
|
||||
coves__isnull=False
|
||||
).distinct()
|
||||
for pedimento in pedimentos:
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles;
|
||||
# registros en 'error' o con tope agotado solo se relanzan de forma manual
|
||||
pendientes = pedimento.coves.filter(
|
||||
cove_estado=EstadoDescarga.PENDIENTE,
|
||||
cove_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
coves_batch = list(pendientes)
|
||||
if coves_batch:
|
||||
if pedimento.coves.filter(cove_descargado=False).exists(): # Tipo 3: Remesa
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
@@ -407,27 +281,19 @@ def procesar_coves(organizacion_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in coves_batch],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento; los reintentos internos
|
||||
# del worker (Celery/SOAP) pertenecen a este mismo intento
|
||||
pendientes.update(cove_intentos=F('cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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}")
|
||||
continue
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_acuse_coves(organizacion_id):
|
||||
@@ -437,13 +303,7 @@ def procesar_acuse_coves(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.coves.filter(
|
||||
acuse_cove_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_cove_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
coves_batch = list(pendientes)
|
||||
if coves_batch:
|
||||
if pedimento.coves.filter(acuse_cove_descargado=False).exists(): # Tipo 3: Remesa
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
@@ -452,26 +312,19 @@ def procesar_acuse_coves(organizacion_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"coves": [cove_to_dict(cove) for cove in coves_batch],
|
||||
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(acuse_cove_intentos=F('acuse_cove_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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}")
|
||||
continue
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_acuses(organizacion_id):
|
||||
@@ -481,13 +334,7 @@ def procesar_acuses(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.documentos.filter(
|
||||
acuse_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
edocs_batch = list(pendientes)
|
||||
if edocs_batch:
|
||||
if pedimento.documentos.filter(acuse_descargado=False).exists(): # Tipo 3: Remesa
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
@@ -496,26 +343,19 @@ def procesar_acuses(organizacion_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(acuse_intentos=F('acuse_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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}")
|
||||
continue
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_edocs(organizacion_id):
|
||||
@@ -525,13 +365,7 @@ def procesar_edocs(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
# Compuerta del automático (T2026-05-027): solo 'pendiente' con intentos disponibles
|
||||
pendientes = pedimento.documentos.filter(
|
||||
edocument_estado=EstadoDescarga.PENDIENTE,
|
||||
edocument_intentos__lt=MAX_INTENTOS_AUTO,
|
||||
)
|
||||
edocs_batch = list(pendientes)
|
||||
if edocs_batch:
|
||||
if pedimento.documentos.filter(edocument_descargado=False).exists(): # Tipo 3: Remesa
|
||||
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
@@ -540,26 +374,19 @@ def procesar_edocs(organizacion_id):
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
|
||||
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
# Un ciclo de orquestación = un intento
|
||||
pendientes.update(edocument_intentos=F('edocument_intentos') + 1, ultimo_intento_at=timezone.now())
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
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}")
|
||||
continue
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def procesar_partidas(organizacion_id):
|
||||
@@ -569,40 +396,27 @@ def procesar_partidas(organizacion_id):
|
||||
).distinct()
|
||||
|
||||
for pedimento in pedimentos:
|
||||
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
|
||||
if not partidas_pendientes:
|
||||
continue
|
||||
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas
|
||||
# Convertir el pedimento a JSON usando el serializer
|
||||
pedimento_dict = pedimento_to_dict(pedimento)
|
||||
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
|
||||
|
||||
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)
|
||||
credenciales_dict = credenciales_to_dict(credenciales)
|
||||
|
||||
payload = {
|
||||
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
payload = {
|
||||
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
|
||||
"pedimento": pedimento_dict,
|
||||
"credencial": credenciales_dict
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{SERVICE_API_URL_V2}/services/all/partidas/",
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=60
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
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}"
|
||||
)
|
||||
continue
|
||||
# Aquí puedes continuar con el resto de tu lógica
|
||||
|
||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
||||
|
||||
@shared_task
|
||||
def documentos_con_errores(organizacion_id):
|
||||
@@ -614,34 +428,6 @@ def documentos_con_errores(organizacion_id):
|
||||
# Aquí puedes agregar lógica adicional para manejar documentos con errores
|
||||
# como enviar notificaciones, registrar en un log, etc.
|
||||
|
||||
@shared_task
|
||||
def procesar_procesamiento_pedimento(organizacion_id):
|
||||
# print("Creando procesamientos de pedimentos para organización:", organizacion_id)
|
||||
|
||||
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
|
||||
# pedimentos = Pedimento.objects.filter(id='1c061182-ac68-45b0-b3d7-35bf2264982b')
|
||||
if not pedimentos.exists():
|
||||
print("No se encontraron pedimentos para la organización:", organizacion_id)
|
||||
return
|
||||
for pedimento in pedimentos:
|
||||
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
|
||||
|
||||
procesamiento_pedimento = ProcesamientoPedimento.objects.filter(
|
||||
pedimento_id=pedimento.id,
|
||||
servicio_id=3, # servicio 3: Pedimento Completo
|
||||
)
|
||||
|
||||
if not procesamiento_pedimento.exists():
|
||||
ProcesamientoPedimento.objects.create(
|
||||
pedimento_id=pedimento.id
|
||||
, organizacion_id=pedimento.organizacion_id
|
||||
, estado_id =1
|
||||
, servicio_id=3
|
||||
, tipo_procesamiento_id=2) # servicio 3: Pedimento Completo
|
||||
|
||||
# print("Procesamiento creado para pedimento:", pedimento.pedimento_app)
|
||||
|
||||
procesar_pedimentos_completos.delay(organizacion_id)
|
||||
|
||||
def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento):
|
||||
if procesamiento == 'coves':
|
||||
@@ -658,11 +444,9 @@ def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento):
|
||||
procesar_pedimentos_completos.delay(organizacion_id)
|
||||
elif procesamiento == 'remesas':
|
||||
procesar_remesas.delay(organizacion_id)
|
||||
elif procesamiento == 'procesamiento_pedimento':
|
||||
procesar_procesamiento_pedimento.delay(organizacion_id)
|
||||
|
||||
else:
|
||||
# Procesamiento no reconocido
|
||||
# print(f"Procesamiento no reconocido: {procesamiento}")
|
||||
pass
|
||||
|
||||
def ejecutar_todos_por_organizacion(organizacion_id):
|
||||
@@ -674,93 +458,4 @@ def ejecutar_todos_por_organizacion(organizacion_id):
|
||||
procesar_pedimentos_completos.delay(organizacion_id)
|
||||
procesar_remesas.delay(organizacion_id)
|
||||
|
||||
def ejecutar_basicos_organizacion(organizacion_id):
|
||||
# solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres
|
||||
procesar_coves.delay(organizacion_id)
|
||||
procesar_acuse_coves.delay(organizacion_id)
|
||||
procesar_edocs.delay(organizacion_id)
|
||||
procesar_acuses.delay(organizacion_id)
|
||||
# procesar_partidas.delay(organizacion_id)
|
||||
# procesar_pedimentos_completos.delay(organizacion_id)
|
||||
# procesar_remesas.delay(organizacion_id)
|
||||
|
||||
@shared_task
|
||||
def process_organization_batch(org_id):
|
||||
"""
|
||||
Procesa todos los tipos de documentos pendientes para una organización.
|
||||
"""
|
||||
ejecutar_basicos_organizacion(org_id)
|
||||
|
||||
@shared_task
|
||||
def process_all_organizations():
|
||||
"""
|
||||
Envía una tarea por organización activa a la cola org_processing.
|
||||
"""
|
||||
active_orgs = Organizacion.objects.filter(
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
apply_auto_download=True,
|
||||
)
|
||||
for org in active_orgs:
|
||||
process_organization_batch.apply_async(
|
||||
args=[str(org.id)],
|
||||
queue='org_processing'
|
||||
)
|
||||
return f"Dispatched {active_orgs.count()} organizations"
|
||||
|
||||
@shared_task
|
||||
def reintentar_descargas_pendientes():
|
||||
"""
|
||||
Reintento recurrente de descargas VUCEM (T2026-05-027): transiciona a 'error'
|
||||
los registros que agotaron MAX_INTENTOS_AUTO y relanza los pendientes por
|
||||
organización. El incremento del contador vive en las tareas procesar_*
|
||||
(puerta común de todos los flujos automáticos), por lo que aquí solo se orquesta.
|
||||
"""
|
||||
ahora = timezone.now()
|
||||
mensaje_tope = (
|
||||
f"Se agotaron {MAX_INTENTOS_AUTO} intentos automáticos de descarga; "
|
||||
f"requiere reproceso manual"
|
||||
)
|
||||
|
||||
# 1) Transicionar a 'error' lo que agotó el tope automático.
|
||||
# update() no pasa por save(): sincronizar también el booleano legado y updated_at.
|
||||
edocs_err = EDocument.objects.filter(
|
||||
edocument_estado=EstadoDescarga.PENDIENTE,
|
||||
edocument_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(edocument_estado=EstadoDescarga.ERROR, edocument_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
acuses_err = EDocument.objects.filter(
|
||||
acuse_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(acuse_estado=EstadoDescarga.ERROR, acuse_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
coves_err = Cove.objects.filter(
|
||||
cove_estado=EstadoDescarga.PENDIENTE,
|
||||
cove_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(cove_estado=EstadoDescarga.ERROR, cove_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
acuse_coves_err = Cove.objects.filter(
|
||||
acuse_cove_estado=EstadoDescarga.PENDIENTE,
|
||||
acuse_cove_intentos__gte=MAX_INTENTOS_AUTO,
|
||||
).update(acuse_cove_estado=EstadoDescarga.ERROR, acuse_cove_descargado=False,
|
||||
ultimo_error=mensaje_tope, updated_at=ahora)
|
||||
|
||||
if edocs_err or acuses_err or coves_err or acuse_coves_err:
|
||||
logger.info(
|
||||
f"Tope de intentos agotado -> error: edocs={edocs_err}, acuses={acuses_err}, "
|
||||
f"coves={coves_err}, acuse_coves={acuse_coves_err}"
|
||||
)
|
||||
|
||||
# 2) Relanzar por organización (procesar_* aplica la compuerta e incrementa el contador)
|
||||
active_orgs = Organizacion.objects.filter(
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
apply_auto_download=True,
|
||||
)
|
||||
for org in active_orgs:
|
||||
process_organization_batch.apply_async(
|
||||
args=[str(org.id)],
|
||||
queue='org_processing'
|
||||
)
|
||||
return f"Reintentos despachados para {active_orgs.count()} organizaciones"
|
||||
|
||||
|
||||
@@ -3,12 +3,7 @@ from django.urls import reverse
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from unittest.mock import patch
|
||||
from io import BytesIO
|
||||
import zipfile
|
||||
from api.organization.models import Organizacion
|
||||
from api.licence.models import Licencia
|
||||
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
|
||||
|
||||
User = get_user_model()
|
||||
@@ -80,419 +75,3 @@ class CustomsViewsTests(APITestCase):
|
||||
self.client.force_authenticate(user=self.admin)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de integración para bulk-create (ViewSetPedimento.bulk_create)
|
||||
# Verifica que al re-cargar un pedimento existente sus documentos se actualicen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BulkCreateDocumentReplaceTests(APITestCase):
|
||||
"""Verifica que bulk-create actualiza los documentos de pedimentos existentes
|
||||
en vez de ignorarlos, y que no quedan archivos residuales en el storage."""
|
||||
|
||||
PEDIMENTO_APP = "24-01-3420-1234567"
|
||||
|
||||
def setUp(self):
|
||||
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
|
||||
self.org = Organizacion.objects.create(
|
||||
nombre="OrgBulkCreate",
|
||||
licencia=self.licencia,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="bulkcreateuser", password="pass", organizacion=self.org
|
||||
)
|
||||
self.pedimento = Pedimento.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento="1234567",
|
||||
pedimento_app=self.PEDIMENTO_APP,
|
||||
)
|
||||
from api.record.models import DocumentType, Fuente
|
||||
self.doc_type = DocumentType.objects.get_or_create(nombre="Pedimento")[0]
|
||||
# bulk_create usa fuente_id=4 hardcodeado; debe existir en la DB de test
|
||||
Fuente.objects.get_or_create(id=4, defaults={"nombre": "Bulk Create"})
|
||||
self.url = reverse("Pedimento-bulk-create")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _make_zip(self, files_dict):
|
||||
"""Crea un ZIP en memoria. files_dict = {nombre_archivo: contenido_bytes}"""
|
||||
buf = BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, content in files_dict.items():
|
||||
zf.writestr(name, content)
|
||||
buf.seek(0)
|
||||
return SimpleUploadedFile(
|
||||
f"{self.PEDIMENTO_APP}.zip", buf.read(), content_type="application/zip"
|
||||
)
|
||||
|
||||
def _post_zip(self, files_dict):
|
||||
return self.client.post(
|
||||
self.url,
|
||||
{"contribuyente": "XAXX010101000", "archivos": [self._make_zip(files_dict)]},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
@patch("api.customs.views.storage_service")
|
||||
def test_existing_pedimento_not_duplicated(self, mock_st):
|
||||
"""Re-subir un pedimento existente NO debe crear un segundo Pedimento."""
|
||||
mock_st.save_document_from_path.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
|
||||
|
||||
self._post_zip({"informe.pdf": b"contenido"})
|
||||
|
||||
self.assertEqual(
|
||||
Pedimento.objects.filter(
|
||||
organizacion=self.org, pedimento_app=self.PEDIMENTO_APP
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
@patch("api.customs.views.storage_service")
|
||||
def test_existing_pedimento_document_replaced_not_duplicated(self, mock_st):
|
||||
"""Documento existente con el mismo nombre base se reemplaza, no se duplica."""
|
||||
from api.record.models import Document
|
||||
|
||||
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
|
||||
old_doc = Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=self.doc_type,
|
||||
archivo=old_path,
|
||||
size=500,
|
||||
extension="pdf",
|
||||
)
|
||||
new_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
|
||||
mock_st.save_document_from_path.return_value = new_path
|
||||
mock_st.delete_file.return_value = True
|
||||
|
||||
self._post_zip({"informe.pdf": b"contenido actualizado"})
|
||||
|
||||
docs = Document.objects.filter(pedimento=self.pedimento)
|
||||
# Sin duplicados
|
||||
self.assertEqual(docs.count(), 1)
|
||||
# Mismo registro
|
||||
self.assertEqual(docs.first().id, old_doc.id)
|
||||
# Archivo actualizado
|
||||
old_doc.refresh_from_db()
|
||||
self.assertEqual(old_doc.archivo.name, new_path)
|
||||
|
||||
@patch("api.customs.views.storage_service")
|
||||
def test_existing_pedimento_stale_file_deleted_from_storage(self, mock_st):
|
||||
"""Al reemplazar un documento, el archivo viejo debe eliminarse del storage."""
|
||||
from api.record.models import Document
|
||||
|
||||
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
|
||||
Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=self.doc_type,
|
||||
archivo=old_path,
|
||||
size=500,
|
||||
extension="pdf",
|
||||
)
|
||||
mock_st.save_document_from_path.return_value = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
|
||||
mock_st.delete_file.return_value = True
|
||||
|
||||
self._post_zip({"informe.pdf": b"contenido"})
|
||||
|
||||
# delete_file debe haberse llamado con la ruta del archivo viejo
|
||||
mock_st.delete_file.assert_called()
|
||||
called_arg = str(mock_st.delete_file.call_args[0][0])
|
||||
self.assertIn("informe_a1b2c3d4", called_arg)
|
||||
|
||||
@patch("api.customs.views.storage_service")
|
||||
def test_existing_pedimento_new_file_added(self, mock_st):
|
||||
"""Archivo nuevo en el ZIP se añade al pedimento existente."""
|
||||
from api.record.models import Document
|
||||
|
||||
mock_st.save_document_from_path.return_value = "org_1/documents/ped/nuevo_b5c6d7e8.pdf"
|
||||
|
||||
self._post_zip({"nuevo_documento.pdf": b"contenido nuevo"})
|
||||
|
||||
self.assertGreaterEqual(
|
||||
Document.objects.filter(pedimento=self.pedimento).count(), 1
|
||||
)
|
||||
|
||||
@patch("api.customs.views.storage_service")
|
||||
def test_already_existing_count_in_response(self, mock_st):
|
||||
"""La respuesta debe indicar que el pedimento ya existía (already_existing_count >= 1)."""
|
||||
mock_st.save_document_from_path.return_value = "org_1/documents/ped/f_a1b2c3d4.pdf"
|
||||
|
||||
response = self._post_zip({"archivo.pdf": b"contenido"})
|
||||
|
||||
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
|
||||
data = response.json()
|
||||
self.assertGreaterEqual(data.get("already_existing_count", 0), 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests del comando fix_partidas_error
|
||||
# Una partida descargado=True solo es válida si alguno de sus documentos
|
||||
# contiene consultarPartidaRespuesta sin tieneError=true. Partidas que solo
|
||||
# tienen el REQUEST (o errores) deben volver a descargado=False.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from io import StringIO
|
||||
from types import SimpleNamespace
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
XML_RESPUESTA_VALIDA = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||
"<tieneError>false</tieneError><ns9:partida/></ns9:consultarPartidaRespuesta>"
|
||||
"</S:Body></S:Envelope>"
|
||||
)
|
||||
|
||||
XML_ERROR_VUCEM = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body>'
|
||||
'<ns9:consultarPartidaRespuesta xmlns:ns9="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida">'
|
||||
"<tieneError>true</tieneError></ns9:consultarPartidaRespuesta>"
|
||||
"</S:Body></S:Envelope>"
|
||||
)
|
||||
|
||||
XML_ECO_REQUEST = (
|
||||
"<?xml version='1.0' encoding='UTF-8'?>"
|
||||
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
|
||||
' xmlns:con="http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpartida"><soapenv:Body>'
|
||||
"<con:consultarPartidaPeticion><con:peticion/></con:consultarPartidaPeticion>"
|
||||
"</soapenv:Body></soapenv:Envelope>"
|
||||
)
|
||||
|
||||
|
||||
class _FakeMinioObject:
|
||||
"""Simula el objeto retornado por minio get_object."""
|
||||
|
||||
def __init__(self, content):
|
||||
self._content = content
|
||||
|
||||
def read(self):
|
||||
return self._content
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def release_conn(self):
|
||||
pass
|
||||
|
||||
|
||||
class FixPartidasErrorCommandTests(TestCase):
|
||||
PED_APP = "24-01-3420-1234567"
|
||||
|
||||
def setUp(self):
|
||||
from api.customs.models import Partida
|
||||
from api.record.models import DocumentType
|
||||
|
||||
self.licencia = Licencia.objects.create(nombre="LicFixPartidas", almacenamiento=100)
|
||||
self.org = Organizacion.objects.create(
|
||||
nombre="OrgFixPartidas", licencia=self.licencia, is_active=True, is_verified=True
|
||||
)
|
||||
# Pedimento VÁLIDO (no malformado): el comando ya no se limita a malformados
|
||||
self.pedimento = Pedimento.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento="1234567",
|
||||
pedimento_app=self.PED_APP,
|
||||
aduana="034",
|
||||
patente="3420",
|
||||
numero_operacion="12345678",
|
||||
)
|
||||
self.partida = Partida.objects.create(
|
||||
pedimento=self.pedimento,
|
||||
organizacion=self.org,
|
||||
numero_partida=1,
|
||||
descargado=True,
|
||||
)
|
||||
self.type_resp = DocumentType.objects.get_or_create(id=1, defaults={"nombre": "XML"})[0]
|
||||
self.type_req = DocumentType.objects.get_or_create(id=17, defaults={"nombre": "PT Request"})[0]
|
||||
self.type_err = DocumentType.objects.get_or_create(id=18, defaults={"nombre": "PT Error"})[0]
|
||||
|
||||
# Storage simulado: dict path -> bytes
|
||||
self.storage = {}
|
||||
patcher = patch("api.customs.management.commands.fix_partidas_error.minio_client")
|
||||
self.minio = patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
self.minio._bucket_name = "test-bucket"
|
||||
self.minio.file_exists.side_effect = lambda name: name in self.storage
|
||||
self.minio._client.get_object.side_effect = (
|
||||
lambda bucket, name: _FakeMinioObject(self.storage[name])
|
||||
)
|
||||
self.minio.upload_file.side_effect = (
|
||||
lambda name, file_data=None, content_type=None: self.storage.__setitem__(
|
||||
name, file_data.read()
|
||||
)
|
||||
)
|
||||
self.minio.delete_file.side_effect = lambda name: self.storage.pop(name, None)
|
||||
|
||||
def _doc(self, filename, doc_type, content=None):
|
||||
from api.record.models import Document
|
||||
|
||||
path = f"org/{self.PED_APP}/{filename}"
|
||||
doc = Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=doc_type,
|
||||
archivo=path,
|
||||
size=100,
|
||||
extension="xml",
|
||||
)
|
||||
if content is not None:
|
||||
self.storage[path] = content.encode("utf-8")
|
||||
return doc
|
||||
|
||||
def _run(self, **kwargs):
|
||||
out = StringIO()
|
||||
call_command("fix_partidas_error", stdout=out, stderr=StringIO(), **kwargs)
|
||||
return out.getvalue()
|
||||
|
||||
def test_partida_solo_request_se_marca_no_descargada(self):
|
||||
"""El caso reportado: descargado=True pero solo existe el XML del REQUEST."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_partida_sin_documentos_se_marca_no_descargada(self):
|
||||
"""descargado=True sin ningún documento tampoco es una descarga real."""
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_partida_con_respuesta_valida_permanece_descargada(self):
|
||||
"""Con consultarPartidaRespuesta sin error la partida no se toca."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_doc_con_error_vucem_se_renombra_y_marca_no_descargada(self):
|
||||
"""tieneError=true: doc → type 18 con sufijo _ERROR y partida → False."""
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
old_path = doc.archivo.name
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 18)
|
||||
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_ERROR.xml"))
|
||||
self.assertTrue(doc.vu)
|
||||
self.assertNotIn(old_path, self.storage)
|
||||
self.assertIn(doc.archivo.name, self.storage)
|
||||
|
||||
def test_eco_de_request_guardado_como_respuesta_se_reclasifica(self):
|
||||
"""Un eco de consultarPartidaPeticion guardado como respuesta se
|
||||
reclasifica a type 17 sin chocar con el REQUEST real existente."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ECO_REQUEST)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 17)
|
||||
# El nombre sin índice ya lo usa el REQUEST real → debe ir con _1
|
||||
self.assertTrue(doc.archivo.name.endswith(f"vu_PT_{self.PED_APP}_1_REQUEST_1.xml"))
|
||||
|
||||
def test_doc_ausente_sin_canario_no_cambia_partida(self):
|
||||
"""Archivo ausente y NINGÚN archivo del pedimento en storage: posible
|
||||
storage equivocado (p. ej. dev) → sin cambios."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_registro_fantasma_con_storage_real_se_marca_no_descargada(self):
|
||||
"""Document type 1 en BD sin archivo en storage, pero el REQUEST sí
|
||||
existe físicamente (canario): el storage es el correcto, el registro es
|
||||
fantasma → la partida no tiene XML de partida → descargado=False."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
fantasma = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, content=None)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
fantasma.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
# El registro fantasma se reporta pero no se modifica ni se borra
|
||||
self.assertEqual(fantasma.document_type_id, 1)
|
||||
|
||||
def test_storage_inaccesible_no_cambia_partida(self):
|
||||
"""Excepción al consultar storage (conexión caída): sin cambios."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
self.minio.file_exists.side_effect = Exception("connection refused")
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_naming_legacy_valida_partida(self):
|
||||
"""Documentos con nomenclatura legacy (partida al final) también validan."""
|
||||
self._doc("vu_PT_010Imp_034_3420_1234567_1.xml", self.type_resp, XML_RESPUESTA_VALIDA)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id))
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_dry_run_no_modifica(self):
|
||||
"""--dry-run reporta pero no toca BD ni storage."""
|
||||
doc = self._doc(f"vu_PT_{self.PED_APP}_1.xml", self.type_resp, XML_ERROR_VUCEM)
|
||||
|
||||
self._run(pedimento=str(self.pedimento.id), dry_run=True)
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
doc.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
self.assertEqual(doc.document_type_id, 1)
|
||||
self.assertIn(doc.archivo.name, self.storage)
|
||||
|
||||
def test_universo_general_incluye_pedimentos_validos(self):
|
||||
"""Sin --pedimento ni --solo-malformados también procesa pedimentos bien formados."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run()
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertFalse(self.partida.descargado)
|
||||
|
||||
def test_solo_malformados_excluye_pedimentos_validos(self):
|
||||
"""Con --solo-malformados un pedimento bien formado no se procesa."""
|
||||
self._doc(f"vu_PT_{self.PED_APP}_1_REQUEST.xml", self.type_req, XML_ECO_REQUEST)
|
||||
|
||||
self._run(solo_malformados=True)
|
||||
|
||||
self.partida.refresh_from_db()
|
||||
self.assertTrue(self.partida.descargado)
|
||||
|
||||
def test_no_confunde_partida_1_con_11(self):
|
||||
"""La asignación por nombre no debe mezclar partida 1 con partida 11."""
|
||||
from api.customs.management.commands.fix_partidas_error import Command
|
||||
|
||||
docs = [
|
||||
SimpleNamespace(id=1, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1.xml")),
|
||||
SimpleNamespace(id=2, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_1_REQUEST.xml")),
|
||||
SimpleNamespace(id=3, document_type_id=1, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11.xml")),
|
||||
SimpleNamespace(id=4, document_type_id=17, archivo=SimpleNamespace(name=f"org/x/vu_PT_{self.PED_APP}_11_REQUEST.xml")),
|
||||
]
|
||||
cmd = Command()
|
||||
|
||||
ids_p1 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 1)}
|
||||
ids_p11 = {d.id for d in cmd._docs_de_partida(docs, self.PED_APP, 11)}
|
||||
|
||||
self.assertEqual(ids_p1, {1, 2})
|
||||
self.assertEqual(ids_p11, {3, 4})
|
||||
|
||||
@@ -10,8 +10,7 @@ from .views import (
|
||||
ViewSetEDocument,
|
||||
ViewSetCove,
|
||||
ImportadorViewSet,
|
||||
PartidaViewSet,
|
||||
EjecutarComandoView
|
||||
PartidaViewSet
|
||||
)
|
||||
# from .views import YourViewSet # Import your viewsets here
|
||||
|
||||
@@ -39,7 +38,6 @@ from .views_auditor import (
|
||||
auditar_acuse_cove_endpoint,
|
||||
auditar_edocuments_endpoint,
|
||||
auditar_acuse_endpoint,
|
||||
auditar_remesas_endpoint,
|
||||
auditar_cove_pedimento_endpoint,
|
||||
auditar_acuse_cove_pedimento_endpoint,
|
||||
auditar_edocument_pedimento_endpoint,
|
||||
@@ -62,27 +60,6 @@ from .views_auditor import (
|
||||
auditor_obtener_peticion_edocument_vu,
|
||||
auditor_obtener_respuesta_edocument_vu,
|
||||
auditar_pedimento_endpoint,
|
||||
procesar_pedimento_completo_endpoint,
|
||||
auto_corregir_pedamentos_endpoint,
|
||||
auditar_pedamentos_incompletos_endpoint,
|
||||
auditar_pedamento_incompleto_endpoint,
|
||||
auto_corregir_pedamento_endpoint,
|
||||
auditar_integridad_partidas_endpoint,
|
||||
auditar_integridad_partidas_pedimento_endpoint,
|
||||
auditar_integridad_edocuments_endpoint,
|
||||
auditar_integridad_edocuments_pedimento_endpoint,
|
||||
auditar_integridad_coves_endpoint,
|
||||
auditar_integridad_coves_pedimento_endpoint,
|
||||
auditar_integridad_remesa_endpoint,
|
||||
auditar_integridad_remesa_pedimento_endpoint,
|
||||
corregir_integridad_partidas_endpoint,
|
||||
corregir_integridad_partidas_pedimento_endpoint,
|
||||
corregir_integridad_edocuments_endpoint,
|
||||
corregir_integridad_edocuments_pedimento_endpoint,
|
||||
corregir_integridad_coves_endpoint,
|
||||
corregir_integridad_coves_pedimento_endpoint,
|
||||
corregir_integridad_remesa_endpoint,
|
||||
corregir_integridad_remesa_pedimento_endpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -94,18 +71,12 @@ urlpatterns = [
|
||||
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
|
||||
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
|
||||
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
|
||||
path('auditor/auditar-remesas/', auditar_remesas_endpoint, name='auditar-remesas'),
|
||||
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
|
||||
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
|
||||
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-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-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
|
||||
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
|
||||
path('auditor/auto-corregir-pedamentos/', auto_corregir_pedamentos_endpoint, name='auto-corregir-pedamentos'),
|
||||
path('auditor/auditar-pedamentos-incompletos/', auditar_pedamentos_incompletos_endpoint, name='auditar-pedamentos-incompletos'),
|
||||
path('auditor/auto-corregir-pedamento/', auto_corregir_pedamento_endpoint, name='auto-corregir-pedamento'),
|
||||
path('auditor/auditar-pedamento-incompleto/', auditar_pedamento_incompleto_endpoint, name='auditar-pedamento-incompleto'),
|
||||
|
||||
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
|
||||
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
|
||||
@@ -124,25 +95,4 @@ urlpatterns = [
|
||||
path('auditor/obtener-respuesta/acuse-cove-vu/', auditor_obtener_respuesta_acuse_cove_vu, name='obtener-respuesta-acuse-cove-vu'),
|
||||
path('auditor/obtener-peticion/edocument-vu/', auditor_obtener_peticion_edocument_vu, name='obtener-peticion-edocument-vu'),
|
||||
path('auditor/obtener-respuesta/edocument-vu/', auditor_obtener_respuesta_edocument_vu, name='obtener-respuesta-edocument-vu'),
|
||||
|
||||
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'),
|
||||
|
||||
]
|
||||
3464
api/customs/views.py
3464
api/customs/views.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,195 +0,0 @@
|
||||
"""
|
||||
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."))
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
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=[],
|
||||
),
|
||||
]
|
||||
@@ -1,44 +0,0 @@
|
||||
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=[],
|
||||
),
|
||||
]
|
||||
@@ -3,8 +3,7 @@ from django.db import models
|
||||
# Create your models here.
|
||||
class DataStage(models.Model):
|
||||
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
|
||||
# archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
|
||||
archivo = models.CharField(max_length=500, blank=True, null=True)
|
||||
archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
|
||||
contribuyente = models.CharField(max_length=100, blank=False, null=False)
|
||||
procesado = models.BooleanField(default=False)
|
||||
|
||||
@@ -85,8 +84,6 @@ class Registro501(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro501'
|
||||
|
||||
@@ -106,8 +103,6 @@ class Registro502(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True)
|
||||
patente = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro502'
|
||||
|
||||
@@ -124,8 +119,6 @@ class Registro503(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro503'
|
||||
|
||||
@@ -142,8 +135,6 @@ class Registro504(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro504'
|
||||
|
||||
@@ -173,8 +164,6 @@ class Registro505(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True)
|
||||
patente = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro505'
|
||||
|
||||
@@ -191,8 +180,6 @@ class Registro506(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro506'
|
||||
|
||||
@@ -211,8 +198,6 @@ class Registro507(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro507'
|
||||
|
||||
@@ -237,8 +222,6 @@ class Registro508(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro508'
|
||||
|
||||
@@ -257,8 +240,6 @@ class Registro509(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro509'
|
||||
|
||||
@@ -279,8 +260,6 @@ class Registro510(models.Model):
|
||||
forma_pago = models.CharField(max_length=3, null=True, blank=True)
|
||||
importe_pago = models.CharField(max_length=12, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro510'
|
||||
|
||||
@@ -298,8 +277,6 @@ class Registro511(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro511'
|
||||
|
||||
@@ -323,8 +300,6 @@ class Registro512(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro512'
|
||||
|
||||
@@ -387,8 +362,6 @@ class Registro551(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True)
|
||||
entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro551'
|
||||
|
||||
@@ -407,8 +380,6 @@ class Registro552(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro552'
|
||||
|
||||
@@ -430,8 +401,6 @@ class Registro553(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True)
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro553'
|
||||
|
||||
@@ -451,8 +420,6 @@ class Registro554(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro554'
|
||||
|
||||
@@ -478,8 +445,6 @@ class Registro555(models.Model):
|
||||
created_by = models.IntegerField(null=True, blank=True)
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro555'
|
||||
|
||||
@@ -499,8 +464,6 @@ class Registro556(models.Model):
|
||||
fraccion = models.CharField(max_length=8, null=True, blank=True)
|
||||
secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro556'
|
||||
|
||||
@@ -520,8 +483,6 @@ class Registro557(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True)
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro557'
|
||||
|
||||
@@ -540,8 +501,6 @@ class Registro558(models.Model):
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True)
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro558'
|
||||
|
||||
@@ -562,8 +521,6 @@ class RegistroSel(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro_sel'
|
||||
|
||||
@@ -588,8 +545,6 @@ class Registro701(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro701'
|
||||
|
||||
@@ -608,8 +563,6 @@ class Registro702(models.Model):
|
||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'registro702'
|
||||
|
||||
|
||||
@@ -1,86 +1,12 @@
|
||||
from api.utils.storage_service import storage_service
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import DataStage
|
||||
from api.organization.models import Organizacion
|
||||
|
||||
class DataStageSerializer(serializers.ModelSerializer):
|
||||
archivo = serializers.FileField(write_only=True, required=False, allow_null=True)
|
||||
download_url = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
|
||||
|
||||
class Meta:
|
||||
model = DataStage
|
||||
fields = '__all__'
|
||||
read_only_fields = ('id', 'created_at', 'updated_at')
|
||||
# extra_kwargs = {'archivo': {'read_only': True},}
|
||||
|
||||
def get_download_url(self, obj):
|
||||
"""Retorna URL de descarga según dónde esté el archivo"""
|
||||
if not obj.archivo:
|
||||
return None
|
||||
|
||||
if storage_service.is_minio_path(obj.archivo):
|
||||
return storage_service.get_file_url(obj.archivo)
|
||||
else:
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
return request.build_absolute_uri(
|
||||
f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
|
||||
)
|
||||
return f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Override para manejar la subida del archivo a MinIO"""
|
||||
archivo_file = validated_data.pop('archivo', None)
|
||||
organizacion = validated_data.get('organizacion')
|
||||
datastage = super().create(validated_data)
|
||||
print(f"ENDPOINT DE CREATE >>>>")
|
||||
# guardarlo en MinIO
|
||||
if archivo_file:
|
||||
ruta = storage_service.save_datastage(
|
||||
file=archivo_file,
|
||||
organizacion_id=organizacion.id if organizacion else datastage.organizacion.id,
|
||||
metadata={
|
||||
'datastage_id': str(datastage.id),
|
||||
'nombre': datastage.nombre if hasattr(datastage, 'nombre') else ''
|
||||
}
|
||||
)
|
||||
|
||||
if ruta:
|
||||
datastage.archivo = ruta
|
||||
datastage.save()
|
||||
else:
|
||||
# eliminar el registro creado
|
||||
datastage.delete()
|
||||
raise serializers.ValidationError({"archivo": "Error al guardar el archivo en el almacenamiento"})
|
||||
|
||||
return datastage
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Override para manejar actualización de archivo"""
|
||||
archivo_file = validated_data.pop('archivo', None)
|
||||
organizacion = validated_data.get('organizacion', instance.organizacion)
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
# Si hay nuevo archivo, reemplazarlo
|
||||
if archivo_file:
|
||||
if instance.archivo:
|
||||
storage_service.delete_file(instance.archivo)
|
||||
|
||||
ruta = storage_service.save_datastage(
|
||||
file=archivo_file,
|
||||
organizacion_id=organizacion.id,
|
||||
metadata={
|
||||
'datastage_id': str(instance.id),
|
||||
'updated': 'true'
|
||||
}
|
||||
)
|
||||
|
||||
if ruta:
|
||||
instance.archivo = ruta
|
||||
instance.save()
|
||||
else:
|
||||
raise serializers.ValidationError({"archivo": "Error al guardar el nuevo archivo"})
|
||||
return instance
|
||||
@@ -1,4 +1,3 @@
|
||||
import tempfile
|
||||
from celery import group
|
||||
from celery import shared_task
|
||||
import logging
|
||||
@@ -7,132 +6,81 @@ from django.utils import timezone
|
||||
import os
|
||||
import zipfile
|
||||
import re
|
||||
from api.utils.storage_service import storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@shared_task
|
||||
def procesar_datastage_task(datastage_id, user_organizacion_id=None):
|
||||
import traceback
|
||||
tmp_path = None
|
||||
try:
|
||||
from api.datastage.models import DataStage
|
||||
from api.organization.models import Organizacion
|
||||
|
||||
# Obtener datastage
|
||||
try:
|
||||
datastage = DataStage.objects.get(id=datastage_id)
|
||||
except DataStage.DoesNotExist:
|
||||
return {'error': f'DataStage {datastage_id} no encontrado'}
|
||||
|
||||
# Validar archivo
|
||||
if not datastage.archivo:
|
||||
print("DataStage no tiene archivo asociado")
|
||||
return {'detail': 'No hay archivo asociado a este DataStage.'}
|
||||
|
||||
ruta_archivo = str(datastage.archivo)
|
||||
|
||||
if not ruta_archivo.lower().endswith('.zip'):
|
||||
return {'detail': 'El archivo no es un .zip.'}
|
||||
|
||||
# Descargar archivo
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
success = storage_service.download_file(ruta_archivo, tmp_path)
|
||||
|
||||
if not success:
|
||||
print(f"No se pudo descargar: {ruta_archivo}")
|
||||
return {'detail': f'No se pudo descargar el archivo: {ruta_archivo}'}
|
||||
|
||||
file_path = tmp_path
|
||||
|
||||
# Obtener organización
|
||||
user_organizacion = None
|
||||
if user_organizacion_id:
|
||||
try:
|
||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
||||
except Organizacion.DoesNotExist:
|
||||
print(f"Organización no encontrada: {user_organizacion_id}")
|
||||
|
||||
# Leer ZIP y lanzar subtareas
|
||||
subtasks = []
|
||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||
namelist = zip_ref.namelist()
|
||||
|
||||
for asc_name in namelist:
|
||||
if asc_name.endswith('.asc'):
|
||||
subtasks.append(
|
||||
procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)
|
||||
)
|
||||
|
||||
if subtasks:
|
||||
job = group(subtasks).apply_async()
|
||||
print(f"Grupo de tareas lanzado: {job.id}")
|
||||
return {
|
||||
'group_id': job.id,
|
||||
'subtask_ids': [t.id for t in job.results],
|
||||
'detail': f'Procesamiento lanzado. {len(subtasks)} archivos .ASC en cola.'
|
||||
}
|
||||
|
||||
print("No se encontraron archivos .ASC")
|
||||
return {'detail': 'No se encontraron archivos .asc'}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'traceback': traceback.format_exc()}
|
||||
|
||||
finally:
|
||||
# Limpiar temporal
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception as e:
|
||||
print(f"No se pudo eliminar temporal: {e}")
|
||||
|
||||
@shared_task
|
||||
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
"""
|
||||
Procesa un archivo .ASC individual dentro del ZIP
|
||||
"""
|
||||
tmp_path = None
|
||||
try:
|
||||
logger = logging.getLogger(__name__)
|
||||
from api.datastage.models import DataStage
|
||||
from api.organization.models import Organizacion
|
||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
||||
import datetime
|
||||
|
||||
# Obtener datastage
|
||||
datastage = DataStage.objects.get(id=datastage_id)
|
||||
if not datastage.archivo:
|
||||
return {'detail': 'No hay archivo asociado a este DataStage.'}
|
||||
file_path = datastage.archivo.path
|
||||
if not os.path.exists(file_path):
|
||||
return {'detail': 'El archivo no existe en el servidor.'}
|
||||
if not file_path.endswith('.zip'):
|
||||
return {'detail': 'El archivo no es un .zip.'}
|
||||
|
||||
documentos_encontrados = []
|
||||
registros_cargados = {}
|
||||
registros_por_archivo = {}
|
||||
errores_por_archivo = {}
|
||||
errores_pedimento = []
|
||||
user_organizacion = None
|
||||
|
||||
if user_organizacion_id:
|
||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
||||
|
||||
ruta_archivo = str(datastage.archivo)
|
||||
|
||||
# Descargar archivo
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
success = storage_service.download_file(ruta_archivo, tmp_path)
|
||||
if not success:
|
||||
return {'errores': [f'No se pudo descargar el archivo: {ruta_archivo}']}
|
||||
|
||||
file_path = tmp_path
|
||||
|
||||
def to_snake_case(name):
|
||||
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||
return s2.replace('__', '_').lower()
|
||||
|
||||
objects_to_create = []
|
||||
# Lanzar una subtarea por cada archivo ASC
|
||||
subtasks = []
|
||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||
for asc_name in zip_ref.namelist():
|
||||
if asc_name.endswith('.asc'):
|
||||
subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name))
|
||||
if subtasks:
|
||||
job = group(subtasks).apply_async()
|
||||
return {
|
||||
'group_id': job.id,
|
||||
'subtask_ids': [t.id for t in job.results],
|
||||
'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.'
|
||||
}
|
||||
return {'detail': 'No se encontraron archivos .asc'}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
return {'error': str(e), 'traceback': traceback.format_exc()}
|
||||
|
||||
@shared_task
|
||||
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
import traceback
|
||||
try:
|
||||
logger = logging.getLogger(__name__)
|
||||
from api.datastage.models import DataStage
|
||||
from api.organization.models import Organizacion
|
||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
||||
from django.apps import apps
|
||||
import zipfile
|
||||
import re
|
||||
datastage = DataStage.objects.get(id=datastage_id)
|
||||
user_organizacion = None
|
||||
if user_organizacion_id:
|
||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
||||
file_path = datastage.archivo.path
|
||||
def to_snake_case(name):
|
||||
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||
return s2.replace('__', '_').lower()
|
||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||
if asc_name not in zip_ref.namelist():
|
||||
print(f"❌ {asc_name} no encontrado en el ZIP")
|
||||
return {'errores': [f'{asc_name} no encontrado en el zip']}
|
||||
|
||||
# Determinar modelo
|
||||
match = re.match(r'.*_(\d+)\.asc$', asc_name)
|
||||
if match:
|
||||
registro_key = match.group(1)
|
||||
@@ -148,86 +96,71 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
Model = apps.get_model('datastage', model_name)
|
||||
except LookupError:
|
||||
return {'errores': [f"No existe el modelo para {model_name}"]}
|
||||
|
||||
# Procesar archivo
|
||||
with zip_ref.open(asc_name) as asc_file:
|
||||
first = True
|
||||
field_names = []
|
||||
field_names_snake = []
|
||||
line_count = 0
|
||||
|
||||
objects_to_create = []
|
||||
errores_pedimento = []
|
||||
for line in asc_file:
|
||||
line_count += 1
|
||||
line_decoded = None
|
||||
try:
|
||||
line_decoded = line.decode('utf-8').strip()
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
line_decoded = line.decode('latin-1').strip()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
continue
|
||||
if not line_decoded:
|
||||
continue
|
||||
|
||||
if first:
|
||||
field_names = line_decoded.split('|')
|
||||
# Eliminar columnas vacías del final (líneas terminan con |)
|
||||
while field_names and field_names[-1] == '':
|
||||
field_names.pop()
|
||||
field_names = [f for f in line_decoded.split('|')]
|
||||
field_names_snake = [to_snake_case(f) for f in field_names]
|
||||
first = False
|
||||
continue
|
||||
|
||||
values = line_decoded.split('|')
|
||||
while values and values[-1] == '':
|
||||
values.pop()
|
||||
if len(values) == len(field_names_snake) + 1 and values[-1] == '':
|
||||
values = values[:-1]
|
||||
if len(values) < len(field_names_snake):
|
||||
values += [None] * (len(field_names_snake) - len(values))
|
||||
if len(values) != len(field_names_snake):
|
||||
logger.debug(
|
||||
"%s línea %d: esperados %d campos, recibidos %d — se omite",
|
||||
asc_name, line_count, len(field_names_snake), len(values)
|
||||
)
|
||||
continue
|
||||
|
||||
data = dict(zip(field_names_snake, values))
|
||||
|
||||
if hasattr(Model, 'organizacion_id'):
|
||||
data['organizacion_id'] = user_organizacion.id if user_organizacion else None
|
||||
if hasattr(Model, 'datastage_id'):
|
||||
data['datastage_id'] = datastage.id
|
||||
|
||||
# Parsear y normalizar todos los campos de fecha/datetime
|
||||
# Limpiar campos de fecha vacíos ('') a None
|
||||
for field in Model._meta.get_fields():
|
||||
if not hasattr(field, 'get_internal_type'):
|
||||
continue
|
||||
field_type = field.get_internal_type()
|
||||
val = data.get(field.name)
|
||||
if val == '' or val is None:
|
||||
data[field.name] = None
|
||||
continue
|
||||
if field_type == 'DateTimeField' and isinstance(val, str):
|
||||
dt = None
|
||||
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
|
||||
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]:
|
||||
if data.get(field.name) == "":
|
||||
data[field.name] = None
|
||||
# Convertir fecha_pago_real a timezone-aware si existe
|
||||
if 'fecha_pago_real' in data and data['fecha_pago_real']:
|
||||
from django.utils import timezone
|
||||
import datetime
|
||||
fecha_val = data['fecha_pago_real']
|
||||
if isinstance(fecha_val, str):
|
||||
try:
|
||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.datetime.strptime(val, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d')
|
||||
except Exception:
|
||||
dt = None
|
||||
if dt and timezone.is_naive(dt):
|
||||
dt = timezone.make_aware(dt)
|
||||
data[field.name] = dt
|
||||
|
||||
# Filtrar data para solo incluir campos válidos del modelo
|
||||
valid_fields = set()
|
||||
for f in Model._meta.get_fields():
|
||||
if hasattr(f, 'name'):
|
||||
valid_fields.add(f.name)
|
||||
if hasattr(f, 'attname'):
|
||||
valid_fields.add(f.attname)
|
||||
data = {k: v for k, v in data.items() if k in valid_fields}
|
||||
|
||||
if dt:
|
||||
data['fecha_pago_real'] = dt
|
||||
elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val):
|
||||
data['fecha_pago_real'] = timezone.make_aware(fecha_val)
|
||||
try:
|
||||
obj = Model(**data)
|
||||
objects_to_create.append(obj)
|
||||
|
||||
# Si es Registro501, crear Pedimento
|
||||
if model_name == 'Registro501':
|
||||
organizacion_instance = None
|
||||
@@ -236,7 +169,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
try:
|
||||
organizacion_instance = Organizacion.objects.get(id=org_id)
|
||||
except Exception as org_exc:
|
||||
print(f"No se encontró la organización con id {org_id}: {org_exc}")
|
||||
logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}")
|
||||
if not organizacion_instance:
|
||||
organizacion_instance = user_organizacion
|
||||
fecha_pago_raw = data.get('fecha_pago_real')
|
||||
@@ -249,7 +182,6 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
else:
|
||||
fecha_pago = fecha_pago_raw
|
||||
aduana = data.get('seccion_aduanera')
|
||||
# logger.info(f"aduana >>>> {aduana}")
|
||||
patente = data.get('patente')
|
||||
pedimento_num = data.get('pedimento')
|
||||
pedimento_app = ""
|
||||
@@ -259,13 +191,9 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
year = fecha_pago[:4]
|
||||
else:
|
||||
year = str(fecha_pago.year)
|
||||
# mantener aduana con sus digitos intactos
|
||||
# pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||
# pedimento_app = f"{year[-2:]}-{str(aduana)}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||
# logger.info(f"pedimento_app >>>> {pedimento_app}")
|
||||
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
|
||||
except Exception as ped_app_exc:
|
||||
print(f"No se pudo generar pedimento_app: {ped_app_exc}")
|
||||
logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}")
|
||||
tipo_operacion_val = data.get('tipo_operacion')
|
||||
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
|
||||
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
|
||||
@@ -297,23 +225,18 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
"importe_pedimento": data.get('importe_pedimento', 0.0),
|
||||
"existe_expediente": data.get('existe_expediente', False),
|
||||
"remesas": data.get('remesas', False),
|
||||
"consultar_vucem": True,
|
||||
}
|
||||
try:
|
||||
Pedimento.objects.create(**pedimento_data)
|
||||
except Exception as ped_exc:
|
||||
logger.warning("No se pudo crear Pedimento %s: %s", pedimento_app, ped_exc)
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error("%s línea %d: error creando objeto %s: %s", asc_name, line_count, model_name, e)
|
||||
continue
|
||||
|
||||
# Bulk create
|
||||
if objects_to_create:
|
||||
try:
|
||||
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
||||
except Exception as e:
|
||||
return {'archivo': asc_name, 'error': str(e)}
|
||||
|
||||
if objects_to_create:
|
||||
try:
|
||||
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
||||
except Exception as e:
|
||||
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||
return {
|
||||
'archivo': asc_name,
|
||||
'insertados': len(objects_to_create)
|
||||
@@ -322,10 +245,32 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
||||
import traceback
|
||||
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
||||
|
||||
finally:
|
||||
# Limpiar temporal
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
detalles = {}
|
||||
for key in ['502', '503', '504']:
|
||||
model_name = f'Registro{key}'
|
||||
asc_file = None
|
||||
encabezado = None
|
||||
errores = []
|
||||
for asc_name in registros_por_archivo:
|
||||
if asc_name.endswith(f'_{key}.asc'):
|
||||
asc_file = asc_name
|
||||
break
|
||||
if asc_file:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
with zipfile.ZipFile(file_path, 'r') as zip_ref:
|
||||
with zip_ref.open(asc_file) as f:
|
||||
for line in f:
|
||||
try:
|
||||
encabezado = line.decode('utf-8').strip()
|
||||
except UnicodeDecodeError:
|
||||
encabezado = line.decode('latin-1').strip()
|
||||
break
|
||||
except Exception as e:
|
||||
print(f"No se pudo eliminar temporal: {e}")
|
||||
encabezado = f'Error leyendo encabezado: {e}'
|
||||
errores = errores_por_archivo.get(asc_file, [])
|
||||
detalles[model_name] = {
|
||||
'archivo': asc_file,
|
||||
'encabezado': encabezado,
|
||||
'errores': errores
|
||||
}
|
||||
return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import atexit
|
||||
import tempfile
|
||||
|
||||
from api.utils.storage_service import storage_service
|
||||
from config import settings
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from api.customs.models import Pedimento, TipoOperacion, Regimen
|
||||
from django.shortcuts import render
|
||||
@@ -12,138 +7,107 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from django.http import FileResponse, Http404
|
||||
import os
|
||||
|
||||
from .models import DataStage
|
||||
from .serializer import DataStageSerializer
|
||||
|
||||
from api.logger.mixins import LoggingMixin
|
||||
from core.permissions import get_org_context, is_internal_service_request, require_permission
|
||||
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
|
||||
from core.permissions import (
|
||||
IsSameOrganization,
|
||||
IsSameOrganizationDeveloper,
|
||||
IsSameOrganizationAndAdmin,
|
||||
IsSuperUser
|
||||
)
|
||||
# Create your views here.
|
||||
class DataStagePagination(PageNumberPagination):
|
||||
page_size = 20 # Valor por defecto
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 1000
|
||||
|
||||
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||
|
||||
|
||||
"""
|
||||
ViewSet for managing DataStage instances.
|
||||
Provides CRUD operations for DataStage.
|
||||
"""
|
||||
|
||||
|
||||
serializer_class = DataStageSerializer
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
model = DataStage
|
||||
my_tags = ['DataStage']
|
||||
pagination_class = DataStagePagination
|
||||
|
||||
def get_permissions(self):
|
||||
perms = {
|
||||
'list': 'datastage.view',
|
||||
'retrieve': 'datastage.view',
|
||||
'create': 'datastage.create',
|
||||
'update': 'datastage.create',
|
||||
'partial_update': 'datastage.create',
|
||||
'destroy': 'datastage.delete',
|
||||
'procesar': 'datastage.process',
|
||||
'download_datastage': 'datastage.view',
|
||||
'task_status': 'datastage.view',
|
||||
}
|
||||
codename = perms.get(self.action, 'datastage.view')
|
||||
return [IsAuthenticated(), require_permission(codename)()]
|
||||
|
||||
def get_queryset(self):
|
||||
if is_internal_service_request(self.request):
|
||||
if self.request.user.is_superuser:
|
||||
return DataStage.objects.all().order_by('-created_at')
|
||||
org = get_org_context(self.request.user)
|
||||
if not org:
|
||||
return DataStage.objects.none()
|
||||
return DataStage.objects.filter(organizacion=org).order_by('-created_at')
|
||||
|
||||
if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at')
|
||||
|
||||
return self.get_queryset_filtrado_por_organizacion().order_by('-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
org = get_org_context(self.request.user)
|
||||
datastage = serializer.save(organizacion=org)
|
||||
self._trigger_processing(datastage)
|
||||
"""
|
||||
Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado.
|
||||
"""
|
||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||
raise ValueError("Usuario no autenticado o sin organización")
|
||||
|
||||
def _trigger_processing(self, datastage):
|
||||
from api.datastage.tasks import procesar_datastage_task
|
||||
org = get_org_context(self.request.user)
|
||||
datastage.procesado = True
|
||||
datastage.save()
|
||||
procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||
data = serializer.validated_data
|
||||
organizacion = data.get('organizacion')
|
||||
|
||||
def perform_update(self, serializer):
|
||||
if is_internal_service_request(self.request):
|
||||
if self.request.user.is_superuser:
|
||||
# Permitir que el superusuario cree sin organización o la especifique
|
||||
serializer.save()
|
||||
return
|
||||
org = get_org_context(self.request.user)
|
||||
serializer.save(organizacion=org)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.archivo:
|
||||
storage_service.delete_file(instance.archivo)
|
||||
instance.delete()
|
||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
if not organizacion:
|
||||
serializer.save(organizacion=self.request.user.organizacion)
|
||||
else:
|
||||
serializer.save()
|
||||
return
|
||||
|
||||
raise ValueError("No cuentas con los permisos necesarios para crear un DataStage")
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""
|
||||
Override to ensure organization is set on update.
|
||||
"""
|
||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||
raise ValueError("Usuario no autenticado o sin organización")
|
||||
|
||||
if self.request.user.is_superuser:
|
||||
# Allow superuser to update without organization
|
||||
serializer.save()
|
||||
return
|
||||
|
||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
serializer.save(organizacion=self.request.user.organizacion)
|
||||
return
|
||||
|
||||
raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage")
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
|
||||
def download_datastage(self, request, pk=None):
|
||||
"""
|
||||
Endpoint para descargar el archivo asociado a un DataStage.
|
||||
Soporta tanto archivos en MinIO como archivos locales antiguos.
|
||||
"""
|
||||
try:
|
||||
datastage = self.get_object()
|
||||
if not datastage.archivo:
|
||||
raise Http404("No hay archivo asociado a este DataStage.")
|
||||
|
||||
# Detectar si es ruta de MinIO o local
|
||||
is_minio_path = datastage.archivo.startswith('org_')
|
||||
|
||||
if is_minio_path:
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
success = storage_service.download_file(datastage.archivo, tmp_path)
|
||||
|
||||
if not success:
|
||||
raise Http404("No se pudo descargar el archivo de MinIO")
|
||||
|
||||
filename = os.path.basename(datastage.archivo)
|
||||
|
||||
response = FileResponse(
|
||||
open(tmp_path, 'rb'),
|
||||
as_attachment=True,
|
||||
filename=filename
|
||||
)
|
||||
|
||||
import atexit
|
||||
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
|
||||
|
||||
return response
|
||||
|
||||
else:
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, str(datastage.archivo))
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise Http404(f"El archivo no existe: {file_path}")
|
||||
|
||||
filename = os.path.basename(file_path)
|
||||
|
||||
response = FileResponse(
|
||||
open(file_path, 'rb'),
|
||||
as_attachment=True,
|
||||
filename=filename
|
||||
)
|
||||
return response
|
||||
|
||||
file_path = datastage.archivo.path
|
||||
if not os.path.exists(file_path):
|
||||
raise Http404("El archivo no existe en el servidor.")
|
||||
response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path))
|
||||
return response
|
||||
except Exception as e:
|
||||
return Response({'detail': str(e)}, status=404)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""
|
||||
Al eliminar un DataStage, también eliminar su archivo asociado.
|
||||
"""
|
||||
if instance.archivo:
|
||||
storage_service.delete_file(instance.archivo)
|
||||
instance.delete()
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='procesar')
|
||||
def procesar(self, request, pk=None):
|
||||
"""
|
||||
@@ -151,8 +115,9 @@ class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||
"""
|
||||
from api.datastage.tasks import procesar_datastage_task
|
||||
datastage = self.get_object()
|
||||
org = get_org_context(self.request.user)
|
||||
task = procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||
user_organizacion = getattr(self.request.user, 'organizacion', None)
|
||||
user_organizacion_id = user_organizacion.id if user_organizacion else None
|
||||
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
|
||||
return Response({
|
||||
'task_id': task.id,
|
||||
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'
|
||||
|
||||
@@ -58,7 +58,8 @@ class UserActivityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
if not self.request.user.is_authenticated:
|
||||
return UserActivity.objects.none()
|
||||
|
||||
if self.request.user.is_superuser:
|
||||
# Los usuarios normales solo ven su propia actividad
|
||||
if self.request.user.is_staff:
|
||||
return UserActivity.objects.all()
|
||||
return UserActivity.objects.filter(user=self.request.user)
|
||||
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
|
||||
from minio import Minio
|
||||
|
||||
from api.record.models import Document
|
||||
from api.datastage.models import DataStage
|
||||
from api.vucem.models import Vucem
|
||||
from api.reports.models import ReportDocument
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Migra archivos existentes del sistema local a MinIO (versión optimizada)'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--dry-run', action='store_true', help='Solo muestra lo que se migraría')
|
||||
parser.add_argument('--model', type=str, help='Document, DataStage, Vucem, ReportDocument')
|
||||
parser.add_argument('--limit', type=int, help='Límite de registros')
|
||||
parser.add_argument('--batch-size', type=int, default=200, help='Tamaño del lote (default: 200)')
|
||||
parser.add_argument('--workers', type=int, default=3, help='Número de workers (default: 3)')
|
||||
parser.add_argument('--offset', type=int, default=0, help='Offset inicial (para reanudar)')
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.client = None
|
||||
self.bucket_name = None
|
||||
|
||||
def _init_minio_client(self):
|
||||
"""Inicializa el cliente MinIO"""
|
||||
if self.client is None:
|
||||
self.client = Minio(
|
||||
endpoint=os.getenv('MINIO_ENDPOINT', 'minio:9000'),
|
||||
access_key=os.getenv('MINIO_ACCESS_KEY'),
|
||||
secret_key=os.getenv('MINIO_SECRET_KEY'),
|
||||
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true'
|
||||
)
|
||||
self.bucket_name = os.getenv('MINIO_BUCKET_NAME', 'efc-backend-dev')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
dry_run = options.get('dry_run', False)
|
||||
model_filter = options.get('model')
|
||||
limit = options.get('limit')
|
||||
batch_size = options.get('batch_size', 200)
|
||||
workers = options.get('workers', 3)
|
||||
offset = options.get('offset', 0)
|
||||
|
||||
self.stdout.write(self.style.WARNING('=' * 60))
|
||||
self.stdout.write(self.style.WARNING('INICIANDO MIGRACIÓN A MINIO (OPTIMIZADA)'))
|
||||
self.stdout.write(self.style.WARNING(f'Batch size: {batch_size} | Workers: {workers} | Offset: {offset}'))
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.WARNING('MODO: DRY RUN (sin cambios)'))
|
||||
self.stdout.write(self.style.WARNING('=' * 60))
|
||||
|
||||
results = {}
|
||||
|
||||
if not model_filter or model_filter.lower() == 'document':
|
||||
results['Document'] = self.migrate_documents(dry_run, limit, batch_size, workers, offset)
|
||||
|
||||
if not model_filter or model_filter.lower() == 'datastage':
|
||||
results['DataStage'] = self.migrate_datastage(dry_run, limit, batch_size, workers, offset)
|
||||
|
||||
if not model_filter or model_filter.lower() == 'vucem':
|
||||
results['Vucem'] = self.migrate_vucem(dry_run, limit, workers)
|
||||
|
||||
if not model_filter or model_filter.lower() == 'reportdocument':
|
||||
results['ReportDocument'] = self.migrate_reports(dry_run, limit, batch_size, workers, offset)
|
||||
|
||||
# Resumen final
|
||||
self.stdout.write('\n' + '=' * 60)
|
||||
self.stdout.write(self.style.SUCCESS('RESUMEN DE MIGRACIÓN'))
|
||||
self.stdout.write('=' * 60)
|
||||
|
||||
total_migrados = 0
|
||||
total_no_encontrados = 0
|
||||
total_errores = 0
|
||||
|
||||
for model_name, stats in results.items():
|
||||
self.stdout.write(f"\n📁 {model_name}:")
|
||||
self.stdout.write(f" ✅ Migrados: {stats['migrated']}")
|
||||
self.stdout.write(f" ⚠️ No encontrados: {stats['not_found']}")
|
||||
self.stdout.write(f" ❌ Errores: {stats['errors']}")
|
||||
total_migrados += stats['migrated']
|
||||
total_no_encontrados += stats['not_found']
|
||||
total_errores += stats['errors']
|
||||
|
||||
self.stdout.write('\n' + '-' * 40)
|
||||
self.stdout.write(f"📊 TOTAL Migrados: {total_migrados}")
|
||||
self.stdout.write(f"📊 TOTAL No encontrados: {total_no_encontrados}")
|
||||
self.stdout.write(f"📊 TOTAL Errores: {total_errores}")
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write('\n' + self.style.WARNING('⚠️ MODO DRY RUN - No se realizaron cambios'))
|
||||
|
||||
def get_local_file_path(self, path_str):
|
||||
"""Obtiene la ruta completa del archivo local"""
|
||||
return Path(settings.MEDIA_ROOT) / path_str
|
||||
|
||||
def migrate_documents(self, dry_run, limit, batch_size, workers, offset):
|
||||
"""Migra documentos del modelo Document"""
|
||||
self._init_minio_client()
|
||||
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||
|
||||
queryset = Document.objects.exclude(archivo='').exclude(archivo__isnull=True)
|
||||
queryset = queryset.exclude(archivo__startswith='org_')
|
||||
queryset = queryset.order_by('created_at')
|
||||
|
||||
if offset:
|
||||
queryset = queryset[offset:]
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"\n📄 Procesando {total} documentos...")
|
||||
|
||||
if total == 0:
|
||||
return stats
|
||||
|
||||
start_time = time.time()
|
||||
processed = 0
|
||||
|
||||
# Procesar en lotes
|
||||
for batch_start in range(0, total, batch_size):
|
||||
batch = queryset[batch_start:batch_start + batch_size]
|
||||
batch_docs = list(batch)
|
||||
|
||||
if dry_run:
|
||||
stats['migrated'] += len(batch_docs)
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
continue
|
||||
|
||||
# Preparar items para workers
|
||||
items = []
|
||||
for doc in batch_docs:
|
||||
path_str = str(doc.archivo)
|
||||
local_path = self.get_local_file_path(path_str)
|
||||
|
||||
if not local_path.exists():
|
||||
stats['not_found'] += 1
|
||||
continue
|
||||
|
||||
pedimento_app = doc.pedimento.pedimento_app if doc.pedimento else 'unknown'
|
||||
items.append({
|
||||
'doc': doc,
|
||||
'local_path': local_path,
|
||||
'path_str': path_str,
|
||||
'pedimento_app': pedimento_app
|
||||
})
|
||||
|
||||
# Procesar en paralelo
|
||||
if items:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {executor.submit(self._upload_document, item): item for item in items}
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result['success']:
|
||||
stats['migrated'] += 1
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||
|
||||
return stats
|
||||
|
||||
def _upload_document(self, item):
|
||||
"""Sube un documento directamente a MinIO"""
|
||||
try:
|
||||
doc = item['doc']
|
||||
local_path = item['local_path']
|
||||
pedimento_app = item['pedimento_app']
|
||||
filename = local_path.name
|
||||
|
||||
# Generar ruta MinIO
|
||||
object_name = f"org_{doc.organizacion_id}/documents/{pedimento_app}/{filename}"
|
||||
|
||||
# Subir directamente a MinIO
|
||||
self.client.fput_object(
|
||||
bucket_name=self.bucket_name,
|
||||
object_name=object_name,
|
||||
file_path=str(local_path)
|
||||
)
|
||||
|
||||
# Actualizar base de datos
|
||||
doc.archivo = object_name
|
||||
doc.save(update_fields=['archivo'])
|
||||
|
||||
return {'success': True, 'doc_id': doc.id}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'doc_id': doc.id, 'error': str(e)}
|
||||
|
||||
def migrate_datastage(self, dry_run, limit, batch_size, workers, offset):
|
||||
"""Migra archivos del modelo DataStage"""
|
||||
self._init_minio_client()
|
||||
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||
|
||||
queryset = DataStage.objects.exclude(archivo='').exclude(archivo__isnull=True)
|
||||
queryset = queryset.exclude(archivo__startswith='org_')
|
||||
queryset = queryset.order_by('created_at')
|
||||
|
||||
if offset:
|
||||
queryset = queryset[offset:]
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"\n📦 Procesando {total} archivos DataStage...")
|
||||
|
||||
if total == 0:
|
||||
return stats
|
||||
|
||||
start_time = time.time()
|
||||
processed = 0
|
||||
|
||||
for batch_start in range(0, total, batch_size):
|
||||
batch = queryset[batch_start:batch_start + batch_size]
|
||||
batch_docs = list(batch)
|
||||
|
||||
if dry_run:
|
||||
stats['migrated'] += len(batch_docs)
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
continue
|
||||
|
||||
items = []
|
||||
for ds in batch_docs:
|
||||
path_str = str(ds.archivo)
|
||||
local_path = self.get_local_file_path(path_str)
|
||||
|
||||
if not local_path.exists():
|
||||
stats['not_found'] += 1
|
||||
continue
|
||||
|
||||
items.append({'ds': ds, 'local_path': local_path})
|
||||
|
||||
if items:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {executor.submit(self._upload_datastage, item): item for item in items}
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result['success']:
|
||||
stats['migrated'] += 1
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||
|
||||
return stats
|
||||
|
||||
def _upload_datastage(self, item):
|
||||
"""Sube un DataStage directamente a MinIO"""
|
||||
try:
|
||||
ds = item['ds']
|
||||
local_path = item['local_path']
|
||||
filename = local_path.name
|
||||
|
||||
object_name = f"org_{ds.organizacion_id}/datastage/{filename}"
|
||||
|
||||
self.client.fput_object(
|
||||
bucket_name=self.bucket_name,
|
||||
object_name=object_name,
|
||||
file_path=str(local_path)
|
||||
)
|
||||
|
||||
ds.archivo = object_name
|
||||
ds.save(update_fields=['archivo'])
|
||||
|
||||
return {'success': True, 'id': ds.id}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'id': ds.id, 'error': str(e)}
|
||||
|
||||
def migrate_vucem(self, dry_run, limit, workers):
|
||||
"""Migra archivos key y cer del modelo Vucem"""
|
||||
self._init_minio_client()
|
||||
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||
|
||||
queryset = Vucem.objects.all()
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
total = queryset.count() * 2
|
||||
self.stdout.write(f"\n🔐 Procesando {queryset.count()} registros VUCEM (key + cer)...")
|
||||
|
||||
if total == 0:
|
||||
return stats
|
||||
|
||||
items = []
|
||||
for vucem in queryset:
|
||||
if vucem.key and not str(vucem.key).startswith('org_'):
|
||||
path_str = str(vucem.key)
|
||||
local_path = self.get_local_file_path(path_str)
|
||||
if local_path.exists():
|
||||
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'key'})
|
||||
else:
|
||||
stats['not_found'] += 1
|
||||
|
||||
if vucem.cer and not str(vucem.cer).startswith('org_'):
|
||||
path_str = str(vucem.cer)
|
||||
local_path = self.get_local_file_path(path_str)
|
||||
if local_path.exists():
|
||||
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'cer'})
|
||||
else:
|
||||
stats['not_found'] += 1
|
||||
|
||||
if dry_run:
|
||||
stats['migrated'] = len(items)
|
||||
self.stdout.write(f" 📝 [DRY RUN] Se migrarían {len(items)} archivos")
|
||||
return stats
|
||||
|
||||
if items:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {executor.submit(self._upload_vucem, item): item for item in items}
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result['success']:
|
||||
stats['migrated'] += 1
|
||||
self.stdout.write(self.style.SUCCESS(f" ✅ {result['tipo']} migrado: {result['id']}"))
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def _upload_vucem(self, item):
|
||||
"""Sube un archivo VUCEM directamente a MinIO"""
|
||||
try:
|
||||
vucem = item['vucem']
|
||||
local_path = item['local_path']
|
||||
tipo = item['tipo']
|
||||
filename = local_path.name
|
||||
|
||||
if tipo == 'key':
|
||||
object_name = f"org_{vucem.organizacion_id}/vucem_keys/{filename}"
|
||||
vucem.key = object_name
|
||||
vucem.save(update_fields=['key'])
|
||||
else:
|
||||
object_name = f"org_{vucem.organizacion_id}/vucem_certs/{filename}"
|
||||
vucem.cer = object_name
|
||||
vucem.save(update_fields=['cer'])
|
||||
|
||||
self.client.fput_object(
|
||||
bucket_name=self.bucket_name,
|
||||
object_name=object_name,
|
||||
file_path=str(local_path)
|
||||
)
|
||||
|
||||
return {'success': True, 'id': vucem.id, 'tipo': tipo}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'id': vucem.id, 'tipo': tipo, 'error': str(e)}
|
||||
|
||||
def migrate_reports(self, dry_run, limit, batch_size, workers, offset):
|
||||
"""Migra archivos del modelo ReportDocument"""
|
||||
self._init_minio_client()
|
||||
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
|
||||
|
||||
queryset = ReportDocument.objects.exclude(file='').exclude(file__isnull=True)
|
||||
queryset = queryset.exclude(file__startswith='org_')
|
||||
queryset = queryset.order_by('created_at')
|
||||
|
||||
if offset:
|
||||
queryset = queryset[offset:]
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
|
||||
total = queryset.count()
|
||||
self.stdout.write(f"\n📊 Procesando {total} reportes...")
|
||||
|
||||
if total == 0:
|
||||
return stats
|
||||
|
||||
start_time = time.time()
|
||||
processed = 0
|
||||
|
||||
for batch_start in range(0, total, batch_size):
|
||||
batch = queryset[batch_start:batch_start + batch_size]
|
||||
batch_docs = list(batch)
|
||||
|
||||
if dry_run:
|
||||
stats['migrated'] += len(batch_docs)
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
continue
|
||||
|
||||
items = []
|
||||
for report in batch_docs:
|
||||
path_str = str(report.file)
|
||||
local_path = self.get_local_file_path(path_str)
|
||||
|
||||
if not local_path.exists():
|
||||
stats['not_found'] += 1
|
||||
continue
|
||||
|
||||
items.append({'report': report, 'local_path': local_path})
|
||||
|
||||
if items:
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {executor.submit(self._upload_report, item): item for item in items}
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
if result['success']:
|
||||
stats['migrated'] += 1
|
||||
else:
|
||||
stats['errors'] += 1
|
||||
|
||||
processed += len(batch_docs)
|
||||
self._print_progress(processed, total, start_time, stats)
|
||||
|
||||
total_time = time.time() - start_time
|
||||
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
|
||||
|
||||
return stats
|
||||
|
||||
def _upload_report(self, item):
|
||||
"""Sube un reporte directamente a MinIO"""
|
||||
try:
|
||||
report = item['report']
|
||||
local_path = item['local_path']
|
||||
filename = local_path.name
|
||||
|
||||
filters = report.filters or {}
|
||||
org_id = filters.get('organizacion_id', 'unknown')
|
||||
|
||||
object_name = f"org_{org_id}/reports/{filename}"
|
||||
|
||||
self.client.fput_object(
|
||||
bucket_name=self.bucket_name,
|
||||
object_name=object_name,
|
||||
file_path=str(local_path)
|
||||
)
|
||||
|
||||
report.file = object_name
|
||||
report.save(update_fields=['file'])
|
||||
|
||||
return {'success': True, 'id': report.id}
|
||||
|
||||
except Exception as e:
|
||||
return {'success': False, 'id': report.id, 'error': str(e)}
|
||||
|
||||
def _print_progress(self, processed, total, start_time, stats):
|
||||
"""Imprime el progreso actual"""
|
||||
elapsed = time.time() - start_time
|
||||
rate = processed / elapsed if elapsed > 0 else 0
|
||||
pct = processed * 100 / total if total > 0 else 0
|
||||
|
||||
self.stdout.write(
|
||||
f" 📊 {processed}/{total} ({pct:.1f}%) | "
|
||||
f"{rate:.0f} docs/seg | "
|
||||
f"✅ {stats['migrated']} | "
|
||||
f"⚠️ {stats['not_found']} | "
|
||||
f"❌ {stats['errors']}"
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -21,7 +21,6 @@ class Notificacion(models.Model):
|
||||
|
||||
|
||||
mensaje = models.TextField(help_text="Mensaje de la notificación")
|
||||
datos = models.JSONField(null=True, blank=True)
|
||||
fecha_envio = models.DateTimeField(blank=True, null=True, help_text="Fecha de envío de la notificación")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la notificación")
|
||||
visto = models.BooleanField(default=False, help_text="Indica si la notificación ha sido vista")
|
||||
|
||||
@@ -16,11 +16,10 @@ class NotificacionSerializer(serializers.ModelSerializer):
|
||||
'tipo',
|
||||
'dirigido',
|
||||
'mensaje',
|
||||
'datos',
|
||||
'fecha_envio',
|
||||
'created_at',
|
||||
'visto'
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje', 'datos']
|
||||
read_only_fields = ['id', 'created_at', 'tipo', 'dirigido', 'fecha_envio', 'mensaje']
|
||||
|
||||
|
||||
@@ -4,43 +4,31 @@ from django.dispatch import receiver
|
||||
from api.notificaciones.models import Notificacion
|
||||
from api.record.models import Document
|
||||
|
||||
|
||||
@receiver(post_save, sender=Document)
|
||||
def trigger_notificacion(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
if created:
|
||||
from api.cuser.models import CustomUser
|
||||
from api.customs.models import Pedimento
|
||||
from api.notificaciones.models import TipoNotificacion
|
||||
|
||||
from api.cuser.models import CustomUser
|
||||
from api.notificaciones.models import TipoNotificacion
|
||||
from core.permissions import user_has_permission
|
||||
# Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos)
|
||||
tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"})
|
||||
|
||||
tipo_info, _ = TipoNotificacion.objects.get_or_create(
|
||||
tipo='info',
|
||||
defaults={'descripcion': 'Notificación informativa'},
|
||||
)
|
||||
|
||||
mensaje = (
|
||||
f"Se agregó el documento {instance.archivo} "
|
||||
f"al pedimento {instance.pedimento.pedimento}\n"
|
||||
f"{instance.document_type.nombre}"
|
||||
)
|
||||
|
||||
usuarios_org = CustomUser.objects.filter(
|
||||
organizacion=instance.organizacion,
|
||||
is_active=True,
|
||||
).prefetch_related('rfc')
|
||||
|
||||
for usuario in usuarios_org:
|
||||
if not user_has_permission(usuario, 'notificaciones.receive'):
|
||||
continue
|
||||
|
||||
# Importadores: solo si el pedimento corresponde a uno de sus RFC
|
||||
if usuario.is_importador:
|
||||
if instance.pedimento.contribuyente not in usuario.rfc.all():
|
||||
continue
|
||||
|
||||
Notificacion.objects.create(
|
||||
tipo=tipo_info,
|
||||
dirigido=usuario,
|
||||
mensaje=mensaje,
|
||||
)
|
||||
# Notificar a todos los usuarios de la organización
|
||||
usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion)
|
||||
for usuario in usuarios_org:
|
||||
# Notificar solo a importadores cuyo RFC coincide
|
||||
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()):
|
||||
if usuario.rfc == instance.pedimento.contribuyente:
|
||||
Notificacion.objects.create(
|
||||
tipo=tipo_info,
|
||||
dirigido=usuario,
|
||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
||||
)
|
||||
# Notificar a otros roles (no importadores)
|
||||
elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()):
|
||||
Notificacion.objects.create(
|
||||
tipo=tipo_info,
|
||||
dirigido=usuario,
|
||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
||||
)
|
||||
@@ -1,38 +1,39 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from django.shortcuts import render
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from .models import Notificacion, TipoNotificacion
|
||||
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
|
||||
from core.permissions import require_permission
|
||||
|
||||
from core.permissions import (
|
||||
IsSameOrganization,
|
||||
IsSameOrganizationDeveloper,
|
||||
IsSameOrganizationAndAdmin,
|
||||
IsSuperUser
|
||||
)
|
||||
# Create your views here.
|
||||
|
||||
class TipoNotificacionViewSet(viewsets.ModelViewSet):
|
||||
queryset = TipoNotificacion.objects.all()
|
||||
serializer_class = TipoNotificacionSerializer
|
||||
http_method_names = ['get']
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
|
||||
my_tags = ['Notificaciones']
|
||||
|
||||
def get_queryset(self):
|
||||
return self.queryset.order_by('tipo')
|
||||
|
||||
|
||||
class NotificacionViewSet(viewsets.ModelViewSet):
|
||||
queryset = Notificacion.objects.all()
|
||||
serializer_class = NotificacionSerializer
|
||||
http_method_names = ['get', 'post', 'put', 'patch', 'delete']
|
||||
filterset_fields = ['visto']
|
||||
|
||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
||||
my_tags = ['Notificaciones']
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ('list', 'retrieve'):
|
||||
return [IsAuthenticated(), require_permission('notificaciones.view')()]
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
# Evita error en generación de esquema Swagger
|
||||
if getattr(self, 'swagger_fake_view', False):
|
||||
return Notificacion.objects.none()
|
||||
user = self.request.user
|
||||
@@ -44,14 +45,6 @@ class NotificacionViewSet(viewsets.ModelViewSet):
|
||||
if not self.request.user.is_authenticated:
|
||||
raise PermissionDenied("Usuario no autenticado")
|
||||
if self.request.user.is_superuser:
|
||||
# Allow superusers and admins to create notifications for any user
|
||||
serializer.save()
|
||||
return
|
||||
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
|
||||
|
||||
@action(detail=False, methods=['get'], url_path=r'by-task/(?P<task_id>[^/.]+)')
|
||||
def by_task(self, request, task_id=None):
|
||||
"""Recupera la notificación de una tarea de auditoría por su task_id (Celery)."""
|
||||
notif = self.get_queryset().filter(datos__task_id=task_id).first()
|
||||
if not notif:
|
||||
return Response({'detail': 'No encontrada.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
return Response(self.get_serializer(notif).data)
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import Organizacion
|
||||
# Register your models here.
|
||||
|
||||
|
||||
@admin.register(Organizacion)
|
||||
class OrganizacionAdmin(admin.ModelAdmin):
|
||||
list_display = ('nombre', 'rfc', 'hub_tenant_slug', 'email', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
|
||||
search_fields = ('nombre', 'rfc', 'email', 'hub_tenant_slug')
|
||||
list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
|
||||
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento')
|
||||
search_fields = ('nombre', 'rfc', 'email')
|
||||
list_filter = ('is_active', 'is_verified')
|
||||
ordering = ('nombre',)
|
||||
autocomplete_fields = ('owner',)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
fieldsets = (
|
||||
(None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
|
||||
('Integración Hub', {
|
||||
'fields': ('hub_tenant_slug',),
|
||||
'description': 'Slug único del tenant en Aduanasoft Hub. Debe coincidir exactamente con el slug creado en el panel del Hub.',
|
||||
}),
|
||||
('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}),
|
||||
('Administrador maestro', {'fields': ('owner',)}),
|
||||
('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}),
|
||||
('Vigencia', {'fields': ('inicio', 'vencimiento')}),
|
||||
('Observaciones', {'fields': ('observaciones',)}),
|
||||
('Auditoría', {'fields': ('created_at', 'updated_at')}),
|
||||
)
|
||||
|
||||
# class UsuarioOrganizacionAdmin(admin.ModelAdmin):
|
||||
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified')
|
||||
# search_fields = ('email', 'telefono', 'puesto')
|
||||
# list_filter = ('is_active', 'is_verified')
|
||||
# ordering = ('email',)
|
||||
|
||||
admin.site.register(Organizacion)
|
||||
# admin.site.register(UsuarioOrganizacion)
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,25 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,16 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('organization', '0004_organizacion_owner'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='organizacion',
|
||||
name='hub_tenant_slug',
|
||||
field=models.CharField(blank=True, default='', max_length=100),
|
||||
),
|
||||
]
|
||||
@@ -40,19 +40,8 @@ class Organizacion(models.Model):
|
||||
estado = models.CharField(max_length=50)
|
||||
ciudad = models.CharField(max_length=50)
|
||||
|
||||
# Administrador maestro: acceso total a su org, no puede ser removido de su rol por otros admins.
|
||||
# on_delete=PROTECT: no se puede eliminar el usuario sin reasignar el ownership primero.
|
||||
owner = models.ForeignKey(
|
||||
'cuser.CustomUser',
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='organizaciones_que_administra',
|
||||
)
|
||||
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
apply_auto_download = models.BooleanField(default=False)
|
||||
|
||||
inicio = models.DateField(null=True, blank=True)
|
||||
vencimiento = models.DateField(null=True, blank=True)
|
||||
@@ -62,9 +51,6 @@ class Organizacion(models.Model):
|
||||
|
||||
observaciones = models.TextField(null=True, blank=True)
|
||||
|
||||
# Slug del tenant en Hub — "temex", "empresa-abc", etc.
|
||||
hub_tenant_slug = models.CharField(max_length=100, blank=True, default='')
|
||||
|
||||
|
||||
@property
|
||||
def espacio_utilizado(self):
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import Organizacion, UsoAlmacenamiento
|
||||
|
||||
|
||||
@receiver(post_save, sender=Organizacion)
|
||||
def crear_uso_almacenamiento(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Organizacion)
|
||||
def crear_roles_default(sender, instance, created, **kwargs):
|
||||
"""Al crear una organización nueva, genera automáticamente los 5 roles por defecto
|
||||
con sus permisos. Depende de que el catálogo RolePermission ya exista (post-migration)."""
|
||||
if not created:
|
||||
return
|
||||
try:
|
||||
from api.rbac.roles import crear_roles_para_organizacion
|
||||
crear_roles_para_organizacion(instance)
|
||||
except Exception:
|
||||
# Si la app rbac aún no está migrada (ej. primer deploy), no bloquear la creación de org
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
'No se pudieron crear roles para org %s — verifica que rbac esté migrado.',
|
||||
instance.id,
|
||||
)
|
||||
|
||||
@@ -9,10 +9,7 @@ from core.permissions import (
|
||||
IsSameOrganization,
|
||||
IsSameOrganizationDeveloper,
|
||||
IsSameOrganizationAndAdmin,
|
||||
IsSuperUser,
|
||||
get_org_context,
|
||||
is_internal_service_request,
|
||||
user_has_permission,
|
||||
IsSuperUser
|
||||
)
|
||||
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
|
||||
from .models import Organizacion, UsoAlmacenamiento
|
||||
@@ -35,19 +32,21 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr
|
||||
my_tags = ['Organizaciones']
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
if not user.is_authenticated:
|
||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||
return Organizacion.objects.none()
|
||||
|
||||
if is_internal_service_request(self.request):
|
||||
if self.request.user.is_superuser:
|
||||
# Superuser can see all organizations
|
||||
return Organizacion.objects.all()
|
||||
|
||||
org = get_org_context(user)
|
||||
if not org:
|
||||
return Organizacion.objects.none()
|
||||
if (self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter('developer').exists() or self.request.user.groups.filter('user')) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
# Importers can only see their own organization
|
||||
return Organizacion.objects.filter(users=self.request.user)
|
||||
|
||||
# Superuser ve solo su org activa, no todas
|
||||
return Organizacion.objects.filter(id=org.id)
|
||||
if self.request.user.groups.filter(name='importador').exists():
|
||||
return Organizacion.objects.filter(users=self.request.user)
|
||||
|
||||
return Organizacion.objects.none()
|
||||
|
||||
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
@@ -61,26 +60,31 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
||||
my_tags = ['Uso de Almacenamiento']
|
||||
|
||||
def get_queryset(self):
|
||||
if not self.request.user.is_authenticated:
|
||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||
return UsoAlmacenamiento.objects.none()
|
||||
|
||||
if is_internal_service_request(self.request):
|
||||
|
||||
if self.request.user.is_superuser:
|
||||
# Superuser can see all storage usage
|
||||
return UsoAlmacenamiento.objects.all()
|
||||
|
||||
org = get_org_context(self.request.user)
|
||||
if not org:
|
||||
return UsoAlmacenamiento.objects.none()
|
||||
if (self.request.user.groups.filter(name='developer').exists() or
|
||||
self.request.user.groups.filter(name='admin').exists() or
|
||||
self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
||||
# Developers, Admins, and Users can see their organization's storage usage
|
||||
return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion)
|
||||
|
||||
if self.request.user.is_importador:
|
||||
if self.request.user.groups.filter(name='importador').exists():
|
||||
# Importers can only see their own organization's storage usage
|
||||
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
|
||||
|
||||
return UsoAlmacenamiento.objects.filter(organizacion=org)
|
||||
return UsoAlmacenamiento.objects.none()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def mi_organizacion(self, request):
|
||||
|
||||
"""Obtiene el uso de almacenamiento de la organización del usuario actual"""
|
||||
organizacion = get_org_context(request.user)
|
||||
organizacion = request.user.organizacion
|
||||
|
||||
# Obtener o crear el registro de uso
|
||||
uso, created = UsoAlmacenamiento.objects.get_or_create(
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||
|
||||
|
||||
@admin.register(RolePermission)
|
||||
class RolePermissionAdmin(admin.ModelAdmin):
|
||||
list_display = ('codename', 'modulo', 'descripcion')
|
||||
list_filter = ('modulo',)
|
||||
search_fields = ('codename', 'descripcion')
|
||||
ordering = ('modulo', 'codename')
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# Al editar un permiso existente los campos son readonly para evitar inconsistencias
|
||||
if obj:
|
||||
return ('codename', 'modulo', 'descripcion')
|
||||
return ()
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
class UserRoleInline(admin.TabularInline):
|
||||
model = UserRole
|
||||
extra = 0
|
||||
autocomplete_fields = ('user',)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
|
||||
@admin.register(OrganizationRole)
|
||||
class OrganizationRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ('nombre', 'organizacion', 'is_admin_role', 'permisos_count', 'usuarios_count')
|
||||
list_filter = ('organizacion', 'is_admin_role')
|
||||
search_fields = ('nombre', 'organizacion__nombre')
|
||||
filter_horizontal = ('permissions',)
|
||||
inlines = (UserRoleInline,)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
def permisos_count(self, obj):
|
||||
return obj.permissions.count()
|
||||
permisos_count.short_description = 'Permisos'
|
||||
|
||||
def usuarios_count(self, obj):
|
||||
return obj.user_roles.count()
|
||||
usuarios_count.short_description = 'Usuarios'
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj and obj.is_admin_role:
|
||||
return False
|
||||
return request.user.is_superuser
|
||||
|
||||
|
||||
@admin.register(UserRole)
|
||||
class UserRoleAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'role', 'organizacion', 'created_at')
|
||||
list_filter = ('role__organizacion', 'role__nombre')
|
||||
search_fields = ('user__username', 'user__email', 'role__nombre')
|
||||
autocomplete_fields = ('user',)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
def organizacion(self, obj):
|
||||
return obj.role.organizacion
|
||||
organizacion.short_description = 'Organización'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
# Bloquear remoción del rol admin_role al owner de la org
|
||||
if change and obj.role.is_admin_role:
|
||||
org = obj.role.organizacion
|
||||
if hasattr(org, 'owner') and org.owner == obj.user:
|
||||
from django.contrib import messages
|
||||
self.message_user(
|
||||
request,
|
||||
'No se puede remover el rol de administrador maestro al owner de la organización.',
|
||||
level=messages.ERROR,
|
||||
)
|
||||
return
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(UserPermission)
|
||||
class UserPermissionAdmin(admin.ModelAdmin):
|
||||
list_display = ('user', 'permission', 'granted', 'organizacion', 'created_at')
|
||||
list_filter = ('granted', 'permission__modulo')
|
||||
search_fields = ('user__username', 'user__email', 'permission__codename')
|
||||
autocomplete_fields = ('user',)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
def organizacion(self, obj):
|
||||
return getattr(obj.user, 'organizacion', '—')
|
||||
organizacion.short_description = 'Organización'
|
||||
@@ -1,8 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RbacConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'api.rbac'
|
||||
label = 'rbac'
|
||||
verbose_name = 'RBAC'
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Sincroniza el catálogo de permisos de roles.py con la base de datos.
|
||||
|
||||
Uso básico (solo catálogo):
|
||||
python manage.py sync_rbac
|
||||
|
||||
Con propagación a roles existentes (agrega permisos nuevos a roles que ya existen):
|
||||
python manage.py sync_rbac --roles
|
||||
|
||||
Con listado de lo que hay actualmente:
|
||||
python manage.py sync_rbac --list
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from api.rbac.roles import DEFAULT_ROLES, PERMISSIONS_CATALOG
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Sincroniza el catálogo de permisos (roles.py → BD) sin necesidad de migración.'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--roles',
|
||||
action='store_true',
|
||||
help='Propaga los permisos nuevos a los OrganizationRoles existentes que coincidan con DEFAULT_ROLES.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list',
|
||||
action='store_true',
|
||||
help='Lista los permisos actuales en la BD agrupados por módulo.',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from api.rbac.models import OrganizationRole, RolePermission
|
||||
|
||||
if options['list']:
|
||||
self._list_permisos(RolePermission)
|
||||
return
|
||||
|
||||
self._sync_catalogo(RolePermission)
|
||||
|
||||
if options['roles']:
|
||||
self._sync_roles(RolePermission, OrganizationRole)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _sync_catalogo(self, RolePermission):
|
||||
creados = 0
|
||||
existentes = 0
|
||||
|
||||
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
|
||||
_, created = RolePermission.objects.get_or_create(
|
||||
codename=codename,
|
||||
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||
)
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f' [+] {codename} ({modulo})'))
|
||||
creados += 1
|
||||
else:
|
||||
existentes += 1
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nCatálogo: {creados} permisos creados, {existentes} ya existían.')
|
||||
)
|
||||
|
||||
def _sync_roles(self, RolePermission, OrganizationRole):
|
||||
perms_map = {p.codename: p for p in RolePermission.objects.all()}
|
||||
roles_actualizados = 0
|
||||
permisos_agregados = 0
|
||||
|
||||
for org_role in OrganizationRole.objects.select_related('organizacion').prefetch_related('permissions'):
|
||||
config = DEFAULT_ROLES.get(org_role.nombre)
|
||||
if not config:
|
||||
continue
|
||||
|
||||
esperados = {c: perms_map[c] for c in config['permissions'] if c in perms_map}
|
||||
actuales = {p.codename for p in org_role.permissions.all()}
|
||||
nuevos = {c: p for c, p in esperados.items() if c not in actuales}
|
||||
|
||||
if nuevos:
|
||||
org_role.permissions.add(*nuevos.values())
|
||||
roles_actualizados += 1
|
||||
permisos_agregados += len(nuevos)
|
||||
self.stdout.write(
|
||||
f' Rol "{org_role.nombre}" en {org_role.organizacion}: '
|
||||
f'+{len(nuevos)} → {", ".join(nuevos.keys())}'
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nRoles: {roles_actualizados} roles actualizados, {permisos_agregados} asignaciones nuevas.'
|
||||
)
|
||||
)
|
||||
|
||||
def _list_permisos(self, RolePermission):
|
||||
modulo_actual = None
|
||||
for perm in RolePermission.objects.order_by('modulo', 'codename'):
|
||||
if perm.modulo != modulo_actual:
|
||||
modulo_actual = perm.modulo
|
||||
self.stdout.write(self.style.HTTP_INFO(f'\n {modulo_actual}'))
|
||||
self.stdout.write(f' {perm.codename:<40} {perm.descripcion}')
|
||||
@@ -1,116 +0,0 @@
|
||||
import uuid
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('organization', '0003_organizacion_apply_auto_download'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='RolePermission',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('codename', models.CharField(max_length=100, unique=True)),
|
||||
('descripcion', models.CharField(max_length=255)),
|
||||
('modulo', models.CharField(max_length=50)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Permiso',
|
||||
'verbose_name_plural': 'Permisos',
|
||||
'db_table': 'rbac_role_permission',
|
||||
'ordering': ['modulo', 'codename'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrganizationRole',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('nombre', models.CharField(max_length=100)),
|
||||
('descripcion', models.CharField(blank=True, max_length=255)),
|
||||
('is_admin_role', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('organizacion', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='roles',
|
||||
to='organization.organizacion',
|
||||
)),
|
||||
('permissions', models.ManyToManyField(
|
||||
blank=True,
|
||||
related_name='roles',
|
||||
to='rbac.rolepermission',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Rol de Organización',
|
||||
'verbose_name_plural': 'Roles de Organización',
|
||||
'db_table': 'rbac_organization_role',
|
||||
'ordering': ['nombre'],
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='organizationrole',
|
||||
constraint=models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserRole',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='user_roles',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
)),
|
||||
('role', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='user_roles',
|
||||
to='rbac.organizationrole',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Rol de Usuario',
|
||||
'verbose_name_plural': 'Roles de Usuario',
|
||||
'db_table': 'rbac_user_role',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='userrole',
|
||||
constraint=models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserPermission',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('granted', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='rbac_permissions',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
)),
|
||||
('permission', models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='user_overrides',
|
||||
to='rbac.rolepermission',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Permiso Singular',
|
||||
'verbose_name_plural': 'Permisos Singulares',
|
||||
'db_table': 'rbac_user_permission',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='userpermission',
|
||||
constraint=models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
|
||||
),
|
||||
]
|
||||
@@ -1,88 +0,0 @@
|
||||
"""
|
||||
Data migration que:
|
||||
1. Crea el catálogo global de permisos (RolePermission).
|
||||
2. Para cada Organizacion existente, crea los 5 roles por defecto con sus permisos.
|
||||
3. Para cada CustomUser existente, mapea sus auth.Group actuales al UserRole equivalente.
|
||||
|
||||
Usa get_or_create en todos los pasos — segura de ejecutar múltiples veces.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
# Importamos solo constantes (no modelos ni funciones con imports de Django)
|
||||
# para que la migration sea estable ante futuros refactors del código de la app.
|
||||
from api.rbac.roles import PERMISSIONS_CATALOG, DEFAULT_ROLES
|
||||
|
||||
|
||||
def _crear_permisos(RolePermission):
|
||||
perms_map = {}
|
||||
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
|
||||
perm, _ = RolePermission.objects.get_or_create(
|
||||
codename=codename,
|
||||
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||
)
|
||||
perms_map[codename] = perm
|
||||
return perms_map
|
||||
|
||||
|
||||
def _crear_roles_org(OrganizationRole, org, perms_map):
|
||||
for nombre, config in DEFAULT_ROLES.items():
|
||||
role, created = OrganizationRole.objects.get_or_create(
|
||||
organizacion=org,
|
||||
nombre=nombre,
|
||||
defaults={
|
||||
'descripcion': config['descripcion'],
|
||||
'is_admin_role': config.get('is_admin_role', False),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
|
||||
role.permissions.set(role_perms)
|
||||
|
||||
|
||||
def seed_rbac_data(apps, schema_editor):
|
||||
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||
UserRole = apps.get_model('rbac', 'UserRole')
|
||||
Organizacion = apps.get_model('organization', 'Organizacion')
|
||||
CustomUser = apps.get_model('cuser', 'CustomUser')
|
||||
|
||||
# Paso 1 — Catálogo de permisos
|
||||
perms_map = _crear_permisos(RolePermission)
|
||||
|
||||
# Paso 2 — Roles por defecto para cada organización existente
|
||||
for org in Organizacion.objects.all():
|
||||
_crear_roles_org(OrganizationRole, org, perms_map)
|
||||
|
||||
# Paso 3 — Mapeo de usuarios: auth.Group → UserRole
|
||||
# Solo usuarios que tengan organización asignada y grupos asignados
|
||||
for user in CustomUser.objects.filter(organizacion__isnull=False).prefetch_related('groups'):
|
||||
for group in user.groups.all():
|
||||
try:
|
||||
role = OrganizationRole.objects.get(
|
||||
organizacion=user.organizacion,
|
||||
nombre=group.name,
|
||||
)
|
||||
UserRole.objects.get_or_create(user=user, role=role)
|
||||
except OrganizationRole.DoesNotExist:
|
||||
# El grupo no tiene equivalente en los roles por defecto — se ignora
|
||||
pass
|
||||
|
||||
|
||||
def reverse_seed(apps, schema_editor):
|
||||
# Revertir borra todos los datos RBAC. Los auth.Group originales no se tocan.
|
||||
apps.get_model('rbac', 'UserRole').objects.all().delete()
|
||||
apps.get_model('rbac', 'OrganizationRole').objects.all().delete()
|
||||
apps.get_model('rbac', 'RolePermission').objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rbac', '0001_initial'),
|
||||
('cuser', '0005_customuser_rfc_fk_to_m2m'),
|
||||
('organization', '0003_organizacion_apply_auto_download'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_rbac_data, reverse_code=reverse_seed),
|
||||
]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""
|
||||
Agrega el permiso notificaciones.receive al catálogo y lo asigna a todos los
|
||||
OrganizationRole que correspondan a los 5 roles por defecto (en todas las orgs).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
NUEVO_PERMISO = (
|
||||
'notificaciones.receive',
|
||||
'Recibir notificaciones automáticas de eventos',
|
||||
'notificaciones',
|
||||
)
|
||||
|
||||
# Todos los roles por defecto deben recibir notificaciones
|
||||
ROLES_CON_PERMISO = ['admin', 'developer', 'Agente Aduanal', 'user', 'Importador']
|
||||
|
||||
|
||||
def agregar_notificaciones_receive(apps, schema_editor):
|
||||
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||
|
||||
codename, descripcion, modulo = NUEVO_PERMISO
|
||||
perm, _ = RolePermission.objects.get_or_create(
|
||||
codename=codename,
|
||||
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||
)
|
||||
|
||||
roles = OrganizationRole.objects.filter(nombre__in=ROLES_CON_PERMISO)
|
||||
for role in roles:
|
||||
role.permissions.add(perm)
|
||||
|
||||
|
||||
def revertir(apps, schema_editor):
|
||||
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||
|
||||
try:
|
||||
perm = RolePermission.objects.get(codename='notificaciones.receive')
|
||||
except RolePermission.DoesNotExist:
|
||||
return
|
||||
|
||||
for role in OrganizationRole.objects.all():
|
||||
role.permissions.remove(perm)
|
||||
|
||||
perm.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rbac', '0002_data_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(agregar_notificaciones_receive, reverse_code=revertir),
|
||||
]
|
||||
@@ -1,57 +0,0 @@
|
||||
"""
|
||||
Agrega los permisos auditoria.view y auditoria.process al catálogo y los asigna
|
||||
a los roles admin, developer (ambos) y Agente Aduanal (solo view).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
NUEVOS_PERMISOS = [
|
||||
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
|
||||
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
|
||||
]
|
||||
|
||||
ROLES_AUDITORIA_FULL = ['admin', 'developer']
|
||||
ROLES_AUDITORIA_VIEW = ['Agente Aduanal']
|
||||
|
||||
|
||||
def agregar_auditoria(apps, schema_editor):
|
||||
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||
|
||||
perms = {}
|
||||
for codename, descripcion, modulo in NUEVOS_PERMISOS:
|
||||
perm, _ = RolePermission.objects.get_or_create(
|
||||
codename=codename,
|
||||
defaults={'descripcion': descripcion, 'modulo': modulo},
|
||||
)
|
||||
perms[codename] = perm
|
||||
|
||||
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_FULL):
|
||||
role.permissions.add(perms['auditoria.view'], perms['auditoria.process'])
|
||||
|
||||
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_VIEW):
|
||||
role.permissions.add(perms['auditoria.view'])
|
||||
|
||||
|
||||
def revertir(apps, schema_editor):
|
||||
RolePermission = apps.get_model('rbac', 'RolePermission')
|
||||
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
|
||||
|
||||
for codename, _, _ in NUEVOS_PERMISOS:
|
||||
try:
|
||||
perm = RolePermission.objects.get(codename=codename)
|
||||
except RolePermission.DoesNotExist:
|
||||
continue
|
||||
for role in OrganizationRole.objects.all():
|
||||
role.permissions.remove(perm)
|
||||
perm.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rbac', '0003_notificaciones_receive'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(agregar_auditoria, reverse_code=revertir),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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'),
|
||||
),
|
||||
]
|
||||
@@ -1,109 +0,0 @@
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class RolePermission(models.Model):
|
||||
"""Catálogo global de permisos de la aplicación. Se define una vez y es compartido por todas las orgs."""
|
||||
codename = models.CharField(max_length=100, unique=True)
|
||||
descripcion = models.CharField(max_length=255)
|
||||
modulo = models.CharField(max_length=50)
|
||||
|
||||
def __str__(self):
|
||||
return self.codename
|
||||
|
||||
class Meta:
|
||||
db_table = 'rbac_role_permission'
|
||||
ordering = ['modulo', 'codename']
|
||||
verbose_name = 'Permiso'
|
||||
verbose_name_plural = 'Permisos'
|
||||
|
||||
|
||||
class OrganizationRole(models.Model):
|
||||
"""Rol de una organización. Cada org tiene su propio conjunto de roles con sus permisos."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
organizacion = models.ForeignKey(
|
||||
'organization.Organizacion',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='roles',
|
||||
)
|
||||
nombre = models.CharField(max_length=100)
|
||||
descripcion = models.CharField(max_length=255, blank=True)
|
||||
# El rol admin maestro no puede ser removido del owner de la org
|
||||
is_admin_role = models.BooleanField(default=False)
|
||||
permissions = models.ManyToManyField(
|
||||
RolePermission,
|
||||
blank=True,
|
||||
related_name='roles',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.nombre} ({self.organizacion})'
|
||||
|
||||
class Meta:
|
||||
db_table = 'rbac_organization_role'
|
||||
ordering = ['nombre']
|
||||
verbose_name = 'Rol de Organización'
|
||||
verbose_name_plural = 'Roles de Organización'
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
|
||||
]
|
||||
|
||||
|
||||
class UserRole(models.Model):
|
||||
"""Asignación de un rol a un usuario dentro de su organización."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_roles',
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
OrganizationRole,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_roles',
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.user} → {self.role.nombre}'
|
||||
|
||||
class Meta:
|
||||
db_table = 'rbac_user_role'
|
||||
verbose_name = 'Rol de Usuario'
|
||||
verbose_name_plural = 'Roles de Usuario'
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
|
||||
]
|
||||
|
||||
|
||||
class UserPermission(models.Model):
|
||||
"""Permiso singular asignado directamente a un usuario, sin necesidad de rol.
|
||||
granted=True otorga, granted=False deniega explícitamente (override sobre roles)."""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rbac_permissions',
|
||||
)
|
||||
permission = models.ForeignKey(
|
||||
RolePermission,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='user_overrides',
|
||||
)
|
||||
granted = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
estado = 'GRANT' if self.granted else 'DENY'
|
||||
return f'{estado}: {self.user} → {self.permission.codename}'
|
||||
|
||||
class Meta:
|
||||
db_table = 'rbac_user_permission'
|
||||
verbose_name = 'Permiso Singular'
|
||||
verbose_name_plural = 'Permisos Singulares'
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
|
||||
]
|
||||
@@ -1,176 +0,0 @@
|
||||
# Catálogo de permisos y configuración de roles por defecto.
|
||||
# Este módulo es importado tanto por la data migration como por el signal de Organizacion,
|
||||
# por lo que NO debe importar modelos directamente al nivel de módulo.
|
||||
|
||||
# --- CATÁLOGO DE PERMISOS ---
|
||||
# (codename, descripcion, modulo)
|
||||
PERMISSIONS_CATALOG = [
|
||||
# Usuarios
|
||||
('usuarios.view', 'Ver usuarios de la organización', 'usuarios'),
|
||||
('usuarios.create', 'Crear usuarios en la organización', 'usuarios'),
|
||||
('usuarios.edit', 'Modificar usuarios de la organización', 'usuarios'),
|
||||
('usuarios.delete', 'Eliminar usuarios de la organización', 'usuarios'),
|
||||
('usuarios.manage_roles', 'Asignar y revocar roles a usuarios', 'usuarios'),
|
||||
('usuarios.change_password', 'Cambiar contraseña de otro usuario', 'usuarios'),
|
||||
# Pedimentos
|
||||
('pedimentos.view', 'Ver pedimentos', 'pedimentos'),
|
||||
('pedimentos.create', 'Crear e importar pedimentos', 'pedimentos'),
|
||||
('pedimentos.edit', 'Modificar pedimentos', 'pedimentos'),
|
||||
('pedimentos.delete', 'Eliminar pedimentos', 'pedimentos'),
|
||||
('pedimentos.process', 'Procesar pedimentos contra VUCEM', 'pedimentos'),
|
||||
# Importadores
|
||||
('importadores.view', 'Ver importadores', 'importadores'),
|
||||
('importadores.create', 'Crear importadores', 'importadores'),
|
||||
('importadores.edit', 'Modificar importadores', 'importadores'),
|
||||
('importadores.delete', 'Eliminar importadores', 'importadores'),
|
||||
# Partidas
|
||||
('partidas.view', 'Ver partidas', 'partidas'),
|
||||
('partidas.create', 'Crear partidas', 'partidas'),
|
||||
('partidas.edit', 'Modificar partidas', 'partidas'),
|
||||
('partidas.delete', 'Eliminar partidas', 'partidas'),
|
||||
# Remesas
|
||||
('remesas.view', 'Ver remesas', 'remesas'),
|
||||
# COVEs
|
||||
('coves.view', 'Ver COVEs', 'coves'),
|
||||
('coves.create', 'Crear COVEs', 'coves'),
|
||||
('coves.edit', 'Modificar COVEs', 'coves'),
|
||||
('coves.delete', 'Eliminar COVEs', 'coves'),
|
||||
# E-Documents
|
||||
('edocuments.view', 'Ver E-Documents', 'edocuments'),
|
||||
('edocuments.create', 'Crear E-Documents', 'edocuments'),
|
||||
('edocuments.edit', 'Modificar E-Documents', 'edocuments'),
|
||||
('edocuments.delete', 'Eliminar E-Documents', 'edocuments'),
|
||||
# Acuses
|
||||
('acuses.view', 'Ver acuses', 'acuses'),
|
||||
# Documentos (expediente)
|
||||
('documentos.view', 'Ver documentos del expediente', 'documentos'),
|
||||
('documentos.upload', 'Cargar documentos', 'documentos'),
|
||||
('documentos.download', 'Descargar documentos y ZIPs', 'documentos'),
|
||||
('documentos.delete', 'Eliminar documentos del expediente', 'documentos'),
|
||||
# VUCEM
|
||||
('vucem.view', 'Ver credenciales VUCEM', 'vucem'),
|
||||
('vucem.manage', 'Gestionar credenciales VUCEM', 'vucem'),
|
||||
# Reportes
|
||||
('reportes.view', 'Ver reportes y dashboard', 'reportes'),
|
||||
('reportes.export', 'Exportar reportes a CSV/Excel', 'reportes'),
|
||||
# DataStage
|
||||
('datastage.view', 'Ver DataStages', 'datastage'),
|
||||
('datastage.create', 'Crear DataStages', 'datastage'),
|
||||
('datastage.process', 'Procesar DataStages', 'datastage'),
|
||||
('datastage.delete', 'Eliminar DataStages', 'datastage'),
|
||||
# Organización
|
||||
('organizacion.view', 'Ver datos de la organización', 'organizacion'),
|
||||
('organizacion.edit', 'Modificar datos de la organización', 'organizacion'),
|
||||
# Notificaciones
|
||||
('notificaciones.view', 'Ver notificaciones propias', 'notificaciones'),
|
||||
('notificaciones.receive', 'Recibir notificaciones automáticas de eventos', 'notificaciones'),
|
||||
# Cards / Analytics
|
||||
('cards.view', 'Ver dashboard y analytics', 'cards'),
|
||||
# Auditoría
|
||||
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
|
||||
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
|
||||
]
|
||||
|
||||
# Conjuntos reutilizables para armar la matriz de permisos por rol
|
||||
_IMPORTADORES_FULL = ['importadores.view', 'importadores.create', 'importadores.edit', 'importadores.delete']
|
||||
_PEDIMENTOS_FULL = ['pedimentos.view', 'pedimentos.create', 'pedimentos.edit', 'pedimentos.delete', 'pedimentos.process']
|
||||
_PARTIDAS_FULL = ['partidas.view', 'partidas.create', 'partidas.edit', 'partidas.delete']
|
||||
_COVES_FULL = ['coves.view', 'coves.create', 'coves.edit', 'coves.delete']
|
||||
_EDOCUMENTS_FULL = ['edocuments.view', 'edocuments.create', 'edocuments.edit', 'edocuments.delete']
|
||||
_DOCUMENTOS_FULL = ['documentos.view', 'documentos.upload', 'documentos.download', 'documentos.delete']
|
||||
_VUCEM_FULL = ['vucem.view', 'vucem.manage']
|
||||
_REPORTES_FULL = ['reportes.view', 'reportes.export']
|
||||
_DATASTAGE_FULL = ['datastage.view', 'datastage.create', 'datastage.process']
|
||||
|
||||
# --- ROLES POR DEFECTO ---
|
||||
# Cada entrada: nombre → { descripcion, is_admin_role, permissions }
|
||||
DEFAULT_ROLES = {
|
||||
'admin': {
|
||||
'descripcion': 'Administrador de la organización',
|
||||
'is_admin_role': True,
|
||||
'permissions': [
|
||||
'usuarios.view', 'usuarios.create', 'usuarios.edit', 'usuarios.delete',
|
||||
'usuarios.manage_roles', 'usuarios.change_password',
|
||||
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
|
||||
*_IMPORTADORES_FULL,
|
||||
*_REPORTES_FULL, *_DATASTAGE_FULL,
|
||||
'organizacion.view', 'organizacion.edit',
|
||||
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||
'auditoria.view', 'auditoria.process',
|
||||
],
|
||||
},
|
||||
'developer': {
|
||||
'descripcion': 'Desarrollador con acceso técnico avanzado',
|
||||
'is_admin_role': False,
|
||||
'permissions': [
|
||||
'usuarios.view', 'usuarios.create',
|
||||
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||
*_DOCUMENTOS_FULL, *_VUCEM_FULL, *_IMPORTADORES_FULL,
|
||||
*_REPORTES_FULL, *_DATASTAGE_FULL,
|
||||
'organizacion.view',
|
||||
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||
'auditoria.view', 'auditoria.process',
|
||||
],
|
||||
},
|
||||
'Agente Aduanal': {
|
||||
'descripcion': 'Agente aduanal operativo',
|
||||
'is_admin_role': False,
|
||||
'permissions': [
|
||||
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
|
||||
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
|
||||
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
|
||||
*_REPORTES_FULL,
|
||||
'organizacion.view',
|
||||
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||
'auditoria.view',
|
||||
],
|
||||
},
|
||||
'user': {
|
||||
'descripcion': 'Usuario básico de la organización',
|
||||
'is_admin_role': False,
|
||||
'permissions': [
|
||||
'pedimentos.view', 'pedimentos.process',
|
||||
'partidas.view', 'remesas.view',
|
||||
'coves.view', 'edocuments.view', 'acuses.view',
|
||||
'documentos.view', 'documentos.upload', 'documentos.download',
|
||||
'reportes.view', 'datastage.view',
|
||||
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||
],
|
||||
},
|
||||
'Importador': {
|
||||
'descripcion': 'Importador con acceso filtrado por RFC',
|
||||
'is_admin_role': False,
|
||||
'permissions': [
|
||||
'pedimentos.view', 'partidas.view', 'remesas.view',
|
||||
'coves.view', 'edocuments.view', 'acuses.view',
|
||||
'documentos.view', 'documentos.download',
|
||||
'vucem.view', 'vucem.manage',
|
||||
'reportes.view',
|
||||
'notificaciones.view', 'notificaciones.receive', 'cards.view',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def crear_roles_para_organizacion(organizacion):
|
||||
"""Crea los 5 roles por defecto para una organización, con sus permisos.
|
||||
Usa get_or_create — seguro de ejecutar múltiples veces."""
|
||||
from api.rbac.models import RolePermission, OrganizationRole
|
||||
|
||||
perms_map = {p.codename: p for p in RolePermission.objects.all()}
|
||||
|
||||
for nombre, config in DEFAULT_ROLES.items():
|
||||
role, created = OrganizationRole.objects.get_or_create(
|
||||
organizacion=organizacion,
|
||||
nombre=nombre,
|
||||
defaults={
|
||||
'descripcion': config['descripcion'],
|
||||
'is_admin_role': config.get('is_admin_role', False),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
|
||||
role.permissions.set(role_perms)
|
||||
@@ -1,105 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||
|
||||
|
||||
class RolePermissionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = RolePermission
|
||||
fields = ['id', 'codename', 'descripcion', 'modulo']
|
||||
|
||||
|
||||
class OrganizationRoleSerializer(serializers.ModelSerializer):
|
||||
permissions = RolePermissionSerializer(many=True, read_only=True)
|
||||
permission_ids = serializers.PrimaryKeyRelatedField(
|
||||
queryset=RolePermission.objects.all(),
|
||||
many=True,
|
||||
write_only=True,
|
||||
source='permissions',
|
||||
required=False,
|
||||
)
|
||||
user_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = OrganizationRole
|
||||
fields = [
|
||||
'id', 'nombre', 'descripcion', 'is_admin_role',
|
||||
'permissions', 'permission_ids', 'user_count',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['id', 'is_admin_role', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class OrganizationRoleWriteSerializer(serializers.ModelSerializer):
|
||||
"""Serializer para crear/editar roles — recibe lista de IDs de permisos."""
|
||||
permission_ids = serializers.PrimaryKeyRelatedField(
|
||||
queryset=RolePermission.objects.all(),
|
||||
many=True,
|
||||
source='permissions',
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = OrganizationRole
|
||||
fields = ['nombre', 'descripcion', 'permission_ids']
|
||||
|
||||
def create(self, validated_data):
|
||||
perms = validated_data.pop('permissions', [])
|
||||
role = OrganizationRole.objects.create(**validated_data)
|
||||
role.permissions.set(perms)
|
||||
return role
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
perms = validated_data.pop('permissions', None)
|
||||
for attr, value in validated_data.items():
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
if perms is not None:
|
||||
instance.permissions.set(perms)
|
||||
return instance
|
||||
|
||||
|
||||
class _UserMinimalSerializer(serializers.Serializer):
|
||||
id = serializers.UUIDField()
|
||||
username = serializers.CharField()
|
||||
email = serializers.EmailField()
|
||||
first_name = serializers.CharField()
|
||||
last_name = serializers.CharField()
|
||||
|
||||
|
||||
class _RoleMinimalSerializer(serializers.Serializer):
|
||||
id = serializers.UUIDField()
|
||||
nombre = serializers.CharField()
|
||||
descripcion = serializers.CharField()
|
||||
|
||||
|
||||
class UserRoleSerializer(serializers.ModelSerializer):
|
||||
user = _UserMinimalSerializer(read_only=True)
|
||||
role = _RoleMinimalSerializer(read_only=True)
|
||||
# write
|
||||
user_id = serializers.UUIDField(write_only=True, source='user')
|
||||
role_id = serializers.UUIDField(write_only=True, source='role')
|
||||
|
||||
class Meta:
|
||||
model = UserRole
|
||||
fields = ['id', 'user', 'user_id', 'role', 'role_id', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class UserPermissionSerializer(serializers.ModelSerializer):
|
||||
user = _UserMinimalSerializer(read_only=True)
|
||||
permission = RolePermissionSerializer(read_only=True)
|
||||
# write
|
||||
user_id = serializers.UUIDField(write_only=True, source='user')
|
||||
permission_id = serializers.IntegerField(write_only=True, source='permission')
|
||||
|
||||
class Meta:
|
||||
model = UserPermission
|
||||
fields = ['id', 'user', 'user_id', 'permission', 'permission_id', 'granted', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
|
||||
class MyPermissionsSerializer(serializers.Serializer):
|
||||
"""Respuesta de /rbac/my-permissions/ — permisos efectivos del usuario autenticado."""
|
||||
permissions = serializers.ListField(child=serializers.CharField())
|
||||
roles = serializers.ListField(child=serializers.CharField())
|
||||
@@ -1,23 +0,0 @@
|
||||
from django.urls import include, path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from api.rbac.views import (
|
||||
MyPermissionsView,
|
||||
OrganizationRoleViewSet,
|
||||
RolePermissionViewSet,
|
||||
SwitchOrganizationView,
|
||||
UserPermissionViewSet,
|
||||
UserRoleViewSet,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'permissions', RolePermissionViewSet, basename='rbac-permission')
|
||||
router.register(r'roles', OrganizationRoleViewSet, basename='rbac-role')
|
||||
router.register(r'user-roles', UserRoleViewSet, basename='rbac-user-role')
|
||||
router.register(r'user-permissions', UserPermissionViewSet, basename='rbac-user-permission')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('my-permissions/', MyPermissionsView.as_view(), name='rbac-my-permissions'),
|
||||
path('switch-organization/', SwitchOrganizationView.as_view(), name='rbac-switch-org'),
|
||||
]
|
||||
@@ -1,412 +0,0 @@
|
||||
from django.db.models import Count
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
|
||||
from api.rbac.serializers import (
|
||||
MyPermissionsSerializer,
|
||||
OrganizationRoleSerializer,
|
||||
OrganizationRoleWriteSerializer,
|
||||
RolePermissionSerializer,
|
||||
UserPermissionSerializer,
|
||||
UserRoleSerializer,
|
||||
)
|
||||
from core.permissions import OrgScopedPermission, get_org_context, is_internal_service_request, require_permission, user_has_permission
|
||||
|
||||
|
||||
def _require_manage_roles(user):
|
||||
"""Retorna True si el usuario puede gestionar roles/permisos en su org."""
|
||||
return user.is_superuser or user_has_permission(user, 'usuarios.manage_roles')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catálogo de permisos (lectura para todos los autenticados con org)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RolePermissionViewSet(ReadOnlyModelViewSet):
|
||||
"""Lista el catálogo global de permisos disponibles, agrupados por módulo."""
|
||||
my_tags = ['RBAC']
|
||||
serializer_class = RolePermissionSerializer
|
||||
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||
|
||||
def get_queryset(self):
|
||||
return RolePermission.objects.all().order_by('modulo', 'codename')
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='by-module')
|
||||
def by_module(self, request):
|
||||
"""Devuelve el catálogo agrupado por módulo."""
|
||||
perms = self.get_queryset()
|
||||
result = {}
|
||||
for p in perms:
|
||||
result.setdefault(p.modulo, []).append(
|
||||
RolePermissionSerializer(p).data
|
||||
)
|
||||
return Response(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Roles de la organización
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrganizationRoleViewSet(ModelViewSet):
|
||||
"""
|
||||
CRUD de roles de la organización activa.
|
||||
Solo usuarios con usuarios.manage_roles pueden crear/editar/eliminar.
|
||||
"""
|
||||
my_tags = ['RBAC']
|
||||
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||
|
||||
def get_queryset(self):
|
||||
if is_internal_service_request(self.request):
|
||||
return (
|
||||
OrganizationRole.objects
|
||||
.annotate(user_count=Count('user_roles'))
|
||||
.prefetch_related('permissions')
|
||||
.order_by('nombre')
|
||||
)
|
||||
org = get_org_context(self.request.user)
|
||||
if not org:
|
||||
return OrganizationRole.objects.none()
|
||||
return (
|
||||
OrganizationRole.objects
|
||||
.filter(organizacion=org)
|
||||
.annotate(user_count=Count('user_roles'))
|
||||
.prefetch_related('permissions')
|
||||
.order_by('nombre')
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action in ('create', 'update', 'partial_update'):
|
||||
return OrganizationRoleWriteSerializer
|
||||
return OrganizationRoleSerializer
|
||||
|
||||
def _check_manage_roles(self):
|
||||
if not _require_manage_roles(self.request.user):
|
||||
return Response(
|
||||
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return None
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
err = self._check_manage_roles()
|
||||
if err:
|
||||
return err
|
||||
org = get_org_context(request.user)
|
||||
if not org:
|
||||
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save(organizacion=org)
|
||||
return Response(
|
||||
OrganizationRoleSerializer(serializer.instance).data,
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
err = self._check_manage_roles()
|
||||
if err:
|
||||
return err
|
||||
instance = self.get_object()
|
||||
# No se puede cambiar nombre ni permisos de un rol is_admin_role
|
||||
if instance.is_admin_role and not request.user.is_superuser:
|
||||
return Response(
|
||||
{'detail': 'No se puede modificar un rol de administrador.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
err = self._check_manage_roles()
|
||||
if err:
|
||||
return err
|
||||
instance = self.get_object()
|
||||
if instance.is_admin_role and not request.user.is_superuser:
|
||||
return Response(
|
||||
{'detail': 'No se puede eliminar un rol de administrador.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
if instance.user_roles.exists():
|
||||
return Response(
|
||||
{'detail': 'No se puede eliminar un rol con usuarios asignados.'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Asignación de roles a usuarios
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UserRoleViewSet(ModelViewSet):
|
||||
"""
|
||||
Asigna y revoca roles de usuarios en la organización activa.
|
||||
Solo usuarios con usuarios.manage_roles pueden modificar.
|
||||
"""
|
||||
my_tags = ['RBAC']
|
||||
serializer_class = UserRoleSerializer
|
||||
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||
http_method_names = ['get', 'post', 'delete', 'head', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
if is_internal_service_request(self.request):
|
||||
qs = UserRole.objects.select_related('user', 'role')
|
||||
user_id = self.request.query_params.get('user_id')
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
return qs
|
||||
org = get_org_context(self.request.user)
|
||||
if not org:
|
||||
return UserRole.objects.none()
|
||||
qs = (
|
||||
UserRole.objects
|
||||
.filter(role__organizacion=org)
|
||||
.select_related('user', 'role')
|
||||
)
|
||||
user_id = self.request.query_params.get('user_id')
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
return qs
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not _require_manage_roles(request.user):
|
||||
return Response(
|
||||
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
org = get_org_context(request.user)
|
||||
if not org:
|
||||
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user_id = request.data.get('user_id')
|
||||
role_id = request.data.get('role_id')
|
||||
|
||||
if not user_id or not role_id:
|
||||
return Response({'detail': 'user_id y role_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Verificar que el rol pertenece a la misma org
|
||||
try:
|
||||
role = OrganizationRole.objects.get(id=role_id, organizacion=org)
|
||||
except OrganizationRole.DoesNotExist:
|
||||
return Response({'detail': 'El rol no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Verificar que el usuario pertenece a la misma org
|
||||
from api.cuser.models import CustomUser
|
||||
try:
|
||||
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
|
||||
except CustomUser.DoesNotExist:
|
||||
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
user_role, created = UserRole.objects.get_or_create(user=target_user, role=role)
|
||||
serializer = self.get_serializer(user_role)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
if not _require_manage_roles(request.user):
|
||||
return Response(
|
||||
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
instance = self.get_object()
|
||||
org = get_org_context(request.user)
|
||||
|
||||
# Proteger al owner de la org: no se le puede quitar el rol admin
|
||||
if org and hasattr(org, 'owner') and org.owner and instance.user == org.owner:
|
||||
if instance.role.is_admin_role:
|
||||
return Response(
|
||||
{'detail': 'No se puede revocar el rol de administrador al propietario de la organización.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permisos singulares (overrides por usuario)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class UserPermissionViewSet(ModelViewSet):
|
||||
"""
|
||||
Otorga o deniega permisos singulares a usuarios, sin necesidad de crear un rol.
|
||||
granted=true → otorgar; granted=false → denegar explícitamente (override sobre roles).
|
||||
Solo usuarios con usuarios.manage_roles pueden modificar.
|
||||
"""
|
||||
my_tags = ['RBAC']
|
||||
serializer_class = UserPermissionSerializer
|
||||
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
|
||||
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
|
||||
|
||||
def get_queryset(self):
|
||||
if is_internal_service_request(self.request):
|
||||
qs = UserPermission.objects.select_related('user', 'permission')
|
||||
user_id = self.request.query_params.get('user_id')
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
return qs
|
||||
org = get_org_context(self.request.user)
|
||||
if not org:
|
||||
return UserPermission.objects.none()
|
||||
qs = (
|
||||
UserPermission.objects
|
||||
.filter(user__organizacion=org)
|
||||
.select_related('user', 'permission')
|
||||
)
|
||||
user_id = self.request.query_params.get('user_id')
|
||||
if user_id:
|
||||
qs = qs.filter(user_id=user_id)
|
||||
return qs
|
||||
|
||||
def _check(self):
|
||||
if not _require_manage_roles(self.request.user):
|
||||
return Response(
|
||||
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return None
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
err = self._check()
|
||||
if err:
|
||||
return err
|
||||
org = get_org_context(request.user)
|
||||
if not org:
|
||||
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
user_id = request.data.get('user_id')
|
||||
permission_id = request.data.get('permission_id')
|
||||
granted = request.data.get('granted', True)
|
||||
|
||||
if not user_id or not permission_id:
|
||||
return Response({'detail': 'user_id y permission_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from api.cuser.models import CustomUser
|
||||
try:
|
||||
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
|
||||
except CustomUser.DoesNotExist:
|
||||
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
try:
|
||||
perm = RolePermission.objects.get(id=permission_id)
|
||||
except RolePermission.DoesNotExist:
|
||||
return Response({'detail': 'Permiso no encontrado.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
override, created = UserPermission.objects.update_or_create(
|
||||
user=target_user,
|
||||
permission=perm,
|
||||
defaults={'granted': granted},
|
||||
)
|
||||
serializer = self.get_serializer(override)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
err = self._check()
|
||||
if err:
|
||||
return err
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
err = self._check()
|
||||
if err:
|
||||
return err
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mis permisos efectivos (para el frontend)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MyPermissionsView(APIView):
|
||||
"""
|
||||
Retorna los permisos efectivos del usuario autenticado.
|
||||
El frontend usa esto para decidir qué mostrar/ocultar.
|
||||
"""
|
||||
my_tags = ['RBAC']
|
||||
permission_classes = [IsAuthenticated & OrgScopedPermission]
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
org = get_org_context(user)
|
||||
|
||||
if user.is_superuser:
|
||||
all_perms = list(RolePermission.objects.values_list('codename', flat=True))
|
||||
return Response({'permissions': all_perms, 'roles': ['superuser']})
|
||||
|
||||
if not org:
|
||||
return Response({'permissions': [], 'roles': []})
|
||||
|
||||
# Roles del usuario en la org
|
||||
roles = list(
|
||||
UserRole.objects.filter(user=user, role__organizacion=org)
|
||||
.values_list('role__nombre', flat=True)
|
||||
)
|
||||
|
||||
# Permisos de roles
|
||||
perms_set = set(
|
||||
UserRole.objects.filter(user=user, role__organizacion=org)
|
||||
.values_list('role__permissions__codename', flat=True)
|
||||
)
|
||||
perms_set.discard(None)
|
||||
|
||||
# Aplicar overrides singulares
|
||||
for override in UserPermission.objects.filter(user=user).select_related('permission'):
|
||||
if override.granted:
|
||||
perms_set.add(override.permission.codename)
|
||||
else:
|
||||
perms_set.discard(override.permission.codename)
|
||||
|
||||
return Response({'permissions': sorted(perms_set), 'roles': roles})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Switch de organización (solo superusuarios)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SwitchOrganizationView(APIView):
|
||||
"""
|
||||
Permite a un superusuario cambiar su organización activa.
|
||||
POST { "organization_id": "<uuid>" } → actualiza active_organization del superuser.
|
||||
DELETE → limpia active_organization (el superuser queda sin contexto de org).
|
||||
"""
|
||||
my_tags = ['RBAC']
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
if not request.user.is_superuser:
|
||||
return Response(
|
||||
{'detail': 'Solo superusuarios pueden cambiar de organización.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
org_id = request.data.get('organization_id')
|
||||
if not org_id:
|
||||
return Response({'detail': 'organization_id es requerido.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
from api.organization.models import Organizacion
|
||||
try:
|
||||
import uuid as _uuid
|
||||
org = Organizacion.objects.get(id=_uuid.UUID(str(org_id)))
|
||||
except (Organizacion.DoesNotExist, ValueError):
|
||||
return Response({'detail': 'Organización no encontrada.'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
request.user.active_organization = org
|
||||
request.user.save(update_fields=['active_organization'])
|
||||
|
||||
return Response({
|
||||
'detail': f'Organización activa actualizada a: {org.nombre}',
|
||||
'organization': {'id': str(org.id), 'nombre': org.nombre},
|
||||
})
|
||||
|
||||
def delete(self, request):
|
||||
if not request.user.is_superuser:
|
||||
return Response(
|
||||
{'detail': 'Solo superusuarios pueden limpiar la organización activa.'},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
request.user.active_organization = None
|
||||
request.user.save(update_fields=['active_organization'])
|
||||
return Response({'detail': 'Organización activa removida.'})
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -15,7 +15,6 @@ class Document(models.Model):
|
||||
extension = models.CharField(max_length=60, blank=True, null=True)
|
||||
size = models.PositiveIntegerField()
|
||||
fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True)
|
||||
vu = models.BooleanField(default=False)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -23,13 +22,6 @@ class Document(models.Model):
|
||||
def save(self, *args, **kwargs):
|
||||
is_new = self._state.adding
|
||||
|
||||
# Calcular automáticamente el campo vu
|
||||
if self.document_type_id:
|
||||
# rango de IDs que indican documentos VU
|
||||
self.vu = 13 <= self.document_type_id <= 26
|
||||
else:
|
||||
self.vu = False
|
||||
|
||||
# Usar get_or_create en lugar de get para manejar el caso cuando no existe
|
||||
uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create(
|
||||
organizacion=self.organizacion,
|
||||
|
||||
@@ -13,18 +13,10 @@ class DocumentSerializer(serializers.ModelSerializer):
|
||||
fuente = serializers.PrimaryKeyRelatedField(queryset=Fuente.objects.all())
|
||||
class Meta:
|
||||
model = Document
|
||||
fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','fuente_nombre','created_at', 'updated_at','vu')
|
||||
fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','fuente_nombre','created_at', 'updated_at')
|
||||
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
|
||||
|
||||
def get_pedimento_numero(self, obj):
|
||||
# Si es un diccionario (durante create)
|
||||
if isinstance(obj, dict):
|
||||
pedimento = obj.get('pedimento')
|
||||
if pedimento and hasattr(pedimento, 'pedimento_app'):
|
||||
return pedimento.pedimento_app
|
||||
return None
|
||||
|
||||
# Si es una instancia del modelo (durante retrieve/list)
|
||||
if obj.pedimento:
|
||||
return obj.pedimento.pedimento_app
|
||||
return None
|
||||
@@ -36,19 +28,9 @@ class DocumentSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
def get_fuente_nombre(self, obj):
|
||||
"""Obtiene el nombre de la fuente de forma segura"""
|
||||
if isinstance(obj, dict):
|
||||
fuente = obj.get('fuente')
|
||||
if fuente and hasattr(fuente, 'nombre'):
|
||||
return fuente.nombre
|
||||
return "Desconocido"
|
||||
|
||||
try:
|
||||
if obj.fuente:
|
||||
return obj.fuente.nombre
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Método 1: Si la fuente está precargada con select_related
|
||||
if obj.fuente:
|
||||
return obj.fuente.nombre
|
||||
return "Desconocido"
|
||||
|
||||
class FuenteSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
|
||||
from django.urls import reverse
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
from rest_framework import status
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from unittest.mock import patch, MagicMock
|
||||
from api.organization.models import Organizacion, UsoAlmacenamiento
|
||||
from api.cuser.models import CustomUser
|
||||
from api.customs.models import Pedimento
|
||||
from api.licence.models import Licencia
|
||||
from api.customs.views import is_same_document, get_clean_base_filename
|
||||
from .models import Document, DocumentType
|
||||
from .models import Document
|
||||
import io
|
||||
|
||||
class DocumentViewSetTests(APITestCase):
|
||||
@@ -99,177 +95,3 @@ class DocumentViewSetTests(APITestCase):
|
||||
url = reverse('descargar-documento', args=[doc.id])
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests unitarios para las funciones helper de comparación de documentos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DocumentNameHelperTests(TestCase):
|
||||
"""Verifica que get_clean_base_filename e is_same_document manejan
|
||||
correctamente el sufijo UUID de 8 chars que añade storage_service."""
|
||||
|
||||
def test_strips_uuid_suffix(self):
|
||||
self.assertEqual(get_clean_base_filename('informe_a1b2c3d4.pdf'), 'informe')
|
||||
|
||||
def test_no_suffix_unchanged(self):
|
||||
self.assertEqual(get_clean_base_filename('informe.pdf'), 'informe')
|
||||
|
||||
def test_is_same_document_matches_stored_uuid_name(self):
|
||||
"""El archivo guardado tiene sufijo, el nuevo no — deben coincidir."""
|
||||
doc = MagicMock()
|
||||
doc.archivo.name = 'org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf'
|
||||
doc.extension = 'pdf'
|
||||
self.assertTrue(is_same_document(doc, 'informe.pdf'))
|
||||
|
||||
def test_is_same_document_different_name_no_match(self):
|
||||
doc = MagicMock()
|
||||
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
|
||||
doc.extension = 'pdf'
|
||||
self.assertFalse(is_same_document(doc, 'otro.pdf'))
|
||||
|
||||
def test_is_same_document_different_extension_no_match(self):
|
||||
doc = MagicMock()
|
||||
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
|
||||
doc.extension = 'pdf'
|
||||
self.assertFalse(is_same_document(doc, 'informe.xml'))
|
||||
|
||||
def test_both_clean_names_equal(self):
|
||||
"""Dos archivos con UUID distintos pero mismo nombre base deben coincidir."""
|
||||
doc = MagicMock()
|
||||
doc.archivo.name = 'org_1/documents/ped/pedimento_a1b2c3d4.xml'
|
||||
doc.extension = 'xml'
|
||||
self.assertTrue(is_same_document(doc, 'pedimento_b5c6d7e8.xml'))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests de integración para bulk-upload (DocumentViewSet.bulk_upload)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BulkUploadReplaceTests(APITestCase):
|
||||
"""Verifica que bulk-upload reemplaza documentos existentes en vez de duplicar
|
||||
y que no quedan archivos residuales en el storage."""
|
||||
|
||||
def setUp(self):
|
||||
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
|
||||
self.org = Organizacion.objects.create(
|
||||
nombre="OrgBulkUpload",
|
||||
licencia=self.licencia,
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
self.user = CustomUser.objects.create_user(
|
||||
username="bulkuploaduser", password="pass", organizacion=self.org
|
||||
)
|
||||
self.pedimento = Pedimento.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento="1234567",
|
||||
pedimento_app="24-01-3420-1234567",
|
||||
)
|
||||
self.doc_type = DocumentType.objects.get_or_create(nombre="Documento General")[0]
|
||||
self.url = reverse("Document-bulk-upload")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _post_file(self, filename, content=b"contenido de prueba"):
|
||||
archivo = SimpleUploadedFile(filename, content, content_type="application/pdf")
|
||||
return self.client.post(
|
||||
self.url,
|
||||
{"pedimento_id": str(self.pedimento.id), "files": [archivo]},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
@patch("api.record.views.storage_service")
|
||||
def test_new_file_creates_document(self, mock_st):
|
||||
"""Subir un archivo nuevo crea exactamente un Document."""
|
||||
mock_st.save_document.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
|
||||
|
||||
response = self._post_file("informe.pdf")
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 1)
|
||||
mock_st.delete_file.assert_not_called()
|
||||
|
||||
@patch("api.record.views.storage_service")
|
||||
def test_duplicate_replaces_not_creates(self, mock_st):
|
||||
"""Re-subir el mismo archivo debe actualizar el Document existente,
|
||||
no crear uno nuevo."""
|
||||
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
|
||||
old_doc = Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=self.doc_type,
|
||||
archivo=old_path,
|
||||
size=500,
|
||||
extension="pdf",
|
||||
)
|
||||
new_path = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
|
||||
mock_st.save_document.return_value = new_path
|
||||
mock_st.delete_file.return_value = True
|
||||
|
||||
response = self._post_file("informe.pdf", b"contenido actualizado")
|
||||
|
||||
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_207_MULTI_STATUS])
|
||||
docs = Document.objects.filter(pedimento=self.pedimento)
|
||||
# Un único Document — sin duplicados
|
||||
self.assertEqual(docs.count(), 1)
|
||||
# Es el mismo registro (mismo UUID)
|
||||
self.assertEqual(docs.first().id, old_doc.id)
|
||||
# El campo archivo fue actualizado
|
||||
old_doc.refresh_from_db()
|
||||
self.assertEqual(old_doc.archivo.name, new_path)
|
||||
|
||||
@patch("api.record.views.storage_service")
|
||||
def test_replace_deletes_old_storage_file(self, mock_st):
|
||||
"""Al reemplazar, delete_file debe llamarse con la ruta del archivo viejo."""
|
||||
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
|
||||
Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=self.doc_type,
|
||||
archivo=old_path,
|
||||
size=500,
|
||||
extension="pdf",
|
||||
)
|
||||
mock_st.save_document.return_value = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
|
||||
mock_st.delete_file.return_value = True
|
||||
|
||||
self._post_file("informe.pdf")
|
||||
|
||||
mock_st.delete_file.assert_called_once_with(old_path)
|
||||
|
||||
@patch("api.record.views.storage_service")
|
||||
def test_different_filename_creates_new_document(self, mock_st):
|
||||
"""Archivo con nombre diferente debe crear un Document adicional."""
|
||||
Document.objects.create(
|
||||
organizacion=self.org,
|
||||
pedimento=self.pedimento,
|
||||
document_type=self.doc_type,
|
||||
archivo="org_1/documents/ped/informe_a1b2c3d4.pdf",
|
||||
size=500,
|
||||
extension="pdf",
|
||||
)
|
||||
mock_st.save_document.return_value = "org_1/documents/ped/otro_b5c6d7e8.pdf"
|
||||
|
||||
self._post_file("otro.pdf")
|
||||
|
||||
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
|
||||
mock_st.delete_file.assert_not_called()
|
||||
|
||||
@patch("api.record.views.storage_service")
|
||||
def test_multiple_files_no_cross_replacement(self, mock_st):
|
||||
"""Subir dos archivos distintos en la misma petición crea dos Documents."""
|
||||
mock_st.save_document.side_effect = [
|
||||
"org_1/documents/ped/a_a1b2c3d4.pdf",
|
||||
"org_1/documents/ped/b_a1b2c3d4.pdf",
|
||||
]
|
||||
archivos = [
|
||||
SimpleUploadedFile("a.pdf", b"contenido a", content_type="application/pdf"),
|
||||
SimpleUploadedFile("b.pdf", b"contenido b", content_type="application/pdf"),
|
||||
]
|
||||
self.client.post(
|
||||
self.url,
|
||||
{"pedimento_id": str(self.pedimento.id), "files": archivos},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
|
||||
mock_st.delete_file.assert_not_called()
|
||||
|
||||
1654
api/record/views.py
1654
api/record/views.py
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.3 on 2026-06-11 14:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('reports', '0003_alter_reportdocument_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='reportdocument',
|
||||
name='report_type',
|
||||
field=models.CharField(choices=[('cumplimiento', 'cumplimiento'), ('control_pedimento', 'control_pedimento'), ('datastage', 'datastage')], default='cumplimiento', max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -12,13 +12,11 @@ class ReportDocument(models.Model):
|
||||
TYPE_REPORT = [
|
||||
('cumplimiento', 'cumplimiento'),
|
||||
('control_pedimento', 'control_pedimento'),
|
||||
('datastage', 'datastage'),
|
||||
]
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
|
||||
filters = models.JSONField(blank=True, null=True)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
||||
# file = models.FileField(upload_to='reports/', blank=True, null=True)
|
||||
file = models.CharField(max_length=500, blank=True, null=True)
|
||||
file = models.FileField(upload_to='reports/', blank=True, null=True)
|
||||
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
|
||||
error_message = models.TextField(blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
"""
|
||||
Lógica de exportación de reportes DataStage, extraída de ExportDataStageView
|
||||
para poder ejecutarse dentro de una task Celery (sin request/HttpResponse).
|
||||
|
||||
Cada builder devuelve una tupla (content_bytes, filename, content_type, total_rows).
|
||||
El aislamiento multi-tenant viene resuelto en global_filters['organizacion']
|
||||
(la vista lo resuelve con get_org_context antes de encolar).
|
||||
"""
|
||||
import csv
|
||||
import datetime
|
||||
import hashlib
|
||||
import io
|
||||
import uuid
|
||||
import zipfile
|
||||
|
||||
import openpyxl
|
||||
from django.apps import apps
|
||||
from django.core.paginator import Paginator
|
||||
|
||||
from api.organization.models import Organizacion
|
||||
|
||||
MAX_RECORDS_PER_FILE = 500000 # Límite por archivo Excel antes de particionar en ZIP
|
||||
|
||||
XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'
|
||||
ZIP_CONTENT_TYPE = 'application/zip'
|
||||
|
||||
RELATION_FIELDS = ['seccion_aduanera', 'patente', 'pedimento']
|
||||
|
||||
|
||||
def safe_excel_value(value):
|
||||
"""Convierte cualquier valor a un formato seguro para Excel/CSV."""
|
||||
if value is None:
|
||||
return ''
|
||||
elif isinstance(value, (uuid.UUID,)):
|
||||
return str(value)
|
||||
elif hasattr(value, 'uuid'):
|
||||
return str(value.uuid)
|
||||
elif hasattr(value, 'id'):
|
||||
return str(value.id)
|
||||
elif isinstance(value, (datetime.datetime, datetime.date)):
|
||||
return value.isoformat()
|
||||
elif isinstance(value, (dict, list)):
|
||||
return str(value)
|
||||
else:
|
||||
return str(value)
|
||||
|
||||
|
||||
def apply_global_filters_to_model(global_filters, model):
|
||||
"""Traduce los filtros globales a filtros ORM según los campos del modelo."""
|
||||
filters = {}
|
||||
model_fields = [f.name for f in model._meta.get_fields()]
|
||||
|
||||
# Organización — FK usa UUID, CharField usa el string tal cual
|
||||
org_value = global_filters.get('organizacion')
|
||||
if org_value and org_value != '' and 'organizacion' in model_fields:
|
||||
field = model._meta.get_field('organizacion')
|
||||
if hasattr(field, 'related_model'):
|
||||
try:
|
||||
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||
except Exception:
|
||||
filters['organizacion_id'] = org_value
|
||||
else:
|
||||
filters['organizacion'] = org_value
|
||||
|
||||
rfc_value = global_filters.get('rfc')
|
||||
if rfc_value and rfc_value != '' and 'rfc' in model_fields:
|
||||
filters['rfc'] = rfc_value
|
||||
|
||||
if global_filters.get('patente'):
|
||||
filters['patente'] = global_filters['patente']
|
||||
|
||||
if global_filters.get('pedimento'):
|
||||
filters['pedimento'] = global_filters['pedimento']
|
||||
|
||||
if 'fecha_pago_real' in model_fields:
|
||||
if global_filters.get('fecha_pago_desde'):
|
||||
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||
if global_filters.get('fecha_pago_hasta'):
|
||||
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def apply_related_filters(global_filters, model, related_keys):
|
||||
"""Filtros para modo múltiple: globales + llaves de cruce entre modelos."""
|
||||
filters = {}
|
||||
model_fields = [f.name for f in model._meta.get_fields()]
|
||||
|
||||
if 'organizacion' in model_fields and global_filters.get('organizacion'):
|
||||
org_value = global_filters['organizacion']
|
||||
try:
|
||||
field = model._meta.get_field('organizacion')
|
||||
if hasattr(field, 'related_model'):
|
||||
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||
else:
|
||||
filters['organizacion'] = org_value
|
||||
except Exception:
|
||||
filters['organizacion_id'] = org_value
|
||||
|
||||
if 'rfc' in model_fields and global_filters.get('rfc'):
|
||||
filters['rfc'] = global_filters['rfc']
|
||||
|
||||
if 'fecha_pago_real' in model_fields:
|
||||
if global_filters.get('fecha_pago_desde'):
|
||||
filters['fecha_pago_real__gte'] = global_filters['fecha_pago_desde']
|
||||
if global_filters.get('fecha_pago_hasta'):
|
||||
filters['fecha_pago_real__lte'] = global_filters['fecha_pago_hasta']
|
||||
|
||||
if any(related_keys.values()):
|
||||
if related_keys.get('patentes') and 'patente' in model_fields:
|
||||
filters['patente__in'] = related_keys['patentes']
|
||||
if related_keys.get('pedimentos') and 'pedimento' in model_fields:
|
||||
filters['pedimento__in'] = related_keys['pedimentos']
|
||||
if related_keys.get('datastage_ids') and 'datastage_id' in model_fields:
|
||||
filters['datastage_id__in'] = related_keys['datastage_ids']
|
||||
else:
|
||||
if 'patente' in model_fields and global_filters.get('patente'):
|
||||
filters['patente'] = global_filters['patente']
|
||||
if 'pedimento' in model_fields and global_filters.get('pedimento'):
|
||||
filters['pedimento'] = global_filters['pedimento']
|
||||
|
||||
return filters
|
||||
|
||||
|
||||
def get_related_keys_from_filters(global_filters, models_data):
|
||||
"""
|
||||
Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
|
||||
llave de cruce entre modelos.
|
||||
|
||||
Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
|
||||
'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
|
||||
no se usan como semilla — solo se filtrarán más tarde usando las claves ya
|
||||
construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
|
||||
"""
|
||||
related_keys = {
|
||||
'patentes': set(),
|
||||
'pedimentos': set(),
|
||||
'datastage_ids': set()
|
||||
}
|
||||
|
||||
# Sin filtros significativos → sin cruce
|
||||
if not any(v for v in global_filters.values() if v not in [None, '']):
|
||||
return {}
|
||||
|
||||
rfc_filter_active = bool(global_filters.get('rfc'))
|
||||
date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
|
||||
all_records_with_filters = []
|
||||
|
||||
for model_data in models_data:
|
||||
model_name = model_data.get('model')
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||
|
||||
# Un modelo puede ser semilla de related_keys SOLO si tiene campos
|
||||
# para aplicar TODOS los filtros activos
|
||||
if rfc_filter_active and 'rfc' not in model_field_names:
|
||||
continue
|
||||
if date_filter_active and 'fecha_pago_real' not in model_field_names:
|
||||
continue
|
||||
|
||||
filters = apply_global_filters_to_model(global_filters, model)
|
||||
if not filters:
|
||||
continue
|
||||
|
||||
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
|
||||
all_records_with_filters.extend(list(records))
|
||||
|
||||
except LookupError:
|
||||
continue
|
||||
|
||||
if not all_records_with_filters:
|
||||
return {'patentes': set(), 'pedimentos': set(), 'datastage_ids': set()}
|
||||
|
||||
for record in all_records_with_filters:
|
||||
if record.get('patente'):
|
||||
related_keys['patentes'].add(record['patente'])
|
||||
if record.get('pedimento'):
|
||||
related_keys['pedimentos'].add(record['pedimento'])
|
||||
if record.get('datastage_id'):
|
||||
related_keys['datastage_ids'].add(record['datastage_id'])
|
||||
|
||||
return {k: list(v) for k, v in related_keys.items() if v}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exportación simple (un solo modelo)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_simple_export(model_name, fields, global_filters, export_format, progress_cb=None):
|
||||
progress_cb = progress_cb or (lambda p, m: None)
|
||||
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
except LookupError:
|
||||
raise ValueError(f'Modelo {model_name} no encontrado')
|
||||
|
||||
filters = apply_global_filters_to_model(global_filters, model)
|
||||
queryset = model.objects.filter(**filters).values(*fields)
|
||||
total_records = queryset.count()
|
||||
progress_cb(20, f'{model_name}: {total_records} registros encontrados')
|
||||
|
||||
if export_format == 'excel':
|
||||
if total_records > MAX_RECORDS_PER_FILE:
|
||||
content, filename, content_type = _simple_excel_partitioned(model_name, fields, queryset, progress_cb)
|
||||
else:
|
||||
content, filename, content_type = _simple_excel(model_name, fields, queryset, progress_cb)
|
||||
else:
|
||||
# CSV no tiene límite de filas — siempre un solo archivo
|
||||
content, filename, content_type = _simple_csv(model_name, fields, queryset, progress_cb)
|
||||
|
||||
return content, filename, content_type, total_records
|
||||
|
||||
|
||||
def _simple_excel(model_name, fields, queryset, progress_cb):
|
||||
progress_cb(40, f'Escribiendo Excel de {model_name}...')
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.append(fields)
|
||||
for row in queryset:
|
||||
ws.append([safe_excel_value(row[field]) for field in fields])
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue(), f'{model_name}.xlsx', XLSX_CONTENT_TYPE
|
||||
|
||||
|
||||
def _simple_csv(model_name, fields, queryset, progress_cb):
|
||||
progress_cb(40, f'Escribiendo CSV de {model_name}...')
|
||||
buf = io.StringIO()
|
||||
writer = csv.DictWriter(buf, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for row in queryset:
|
||||
writer.writerow(row)
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
return buf.getvalue().encode('utf-8'), f'{model_name}.csv', CSV_CONTENT_TYPE
|
||||
|
||||
|
||||
def _simple_excel_partitioned(model_name, fields, queryset, progress_cb):
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
|
||||
for page_num in paginator.page_range:
|
||||
pct = 25 + int((page_num / paginator.num_pages) * 55)
|
||||
progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
|
||||
page = paginator.page(page_num)
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = f'Parte_{page_num}'[:31]
|
||||
ws.append(fields)
|
||||
for row in page.object_list:
|
||||
ws.append([safe_excel_value(row[field]) for field in fields])
|
||||
|
||||
part_buffer = io.BytesIO()
|
||||
wb.save(part_buffer)
|
||||
zip_file.writestr(f'{model_name}_part{page_num}.xlsx', part_buffer.getvalue())
|
||||
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
|
||||
|
||||
|
||||
def _simple_csv_partitioned(model_name, fields, queryset, progress_cb):
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
paginator = Paginator(queryset, MAX_RECORDS_PER_FILE)
|
||||
for page_num in paginator.page_range:
|
||||
pct = 25 + int((page_num / paginator.num_pages) * 55)
|
||||
progress_cb(pct, f'Particionando {model_name}: parte {page_num}/{paginator.num_pages}')
|
||||
page = paginator.page(page_num)
|
||||
|
||||
csv_buffer = io.StringIO()
|
||||
writer = csv.writer(csv_buffer)
|
||||
writer.writerow(fields)
|
||||
for row in page.object_list:
|
||||
writer.writerow([safe_excel_value(row[field]) for field in fields])
|
||||
|
||||
zip_file.writestr(f'{model_name}_part{page_num}.csv', csv_buffer.getvalue())
|
||||
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
return zip_buffer.getvalue(), f'{model_name}_particionado.zip', ZIP_CONTENT_TYPE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exportación múltiple (varios modelos agrupados por llaves de cruce)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _collect_multiple_data(models_data, global_filters, related_keys, progress_cb):
|
||||
"""
|
||||
Recolecta y agrupa los registros de todos los modelos por la llave
|
||||
seccion_aduanera + patente + pedimento. Mapea organizacion_id → nombre.
|
||||
"""
|
||||
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||
all_models_data = {}
|
||||
total_models = len(models_data) or 1
|
||||
|
||||
for idx, model_data in enumerate(models_data):
|
||||
model_name = model_data.get('model')
|
||||
fields = model_data.get('fields', [])
|
||||
|
||||
if not model_name or not fields:
|
||||
continue
|
||||
|
||||
# Normalizar campos: 'organizacion' → 'organizacion_id', sin duplicados
|
||||
normalized_fields = []
|
||||
for f in fields:
|
||||
key = f.strip() if isinstance(f, str) else f
|
||||
if isinstance(key, str) and key.lower() == 'organizacion':
|
||||
if 'organizacion_id' not in normalized_fields:
|
||||
normalized_fields.append('organizacion_id')
|
||||
else:
|
||||
if key not in normalized_fields:
|
||||
normalized_fields.append(key)
|
||||
fields = normalized_fields
|
||||
|
||||
for req_field in RELATION_FIELDS:
|
||||
if req_field not in fields:
|
||||
fields.append(req_field)
|
||||
|
||||
try:
|
||||
model = apps.get_model('datastage', model_name)
|
||||
model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
|
||||
if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
|
||||
fields.append('organizacion_id')
|
||||
|
||||
filters = apply_related_filters(global_filters, model, related_keys)
|
||||
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
|
||||
|
||||
count = queryset.count()
|
||||
pct = 20 + int((idx / total_models) * 55)
|
||||
progress_cb(pct, f'Modelo {idx + 1}/{total_models}: {model_name} ({count} registros)')
|
||||
if count == 0:
|
||||
continue
|
||||
|
||||
relation_fields = [fn for fn in RELATION_FIELDS if fn in fields]
|
||||
if not relation_fields:
|
||||
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||
|
||||
for record in queryset:
|
||||
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||
if not key_parts:
|
||||
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||
else:
|
||||
key = "_".join(key_parts)
|
||||
|
||||
processed_record = {}
|
||||
for field_name, value in record.items():
|
||||
if field_name == 'organizacion_id' and value:
|
||||
org_id_str = str(value)
|
||||
if org_id_str in org_mapping:
|
||||
processed_value = org_mapping[org_id_str]
|
||||
else:
|
||||
try:
|
||||
org = Organizacion.objects.filter(id=value).first()
|
||||
processed_value = org.nombre if org else org_id_str
|
||||
org_mapping[org_id_str] = processed_value
|
||||
except Exception:
|
||||
processed_value = org_id_str
|
||||
else:
|
||||
processed_value = value
|
||||
|
||||
if field_name in relation_fields:
|
||||
prefixed_field_name = field_name
|
||||
else:
|
||||
prefixed_field_name = f"{model_name}_{field_name}"
|
||||
|
||||
if field_name == 'organizacion_id':
|
||||
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
||||
|
||||
processed_record[prefixed_field_name] = safe_excel_value(processed_value)
|
||||
|
||||
if key not in all_models_data:
|
||||
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||
|
||||
for rel_field in relation_fields:
|
||||
if rel_field in record:
|
||||
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||
|
||||
if model_name not in all_models_data[key]['model_records']:
|
||||
all_models_data[key]['model_records'][model_name] = []
|
||||
|
||||
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||
|
||||
except LookupError:
|
||||
continue
|
||||
|
||||
return all_models_data
|
||||
|
||||
|
||||
def _build_combined_rows(all_models_data):
|
||||
"""Construye filas combinadas — repite el último registro en lugar de dejar vacíos."""
|
||||
combined_rows = []
|
||||
for key, data in all_models_data.items():
|
||||
relation_fields_data = data['relation_fields']
|
||||
model_records = data['model_records']
|
||||
|
||||
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
|
||||
|
||||
for i in range(max_records_per_key):
|
||||
row_data = {}
|
||||
for rel_field, rel_value in relation_fields_data.items():
|
||||
row_data[rel_field] = safe_excel_value(rel_value)
|
||||
for model_name, records in model_records.items():
|
||||
# Usar posición i o el último registro disponible
|
||||
record = records[i] if i < len(records) else records[-1]
|
||||
for field_name, value in record.items():
|
||||
row_data[field_name] = value
|
||||
combined_rows.append(row_data)
|
||||
|
||||
return combined_rows
|
||||
|
||||
|
||||
def _ordered_fields(combined_rows):
|
||||
"""Encabezados: campos de relación primero, luego organización, luego el resto."""
|
||||
all_fields_set = set()
|
||||
for row in combined_rows:
|
||||
all_fields_set.update(row.keys())
|
||||
|
||||
all_fields = []
|
||||
for rel_field in RELATION_FIELDS:
|
||||
if rel_field in all_fields_set:
|
||||
all_fields.append(rel_field)
|
||||
all_fields_set.discard(rel_field)
|
||||
|
||||
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||
for org_field in org_fields:
|
||||
all_fields.append(org_field)
|
||||
all_fields_set.discard(org_field)
|
||||
|
||||
all_fields.extend(sorted(all_fields_set))
|
||||
return all_fields
|
||||
|
||||
|
||||
def build_multiple_export(models_data, global_filters, export_format, progress_cb=None):
|
||||
progress_cb = progress_cb or (lambda p, m: None)
|
||||
|
||||
progress_cb(15, 'Resolviendo llaves de cruce entre modelos...')
|
||||
related_keys = get_related_keys_from_filters(global_filters, models_data)
|
||||
|
||||
all_models_data = _collect_multiple_data(models_data, global_filters, related_keys, progress_cb)
|
||||
|
||||
# Sin datos → archivo con mensaje, no error (el frontend espera un archivo)
|
||||
if not all_models_data:
|
||||
if export_format == 'excel':
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sin datos"
|
||||
ws.append(["No se encontraron datos para los filtros especificados"])
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue(), 'datastage_sin_datos.xlsx', XLSX_CONTENT_TYPE, 0
|
||||
else:
|
||||
buf = io.StringIO()
|
||||
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
|
||||
return buf.getvalue().encode('utf-8'), 'datastage_sin_datos.csv', CSV_CONTENT_TYPE, 0
|
||||
|
||||
progress_cb(80, 'Combinando filas...')
|
||||
combined_rows = _build_combined_rows(all_models_data)
|
||||
all_fields = _ordered_fields(combined_rows)
|
||||
total_rows = len(combined_rows)
|
||||
|
||||
if export_format == 'excel':
|
||||
content, filename, content_type = _multiple_excel(combined_rows, all_fields, progress_cb)
|
||||
else:
|
||||
content, filename, content_type = _multiple_csv(combined_rows, all_fields, progress_cb)
|
||||
|
||||
return content, filename, content_type, total_rows
|
||||
|
||||
|
||||
def _multiple_excel(combined_rows, all_fields, progress_cb):
|
||||
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
|
||||
title_row = ["Reporte Datastage"]
|
||||
date_row = [f"Generado: {now_str}"]
|
||||
|
||||
def _write_sheet(ws, sheet_name, page_rows):
|
||||
ws.title = sheet_name[:31]
|
||||
ws.append(title_row)
|
||||
ws.append(date_row)
|
||||
ws.append([])
|
||||
ws.append(all_fields)
|
||||
for row_data in page_rows:
|
||||
ws.append([row_data.get(field, '') for field in all_fields])
|
||||
for column in ws.columns:
|
||||
max_length = 0
|
||||
col_letter = column[0].column_letter
|
||||
for cell in column:
|
||||
try:
|
||||
if len(str(cell.value)) > max_length:
|
||||
max_length = len(str(cell.value))
|
||||
except Exception:
|
||||
pass
|
||||
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
|
||||
|
||||
# Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
|
||||
paginator = Paginator(combined_rows, MAX_RECORDS_PER_FILE)
|
||||
|
||||
if paginator.num_pages == 1:
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
wb = openpyxl.Workbook()
|
||||
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
return output.getvalue(), 'datastage_reporte.xlsx', XLSX_CONTENT_TYPE
|
||||
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for page_num in paginator.page_range:
|
||||
progress_cb(80 + int((page_num / paginator.num_pages) * 8),
|
||||
f'Particionando: parte {page_num}/{paginator.num_pages}')
|
||||
page = paginator.page(page_num)
|
||||
current_wb = openpyxl.Workbook()
|
||||
_write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
|
||||
part_buffer = io.BytesIO()
|
||||
current_wb.save(part_buffer)
|
||||
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
||||
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
return zip_buffer.getvalue(), 'datastage_combinado.zip', ZIP_CONTENT_TYPE
|
||||
|
||||
|
||||
def _multiple_csv(combined_rows, all_fields, progress_cb):
|
||||
progress_cb(88, 'Serializando archivo...')
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(all_fields)
|
||||
for row_data in combined_rows:
|
||||
writer.writerow([row_data.get(field, '') for field in all_fields])
|
||||
return buf.getvalue().encode('utf-8'), 'datastage_reporte.csv', CSV_CONTENT_TYPE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def build_datastage_export(payload, progress_cb=None):
|
||||
"""
|
||||
Genera el reporte DataStage a partir del payload persistido en
|
||||
ReportDocument.filters. Lanza ValueError si el payload es inválido.
|
||||
|
||||
Retorna (content_bytes, filename, content_type, total_rows).
|
||||
"""
|
||||
modo = payload.get('modo', 'simple')
|
||||
export_format = payload.get('format', 'csv')
|
||||
global_filters = payload.get('globalFilters') or {}
|
||||
|
||||
if modo == 'multiple':
|
||||
models_data = payload.get('models') or []
|
||||
if not models_data:
|
||||
raise ValueError('models es requerido para exportación múltiple')
|
||||
return build_multiple_export(models_data, global_filters, export_format, progress_cb)
|
||||
|
||||
model_name = payload.get('model')
|
||||
fields = payload.get('fields')
|
||||
if not model_name or not fields:
|
||||
raise ValueError('model y fields son requeridos para exportación simple')
|
||||
return build_simple_export(model_name, fields, global_filters, export_format, progress_cb)
|
||||
@@ -1,3 +0,0 @@
|
||||
# Importa los módulos de tasks para que autodiscover_tasks() los registre en el worker
|
||||
from .report_document import generate_report_document, generate_report_control_pedimento
|
||||
from .report_datastage import generate_report_datastage
|
||||
@@ -1,105 +0,0 @@
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
from celery import shared_task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.utils import timezone
|
||||
|
||||
from api.reports.models import ReportDocument
|
||||
from api.reports.services.datastage_export import build_datastage_export
|
||||
from api.utils.storage_service import storage_service
|
||||
from core.redis_events import publish_task_event
|
||||
|
||||
logger = logging.getLogger('api.reports.tasks')
|
||||
|
||||
|
||||
@shared_task(bind=True, queue='reports', soft_time_limit=1800, time_limit=1860)
|
||||
def generate_report_datastage(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_datastage] report=%s FALLO: %s', report_id, full_msg)
|
||||
if report:
|
||||
report.status = 'error'
|
||||
report.error_message = full_msg
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
publish_task_event(task_id, 'failed', msg, progress=0)
|
||||
|
||||
# ── 1. Obtener reporte ────────────────────────────────────────────────────
|
||||
try:
|
||||
report = ReportDocument.objects.get(id=report_id)
|
||||
except ReportDocument.DoesNotExist:
|
||||
logger.error('[reporte_datastage] ReportDocument %s no existe', report_id)
|
||||
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
|
||||
return
|
||||
|
||||
logger.info('[reporte_datastage] 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:
|
||||
# La organización ya viene resuelta en el payload (la vista la fija antes de encolar)
|
||||
payload = report.filters or {}
|
||||
org_id = payload.get('organizacion_id')
|
||||
|
||||
def _progress(pct, msg):
|
||||
publish_task_event(task_id, 'processing', msg, progress=pct)
|
||||
|
||||
# ── 2. Generar archivo (xlsx / csv / zip según modo, formato y volumen) ──
|
||||
content, filename, content_type, total_rows = build_datastage_export(payload, _progress)
|
||||
|
||||
# ── 3. Subir a almacenamiento ─────────────────────────────────────────
|
||||
logger.info('[reporte_datastage] report=%s archivo=%s size=%.1fKB filas=%d',
|
||||
report_id, filename, len(content) / 1024, total_rows)
|
||||
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
|
||||
|
||||
final_name = f"datastage_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}_{filename}"
|
||||
ruta = storage_service.save_report(
|
||||
file=SimpleUploadedFile(
|
||||
name=final_name,
|
||||
content=content,
|
||||
content_type=content_type,
|
||||
),
|
||||
organizacion_id=org_id,
|
||||
metadata={
|
||||
'report_id': str(report.id),
|
||||
'report_type': 'datastage',
|
||||
'user_id': str(report.user.id) if report.user else None,
|
||||
},
|
||||
)
|
||||
|
||||
if ruta:
|
||||
logger.info('[reporte_datastage] report=%s guardado en storage=%s', report_id, ruta)
|
||||
report.file = ruta
|
||||
report.status = 'ready'
|
||||
else:
|
||||
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
|
||||
return
|
||||
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||
|
||||
resultado = {
|
||||
'report_id': str(report.id),
|
||||
'total_registros': total_rows,
|
||||
'archivo': final_name,
|
||||
}
|
||||
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
|
||||
logger.info('[reporte_datastage] report=%s COMPLETADO filas=%d', report_id, total_rows)
|
||||
return resultado
|
||||
|
||||
except SoftTimeLimitExceeded:
|
||||
_fail('El reporte tardó más de 30 minutos y fue cancelado. Intenta con filtros más acotados.')
|
||||
|
||||
except ValueError as exc:
|
||||
_fail(str(exc))
|
||||
|
||||
except Exception as exc:
|
||||
_fail(str(exc), exc=exc)
|
||||
@@ -1,383 +1,102 @@
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
|
||||
import openpyxl
|
||||
from openpyxl.styles import Alignment, Font, PatternFill
|
||||
|
||||
from celery import shared_task
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from api.customs.models import Cove, EDocument, Partida, Pedimento
|
||||
from api.organization.models import Organizacion
|
||||
from api.record.models import Document
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils import timezone
|
||||
from api.reports.models import ReportDocument
|
||||
from api.utils.storage_service import storage_service
|
||||
from core.redis_events import publish_task_event
|
||||
|
||||
logger = logging.getLogger('api.reports.tasks')
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _estado(flag: bool) -> str:
|
||||
return 'RECUPERADO' if flag else 'PENDIENTE'
|
||||
|
||||
|
||||
def _build_pedimento_filters(filters: dict) -> Q:
|
||||
q = Q()
|
||||
if filters.get('organizacion_id'):
|
||||
q &= Q(organizacion_id=filters['organizacion_id'])
|
||||
if filters.get('fecha_pago__gte'):
|
||||
q &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
|
||||
if filters.get('fecha_pago__lte'):
|
||||
q &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
|
||||
if filters.get('patente'):
|
||||
q &= Q(patente=filters['patente'])
|
||||
if filters.get('aduana'):
|
||||
q &= Q(aduana=filters['aduana'])
|
||||
if filters.get('pedimento'):
|
||||
q &= Q(pedimento=filters['pedimento'])
|
||||
if filters.get('pedimento_app'):
|
||||
q &= Q(pedimento_app=filters['pedimento_app'])
|
||||
if filters.get('regimen'):
|
||||
q &= Q(regimen=filters['regimen'])
|
||||
if filters.get('tipo_operacion'):
|
||||
q &= Q(tipo_operacion_id=filters['tipo_operacion'])
|
||||
rfc_val = filters.get('contribuyente__rfc')
|
||||
if rfc_val:
|
||||
if rfc_val == 'SIN_RFC':
|
||||
q &= Q(contribuyente__isnull=True)
|
||||
else:
|
||||
q &= Q(contribuyente__rfc=rfc_val)
|
||||
return q
|
||||
|
||||
|
||||
def _apply_user_rfc_filter(q: Q, user, requested_rfc: str | None) -> Q:
|
||||
"""Restringe el queryset a los importadores visibles del usuario."""
|
||||
# SIN_RFC ya fue aplicado en _build_pedimento_filters como contribuyente__isnull=True
|
||||
if requested_rfc == 'SIN_RFC':
|
||||
return q
|
||||
user_rfcs = user.rfc.all()
|
||||
if not user_rfcs.exists():
|
||||
if requested_rfc:
|
||||
q &= Q(contribuyente__rfc=requested_rfc)
|
||||
return q
|
||||
if requested_rfc:
|
||||
if user_rfcs.filter(rfc=requested_rfc).exists():
|
||||
q &= Q(contribuyente__rfc=requested_rfc)
|
||||
else:
|
||||
q &= Q(contribuyente__in=user_rfcs)
|
||||
else:
|
||||
q &= Q(contribuyente__in=user_rfcs)
|
||||
return q
|
||||
|
||||
|
||||
# ── tarea principal ───────────────────────────────────────────────────────────
|
||||
|
||||
@shared_task(bind=True, queue='reports', soft_time_limit=600, time_limit=660)
|
||||
def generate_report_document(self, report_id):
|
||||
task_id = self.request.id
|
||||
report = None
|
||||
|
||||
def _fail(msg, exc=None):
|
||||
"""Marca el reporte como error, notifica al frontend y loguea. Sin re-raise."""
|
||||
tb = traceback.format_exc() if exc else ''
|
||||
full_msg = f"{msg}\n\n{tb}".strip() if tb else msg
|
||||
logger.error('[reporte_cumplimiento] report=%s FALLO: %s', report_id, full_msg)
|
||||
if report:
|
||||
report.status = 'error'
|
||||
report.error_message = full_msg
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
publish_task_event(task_id, 'failed', msg, progress=0)
|
||||
|
||||
# ── 1. Obtener reporte ────────────────────────────────────────────────────
|
||||
try:
|
||||
report = ReportDocument.objects.get(id=report_id)
|
||||
except ReportDocument.DoesNotExist:
|
||||
logger.error('[reporte_cumplimiento] ReportDocument %s no existe', report_id)
|
||||
publish_task_event(task_id, 'failed', f'Reporte {report_id} no encontrado', progress=0)
|
||||
return
|
||||
|
||||
logger.info('[reporte_cumplimiento] Iniciando report=%s user=%s', report_id, report.user_id)
|
||||
report.status = 'processing'
|
||||
report.save(update_fields=['status'])
|
||||
publish_task_event(task_id, 'processing', 'Iniciando generación de reporte...', progress=5)
|
||||
|
||||
try:
|
||||
filters = report.filters or {}
|
||||
org_id = filters.get('organizacion_id')
|
||||
|
||||
# ── 2. Filtros y organización ─────────────────────────────────────────
|
||||
q = _build_pedimento_filters(filters)
|
||||
q = _apply_user_rfc_filter(q, report.user, filters.get('contribuyente__rfc'))
|
||||
|
||||
nombre_org = ''
|
||||
if org_id:
|
||||
try:
|
||||
nombre_org = Organizacion.objects.get(id=org_id).nombre
|
||||
except Organizacion.DoesNotExist:
|
||||
pass
|
||||
|
||||
logger.info('[reporte_cumplimiento] report=%s org=%s filtros=%s', report_id, nombre_org, filters)
|
||||
publish_task_event(task_id, 'processing', f'Consultando RFCs de {nombre_org}...', progress=10)
|
||||
|
||||
# ── 3. Listar RFCs (consulta liviana) ────────────────────────────────
|
||||
rfcs_list = list(
|
||||
Pedimento.objects.filter(q)
|
||||
.exclude(contribuyente__isnull=True)
|
||||
.values_list('contribuyente__rfc', flat=True)
|
||||
.distinct()
|
||||
.order_by('contribuyente__rfc')
|
||||
)
|
||||
if Pedimento.objects.filter(q, contribuyente__isnull=True).exists():
|
||||
rfcs_list.append('SIN_RFC')
|
||||
|
||||
total_rfcs = len(rfcs_list)
|
||||
total_pedimentos = Pedimento.objects.filter(q).count()
|
||||
|
||||
logger.info('[reporte_cumplimiento] report=%s total_rfcs=%d total_pedimentos=%d',
|
||||
report_id, total_rfcs, total_pedimentos)
|
||||
|
||||
if total_rfcs == 0:
|
||||
logger.warning('[reporte_cumplimiento] report=%s sin pedimentos para los filtros dados', report_id)
|
||||
|
||||
publish_task_event(
|
||||
task_id, 'processing',
|
||||
f'{total_rfcs} RFC(s) — {total_pedimentos} pedimentos', progress=15,
|
||||
)
|
||||
|
||||
# ── 4. Crear workbook ─────────────────────────────────────────────────
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = 'Reporte Cumplimiento'
|
||||
|
||||
title_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
|
||||
title_font = Font(color='FFFFFF', bold=True, size=12)
|
||||
sub_fill = PatternFill(start_color='2E75B6', end_color='2E75B6', fill_type='solid')
|
||||
sub_font = Font(color='FFFFFF', bold=True, size=10)
|
||||
col_h_fill = PatternFill(start_color='D6E4F0', end_color='D6E4F0', fill_type='solid')
|
||||
col_h_font = Font(bold=True, size=10)
|
||||
footer_fill = PatternFill(start_color='E2EFDA', end_color='E2EFDA', fill_type='solid')
|
||||
center = Alignment(horizontal='center', vertical='center', wrap_text=True)
|
||||
top_left = Alignment(horizontal='left', vertical='top', wrap_text=True)
|
||||
|
||||
COL_HEADERS = [
|
||||
'Año', 'Aduana', 'Patente', 'Pedimento',
|
||||
'Nomenclatura Completo Pedimento', 'Clav', 'Tipo Operación',
|
||||
'Expediente Sí', 'Documento', 'Estatus',
|
||||
]
|
||||
TOTAL_COLS = len(COL_HEADERS)
|
||||
current_row = 1
|
||||
safe_total = max(total_rfcs, 1)
|
||||
|
||||
# ── 5. Procesar RFC por RFC ───────────────────────────────────────────
|
||||
for rfc_idx, rfc in enumerate(rfcs_list):
|
||||
pct = 20 + int((rfc_idx / safe_total) * 65)
|
||||
publish_task_event(
|
||||
task_id, 'processing',
|
||||
f'RFC {rfc_idx + 1}/{total_rfcs}: {rfc}', progress=pct,
|
||||
)
|
||||
|
||||
rfc_q = (
|
||||
q & Q(contribuyente__isnull=True) if rfc == 'SIN_RFC'
|
||||
else q & Q(contribuyente__rfc=rfc)
|
||||
)
|
||||
|
||||
peds = list(
|
||||
Pedimento.objects.filter(rfc_q)
|
||||
.select_related('contribuyente', 'tipo_operacion')
|
||||
.order_by('fecha_pago')
|
||||
)
|
||||
if not peds:
|
||||
logger.warning('[reporte_cumplimiento] report=%s rfc=%s sin pedimentos, omitido', report_id, rfc)
|
||||
continue
|
||||
|
||||
ped_ids = [p.id for p in peds]
|
||||
razon_social = nombre_org or 'Desconocido'
|
||||
|
||||
logger.info('[reporte_cumplimiento] report=%s rfc=%s pedimentos=%d',
|
||||
report_id, rfc, len(peds))
|
||||
|
||||
# documentos de este RFC solamente
|
||||
coves_map: dict = defaultdict(list)
|
||||
for c in Cove.objects.filter(pedimento_id__in=ped_ids):
|
||||
coves_map[c.pedimento_id].append(c)
|
||||
|
||||
edocs_map: dict = defaultdict(list)
|
||||
for e in EDocument.objects.filter(pedimento_id__in=ped_ids):
|
||||
edocs_map[e.pedimento_id].append(e)
|
||||
|
||||
partidas_map: dict = defaultdict(list)
|
||||
for p in Partida.objects.filter(pedimento_id__in=ped_ids).order_by('numero_partida'):
|
||||
partidas_map[p.pedimento_id].append(p)
|
||||
|
||||
remesa_ped_ids: set = set(
|
||||
Document.objects.filter(pedimento_id__in=ped_ids, document_type_id=15)
|
||||
.values_list('pedimento_id', flat=True)
|
||||
)
|
||||
|
||||
total_coves = sum(len(v) for v in coves_map.values())
|
||||
total_edocs = sum(len(v) for v in edocs_map.values())
|
||||
total_partidas = sum(len(v) for v in partidas_map.values())
|
||||
est_rows = len(peds) + total_partidas + total_coves * 2 + total_edocs * 2 + len(remesa_ped_ids)
|
||||
logger.info('[reporte_cumplimiento] report=%s rfc=%s docs coves=%d edocs=%d partidas=%d remesas=%d filas_estimadas=%d',
|
||||
report_id, rfc, total_coves, total_edocs, total_partidas, len(remesa_ped_ids), est_rows)
|
||||
|
||||
# encabezado sección
|
||||
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||
cell = ws.cell(row=current_row, column=1, value='Reporte Integración de Expedientes.')
|
||||
cell.fill, cell.font, cell.alignment = title_fill, title_font, center
|
||||
current_row += 1
|
||||
|
||||
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||
cell = ws.cell(row=current_row, column=1, value=f'Razón Social Importador: {razon_social}')
|
||||
cell.fill, cell.font = sub_fill, sub_font
|
||||
current_row += 1
|
||||
|
||||
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||
cell = ws.cell(row=current_row, column=1, value=f'RFC: {rfc}')
|
||||
cell.fill, cell.font = sub_fill, sub_font
|
||||
current_row += 1
|
||||
|
||||
for col_i, header in enumerate(COL_HEADERS, 1):
|
||||
cell = ws.cell(row=current_row, column=col_i, value=header)
|
||||
cell.fill, cell.font, cell.alignment = col_h_fill, col_h_font, center
|
||||
current_row += 1
|
||||
|
||||
total_exp = len(peds)
|
||||
exp_con_docs = exp_completos = 0
|
||||
|
||||
for ped in peds:
|
||||
doc_rows = [('PEDIMENTO COMPLETO', _estado(ped.existe_expediente))]
|
||||
|
||||
for partida in partidas_map[ped.id]:
|
||||
doc_rows.append((f'PARTIDA{partida.numero_partida}', _estado(partida.descargado)))
|
||||
if ped.remesas:
|
||||
doc_rows.append(('REMESA', _estado(ped.id in remesa_ped_ids)))
|
||||
for cove in coves_map[ped.id]:
|
||||
doc_rows.append((f'COVE{cove.numero_cove}', _estado(cove.cove_descargado)))
|
||||
doc_rows.append((f'ACUSE COVE{cove.numero_cove}', _estado(cove.acuse_cove_descargado)))
|
||||
for edoc in edocs_map[ped.id]:
|
||||
doc_rows.append((f'EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.edocument_descargado)))
|
||||
doc_rows.append((f'ACUSE EDOCUMENTO{edoc.numero_edocument}', _estado(edoc.acuse_descargado)))
|
||||
|
||||
if len(doc_rows) > 1:
|
||||
exp_con_docs += 1
|
||||
if all(e == 'RECUPERADO' for _, e in doc_rows):
|
||||
exp_completos += 1
|
||||
|
||||
n_rows = len(doc_rows)
|
||||
start_row = current_row
|
||||
anio = ped.fecha_pago.year % 100 if ped.fecha_pago else ''
|
||||
base_vals = [
|
||||
anio, ped.aduana or '', ped.patente or '', ped.pedimento or '',
|
||||
ped.pedimento_app or '', ped.clave_pedimento or '',
|
||||
ped.tipo_operacion.tipo if ped.tipo_operacion else '',
|
||||
'SI' if ped.existe_expediente else 'NO',
|
||||
]
|
||||
|
||||
# Sin merge_cells — para datasets grandes merge es O(n^2) y cuelga el proceso.
|
||||
# Los datos base solo se escriben en la primera fila; el resto queda vacío,
|
||||
# visualmente equivalente al merge pero sin el costo de memoria/CPU.
|
||||
for offset, (doc_nombre, doc_est) in enumerate(doc_rows):
|
||||
r = start_row + offset
|
||||
if offset == 0:
|
||||
for col, val in enumerate(base_vals, 1):
|
||||
ws.cell(row=r, column=col, value=val)
|
||||
ws.cell(row=r, column=9, value=doc_nombre)
|
||||
ws.cell(row=r, column=10, value=doc_est)
|
||||
|
||||
current_row += n_rows
|
||||
|
||||
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=TOTAL_COLS)
|
||||
cell = ws.cell(
|
||||
row=current_row, column=1,
|
||||
value=(f'Total de Expedientes= {total_exp} '
|
||||
f'Total De Expedientes Con Documentos= {exp_con_docs} '
|
||||
f'Total De Expedientes Completos= {exp_completos}'),
|
||||
)
|
||||
cell.fill = footer_fill
|
||||
cell.font = Font(bold=True)
|
||||
current_row += 2
|
||||
|
||||
del peds, ped_ids, coves_map, edocs_map, partidas_map, remesa_ped_ids
|
||||
|
||||
for i, w in enumerate([6, 8, 8, 12, 32, 8, 16, 12, 32, 14], 1):
|
||||
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = w
|
||||
|
||||
# ── 6. Serializar y subir ─────────────────────────────────────────────
|
||||
logger.info('[reporte_cumplimiento] report=%s serializando Excel...', report_id)
|
||||
publish_task_event(task_id, 'processing', 'Serializando Excel...', progress=88)
|
||||
|
||||
filename = f"reporte_cumplimiento_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.xlsx"
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
excel_bytes = buf.getvalue()
|
||||
logger.info('[reporte_cumplimiento] report=%s Excel size=%.1fKB', report_id, len(excel_bytes) / 1024)
|
||||
|
||||
publish_task_event(task_id, 'processing', 'Subiendo a almacenamiento...', progress=93)
|
||||
|
||||
ruta = storage_service.save_report(
|
||||
file=SimpleUploadedFile(
|
||||
name=filename,
|
||||
content=excel_bytes,
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
),
|
||||
organizacion_id=org_id,
|
||||
metadata={
|
||||
'report_id': str(report.id),
|
||||
'report_type': 'cumplimiento',
|
||||
'user_id': str(report.user.id) if report.user else None,
|
||||
},
|
||||
)
|
||||
|
||||
if ruta:
|
||||
logger.info('[reporte_cumplimiento] report=%s guardado en storage=%s', report_id, ruta)
|
||||
report.file = ruta
|
||||
report.status = 'ready'
|
||||
else:
|
||||
_fail('Error al guardar el archivo en almacenamiento (storage retornó None)')
|
||||
return
|
||||
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||
|
||||
resultado = {
|
||||
'report_id': str(report.id),
|
||||
'total_rfcs': total_rfcs,
|
||||
'total_pedimentos': total_pedimentos,
|
||||
}
|
||||
publish_task_event(task_id, 'completed', 'Reporte generado exitosamente.', progress=100, resultado=resultado)
|
||||
logger.info('[reporte_cumplimiento] report=%s COMPLETADO rfcs=%d pedimentos=%d',
|
||||
report_id, total_rfcs, total_pedimentos)
|
||||
return resultado
|
||||
|
||||
except SoftTimeLimitExceeded:
|
||||
_fail('El reporte tardó más de 10 minutos y fue cancelado. Intenta con un rango de fechas más acotado.')
|
||||
|
||||
except Exception as exc:
|
||||
_fail(str(exc), exc=exc)
|
||||
|
||||
|
||||
# ── reporte de control de pedimentos (sin cambios) ────────────────────────────
|
||||
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
|
||||
from django.conf import settings
|
||||
|
||||
@shared_task
|
||||
def generate_report_control_pedimento(report_id):
|
||||
report = None
|
||||
def generate_report_document(report_id):
|
||||
try:
|
||||
report = ReportDocument.objects.get(id=report_id)
|
||||
report.status = 'processing'
|
||||
report.save(update_fields=['status'])
|
||||
filters = report.filters or {}
|
||||
pedimentos_filters = Q()
|
||||
if filters.get('organizacion_id'):
|
||||
pedimentos_filters &= Q(organizacion_id=filters['organizacion_id'])
|
||||
if filters.get('fecha_pago__gte'):
|
||||
pedimentos_filters &= Q(fecha_pago__gte=filters['fecha_pago__gte'])
|
||||
if filters.get('fecha_pago__lte'):
|
||||
pedimentos_filters &= Q(fecha_pago__lte=filters['fecha_pago__lte'])
|
||||
if filters.get('contribuyente__rfc'):
|
||||
pedimentos_filters &= Q(contribuyente__rfc=filters['contribuyente__rfc'])
|
||||
if filters.get('patente'):
|
||||
pedimentos_filters &= Q(patente=filters['patente'])
|
||||
if filters.get('aduana'):
|
||||
pedimentos_filters &= Q(aduana=filters['aduana'])
|
||||
if filters.get('pedimento'):
|
||||
pedimentos_filters &= Q(pedimento=filters['pedimento'])
|
||||
if filters.get('pedimento_app'):
|
||||
pedimentos_filters &= Q(pedimento_app=filters['pedimento_app'])
|
||||
if filters.get('regimen'):
|
||||
pedimentos_filters &= Q(regimen=filters['regimen'])
|
||||
if filters.get('tipo_operacion'):
|
||||
pedimentos_filters &= Q(tipo_operacion_id=filters['tipo_operacion'])
|
||||
# Consulta asíncrona de los modelos
|
||||
pedimentos = Pedimento.objects.filter(pedimentos_filters)
|
||||
filename = filters.get('filename')
|
||||
if filename:
|
||||
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
|
||||
else:
|
||||
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.writer(f)
|
||||
headers = [
|
||||
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
|
||||
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
|
||||
]
|
||||
writer.writerow(headers)
|
||||
for ped in pedimentos:
|
||||
for cove in Cove.objects.filter(pedimento=ped):
|
||||
writer.writerow([
|
||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
||||
'COVE', cove.numero_cove, cove.cove_descargado, cove.acuse_cove_descargado
|
||||
])
|
||||
for edoc in EDocument.objects.filter(pedimento=ped):
|
||||
writer.writerow([
|
||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
||||
'EDOC', edoc.numero_edocument, edoc.edocument_descargado, edoc.acuse_descargado
|
||||
])
|
||||
for partida in Partida.objects.filter(pedimento=ped):
|
||||
writer.writerow([
|
||||
ped.aduana, ped.patente, ped.regimen, ped.pedimento, ped.pedimento_app,
|
||||
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
|
||||
'PARTIDA', partida.numero_partida, partida.descargado, ''
|
||||
])
|
||||
# Guardar el archivo en el modelo
|
||||
with open(file_path, 'rb') as f:
|
||||
report.file.save(filename, ContentFile(f.read()), save=True)
|
||||
report.status = 'ready'
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'file', 'finished_at'])
|
||||
except Exception as e:
|
||||
report.status = 'error'
|
||||
report.error_message = str(e)
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
|
||||
@shared_task
|
||||
def generate_report_control_pedimento(report_id):
|
||||
try:
|
||||
|
||||
report = ReportDocument.objects.get(id=report_id)
|
||||
report.status = 'processing'
|
||||
report.save(update_fields=['status'])
|
||||
filters = report.filters or {}
|
||||
|
||||
|
||||
# Construir filtros
|
||||
pedimentos_filters = {}
|
||||
if filters.get('organizacion_id'):
|
||||
pedimentos_filters['organizacion_id'] = filters['organizacion_id']
|
||||
@@ -388,12 +107,15 @@ def generate_report_control_pedimento(report_id):
|
||||
if filters.get('pedimento_app'):
|
||||
pedimentos_filters['pedimento_app'] = filters['pedimento_app']
|
||||
|
||||
# pedimentos por organizacion
|
||||
pedimentos_qs = Pedimento.objects.filter(**pedimentos_filters)
|
||||
pedimentos_total = pedimentos_qs.count()
|
||||
|
||||
|
||||
pedimento_ids = list(pedimentos_qs.values_list('id', flat=True))
|
||||
rfcs_raw = list(pedimentos_qs.values_list('agente_aduanal', flat=True))
|
||||
|
||||
# inicializar totales
|
||||
pedimentos_completos = 0
|
||||
total_documentos = 0
|
||||
documentos_sin_descargar = 0
|
||||
@@ -401,13 +123,15 @@ def generate_report_control_pedimento(report_id):
|
||||
nombre_organizacion = ''
|
||||
if filters.get('organizacion_id'):
|
||||
try:
|
||||
# Asumo que tienes un modelo Organizacion - ajusta según tu modelo real
|
||||
organizacion = Organizacion.objects.get(id=filters['organizacion_id'])
|
||||
nombre_organizacion = organizacion.nombre
|
||||
nombre_organizacion = organizacion.nombre # ajusta el campo según tu modelo
|
||||
except Organizacion.DoesNotExist:
|
||||
nombre_organizacion = f"ID: {filters['organizacion_id']}"
|
||||
except Exception as e:
|
||||
nombre_organizacion = f"Error: {str(e)}"
|
||||
|
||||
# lista de rfc
|
||||
rfc_list = ', '.join(sorted(set([rfc for rfc in rfcs_raw if rfc])))
|
||||
|
||||
fecha_inicio = ''
|
||||
@@ -422,73 +146,103 @@ def generate_report_control_pedimento(report_id):
|
||||
if ultimo_pedimento and ultimo_pedimento.fecha_pago:
|
||||
fecha_fin = ultimo_pedimento.fecha_pago.strftime('%Y-%m-%d')
|
||||
|
||||
# Para cada pedimento, verificar si está completo
|
||||
for pedimento in pedimentos_qs:
|
||||
# Contar documentos de este pedimento
|
||||
docs_pedimento = 0
|
||||
docs_pendientes_pedimento = 0
|
||||
|
||||
# COVES
|
||||
coves_count = Cove.objects.filter(pedimento_id=pedimento.id).count()
|
||||
coves_pendientes = Cove.objects.filter(pedimento_id=pedimento.id, cove_descargado=False).count()
|
||||
docs_pedimento += coves_count
|
||||
docs_pendientes_pedimento += coves_pendientes
|
||||
|
||||
# PARTIDAS
|
||||
partidas_count = Partida.objects.filter(pedimento_id=pedimento.id).count()
|
||||
partidas_pendientes = Partida.objects.filter(pedimento_id=pedimento.id, descargado=False).count()
|
||||
docs_pedimento += partidas_count
|
||||
docs_pendientes_pedimento += partidas_pendientes
|
||||
|
||||
# EDOCUMENTS
|
||||
edocs_count = EDocument.objects.filter(pedimento_id=pedimento.id).count()
|
||||
edocs_pendientes = EDocument.objects.filter(pedimento_id=pedimento.id, edocument_descargado=False).count()
|
||||
docs_pedimento += edocs_count
|
||||
docs_pendientes_pedimento += edocs_pendientes
|
||||
|
||||
# Acumular totales
|
||||
total_documentos += docs_pedimento
|
||||
documentos_sin_descargar += docs_pendientes_pedimento
|
||||
|
||||
# Si no tiene documentos pendientes, está completo
|
||||
if docs_pendientes_pedimento == 0 and docs_pedimento > 0:
|
||||
pedimentos_completos += 1
|
||||
|
||||
# 3. PORCENTAJE
|
||||
porcentaje_faltantes = (documentos_sin_descargar / total_documentos * 100) if total_documentos > 0 else 0
|
||||
|
||||
# 4. GENERAR CSV CON DETALLES
|
||||
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
|
||||
tmp_path = tmp.name
|
||||
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
todas_las_filas = []
|
||||
|
||||
# Recopilar datos detallados - UNA FILA POR CADA DOCUMENTO
|
||||
for pedimento in pedimentos_qs:
|
||||
# DATOS BASE DEL PEDIMENTO (se repiten en cada fila)
|
||||
datos_base_pedimento = [
|
||||
pedimento.aduana or '',
|
||||
pedimento.patente or '',
|
||||
pedimento.regimen or '',
|
||||
pedimento.pedimento or '',
|
||||
pedimento.pedimento_app or '',
|
||||
pedimento.pedimento or '', # No. Pedimento (7 dígitos)
|
||||
pedimento.pedimento_app or '', # No. Pedimento App completo
|
||||
pedimento.clave_pedimento or '',
|
||||
pedimento.tipo_operacion.tipo if pedimento.tipo_operacion else '',
|
||||
str(pedimento.contribuyente_id) if pedimento.contribuyente_id else ''
|
||||
]
|
||||
|
||||
# COVES - Una fila por cada COVE
|
||||
coves = Cove.objects.filter(pedimento_id=pedimento.id)
|
||||
for cove in coves:
|
||||
estado = 'VERDADERO' if cove.cove_descargado else 'FALSO'
|
||||
fila = datos_base_pedimento + [cove.numero_cove, 'COVE', estado]
|
||||
fila = datos_base_pedimento + [
|
||||
# str(cove.id), # Identificador de documento
|
||||
cove.numero_cove,
|
||||
'COVE', # Tipo de documento
|
||||
estado
|
||||
]
|
||||
todas_las_filas.append(fila)
|
||||
|
||||
# PARTIDAS - Una fila por cada Partida
|
||||
partidas = Partida.objects.filter(pedimento_id=pedimento.id)
|
||||
for partida in partidas:
|
||||
estado = 'VERDADERO' if partida.descargado else 'FALSO'
|
||||
fila = datos_base_pedimento + [partida.numero_partida, 'PARTIDA', estado]
|
||||
fila = datos_base_pedimento + [
|
||||
# str(partida.id),
|
||||
partida.numero_partida,
|
||||
'PARTIDA', # Tipo de documento
|
||||
estado
|
||||
]
|
||||
todas_las_filas.append(fila)
|
||||
|
||||
# EDOCUMENTS - Una fila por cada EDocument
|
||||
edocuments = EDocument.objects.filter(pedimento_id=pedimento.id)
|
||||
for edoc in edocuments:
|
||||
estado = 'VERDADERO' if edoc.edocument_descargado else 'FALSO'
|
||||
fila = datos_base_pedimento + [edoc.numero_edocument, 'EDOCUMENT', estado]
|
||||
fila = datos_base_pedimento + [
|
||||
# str(edoc.id),
|
||||
edoc.numero_edocument,
|
||||
'EDOCUMENT', # Tipo de documento
|
||||
estado
|
||||
]
|
||||
todas_las_filas.append(fila)
|
||||
|
||||
import csv
|
||||
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
|
||||
# 5. ESCRIBIR ARCHIVO CSV
|
||||
with open(file_path, 'w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.writer(f)
|
||||
|
||||
# SECCIÓN DE TOTALES
|
||||
writer.writerow(['RESUMEN DEL REPORTE - CONTROL DE PEDIMENTOS'])
|
||||
writer.writerow(['ORGANIZACION:', nombre_organizacion])
|
||||
writer.writerow([])
|
||||
@@ -501,49 +255,29 @@ def generate_report_control_pedimento(report_id):
|
||||
writer.writerow(['LISTA RFC:', rfc_list])
|
||||
writer.writerow([])
|
||||
writer.writerow([])
|
||||
|
||||
# ENCABEZADOS DE DATOS (según requerimiento)
|
||||
headers = [
|
||||
'ADUANA', 'PATENTE', 'REGIMEN', 'NO. PEDIMENTO', 'PEDIMENTO_APP',
|
||||
'CLAVE_PEDIMENTO', 'TIPO_OPERACION', 'CONTRIBUYENTE_ID',
|
||||
'IDENTIFICADOR_DOCUMENTO', 'TIPO_DOCUMENTO', 'ESTADO'
|
||||
]
|
||||
writer.writerow(headers)
|
||||
|
||||
# DATOS DETALLADOS
|
||||
for fila in todas_las_filas:
|
||||
writer.writerow(fila)
|
||||
|
||||
with open(tmp_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
uploaded_file = SimpleUploadedFile(
|
||||
name=filename,
|
||||
content=file_content,
|
||||
content_type='text/csv'
|
||||
)
|
||||
|
||||
ruta = storage_service.save_report(
|
||||
file=uploaded_file,
|
||||
organizacion_id=filters.get('organizacion_id'),
|
||||
metadata={
|
||||
'report_id': str(report.id),
|
||||
'report_type': 'control_pedimento',
|
||||
'user_id': str(report.user.id) if report.user else None
|
||||
}
|
||||
)
|
||||
|
||||
os.unlink(tmp_path)
|
||||
|
||||
if ruta:
|
||||
report.file = ruta
|
||||
report.status = 'ready'
|
||||
else:
|
||||
report.status = 'error'
|
||||
report.error_message = 'Error al guardar el archivo en storage'
|
||||
with open(file_path, 'rb') as f:
|
||||
report.file.save(filename, ContentFile(f.read()), save=True)
|
||||
|
||||
report.status = 'ready'
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||
report.save(update_fields=['status', 'file', 'finished_at'])
|
||||
|
||||
except Exception as e:
|
||||
if report:
|
||||
report.status = 'error'
|
||||
report.error_message = str(e)
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
report.status = 'error'
|
||||
report.error_message = str(e)
|
||||
report.finished_at = timezone.now()
|
||||
report.save(update_fields=['status', 'error_message', 'finished_at'])
|
||||
@@ -1,446 +1,3 @@
|
||||
"""
|
||||
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 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)
|
||||
# Create your tests here.
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"""
|
||||
Tests para el reporte DataStage asíncrono (Celery + SSE).
|
||||
|
||||
Ejecución:
|
||||
python manage.py test api.reports.tests_datastage
|
||||
"""
|
||||
import csv
|
||||
import io
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import openpyxl
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from api.licence.models import Licencia
|
||||
from api.organization.models import Organizacion
|
||||
from api.reports.models import ReportDocument
|
||||
from api.reports.tasks.report_datastage import generate_report_datastage
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
FAKE_PATH = 'org_x/reports/datastage_test.xlsx'
|
||||
|
||||
|
||||
# ── fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _ensure_registro_created_at():
|
||||
"""Las migraciones 0013/0014 de datastage agregan created_at solo en estado
|
||||
(SeparateDatabaseAndState) porque la columna ya existía en la BD real; en la
|
||||
BD de test hay que crearla explícitamente para poder insertar registros."""
|
||||
with connection.cursor() as cur:
|
||||
cur.execute('ALTER TABLE registro501 ADD COLUMN IF NOT EXISTS created_at timestamptz')
|
||||
cur.execute('ALTER TABLE registro502 ADD COLUMN IF NOT EXISTS created_at timestamptz')
|
||||
|
||||
|
||||
def _org(nombre='Org DataStage'):
|
||||
lic = Licencia.objects.create(nombre=f'Lic {nombre}', almacenamiento=10)
|
||||
return Organizacion.objects.create(nombre=nombre, is_active=True, is_verified=True, licencia=lic)
|
||||
|
||||
|
||||
def _user(org, username='ds_user', superuser=False):
|
||||
if superuser:
|
||||
u = User.objects.create_superuser(username=username, password='pass', email=f'{username}@test.mx')
|
||||
# Superuser JWT requiere active_organization (OrgScopedPermission)
|
||||
u.active_organization = org
|
||||
u.save(update_fields=['active_organization'])
|
||||
return u
|
||||
return User.objects.create_user(username=username, password='pass', organizacion=org)
|
||||
|
||||
|
||||
def _registro501(org, pedimento='1000001', rfc='XAXX010101000', patente='3910'):
|
||||
Registro501 = apps.get_model('datastage', 'Registro501')
|
||||
return Registro501.objects.create(
|
||||
organizacion=org, patente=patente, pedimento=pedimento,
|
||||
seccion_aduanera='160', rfc=rfc,
|
||||
)
|
||||
|
||||
|
||||
def _registro502(org, pedimento='1000001', patente='3910', transportista='Transportes Test'):
|
||||
Registro502 = apps.get_model('datastage', 'Registro502')
|
||||
return Registro502.objects.create(
|
||||
organizacion=org, patente=patente, pedimento=pedimento,
|
||||
seccion_aduanera='160', nombre_transportista=transportista,
|
||||
)
|
||||
|
||||
|
||||
def _reporte(user, payload):
|
||||
return ReportDocument.objects.create(
|
||||
user=user, filters=payload, status='pending', report_type='datastage'
|
||||
)
|
||||
|
||||
|
||||
def _payload_simple(org, fmt='excel', model='Registro501', fields=None):
|
||||
return {
|
||||
'modo': 'simple',
|
||||
'format': fmt,
|
||||
'globalFilters': {'organizacion': str(org.id)},
|
||||
'organizacion_id': str(org.id),
|
||||
'model': model,
|
||||
'fields': fields or ['patente', 'pedimento', 'rfc'],
|
||||
}
|
||||
|
||||
|
||||
def _payload_multiple(org, fmt='excel'):
|
||||
return {
|
||||
'modo': 'multiple',
|
||||
'format': fmt,
|
||||
'globalFilters': {'organizacion': str(org.id)},
|
||||
'organizacion_id': str(org.id),
|
||||
'models': [
|
||||
{'model': 'Registro501', 'name': 'Datos generales', 'fields': ['rfc', 'patente', 'pedimento']},
|
||||
{'model': 'Registro502', 'name': 'Transporte', 'fields': ['nombre_transportista']},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _archivo_desde_mock(mock_save):
|
||||
"""Devuelve (nombre, bytes) del archivo que recibió storage_service.save_report."""
|
||||
uf = mock_save.call_args[1]['file']
|
||||
return uf.name, uf.read()
|
||||
|
||||
|
||||
# ── 1. Task Celery ────────────────────────────────────────────────────────────
|
||||
# Se mockean Redis (publish_task_event) y MinIO (storage_service.save_report).
|
||||
|
||||
@patch('api.reports.tasks.report_datastage.publish_task_event')
|
||||
@patch('api.reports.tasks.report_datastage.storage_service.save_report',
|
||||
return_value=FAKE_PATH)
|
||||
class TestGenerateReportDatastage(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
_ensure_registro_created_at()
|
||||
cls.org = _org()
|
||||
cls.user = _user(cls.org)
|
||||
|
||||
def _run(self, report):
|
||||
generate_report_datastage.apply(args=[str(report.id)])
|
||||
report.refresh_from_db()
|
||||
|
||||
# ── 1.1 Simple / Excel ────────────────────────────────────────────────────
|
||||
|
||||
def test_simple_excel_status_ready_y_archivo_xlsx(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
report = _reporte(self.user, _payload_simple(self.org, fmt='excel'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'ready')
|
||||
self.assertEqual(report.file, FAKE_PATH)
|
||||
self.assertIsNotNone(report.finished_at)
|
||||
|
||||
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||
self.assertTrue(nombre.endswith('.xlsx'), f'Esperado .xlsx, recibido: {nombre}')
|
||||
wb = openpyxl.load_workbook(io.BytesIO(contenido))
|
||||
valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
|
||||
self.assertIn('XAXX010101000', valores)
|
||||
|
||||
# ── 1.2 Simple / CSV ──────────────────────────────────────────────────────
|
||||
|
||||
def test_simple_csv_status_ready_y_archivo_csv(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'ready')
|
||||
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||
self.assertTrue(nombre.endswith('.csv'), f'Esperado .csv, recibido: {nombre}')
|
||||
|
||||
rows = list(csv.reader(io.StringIO(contenido.decode('utf-8'))))
|
||||
self.assertEqual(rows[0], ['patente', 'pedimento', 'rfc'])
|
||||
self.assertIn('XAXX010101000', rows[1])
|
||||
|
||||
# ── 1.3 Aislamiento por organización ──────────────────────────────────────
|
||||
|
||||
def test_simple_no_incluye_datos_de_otra_organizacion(self, mock_save, mock_pub):
|
||||
_registro501(self.org, rfc='XAXX010101000')
|
||||
otra_org = _org('Otra Org')
|
||||
_registro501(otra_org, pedimento='9999999', rfc='XEXX010101000')
|
||||
|
||||
report = _reporte(self.user, _payload_simple(self.org, fmt='csv'))
|
||||
self._run(report)
|
||||
|
||||
_, contenido = _archivo_desde_mock(mock_save)
|
||||
texto = contenido.decode('utf-8')
|
||||
self.assertIn('XAXX010101000', texto)
|
||||
self.assertNotIn('XEXX010101000', texto)
|
||||
|
||||
# ── 1.4 Múltiple / Excel ──────────────────────────────────────────────────
|
||||
|
||||
def test_multiple_excel_combina_modelos_por_llave(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
_registro502(self.org)
|
||||
report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'ready')
|
||||
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||
self.assertTrue(nombre.endswith('datastage_reporte.xlsx'), f'Nombre inesperado: {nombre}')
|
||||
|
||||
wb = openpyxl.load_workbook(io.BytesIO(contenido))
|
||||
valores = [str(c.value) for row in wb.active.iter_rows() for c in row if c.value]
|
||||
# Campos de ambos modelos en la misma hoja (prefijados por modelo)
|
||||
self.assertIn('Registro501_rfc', valores)
|
||||
self.assertIn('Registro502_nombre_transportista', valores)
|
||||
self.assertIn('XAXX010101000', valores)
|
||||
self.assertIn('Transportes Test', valores)
|
||||
|
||||
# ── 1.5 Múltiple / CSV ────────────────────────────────────────────────────
|
||||
|
||||
def test_multiple_csv_combina_modelos(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
_registro502(self.org)
|
||||
report = _reporte(self.user, _payload_multiple(self.org, fmt='csv'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'ready')
|
||||
nombre, contenido = _archivo_desde_mock(mock_save)
|
||||
self.assertTrue(nombre.endswith('datastage_reporte.csv'), f'Nombre inesperado: {nombre}')
|
||||
texto = contenido.decode('utf-8')
|
||||
self.assertIn('Registro501_rfc', texto)
|
||||
self.assertIn('Transportes Test', texto)
|
||||
|
||||
# ── 1.6 Sin datos ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_multiple_sin_datos_genera_archivo_sin_datos_y_ready(self, mock_save, mock_pub):
|
||||
report = _reporte(self.user, _payload_multiple(self.org, fmt='excel'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'ready')
|
||||
nombre, _ = _archivo_desde_mock(mock_save)
|
||||
self.assertIn('sin_datos', nombre)
|
||||
|
||||
# ── 1.7 Payload inválido ──────────────────────────────────────────────────
|
||||
|
||||
def test_modelo_inexistente_marca_error_y_publica_failed(self, mock_save, mock_pub):
|
||||
report = _reporte(self.user, _payload_simple(self.org, model='NoExiste'))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'error')
|
||||
self.assertIn('NoExiste', report.error_message)
|
||||
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||
self.assertIn('failed', statuses)
|
||||
self.assertNotIn('completed', statuses)
|
||||
|
||||
def test_payload_sin_model_marca_error(self, mock_save, mock_pub):
|
||||
payload = _payload_simple(self.org)
|
||||
del payload['model']
|
||||
report = _reporte(self.user, payload)
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'error')
|
||||
mock_save.assert_not_called()
|
||||
|
||||
# ── 1.8 Eventos de progreso ───────────────────────────────────────────────
|
||||
|
||||
def test_ultimo_evento_es_completed_con_100_y_resultado(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
report = _reporte(self.user, _payload_simple(self.org))
|
||||
self._run(report)
|
||||
|
||||
ultimo = mock_pub.call_args_list[-1]
|
||||
self.assertEqual(ultimo[0][1], 'completed')
|
||||
self.assertEqual(ultimo[1].get('progress'), 100)
|
||||
resultado = ultimo[1].get('resultado')
|
||||
self.assertEqual(resultado['report_id'], str(report.id))
|
||||
self.assertEqual(resultado['total_registros'], 1)
|
||||
|
||||
def test_se_publican_eventos_de_progreso(self, mock_save, mock_pub):
|
||||
_registro501(self.org)
|
||||
report = _reporte(self.user, _payload_simple(self.org))
|
||||
self._run(report)
|
||||
|
||||
self.assertGreaterEqual(mock_pub.call_count, 4, 'Se esperan mínimo 4 eventos')
|
||||
|
||||
# ── 1.9 Storage falla ─────────────────────────────────────────────────────
|
||||
|
||||
def test_storage_none_deja_status_error_y_failed(self, mock_save, mock_pub):
|
||||
mock_save.return_value = None
|
||||
_registro501(self.org)
|
||||
report = _reporte(self.user, _payload_simple(self.org))
|
||||
self._run(report)
|
||||
|
||||
self.assertEqual(report.status, 'error')
|
||||
self.assertIn('almacenamiento', report.error_message)
|
||||
statuses = [c[0][1] for c in mock_pub.call_args_list]
|
||||
self.assertIn('failed', statuses)
|
||||
|
||||
|
||||
# ── 2. Vista (encolado 202) ───────────────────────────────────────────────────
|
||||
|
||||
class TestExportDataStageView202(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.org = _org('Org Vista')
|
||||
cls.user = _user(cls.org, username='vista_admin', superuser=True)
|
||||
|
||||
def setUp(self):
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
def _post(self, body):
|
||||
with patch('api.reports.views.generate_report_datastage.delay',
|
||||
return_value=MagicMock(id='fake-task-id')) as mock_delay:
|
||||
res = self.client.post('/api/v1/reports/exportmodel/datastage/', body, format='json')
|
||||
return res, mock_delay
|
||||
|
||||
def test_post_simple_responde_202_con_task_y_report(self):
|
||||
body = {
|
||||
'modo': 'simple', 'format': 'excel',
|
||||
'globalFilters': {'organizacion': str(self.org.id)},
|
||||
'model': 'Registro501', 'fields': ['patente', 'pedimento'],
|
||||
}
|
||||
res, mock_delay = self._post(body)
|
||||
|
||||
self.assertEqual(res.status_code, 202)
|
||||
self.assertEqual(res.data['task_id'], 'fake-task-id')
|
||||
self.assertEqual(res.data['status'], 'pending')
|
||||
|
||||
report = ReportDocument.objects.get(id=res.data['report_id'])
|
||||
self.assertEqual(report.report_type, 'datastage')
|
||||
self.assertEqual(report.user_id, self.user.id)
|
||||
self.assertEqual(report.filters['organizacion_id'], str(self.org.id))
|
||||
mock_delay.assert_called_once_with(report.id)
|
||||
|
||||
def test_post_multiple_persiste_models_en_filters(self):
|
||||
body = {
|
||||
'modo': 'multiple', 'format': 'csv',
|
||||
'globalFilters': {'organizacion': str(self.org.id)},
|
||||
'models': [{'model': 'Registro501', 'fields': ['rfc']}],
|
||||
}
|
||||
res, _ = self._post(body)
|
||||
|
||||
self.assertEqual(res.status_code, 202)
|
||||
report = ReportDocument.objects.get(id=res.data['report_id'])
|
||||
self.assertEqual(report.filters['modo'], 'multiple')
|
||||
self.assertEqual(report.filters['models'][0]['model'], 'Registro501')
|
||||
|
||||
def test_post_simple_sin_fields_responde_400(self):
|
||||
body = {
|
||||
'modo': 'simple', 'format': 'excel',
|
||||
'globalFilters': {'organizacion': str(self.org.id)},
|
||||
'model': 'Registro501',
|
||||
}
|
||||
res, mock_delay = self._post(body)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
mock_delay.assert_not_called()
|
||||
self.assertFalse(ReportDocument.objects.filter(report_type='datastage').exists())
|
||||
|
||||
def test_post_multiple_sin_models_responde_400(self):
|
||||
body = {
|
||||
'modo': 'multiple', 'format': 'excel',
|
||||
'globalFilters': {'organizacion': str(self.org.id)},
|
||||
}
|
||||
res, mock_delay = self._post(body)
|
||||
self.assertEqual(res.status_code, 400)
|
||||
mock_delay.assert_not_called()
|
||||
|
||||
def test_post_modelo_inexistente_responde_404(self):
|
||||
body = {
|
||||
'modo': 'simple', 'format': 'excel',
|
||||
'globalFilters': {'organizacion': str(self.org.id)},
|
||||
'model': 'NoExiste', 'fields': ['x'],
|
||||
}
|
||||
res, mock_delay = self._post(body)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
mock_delay.assert_not_called()
|
||||
1117
api/reports/views.py
1117
api/reports/views.py
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,9 @@
|
||||
from api.reports.models import ReportDocument
|
||||
from api.reports.tasks.report_document import generate_report_document, generate_report_control_pedimento
|
||||
from django.http import FileResponse
|
||||
from api.utils.storage_service import storage_service
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
import tempfile
|
||||
import os
|
||||
import atexit
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
@@ -70,13 +66,12 @@ def table_summary(request):
|
||||
status='pending',
|
||||
report_type='cumplimiento'
|
||||
)
|
||||
task = generate_report_document.delay(report.id)
|
||||
generate_report_document.delay(report.id)
|
||||
return Response({
|
||||
"report_id": report.id,
|
||||
"task_id": task.id,
|
||||
"status": report.status,
|
||||
"created_at": report.created_at,
|
||||
"download_url": storage_service.get_file_url(report.file) if report.file else None,
|
||||
"download_url": report.file.url if report.file else None
|
||||
}, status=202)
|
||||
|
||||
@api_view(['GET'])
|
||||
@@ -90,9 +85,7 @@ def report_document_status(request, report_id):
|
||||
"created_at": report.created_at,
|
||||
"finished_at": report.finished_at,
|
||||
"error_message": report.error_message,
|
||||
# "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": report.file.url if report.file else None
|
||||
}
|
||||
return Response(data)
|
||||
except ReportDocument.DoesNotExist:
|
||||
@@ -110,8 +103,7 @@ def report_document_list(request):
|
||||
"created_at": r.created_at,
|
||||
"finished_at": r.finished_at,
|
||||
"error_message": r.error_message,
|
||||
# "download_url": r.file.url if r.file else None
|
||||
"download_url": storage_service.get_file_url(r.file) if r.file else None
|
||||
"download_url": r.file.url if r.file else None
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
@@ -124,22 +116,8 @@ def report_document_download(request, report_id):
|
||||
report = ReportDocument.objects.get(id=report_id, user=request.user)
|
||||
if not report.file:
|
||||
return Response({"error": "El archivo aún no está disponible"}, status=404)
|
||||
|
||||
ruta = str(report.file)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx') as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
success = storage_service.download_file(ruta, tmp_path)
|
||||
if not success:
|
||||
return Response({"error": "No se pudo descargar el archivo"}, status=500)
|
||||
|
||||
filename = os.path.basename(ruta)
|
||||
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
|
||||
|
||||
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
|
||||
|
||||
response = FileResponse(report.file.open('rb'), as_attachment=True, filename=report.file.name)
|
||||
return response
|
||||
|
||||
except ReportDocument.DoesNotExist:
|
||||
return Response({"error": "Reporte no encontrado"}, status=404)
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ class TaskFilter(filters.FilterSet):
|
||||
timestamp_gte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='gte')
|
||||
timestamp_lte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='lte')
|
||||
status = filters.CharFilter(field_name='status')
|
||||
organizacion = filters.UUIDFilter(field_name='organizacion__id') # Cambiado a relación directa
|
||||
|
||||
class Meta:
|
||||
model = Task
|
||||
fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status', 'organizacion']
|
||||
fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status']
|
||||
@@ -1,12 +1,10 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TaskViewSet
|
||||
from django.urls import path, include
|
||||
from .views import TaskStatusView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'tasks', TaskViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
path('status/<str:task_id>/', TaskStatusView.as_view(), name='task-status'),
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user