Compare commits

...

12 Commits

Author SHA1 Message Date
d732602775 Merge pull request 'fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056' (#35) from fix/T2026-05-027-and-T2025-09-004-and-T2025-09-056 into main
Reviewed-on: #35
2026-06-19 13:17:24 +00:00
23ed52c78a fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056 2026-06-15 11:18:58 -06:00
7644446267 Merge pull request 'fix/allow_credentials' (#34) from feature/hub-implementacion into main
Reviewed-on: #34
2026-06-08 14:31:45 +00:00
cab3290f2e fix/allow_credentials 2026-06-08 08:27:54 -06:00
d07c43e590 Merge pull request 'feature/implementacion de hub en EFC' (#33) from feature/hub-implementacion into main
Reviewed-on: #33
2026-06-08 13:21:03 +00:00
e1716d65a7 feature/implementacion de hub en EFC 2026-06-08 07:19:01 -06:00
a9931d2838 Merge pull request 'feature/pedimentos-correccion-partidas' (#32) from feature/pedimentos-correccion-partidas into main
Reviewed-on: #32
2026-05-28 13:19:53 +00:00
709a5dedab feature/pedimentos-correccion-partidas 2026-05-28 07:10:39 -06:00
b1df613651 Merge pull request 'fix/forzar-carga-acuses' (#31) from fix/forzar-carga-acuses into main
Reviewed-on: #31
2026-05-25 20:55:06 +00:00
94846fec8a fix/forzar-carga-acuses 2026-05-25 14:52:06 -06:00
e378f2d949 Merge pull request 'feature/rbac permisos y roles implementados' (#30) from feature/rbac-implementation into main
Reviewed-on: #30
2026-05-21 13:59:50 +00:00
a318b70324 feature/rbac permisos y roles implementados 2026-05-21 07:54:59 -06:00
93 changed files with 11593 additions and 2577 deletions

View File

@@ -8,10 +8,10 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from core.permissions import ( from core.permissions import (
IsSameOrganization, get_org_context,
IsSameOrganizationDeveloper, require_permission,
IsSameOrganizationAndAdmin, user_has_permission,
IsSuperUser user_has_role,
) )
from api.organization.models import UsoAlmacenamiento, Organizacion 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. 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 Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document model = Document
my_tags = ['Cards'] my_tags = ['Cards']
@@ -100,7 +100,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
View para obtener información de uso de servicios relacionados con pedimentos. 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. Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document model = Document
my_tags = ['Cards'] my_tags = ['Cards']
@@ -137,32 +137,28 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
) )
def get_queryset(self): def get_queryset(self):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): user = self.request.user
if not user.is_authenticated or not hasattr(user, 'organizacion'):
return None return None
# Si es super usuario, devuelve todos los procesos org = get_org_context(user)
if self.request.user.is_superuser: if not org:
return ProcesamientoPedimento.objects.all() return ProcesamientoPedimento.objects.none()
# Si es Administrador de la organizacion devuelve todos los servicios de la organizacion qs = ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
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(): # Misma precedencia que los mixins de filtrado: superuser y roles
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion) # operativos ven todo lo de su org; is_importador no los degrada.
if (
# Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion user.is_superuser or
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(): user_has_role(user, 'admin') or
return self.request.user.organizacion.procesamiento_pedimentos.all() user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
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(): user_has_role(user, 'user')
return self.request.user.organizacion.procesamiento_pedimentos.all() ):
return qs
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos if user.is_importador:
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 qs.filter(pedimento__contribuyente__in=user.rfc.all())
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente__in=self.request.user.rfc.all()) return ProcesamientoPedimento.objects.none()
# 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): def get(self, request):
queryset = self.get_queryset() queryset = self.get_queryset()
@@ -193,12 +189,21 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
Endpoint para análisis de actividades de usuario. Endpoint para análisis de actividades de usuario.
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos. Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = UserActivity model = UserActivity
campo_organizacion = 'user__organizacion'
my_tags = ['Cards'] 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( @swagger_auto_schema(
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.", operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
manual_parameters=[ manual_parameters=[
@@ -253,6 +258,8 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
} }
) )
def get_queryset(self): 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): def get(self, request):
@@ -289,11 +296,20 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
Endpoint para análisis de logs de peticiones. 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. Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = RequestLog model = RequestLog
campo_organizacion = 'user__organizacion'
my_tags = ['Cards'] 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( @swagger_auto_schema(
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.", operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
manual_parameters=[ manual_parameters=[
@@ -345,6 +361,8 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
} }
) )
def get_queryset(self): 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): def get(self, request):
@@ -376,7 +394,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin):
View que obtiene los ultimos 10 documentos agregados. 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 Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document model = Document
my_tags = ['Cards'] my_tags = ['Cards']

View File

@@ -30,7 +30,7 @@ class CustomUserAdmin(UserAdmin):
# Fieldsets para editar un usuario # Fieldsets para editar un usuario
fieldsets = ( fieldsets = (
(None, {'fields': ('username', 'password')}), (None, {'fields': ('username', 'password')}),
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'profile_picture', 'is_importador', 'rfc')}), ('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'active_organization', 'profile_picture', 'is_importador', 'rfc')}),
('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), ('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Fechas importantes', {'fields': ('last_login', 'date_joined')}), ('Fechas importantes', {'fields': ('last_login', 'date_joined')}),
) )

239
api/cuser/hub_auth.py Normal file
View File

@@ -0,0 +1,239 @@
"""
Autenticación vía Hub de Aduanasoft (Keycloak).
Tokens locales HS256 (~700 bytes) se emiten tras el exchange con el Hub
para no exceder el límite de 4096 bytes de cookies del browser.
ORDEN CRÍTICO en verify_hub_token:
cache → local HS256 → Hub /auth/me
Si el token local se manda al Hub primero, Hub responde 401 y rompe la
sesión SSO silenciosamente.
"""
import logging
import time
from typing import Optional
import jwt
import requests
from django.conf import settings
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
logger = logging.getLogger(__name__)
# Cache en memoria: {token: (payload, expires_at)}
_token_cache: dict = {}
_CACHE_TTL = 60 # segundos
def _cache_get(token: str) -> Optional[dict]:
entry = _token_cache.get(token)
if entry and entry[1] > time.time():
return entry[0]
_token_cache.pop(token, None)
return None
def _cache_set(token: str, payload: dict):
_token_cache[token] = (payload, time.time() + _CACHE_TTL)
# ---------------------------------------------------------------------------
# Tokens locales
# ---------------------------------------------------------------------------
def create_local_tokens(user_data: dict) -> dict:
"""Emite tokens locales compactos HS256. Caben en cookies del browser."""
import uuid
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
base = {
"sub": str(user_data.get("id") or user_data.get("username", "")),
"preferred_username": user_data.get("username", ""),
"email": user_data.get("email", ""),
"name": user_data.get("name", ""),
"given_name": user_data.get("first_name", ""),
"family_name": user_data.get("last_name", ""),
"is_hub_admin": user_data.get("is_hub_admin", False),
"tenant_id": user_data.get("tenant_id"),
"tenant_slug": user_data.get("tenant_slug") or getattr(settings, "HUB_TENANT_SLUG", ""),
"source": "local",
"iat": int(now.timestamp()),
}
access_payload = {**base, "exp": int((now + timedelta(hours=8)).timestamp())}
refresh_payload = {**base, "exp": int((now + timedelta(days=30)).timestamp())}
secret = settings.SECRET_KEY
return {
"access_token": jwt.encode(access_payload, secret, algorithm="HS256"),
"refresh_token": jwt.encode(refresh_payload, secret, algorithm="HS256"),
"expires_in": 1800,
"source": "local",
}
def _verify_local_token(token: str) -> Optional[dict]:
"""Decodifica token local HS256. Retorna payload o None si no es local."""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
if payload.get("source") == "local":
return payload
return None
except jwt.ExpiredSignatureError:
raise AuthenticationFailed("Token expirado — inicia sesión de nuevo")
except jwt.InvalidTokenError:
return None
# ---------------------------------------------------------------------------
# Verificación contra Hub
# ---------------------------------------------------------------------------
def verify_hub_token(token: str) -> dict:
"""ORDEN: cache → local HS256 → Hub /auth/me."""
cached = _cache_get(token)
if cached:
return cached
# 1. Token local primero (evita 401 del Hub para tokens locales)
local = _verify_local_token(token)
if local:
_cache_set(token, local)
return local
# 2. Validar contra Hub
hub_url = getattr(settings, "HUB_URL", "https://workspace.aduanasoft.com")
me_url = f"{hub_url.rstrip('/')}/api/v1/auth/me"
try:
r = requests.get(me_url, headers={"Authorization": f"Bearer {token}"}, timeout=5)
except requests.exceptions.RequestException as exc:
# Fallback: si hay token local válido lo usamos
local = _verify_local_token(token)
if local:
_cache_set(token, local)
return local
logger.error("Hub no disponible: %s", exc)
raise AuthenticationFailed("Servicio de autenticación no disponible")
if r.status_code == 200:
info = r.json()
_cache_set(token, info)
return info
if r.status_code in (401, 403):
raise AuthenticationFailed("Token inválido o sesión expirada")
logger.error("Hub respondió %s al verificar token", r.status_code)
raise AuthenticationFailed("No se pudo verificar el token")
def _get_django_user(hub_data: dict):
"""Resuelve el CustomUser de Django a partir de los datos del Hub."""
from api.cuser.models import CustomUser
# Token local: sub puede ser Django UUID (login directo) o KC UUID (SSO exchange)
if hub_data.get("source") == "local":
from django.db.models import Q
sub = hub_data.get("sub", "")
if not sub:
return None
# Una sola query: busca por Django UUID o KC UUID simultáneamente
try:
return CustomUser.objects.filter(
Q(id=sub) | Q(keycloak_user_id=sub)
).first()
except Exception:
# sub malformado (no es UUID válido)
return CustomUser.objects.filter(keycloak_user_id=sub).first()
# Token Hub: buscar por keycloak_user_id → email
kc_id = hub_data.get("keycloak_user_id") or hub_data.get("sub")
email = hub_data.get("email")
if kc_id:
user = CustomUser.objects.filter(keycloak_user_id=kc_id).first()
if user:
return user
if email:
return CustomUser.objects.filter(email=email).first()
return None
# ---------------------------------------------------------------------------
# DRF Authentication Backend
# ---------------------------------------------------------------------------
class HubAuthBackend(BaseAuthentication):
"""
Drop-in para reemplazar JWTAuthentication.
Acepta tokens locales (HS256) y tokens del Hub indistintamente.
Se añade JUNTO a JWTAuthentication para compatibilidad durante la migración.
"""
def authenticate(self, request):
token = self._extract_token(request)
if not token:
return None
# Detectar tokens SimpleJWT sin llamar al Hub.
# Decodificamos sin verificar firma solo para leer claims.
# Si el token no tiene source="local" ni claims de KC (realm_access, azp)
# es un token SimpleJWT legacy → dejar que JWTAuthentication lo maneje.
try:
unverified = jwt.decode(
token,
options={"verify_signature": False},
algorithms=["HS256", "RS256"],
)
is_hub_token = (
unverified.get("source") == "local" # token local HS256
or "realm_access" in unverified # token KC directo
or "azp" in unverified # token KC (authorized party)
)
if not is_hub_token:
return None # SimpleJWT — pasar al siguiente backend sin tocar el Hub
except Exception:
return None # JWT malformado — no es nuestro
try:
hub_data = verify_hub_token(token)
except AuthenticationFailed:
return None
except Exception as exc:
logger.error("Error inesperado en HubAuthBackend: %s", exc)
return None
user = _get_django_user(hub_data)
if not user:
# Retornar None permite que endpoints AllowAny pasen sin bloquear.
# Los endpoints IsAuthenticated quedarán como "no autenticado" (sin 401 engañoso).
return None
return (user, token)
@staticmethod
def _extract_token(request) -> Optional[str]:
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
if auth_header.lower().startswith("bearer "):
return auth_header[7:].strip() or None
# Fallback: cookie (flujo SSO con cookies)
return request.COOKIES.get("access_token")
def authenticate_header(self, request):
return "Bearer"
# ---------------------------------------------------------------------------
# Helper cookies
# ---------------------------------------------------------------------------
def set_session_cookies(response, tokens: dict):
"""Escribe las cookies de sesión HTTP-only."""
secure = getattr(settings, "COOKIE_SECURE", not settings.DEBUG)
kw = dict(httponly=True, secure=secure, samesite="Lax")
response.set_cookie("access_token", tokens["access_token"], max_age=1800, **kw)
response.set_cookie("refresh_token", tokens["refresh_token"], max_age=60*60*24*7, **kw)
response.set_cookie("token_type", "bearer", max_age=60*60*24*7,
httponly=False, secure=secure, samesite="Lax")

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.3 on 2026-05-28 18:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cuser', '0006_customuser_active_organization'),
]
operations = [
migrations.AddField(
model_name='customuser',
name='keycloak_user_id',
field=models.CharField(blank=True, help_text='UUID del usuario en Keycloak/Hub', max_length=36, null=True, unique=True),
),
]

View File

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

View File

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

11
api/cuser/sso_urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from .sso_views import login_view, sso_exchange_view, me_view, logout_view, refresh_view, session_refresh_view
urlpatterns = [
path("login/", login_view, name="hub-login"),
path("sso/exchange/", sso_exchange_view, name="hub-sso-exchange"),
path("me/", me_view, name="hub-me"),
path("logout/", logout_view, name="hub-logout"),
path("login/refresh/", refresh_view, name="hub-refresh"), # legacy
path("session/refresh/", session_refresh_view, name="hub-session-refresh"), # cookie-based
]

564
api/cuser/sso_views.py Normal file
View File

@@ -0,0 +1,564 @@
"""
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

View File

@@ -20,7 +20,11 @@ from core.permissions import (
IsSameOrganization, IsSameOrganization,
IsSameOrganizationDeveloper, IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin, IsSameOrganizationAndAdmin,
IsSuperUser IsSuperUser,
get_org_context,
is_internal_service_request,
user_has_permission,
require_permission,
) )
from .serializers import CustomUserSerializer from .serializers import CustomUserSerializer
@@ -74,78 +78,62 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
""" """
ViewSet for CustomUser model. ViewSet for CustomUser model.
""" """
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )]
pagination_class = CustomPagination pagination_class = CustomPagination
model = CustomUser model = CustomUser
serializer_class = CustomUserSerializer serializer_class = CustomUserSerializer
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador'] filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
my_tags = ['User Profile'] my_tags = ['User Profile']
def get_permissions(self): def get_permissions(self):
# Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización if self.action in ('me', 'change_password'):
if self.action == 'destroy': return [IsAuthenticated()]
user = self.request.user perms = {
if not ( 'list': 'usuarios.view',
user.is_superuser or 'retrieve': 'usuarios.view',
user.groups.filter(name='admin').exists() or 'create': 'usuarios.create',
user.groups.filter(name='Agente Aduanal').exists() or 'update': 'usuarios.edit',
user.groups.filter(name='user').exists() 'partial_update': 'usuarios.edit',
): 'destroy': 'usuarios.delete',
from rest_framework.exceptions import PermissionDenied }
raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.") codename = perms.get(self.action, 'usuarios.view')
elif self.action in ['create', 'update', 'partial_update']: return [IsAuthenticated(), require_permission(codename)()]
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): def perform_destroy(self, instance):
# Solo permitir eliminar usuarios de la misma organización user = self.request.user
if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion: org = get_org_context(user)
if user.is_superuser or instance.organizacion == org:
instance.delete() instance.delete()
else: else:
from rest_framework.exceptions import PermissionDenied
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.") raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
def get_queryset(self): def get_queryset(self):
# Si es importador, solo puede ver su propio usuario user = self.request.user
if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists(): if is_internal_service_request(self.request):
return CustomUser.objects.filter(pk=self.request.user.pk) return CustomUser.objects.all()
if not user_has_permission(user, 'usuarios.view'):
# Otros roles: filtrar por organización return CustomUser.objects.none()
return self.get_queryset_filtrado_por_organizacion() org = get_org_context(user)
if not org:
return CustomUser.objects.none()
return CustomUser.objects.filter(organizacion=org)
def perform_create(self, serializer): def perform_create(self, serializer):
# Always assign the creator's organization creator = self.request.user
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 self.request.user.is_superuser: if creator.is_superuser:
# If superuser, allow creating users without organization
user = serializer.save(is_active=False) user = serializer.save(is_active=False)
send_activation_email(user, self.request) # Usa template HTML send_activation_email(user, self.request)
return return
if self.request.user.groups.filter(name='developer').exists(): if creator.is_importador:
# 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.") raise PermissionDenied("Los importadores no pueden crear usuarios.")
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) org = get_org_context(creator)
send_activation_email(user, self.request) # Usa template HTML if not org:
return 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)
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
def me(self, request): def me(self, request):
@@ -167,8 +155,11 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
""" """
user = self.get_object() user = self.get_object()
current_user = request.user current_user = request.user
# Solo el propio usuario, admin o superuser pueden cambiar la contraseña puede_cambiar_ajena = (
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user): current_user.is_superuser or
user_has_permission(current_user, 'usuarios.change_password')
)
if not (puede_cambiar_ajena or user == current_user):
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.") raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
old_password = request.data.get('old_password') old_password = request.data.get('old_password')
@@ -176,8 +167,7 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
if not new_password: if not new_password:
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
# Si no es admin/superuser, debe validar old_password if not puede_cambiar_ajena:
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): if not old_password or not user.check_password(old_password):
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400) return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
@@ -226,11 +216,11 @@ class ProfilePictureView(LoggingMixin, APIView):
my_tags = ['User Profile'] my_tags = ['User Profile']
def get(self, request, user_id): def get(self, request, user_id):
# Obtiene el usuario (automáticamente 404 si no existe)
user = get_object_or_404(CustomUser, pk=user_id) user = get_object_or_404(CustomUser, pk=user_id)
# El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin org = get_org_context(request.user)
# Así que no necesitas validar manualmente los permisos aquí. if not request.user.is_superuser and user.organizacion != org:
raise Http404("No autorizado")
if not user.profile_picture: if not user.profile_picture:
raise Http404("El usuario no tiene imagen de perfil") raise Http404("El usuario no tiene imagen de perfil")
@@ -267,6 +257,8 @@ class PasswordResetConfirmView(APIView):
return Response({'detail': 'Enlace inválido.'}, status=400) return Response({'detail': 'Enlace inválido.'}, status=400)
if not default_token_generator.check_token(user, token): if not default_token_generator.check_token(user, token):
return Response({'detail': 'Token inválido o expirado.'}, status=400) 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') password = request.data.get('password')
if not password: if not password:
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)

View File

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

View File

@@ -0,0 +1,541 @@
"""
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."))

View File

@@ -0,0 +1,110 @@
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"))

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
# 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)'),
),
]

View File

@@ -66,6 +66,13 @@ class Pedimento(models.Model):
['organizacion', 'pedimento_app'] ['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): class Partida(models.Model):
pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida") 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") organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida")
@@ -94,8 +101,28 @@ class EDocument(models.Model):
descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento") 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") 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") 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") 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") 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)
def __str__(self): def __str__(self):
return f"{self.descripcion} - {self.pedimento.pedimento}" return f"{self.descripcion} - {self.pedimento.pedimento}"
@@ -112,8 +139,28 @@ class Cove(models.Model):
numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove") 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") 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") 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") 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") 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)
def __str__(self): def __str__(self):
return f"{self.numero_cove} - {self.pedimento.pedimento}" return f"{self.numero_cove} - {self.pedimento.pedimento}"

View File

@@ -6,7 +6,8 @@ from api.customs.models import (
EDocument, EDocument,
Cove, Cove,
Importador, Importador,
Partida Partida,
EstadoDescarga
) )
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@@ -184,7 +185,7 @@ class EDocumentSerializer(serializers.ModelSerializer):
numero = str(obj.numero_edocument).strip() numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip() # id_pedimento = str(obj.pedimento_id).strip()
# excluir e documents de tipo request y de tipo error # excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
@@ -206,6 +207,22 @@ class EDocumentSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ('created_at', 'updated_at') 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Si no es superusuario, hacer organizacion read_only # Si no es superusuario, hacer organizacion read_only
@@ -221,6 +238,22 @@ class CoveSerializer(serializers.ModelSerializer):
fields = '__all__' fields = '__all__'
read_only_fields = ('created_at', 'updated_at') 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): def get_documentos(self, obj):
""" """
Busca documentos en la tabla `document` que coincidan con el Busca documentos en la tabla `document` que coincidan con el
@@ -240,15 +273,11 @@ class CoveSerializer(serializers.ModelSerializer):
try: try:
numero = str(obj.numero_cove).strip() numero = str(obj.numero_cove).strip()
# Excluir los tipo de documento 20, 24, 23 y 19 # Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
# 20 = error solicitud cove
# 24 = error solicitud acuse cove
# 23 = request acuse cove
# 19 = request cove
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
).exclude(document_type_id__in=[20, 24, 23, 19]) ).exclude(document_type_id__in=[19, 23])
# Filtro por organización si aplica # Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion: if hasattr(obj, 'organizacion') and obj.organizacion:

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -5,8 +5,10 @@ from api.customs.models import *
from api.record.models import * from api.record.models import *
from api.customs.serializers import PedimentoSerializer from api.customs.serializers import PedimentoSerializer
from api.vucem.models import * from api.vucem.models import *
from django.db.models import F
from django.utils import timezone
import requests import requests
from config.settings import SERVICE_API_URL_V2 from config.settings import SERVICE_API_URL_V2, MAX_INTENTOS_AUTO
from datetime import datetime from datetime import datetime
import json import json
import logging import logging
@@ -77,8 +79,10 @@ def partida_to_dict(partida):
@shared_task @shared_task
def procesar_coves_pedimento(pedimento_id): 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) pedimento = Pedimento.objects.get(id=pedimento_id)
if pedimento.coves.filter(cove_descargado=False).exists(): estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
if pedimento.coves.filter(cove_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -86,22 +90,30 @@ def procesar_coves_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)], "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/coves", f"{SERVICE_API_URL_V2}/services/all/coves",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuse_coves_pedimento(pedimento_id): 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) pedimento = Pedimento.objects.get(id=pedimento_id)
if pedimento.coves.filter(acuse_cove_descargado=False).exists(): estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
if pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -109,22 +121,30 @@ def procesar_acuse_coves_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)], "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_edocs_pedimento(pedimento_id): 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) pedimento = Pedimento.objects.get(id=pedimento_id)
if pedimento.documentos.filter(edocument_descargado=False).exists(): estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
if pedimento.documentos.filter(edocument_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -132,22 +152,30 @@ def procesar_edocs_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/all/edocs/", f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuses_pedimento(pedimento_id): 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) pedimento = Pedimento.objects.get(id=pedimento_id)
if pedimento.documentos.filter(acuse_descargado=False).exists(): estados_reprocesables = [EstadoDescarga.PENDIENTE, EstadoDescarga.ERROR]
if pedimento.documentos.filter(acuse_estado__in=estados_reprocesables).exists():
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -155,17 +183,23 @@ def procesar_acuses_pedimento(pedimento_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_estado__in=estados_reprocesables)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_partidas_pedimento(pedimento_id): def procesar_partidas_pedimento(pedimento_id):
@@ -177,18 +211,31 @@ def procesar_partidas_pedimento(pedimento_id):
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
payload = { payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], "partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/", f"{SERVICE_API_URL_V2}/services/all/partidas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
result = response.json()
logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
raise
@shared_task @shared_task
def procesar_remesas_pedimento(pedimento_id): def procesar_remesas_pedimento(pedimento_id):
@@ -205,17 +252,23 @@ def procesar_remesas_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas", f"{SERVICE_API_URL_V2}/services/remesas",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_pedimento_completo_individual(pedimento_id): def procesar_pedimento_completo_individual(pedimento_id, force=False):
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -225,13 +278,19 @@ def procesar_pedimento_completo_individual(pedimento_id):
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/pedimento_completo", f"{SERVICE_API_URL_V2}/services/pedimento_completo",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
return response return response
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_pedimentos_completos(organizacion_id): def procesar_pedimentos_completos(organizacion_id):
@@ -270,13 +329,18 @@ def procesar_pedimentos_completos(organizacion_id):
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo" url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
dataJson = json.dumps(payload) dataJson = json.dumps(payload)
try:
response = requests.post( response = requests.post(
url, url,
data=dataJson, data=dataJson,
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
print(f"Servicio enviado para pedimento {pedimento.pedimento}") logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_remesas(organizacion_id): def procesar_remesas(organizacion_id):
@@ -311,9 +375,11 @@ def procesar_remesas(organizacion_id):
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas/", f"{SERVICE_API_URL_V2}/services/remesas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
logger.info(f"Servicio enviado para pedimento {pedimento.pedimento} — status {response.status_code}") response.raise_for_status()
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
except Exception as e: except Exception as e:
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True) logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
@@ -325,7 +391,14 @@ def procesar_coves(organizacion_id):
coves__isnull=False coves__isnull=False
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.coves.filter(cove_descargado=False).exists(): # Tipo 3: Remesa # 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:
# Convertir el pedimento a JSON usando el serializer # Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
@@ -334,19 +407,27 @@ def procesar_coves(organizacion_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(cove_descargado=False)], "coves": [cove_to_dict(cove) for cove in coves_batch],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_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())
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/coves", f"{SERVICE_API_URL_V2}/services/all/coves",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}") except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuse_coves(organizacion_id): def procesar_acuse_coves(organizacion_id):
@@ -356,7 +437,13 @@ def procesar_acuse_coves(organizacion_id):
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.coves.filter(acuse_cove_descargado=False).exists(): # Tipo 3: Remesa # 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:
# Convertir el pedimento a JSON usando el serializer # Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
@@ -365,19 +452,26 @@ def procesar_acuse_coves(organizacion_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)], "coves": [cove_to_dict(cove) for cove in coves_batch],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_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())
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}") except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuses(organizacion_id): def procesar_acuses(organizacion_id):
@@ -387,7 +481,13 @@ def procesar_acuses(organizacion_id):
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.documentos.filter(acuse_descargado=False).exists(): # Tipo 3: Remesa # 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:
# Convertir el pedimento a JSON usando el serializer # Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
@@ -396,19 +496,26 @@ def procesar_acuses(organizacion_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
# Un ciclo de orquestación = un intento
pendientes.update(acuse_intentos=F('acuse_intentos') + 1, ultimo_intento_at=timezone.now())
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}") except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_edocs(organizacion_id): def procesar_edocs(organizacion_id):
@@ -418,7 +525,13 @@ def procesar_edocs(organizacion_id):
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.documentos.filter(edocument_descargado=False).exists(): # Tipo 3: Remesa # 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:
# Convertir el pedimento a JSON usando el serializer # Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
@@ -427,19 +540,26 @@ def procesar_edocs(organizacion_id):
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in edocs_batch],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
# Un ciclo de orquestación = un intento
pendientes.update(edocument_intentos=F('edocument_intentos') + 1, ultimo_intento_at=timezone.now())
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/all/edocs/", f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}") except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_partidas(organizacion_id): def procesar_partidas(organizacion_id):
@@ -449,27 +569,40 @@ def procesar_partidas(organizacion_id):
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
# Convertir el pedimento a JSON usando el serializer if not partidas_pendientes:
pedimento_dict = pedimento_to_dict(pedimento) continue
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
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 = { payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], "partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/", f"{SERVICE_API_URL_V2}/services/all/partidas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
result = response.json()
print(f"Servicio enviado para pedimento {pedimento.pedimento}") logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
continue
@shared_task @shared_task
def documentos_con_errores(organizacion_id): def documentos_con_errores(organizacion_id):
@@ -575,3 +708,59 @@ def process_all_organizations():
) )
return f"Dispatched {active_orgs.count()} organizations" 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"

View File

@@ -224,3 +224,275 @@ class BulkCreateDocumentReplaceTests(APITestCase):
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED]) self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
data = response.json() data = response.json()
self.assertGreaterEqual(data.get("already_existing_count", 0), 1) 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})

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

View File

@@ -0,0 +1,195 @@
"""
Reprocesa datastages ya cargados: elimina los Registro* existentes del datastage
y reprocesa los archivos .asc de forma SINCRÓNICA (sin Celery).
Casos de uso:
- Los registros quedaron vacíos por un bug y ya fue corregido.
- Se quiere refrescar los datos sin que el usuario vuelva a subir el archivo.
Los Pedimentos existentes NO se tocan (el create en la task falla silenciosamente
por unique_together si ya existen).
Uso:
python manage.py reprocesar_datastages # todos los datastages
python manage.py reprocesar_datastages --organizacion <UUID> # solo una org
python manage.py reprocesar_datastages --datastage 4 7 12 # IDs específicos
python manage.py reprocesar_datastages --organizacion <UUID> --datastage 4
python manage.py reprocesar_datastages --dry-run # sin cambios
"""
import os
import tempfile
import zipfile
from django.core.management.base import BaseCommand, CommandError
from api.datastage.models import (
DataStage,
Registro500, Registro501, Registro502, Registro503, Registro504,
Registro505, Registro506, Registro507, Registro508, Registro509,
Registro510, Registro511, Registro512, Registro520,
Registro551, Registro552, Registro553, Registro554, Registro555,
Registro556, Registro557, Registro558,
RegistroSel,
Registro701, Registro702,
)
REGISTRO_MODELS = [
Registro500, Registro501, Registro502, Registro503, Registro504,
Registro505, Registro506, Registro507, Registro508, Registro509,
Registro510, Registro511, Registro512, Registro520,
Registro551, Registro552, Registro553, Registro554, Registro555,
Registro556, Registro557, Registro558,
RegistroSel,
Registro701, Registro702,
]
class Command(BaseCommand):
help = "Elimina los Registro* de datastages procesados y vuelve a procesarlos de forma sincrónica."
def add_arguments(self, parser):
parser.add_argument(
"--organizacion", metavar="UUID",
help="UUID de la organización. Sin este arg: todas las orgs.",
)
parser.add_argument(
"--datastage", metavar="ID", nargs="+", type=int,
help="Uno o más IDs de DataStage a reprocesar.",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Solo muestra lo que haría, sin borrar ni insertar.",
)
def handle(self, *args, **options):
org_id = options.get("organizacion")
ds_ids = options.get("datastage")
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING(
"=== MODO PRUEBA (--dry-run): sin cambios en BD ===\n"
))
qs = DataStage.objects.select_related("organizacion").order_by("id")
if org_id:
qs = qs.filter(organizacion_id=org_id)
if ds_ids:
qs = qs.filter(id__in=ds_ids)
total = qs.count()
if total == 0:
self.stdout.write(self.style.WARNING("No se encontraron datastages con los filtros indicados."))
return
self.stdout.write(f"Datastages a reprocesar: {total}\n")
ok = err = 0
for ds in qs:
exito = self._reprocesar(ds, dry_run)
if exito:
ok += 1
else:
err += 1
self._print_summary(ok, err, dry_run)
# ------------------------------------------------------------------ #
def _reprocesar(self, ds, dry_run):
org_nombre = ds.organizacion.nombre if ds.organizacion else "sin organización"
self.stdout.write(
f"\nDataStage ID={ds.id} | org={org_nombre} | archivo={ds.archivo or ''}"
)
if not ds.archivo:
self.stdout.write(self.style.ERROR(" → Sin archivo asociado, se omite."))
return False
# 1. Eliminar Registro* existentes
total_borrados = 0
for Model in REGISTRO_MODELS:
qs_modelo = Model.objects.filter(datastage=ds)
count = qs_modelo.count()
if count == 0:
continue
if not dry_run:
qs_modelo.delete()
estado = "[dry-run]" if dry_run else "borrados"
self.stdout.write(f" {Model.__name__}: {count} {estado}")
total_borrados += count
if total_borrados == 0:
self.stdout.write(" → Sin registros existentes en ninguna tabla.")
else:
self.stdout.write(f" Total eliminados: {total_borrados}")
if dry_run:
self.stdout.write(self.style.WARNING(
" → [dry-run] Se procesarían los archivos .asc del datastage."
))
return True
# 2. Descargar ZIP una vez para obtener la lista de .asc
from api.utils.storage_service import storage_service
ruta = str(ds.archivo)
if not storage_service.file_exists(ruta):
self.stdout.write(self.style.ERROR(
f" El archivo no existe en storage: {ruta}"
))
return False
tmp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp:
tmp_path = tmp.name
if not storage_service.download_file(ruta, tmp_path):
self.stdout.write(self.style.ERROR(
f" No se pudo descargar '{ruta}' — verifica conectividad con MinIO."
))
return False
with zipfile.ZipFile(tmp_path, "r") as zf:
asc_files = [n for n in zf.namelist() if n.endswith(".asc")]
finally:
if tmp_path and os.path.exists(tmp_path):
os.unlink(tmp_path)
if not asc_files:
self.stdout.write(self.style.WARNING(" → No se encontraron archivos .asc en el ZIP."))
return True
self.stdout.write(f" Archivos .asc encontrados: {len(asc_files)}")
# 3. Procesar cada .asc de forma sincrónica (sin Celery)
from api.datastage.tasks import procesar_archivo_asc_task
total_insertados = 0
for asc_name in asc_files:
self.stdout.write(f" {asc_name} ... ", ending="")
result = procesar_archivo_asc_task(ds.id, ds.organizacion_id, asc_name)
if "error" in result:
self.stdout.write(self.style.ERROR(f"ERROR: {result['error']}"))
else:
insertados = result.get("insertados", 0)
total_insertados += insertados
self.stdout.write(self.style.SUCCESS(f"{insertados} registros"))
self.stdout.write(f" Total insertados: {total_insertados}")
return True
# ------------------------------------------------------------------ #
def _print_summary(self, ok, err, dry_run):
self.stdout.write(f"\n{'' * 60}")
self.stdout.write(f"RESUMEN: {ok} exitosos, {err} con error.")
if dry_run:
self.stdout.write(self.style.WARNING(
"MODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
))
else:
self.stdout.write(self.style.SUCCESS("Reprocesado completado."))

View File

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

View File

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

View File

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

View File

@@ -12,106 +12,73 @@ from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django.http import FileResponse, Http404 from django.http import FileResponse, Http404
import os import os
from .models import DataStage from .models import DataStage
from .serializer import DataStageSerializer from .serializer import DataStageSerializer
from api.logger.mixins import LoggingMixin from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin from core.permissions import get_org_context, is_internal_service_request, require_permission
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
# Create your views here. # Create your views here.
class DataStagePagination(PageNumberPagination): class DataStagePagination(PageNumberPagination):
page_size = 20 # Valor por defecto page_size = 20 # Valor por defecto
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 1000 max_page_size = 1000
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
""" """
ViewSet for managing DataStage instances. ViewSet for managing DataStage instances.
Provides CRUD operations for DataStage. Provides CRUD operations for DataStage.
""" """
serializer_class = DataStageSerializer serializer_class = DataStageSerializer
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
model = DataStage model = DataStage
my_tags = ['DataStage'] my_tags = ['DataStage']
pagination_class = DataStagePagination 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): def get_queryset(self):
if self.request.user.is_superuser: if is_internal_service_request(self.request):
return DataStage.objects.all().order_by('-created_at') return DataStage.objects.all().order_by('-created_at')
org = get_org_context(self.request.user)
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(): if not org:
return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at') return DataStage.objects.none()
return DataStage.objects.filter(organizacion=org).order_by('-created_at')
return self.get_queryset_filtrado_por_organizacion().order_by('-created_at')
def perform_create(self, serializer): def perform_create(self, serializer):
""" org = get_org_context(self.request.user)
Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado. datastage = serializer.save(organizacion=org)
"""
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("Usuario no autenticado o sin organización")
data = serializer.validated_data
organizacion = data.get('organizacion')
if self.request.user.is_superuser:
# Permitir que el superusuario cree sin organización o la especifique
datastage = serializer.save()
self._trigger_processing(datastage) self._trigger_processing(datastage)
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():
if not organizacion:
datastage = serializer.save(organizacion=self.request.user.organizacion)
else:
datastage = serializer.save()
self._trigger_processing(datastage)
return
raise ValueError("No cuentas con los permisos necesarios para crear un DataStage")
def _trigger_processing(self, datastage): def _trigger_processing(self, datastage):
"""
Método helper para disparar el procesamiento.
"""
from api.datastage.tasks import procesar_datastage_task from api.datastage.tasks import procesar_datastage_task
user_organizacion = getattr(self.request.user, 'organizacion', None) org = get_org_context(self.request.user)
user_organizacion_id = user_organizacion.id if user_organizacion else None
datastage.procesado = True datastage.procesado = True
datastage.save() datastage.save()
procesar_datastage_task.delay(datastage.id, org.id if org else None)
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
def perform_update(self, serializer): def perform_update(self, serializer):
""" if is_internal_service_request(self.request):
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() serializer.save()
return return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
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(): def perform_destroy(self, instance):
serializer.save(organizacion=self.request.user.organizacion) if instance.archivo:
return storage_service.delete_file(instance.archivo)
instance.delete()
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') @action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
def download_datastage(self, request, pk=None): def download_datastage(self, request, pk=None):
@@ -182,12 +149,10 @@ class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
""" """
Endpoint para procesar el DataStage de forma asíncrona usando Celery. Endpoint para procesar el DataStage de forma asíncrona usando Celery.
""" """
# ojo aqui
from api.datastage.tasks import procesar_datastage_task from api.datastage.tasks import procesar_datastage_task
datastage = self.get_object() datastage = self.get_object()
user_organizacion = getattr(self.request.user, 'organizacion', None) org = get_org_context(self.request.user)
user_organizacion_id = user_organizacion.id if user_organizacion else None task = procesar_datastage_task.delay(datastage.id, org.id if org else None)
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
return Response({ return Response({
'task_id': task.id, 'task_id': task.id,
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.' 'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'

View File

@@ -58,8 +58,7 @@ class UserActivityViewSet(viewsets.ReadOnlyModelViewSet):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return UserActivity.objects.none() return UserActivity.objects.none()
# Los usuarios normales solo ven su propia actividad if self.request.user.is_superuser:
if self.request.user.is_staff:
return UserActivity.objects.all() return UserActivity.objects.all()
return UserActivity.objects.filter(user=self.request.user) return UserActivity.objects.filter(user=self.request.user)

View File

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

View File

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

View File

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

View File

@@ -4,31 +4,43 @@ from django.dispatch import receiver
from api.notificaciones.models import Notificacion from api.notificaciones.models import Notificacion
from api.record.models import Document from api.record.models import Document
@receiver(post_save, sender=Document) @receiver(post_save, sender=Document)
def trigger_notificacion(sender, instance, created, **kwargs): def trigger_notificacion(sender, instance, created, **kwargs):
if created: if not created:
return
from api.cuser.models import CustomUser from api.cuser.models import CustomUser
from api.customs.models import Pedimento
from api.notificaciones.models import TipoNotificacion 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, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"}) 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')
# Notificar a todos los usuarios de la organización
usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion)
for usuario in usuarios_org: for usuario in usuarios_org:
# Notificar solo a importadores cuyo RFC coincide if not user_has_permission(usuario, 'notificaciones.receive'):
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()): continue
if instance.pedimento.contribuyente in usuario.rfc.all():
# 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( Notificacion.objects.create(
tipo=tipo_info, tipo=tipo_info,
dirigido=usuario, dirigido=usuario,
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}", mensaje=mensaje,
)
# 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}",
) )

View File

@@ -1,39 +1,38 @@
from django.shortcuts import render from rest_framework import viewsets, status
from rest_framework import viewsets from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from .models import Notificacion, TipoNotificacion from .models import Notificacion, TipoNotificacion
from .serializers import NotificacionSerializer, TipoNotificacionSerializer from .serializers import NotificacionSerializer, TipoNotificacionSerializer
from core.permissions import ( from core.permissions import require_permission
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
# Create your views here.
class TipoNotificacionViewSet(viewsets.ModelViewSet): class TipoNotificacionViewSet(viewsets.ModelViewSet):
queryset = TipoNotificacion.objects.all() queryset = TipoNotificacion.objects.all()
serializer_class = TipoNotificacionSerializer serializer_class = TipoNotificacionSerializer
http_method_names = ['get'] http_method_names = ['get']
permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Notificaciones'] my_tags = ['Notificaciones']
def get_queryset(self): def get_queryset(self):
return self.queryset.order_by('tipo') return self.queryset.order_by('tipo')
class NotificacionViewSet(viewsets.ModelViewSet): class NotificacionViewSet(viewsets.ModelViewSet):
queryset = Notificacion.objects.all() queryset = Notificacion.objects.all()
serializer_class = NotificacionSerializer serializer_class = NotificacionSerializer
http_method_names = ['get', 'post', 'put', 'patch', 'delete'] http_method_names = ['get', 'post', 'put', 'patch', 'delete']
filterset_fields = ['visto'] filterset_fields = ['visto']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Notificaciones'] 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): def get_queryset(self):
# Evita error en generación de esquema Swagger
if getattr(self, 'swagger_fake_view', False): if getattr(self, 'swagger_fake_view', False):
return Notificacion.objects.none() return Notificacion.objects.none()
user = self.request.user user = self.request.user
@@ -45,6 +44,14 @@ class NotificacionViewSet(viewsets.ModelViewSet):
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
raise PermissionDenied("Usuario no autenticado") raise PermissionDenied("Usuario no autenticado")
if self.request.user.is_superuser: if self.request.user.is_superuser:
# Allow superusers and admins to create notifications for any user
serializer.save() serializer.save()
return
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios") raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
@action(detail=False, methods=['get'], url_path=r'by-task/(?P<task_id>[^/.]+)')
def by_task(self, request, task_id=None):
"""Recupera la notificación de una tarea de auditoría por su task_id (Celery)."""
notif = self.get_queryset().filter(datos__task_id=task_id).first()
if not notif:
return Response({'detail': 'No encontrada.'}, status=status.HTTP_404_NOT_FOUND)
return Response(self.get_serializer(notif).data)

View File

@@ -1,18 +1,26 @@
from django.contrib import admin from django.contrib import admin
from .models import Organizacion from .models import Organizacion
# Register your models here.
@admin.register(Organizacion)
class OrganizacionAdmin(admin.ModelAdmin): class OrganizacionAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento') list_display = ('nombre', 'rfc', 'hub_tenant_slug', 'email', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
search_fields = ('nombre', 'rfc', 'email') search_fields = ('nombre', 'rfc', 'email', 'hub_tenant_slug')
list_filter = ('is_active', 'is_verified') list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
ordering = ('nombre',) ordering = ('nombre',)
autocomplete_fields = ('owner',)
# class UsuarioOrganizacionAdmin(admin.ModelAdmin): readonly_fields = ('created_at', 'updated_at')
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified') fieldsets = (
# search_fields = ('email', 'telefono', 'puesto') (None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
# list_filter = ('is_active', 'is_verified') ('Integración Hub', {
# ordering = ('email',) 'fields': ('hub_tenant_slug',),
'description': 'Slug único del tenant en Aduanasoft Hub. Debe coincidir exactamente con el slug creado en el panel del Hub.',
admin.site.register(Organizacion) }),
# admin.site.register(UsuarioOrganizacion) ('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')}),
)

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
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),
),
]

View File

@@ -40,6 +40,16 @@ class Organizacion(models.Model):
estado = models.CharField(max_length=50) estado = models.CharField(max_length=50)
ciudad = 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_active = models.BooleanField(default=True)
is_verified = models.BooleanField(default=False) is_verified = models.BooleanField(default=False)
apply_auto_download = models.BooleanField(default=False) apply_auto_download = models.BooleanField(default=False)
@@ -52,6 +62,9 @@ class Organizacion(models.Model):
observaciones = models.TextField(null=True, blank=True) 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 @property
def espacio_utilizado(self): def espacio_utilizado(self):

View File

@@ -1,8 +1,28 @@
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Organizacion, UsoAlmacenamiento from .models import Organizacion, UsoAlmacenamiento
@receiver(post_save, sender=Organizacion) @receiver(post_save, sender=Organizacion)
def crear_uso_almacenamiento(sender, instance, created, **kwargs): def crear_uso_almacenamiento(sender, instance, created, **kwargs):
if created: if created:
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0) 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,
)

View File

@@ -9,7 +9,10 @@ from core.permissions import (
IsSameOrganization, IsSameOrganization,
IsSameOrganizationDeveloper, IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin, IsSameOrganizationAndAdmin,
IsSuperUser IsSuperUser,
get_org_context,
is_internal_service_request,
user_has_permission,
) )
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
from .models import Organizacion, UsoAlmacenamiento from .models import Organizacion, UsoAlmacenamiento
@@ -32,22 +35,20 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr
my_tags = ['Organizaciones'] my_tags = ['Organizaciones']
def get_queryset(self): def get_queryset(self):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): user = self.request.user
if not user.is_authenticated:
return Organizacion.objects.none() return Organizacion.objects.none()
if self.request.user.is_superuser: if is_internal_service_request(self.request):
# Superuser can see all organizations
return Organizacion.objects.all() return Organizacion.objects.all()
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(): org = get_org_context(user)
# Importers can only see their own organization if not org:
return Organizacion.objects.filter(users=self.request.user)
if self.request.user.groups.filter(name='importador').exists():
return Organizacion.objects.filter(users=self.request.user)
return Organizacion.objects.none() return Organizacion.objects.none()
# Superuser ve solo su org activa, no todas
return Organizacion.objects.filter(id=org.id)
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet): class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
""" """
Vista para consultar el uso de almacenamiento Vista para consultar el uso de almacenamiento
@@ -60,31 +61,26 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
my_tags = ['Uso de Almacenamiento'] my_tags = ['Uso de Almacenamiento']
def get_queryset(self): def get_queryset(self):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if not self.request.user.is_authenticated:
return UsoAlmacenamiento.objects.none() 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() return UsoAlmacenamiento.objects.all()
if (self.request.user.groups.filter(name='developer').exists() or org = get_org_context(self.request.user)
self.request.user.groups.filter(name='admin').exists() or if not org:
self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): return UsoAlmacenamiento.objects.none()
# Developers, Admins, and Users can see their organization's storage usage
return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion)
if self.request.user.groups.filter(name='importador').exists(): if self.request.user.is_importador:
# Importers can only see their own organization's storage usage
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.") raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
return UsoAlmacenamiento.objects.none() return UsoAlmacenamiento.objects.filter(organizacion=org)
@action(detail=False, methods=['get']) @action(detail=False, methods=['get'])
def mi_organizacion(self, request): def mi_organizacion(self, request):
"""Obtiene el uso de almacenamiento de la organización del usuario actual""" """Obtiene el uso de almacenamiento de la organización del usuario actual"""
organizacion = request.user.organizacion organizacion = get_org_context(request.user)
# Obtener o crear el registro de uso # Obtener o crear el registro de uso
uso, created = UsoAlmacenamiento.objects.get_or_create( uso, created = UsoAlmacenamiento.objects.get_or_create(

0
api/rbac/__init__.py Normal file
View File

99
api/rbac/admin.py Normal file
View File

@@ -0,0 +1,99 @@
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'

8
api/rbac/apps.py Normal file
View File

@@ -0,0 +1,8 @@
from django.apps import AppConfig
class RbacConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api.rbac'
label = 'rbac'
verbose_name = 'RBAC'

View File

View File

View File

@@ -0,0 +1,101 @@
"""
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}')

View File

@@ -0,0 +1,116 @@
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'),
),
]

View File

@@ -0,0 +1,88 @@
"""
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),
]

View File

@@ -0,0 +1,56 @@
"""
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),
]

View File

@@ -0,0 +1,57 @@
"""
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),
]

View File

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

View File

109
api/rbac/models.py Normal file
View File

@@ -0,0 +1,109 @@
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'),
]

176
api/rbac/roles.py Normal file
View File

@@ -0,0 +1,176 @@
# 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)

105
api/rbac/serializers.py Normal file
View File

@@ -0,0 +1,105 @@
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())

23
api/rbac/urls.py Normal file
View File

@@ -0,0 +1,23 @@
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'),
]

412
api/rbac/views.py Normal file
View File

@@ -0,0 +1,412 @@
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.'})

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
# 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),
),
]

View File

@@ -12,6 +12,7 @@ class ReportDocument(models.Model):
TYPE_REPORT = [ TYPE_REPORT = [
('cumplimiento', 'cumplimiento'), ('cumplimiento', 'cumplimiento'),
('control_pedimento', 'control_pedimento'), ('control_pedimento', 'control_pedimento'),
('datastage', 'datastage'),
] ]
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents') user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
filters = models.JSONField(blank=True, null=True) filters = models.JSONField(blank=True, null=True)

View File

View File

@@ -0,0 +1,557 @@
"""
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)

View File

@@ -0,0 +1,3 @@
# 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

View File

@@ -0,0 +1,105 @@
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)

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
"""
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()

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,53 +1,54 @@
from django.shortcuts import render
from rest_framework import viewsets, filters from rest_framework import viewsets, filters
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from api.logger.mixins import LoggingMixin from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin from core.permissions import require_permission, user_has_permission, IsInternalService
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
from .models import Task from .models import Task
from .serializers import TaskSerializer from .serializers import TaskSerializer
from .filters import TaskFilter from .filters import TaskFilter
from rest_framework.permissions import IsAuthenticated
# Create your views here.
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
class TaskPagination(PageNumberPagination): class TaskPagination(PageNumberPagination):
page_size = 10 page_size = 10
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 100 max_page_size = 100
class TaskViewSet(LoggingMixin,viewsets.ModelViewSet,OrganizacionFiltradaMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
# Task se relaciona con pedimento, que tiene contribuyente
campo_contribuyente = 'pedimento__contribuyente'
queryset = Task.objects.select_related('pedimento', 'servicio').all() queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer serializer_class = TaskSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = TaskFilter filterset_class = TaskFilter
pagination_class = TaskPagination pagination_class = TaskPagination
ordering_fields = ['timestamp'] ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero ordering = ['-timestamp']
my_tags = ['tasks'] my_tags = ['tasks']
def get_queryset(self): def get_permissions(self):
# Escritura: exclusivo para microservicio interno (Token + superuser)
# Lectura: usuarios con pedimentos.view via JWT
if self.action in ('create', 'update', 'partial_update', 'destroy'):
return [IsAuthenticated(), IsInternalService()]
return [IsAuthenticated(), require_permission('pedimentos.view')()]
""" def get_queryset(self):
Filtra las tareas según la organización del usuario. user = self.request.user
Superusuarios pueden ver todas las tareas. # Service account (Token + superuser): sin filtro de org, accede a todas las tasks
""" if user.is_superuser and isinstance(
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador getattr(self.request, 'successful_authenticator', None), TokenAuthentication
# if user.is_superuser: ):
# return self.queryset return Task.objects.select_related('pedimento', 'servicio').all()
# # return self.queryset.filter(organizacion_id=user.organizacion.id) if not user_has_permission(user, 'pedimentos.view'):
# else: return Task.objects.none()
# return self.queryset.filter(organizacion_id=user.organizacion.id) return self.get_queryset_filtrado_por_organizacion()
return queryset
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -57,20 +58,82 @@ from celery.result import AsyncResult
class TaskStatusView(APIView): class TaskStatusView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated, require_permission('pedimentos.view')]
# Mapeo de status del microservicio → estados estándar
_STATUS_MAP = {
'failed': 'FAILURE',
'completed': 'SUCCESS',
'processing': 'STARTED',
'submitted': 'PENDING',
'pending': 'PENDING',
}
def get(self, request, task_id): def get(self, request, task_id):
""" """
Consulta el estado de una tarea Celery. Consulta el estado de una tarea.
Fuente de verdad: registro Django Task (actualizado por el microservicio vía PUT).
Celery AsyncResult se usa como complemento para tareas de auditoría masiva (SUCCESS)
y como fallback cuando la tarea no está en la BD todavía.
Estados posibles: Estados posibles:
PENDING — en cola, aún no inició PENDING — en cola o aún no registrada
STARTED — worker la tomó y está ejecutando STARTED — worker ejecutando
SUCCESS — terminó correctamente, `result` contiene el resumen SUCCESS — completada sin errores
FAILURE — lanzó una excepción no capturada, `error` describe el problema FAILURE — terminó con error
RETRY — el worker la está reintentando RETRY — el worker la está reintentando
""" """
try: try:
# Prioridad 1: Django Task record (fuente de verdad del microservicio)
try:
django_task = Task.objects.get(task_id=task_id)
effective_state = self._STATUS_MAP.get(
django_task.status.lower(), django_task.status.upper()
)
is_terminal = effective_state in ('SUCCESS', 'FAILURE')
response_data = {
'task_id': task_id,
'status': effective_state,
'ready': is_terminal,
'successful': (effective_state == 'SUCCESS') if is_terminal else None,
'message': django_task.message,
}
if effective_state == 'FAILURE':
response_data['error'] = django_task.message
elif effective_state == 'SUCCESS':
# Para auditoría masiva, intentar enriquecer con resultado de Celery
try:
celery_result = AsyncResult(task_id)
if celery_result.ready() and celery_result.successful():
result = celery_result.result
response_data['result'] = result
if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0:
response_data['mensaje'] = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else:
partes = []
if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores:
partes.append(f'{con_errores} con error')
response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
except Exception:
pass
return Response(response_data, status=status.HTTP_200_OK)
except Task.DoesNotExist:
pass
# Prioridad 2: Celery AsyncResult (tarea aún no registrada en BD)
task_result = AsyncResult(task_id) task_result = AsyncResult(task_id)
state = task_result.state state = task_result.state
@@ -84,25 +147,20 @@ class TaskStatusView(APIView):
if state == 'SUCCESS': if state == 'SUCCESS':
result = task_result.result result = task_result.result
response_data['result'] = result response_data['result'] = result
# Resumen legible cuando es auditoría masiva de organización
if isinstance(result, dict) and 'total_pedimentos' in result: if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0) total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0) completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0) con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0) con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0: if con_pendientes == 0 and con_errores == 0:
mensaje = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes' response_data['mensaje'] = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else: else:
partes = [] partes = []
if con_pendientes: if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes') partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores: if con_errores:
partes.append(f'{con_errores} con error') partes.append(f'{con_errores} con error')
mensaje = f'{completados}/{total} pedimentos completos — {", ".join(partes)}' response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
response_data['mensaje'] = mensaje
elif state == 'FAILURE': elif state == 'FAILURE':
response_data['error'] = str(task_result.info) response_data['error'] = str(task_result.info)

View File

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

View File

@@ -25,15 +25,14 @@ class VucemUpdateSerializer(VucemSerializer):
class Meta(VucemSerializer.Meta): class Meta(VucemSerializer.Meta):
fields = VucemSerializer.Meta.fields fields = VucemSerializer.Meta.fields
from .models import Vucem, CredencialesImportador from .models import Vucem, CredencialesImportador
from core.permissions import IsSameOrganizationDeveloper
from rest_framework import mixins from rest_framework import mixins
from core.permissions import ( from core.permissions import (
IsSameOrganization, IsSameOrganizationAndInAllowedGroups,
IsSameOrganizationDeveloper, get_org_context,
IsSameOrganizationAndAdmin, is_internal_service_request,
IsSuperUser, require_permission,
IsSameOrganizationAndInAllowedGroups user_has_permission,
) )
class CustomVucemPagination(PageNumberPagination): class CustomVucemPagination(PageNumberPagination):
@@ -53,8 +52,6 @@ class CustomVucemPagination(PageNumberPagination):
# Create your views here. # Create your views here.
class VucemView(viewsets.ModelViewSet): class VucemView(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated , (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
queryset = Vucem.objects.all() queryset = Vucem.objects.all()
pagination_class = CustomVucemPagination pagination_class = CustomVucemPagination
filterset_fields = ['organizacion', 'patente', 'usuario', 'is_importador', 'acusecove', 'acuseedocument', 'is_active'] filterset_fields = ['organizacion', 'patente', 'usuario', 'is_importador', 'acusecove', 'acuseedocument', 'is_active']
@@ -68,27 +65,45 @@ class VucemView(viewsets.ModelViewSet):
return VucemSerializer return VucemSerializer
def get_permissions(self): def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']: perms = {
return [IsAuthenticated(), IsSameOrganizationAndInAllowedGroups()] 'list': 'vucem.view',
return super().get_permissions() 'retrieve': 'vucem.view',
'create': 'vucem.manage',
'update': 'vucem.manage',
'partial_update': 'vucem.manage',
'destroy': 'vucem.manage',
'download_cer': 'vucem.view',
'download_key': 'vucem.view',
}
codename = perms.get(self.action, 'vucem.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
# Verificar que el usuario esté autenticado y tenga organización
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
return self.queryset.none() return self.queryset.none()
queryset = self.queryset if is_internal_service_request(self.request):
queryset = self.queryset.all()
importador_rfc = self.request.query_params.get('importador')
if importador_rfc:
queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct()
return queryset
if self.request.user.is_superuser: if not user_has_permission(self.request.user, 'vucem.view'):
queryset = queryset.all() return self.queryset.none()
elif not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
return queryset.none() org = get_org_context(self.request.user)
elif self.request.user.groups.filter(name='Importador').exists(): if not org:
queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario__in=self.request.user.rfc.all()) return self.queryset.none()
if self.request.user.is_importador:
queryset = self.queryset.filter(
organizacion=org,
usuario__in=self.request.user.rfc.all(),
)
else: else:
queryset = queryset.filter(organizacion=self.request.user.organizacion) queryset = self.queryset.filter(organizacion=org)
# Filtro por importador (RFC)
importador_rfc = self.request.query_params.get('importador') importador_rfc = self.request.query_params.get('importador')
if importador_rfc: if importador_rfc:
queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct() queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct()
@@ -96,54 +111,37 @@ class VucemView(viewsets.ModelViewSet):
return queryset return queryset
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if is_internal_service_request(self.request):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") serializer.save(updated_by=self.request.user)
if self.request.user.is_superuser:
organizacion_id = self.request.data.get('organizacion_id')
if not organizacion_id:
raise ValueError("Los superusuarios deben especificar una organización")
try:
# Importa el modelo Organizacion
# from ..organization.models import Organizacion
organizacion = Organizacion.objects.get(id=organizacion_id)
except Organizacion.DoesNotExist:
raise ValueError({"organizacion": "Organización no encontrada"})
serializer.save(
organizacion=organizacion,
created_by=self.request.user,
updated_by=self.request.user
)
return return
else: org = get_org_context(self.request.user)
if not org:
raise ValueError("El usuario debe tener una organización activa para crear credenciales VUCEM.")
serializer.save( serializer.save(
organizacion=self.request.user.organizacion, organizacion=org,
created_by=self.request.user, created_by=self.request.user,
updated_by=self.request.user updated_by=self.request.user,
) )
return
def perform_update(self, serializer): def perform_update(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if is_internal_service_request(self.request):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.")
instance = self.get_object() instance = self.get_object()
if self.request.user.is_superuser:
serializer.save( serializer.save(
created_by=instance.created_by, created_by=instance.created_by,
updated_by=self.request.user updated_by=self.request.user,
) )
return return
else: org = get_org_context(self.request.user)
if not org:
raise ValueError("El usuario debe tener una organización activa para modificar credenciales VUCEM.")
instance = self.get_object()
serializer.save( serializer.save(
organizacion=self.request.user.organizacion, organizacion=org,
created_by=instance.created_by, created_by=instance.created_by,
updated_by=self.request.user updated_by=self.request.user,
) )
return
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @action(detail=True, methods=["get"])
def download_cer(self, request, pk=None): def download_cer(self, request, pk=None):
vucem = self.get_object() vucem = self.get_object()
if not vucem.cer: if not vucem.cer:
@@ -164,7 +162,7 @@ class VucemView(viewsets.ModelViewSet):
return response return response
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @action(detail=True, methods=["get"])
def download_key(self, request, pk=None): def download_key(self, request, pk=None):
vucem = self.get_object() vucem = self.get_object()
if not vucem.key: if not vucem.key:
@@ -194,7 +192,6 @@ class VucemView(viewsets.ModelViewSet):
class CredencialesImportadorViewSet(viewsets.ModelViewSet): class CredencialesImportadorViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
queryset = CredencialesImportador.objects.all() queryset = CredencialesImportador.objects.all()
serializer_class = CredencialesImportadorSimpleSerializer serializer_class = CredencialesImportadorSimpleSerializer
filterset_fields = ['organizacion', 'vucem', 'rfc'] filterset_fields = ['organizacion', 'vucem', 'rfc']
@@ -205,27 +202,34 @@ class CredencialesImportadorViewSet(viewsets.ModelViewSet):
my_tags = ['Credenciales por Importador'] my_tags = ['Credenciales por Importador']
def get_permissions(self): def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']: perms = {
return [IsAuthenticated()] 'list': 'vucem.view',
return super().get_permissions() 'retrieve': 'vucem.view',
'create': 'vucem.manage',
'update': 'vucem.manage',
'partial_update': 'vucem.manage',
'destroy': 'vucem.manage',
}
codename = perms.get(self.action, 'vucem.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
if not self.request.user.is_authenticated:
if self.request.user.is_superuser:
# Si es superusuario, devolver todos los registros
return self.queryset.all()
# Verificar que el usuario esté autenticado y tenga organización
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
return self.queryset.none() return self.queryset.none()
if is_internal_service_request(self.request):
queryset = self.queryset.filter(organizacion=self.request.user.organizacion) return self.queryset.all()
if not user_has_permission(self.request.user, 'vucem.view'):
return self.queryset.none()
return queryset org = get_org_context(self.request.user)
if not org:
return self.queryset.none()
return self.queryset.filter(organizacion=org)
def perform_create(self, serializer): def perform_create(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if is_internal_service_request(self.request):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") serializer.save()
serializer.save(organizacion=self.request.user.organizacion)
return return
org = get_org_context(self.request.user)
if not org:
raise ValueError("El usuario debe tener una organización activa.")
serializer.save(organizacion=org)

View File

@@ -34,6 +34,13 @@ CELERY_BEAT_SCHEDULE = {
'task': 'api.customs.tasks.microservice_v2.process_all_organizations', 'task': 'api.customs.tasks.microservice_v2.process_all_organizations',
'schedule': crontab(hour=7, minute=1), # analizar si se requiere otra en un futuro 'schedule': crontab(hour=7, minute=1), # analizar si se requiere otra en un futuro
}, },
# Reintento recurrente de descargas VUCEM pendientes (T2026-05-027):
# cada ciclo incrementa el contador de intentos y, al agotar
# MAX_INTENTOS_AUTO, transiciona el registro a estado 'error'.
'reintentar_descargas_pendientes': {
'task': 'api.customs.tasks.microservice_v2.reintentar_descargas_pendientes',
'schedule': crontab(minute='*/30'),
},
# 'process_all_organizations': { # 'process_all_organizations': {
# 'task': 'api.customs.tasks.microservice_v2.process_all_organizations', # 'task': 'api.customs.tasks.microservice_v2.process_all_organizations',
# 'schedule': crontab(hour=11, minute=39), # analizar si se requiere otra en un futuro # 'schedule': crontab(hour=11, minute=39), # analizar si se requiere otra en un futuro
@@ -71,6 +78,27 @@ ALLOWED_HOSTS = [
SITE_URL = os.getenv('SITE_URL') SITE_URL = os.getenv('SITE_URL')
SERVICE_API_URL = os.getenv('SERVICE_API_URL') SERVICE_API_URL = os.getenv('SERVICE_API_URL')
SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2') SERVICE_API_URL_V2 = os.getenv('SERVICE_API_URL_V2')
# Tope de intentos automáticos de descarga VUCEM por registro (T2026-05-027).
# Un intento = un ciclo de orquestación completo; los reintentos internos del
# worker no incrementan el contador. Al llegar al tope solo queda el reproceso manual.
MAX_INTENTOS_AUTO = int(os.getenv('MAX_INTENTOS_AUTO', '5'))
# Hub / SSO
HUB_URL = os.getenv('HUB_URL', 'https://workspace.aduanasoft.com')
HUB_PRODUCT_SLUG = os.getenv('HUB_PRODUCT_SLUG', 'efc')
HUB_TENANT_SLUG = os.getenv('HUB_TENANT_SLUG', '')
HUB_PROVISION_SECRET = os.getenv('HUB_PROVISION_SECRET', '')
HUB_TENANT_ID = int(os.getenv('HUB_TENANT_ID', '1'))
COOKIE_SECURE = os.getenv('COOKIE_SECURE', 'false').lower() in ('1', 'true', 'yes')
# Keycloak admin (para auto-provisión de usuarios en migración)
KC_URL = os.getenv('KC_URL', 'http://hub-keycloak:8080')
KC_REALM = os.getenv('KC_REALM', 'master')
KC_ADMIN_USER = os.getenv('KC_ADMIN_USER', 'admin')
KC_ADMIN_PASSWORD = os.getenv('KC_ADMIN_PASSWORD', 'admin')
KC_EFC_CLIENT_ID = os.getenv('KC_EFC_CLIENT_ID', 'efc-backend')
KC_EFC_CLIENT_SECRET = os.getenv('KC_EFC_CLIENT_SECRET', 'efc-backend-secret-dev')
# Application definition # Application definition
BASE_APPS = [ BASE_APPS = [
'django.contrib.admin', 'django.contrib.admin',
@@ -98,6 +126,7 @@ OWN_APPS = [
'api.organization', 'api.organization',
'api.licence', 'api.licence',
'api.cuser', 'api.cuser',
'api.rbac',
'api.datastage', 'api.datastage',
'api.vucem', 'api.vucem',
'api.logger', 'api.logger',
@@ -161,7 +190,7 @@ if DEBUG:
USE_X_FORWARDED_HOST = False USE_X_FORWARDED_HOST = False
else: else:
CORS_ALLOW_ALL_ORIGINS = False CORS_ALLOW_ALL_ORIGINS = False
CORS_ALLOW_CREDENTIALS = False CORS_ALLOW_CREDENTIALS = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS').split(',') CORS_ALLOWED_ORIGINS = os.getenv('CORS_ALLOWED_ORIGINS').split(',')
CSRF_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True
@@ -173,11 +202,14 @@ CORS_ALLOW_HEADERS = list(default_headers) + [
'access-control-allow-credentials', 'access-control-allow-credentials',
] ]
CORS_EXPOSE_HEADERS = ['Content-Disposition']
# # JWT Authentication settings # # JWT Authentication settings
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication', 'api.cuser.hub_auth.HubAuthBackend', # Hub SSO (local + KC)
'rest_framework.authentication.TokenAuthentication', # Añade esta línea 'rest_framework_simplejwt.authentication.JWTAuthentication', # legacy
'rest_framework.authentication.TokenAuthentication',
], ],
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.IsAuthenticated',
@@ -222,7 +254,9 @@ REDOC_SETTINGS = {
CSRF_TRUSTED_ORIGINS = [ CSRF_TRUSTED_ORIGINS = [
"https://api.efc-aduanasoft.com", "https://api.efc-aduanasoft.com",
"http://192.168.1.195", "http://192.168.1.195",
"http://192.168.1.195:8000" "http://192.168.1.195:8000",
"http://localhost:5173",
"http://localhost:8000",
] ]
# URL Configuration # URL Configuration
@@ -318,10 +352,10 @@ CELERY_TIMEZONE = 'America/Denver'
ASGI_APPLICATION = 'config.asgi.application' ASGI_APPLICATION = 'config.asgi.application'
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), # Tokens de acceso cortos por seguridad 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), # 1 hora — reduce frecuencia de refresh
'REFRESH_TOKEN_LIFETIME': timedelta(days=5), # Refresh token de 5 días 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), # 7 días — sesión larga
'ROTATE_REFRESH_TOKENS': True, # Rotar refresh tokens para mayor seguridad 'ROTATE_REFRESH_TOKENS': False, # OFF — evita blacklist en múltiples tabs
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': False, # OFF — sin blacklist, múltiples tabs coexisten
'AUTH_HEADER_TYPES': ('Bearer',), 'AUTH_HEADER_TYPES': ('Bearer',),
} }

View File

@@ -32,11 +32,14 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('api/v1/', include('api.licence.urls')), path('api/v1/', include('api.licence.urls')),
# JWT Authentication # JWT Authentication (legacy — mantener durante transición)
path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('api/v1/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), path('api/v1/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/v1/user/', include(('api.cuser.urls', 'cuser'), namespace='cuser')), # Custom user app path('api/v1/user/', include(('api.cuser.urls', 'cuser'), namespace='cuser')), # Custom user app
# Hub SSO
path('api/v1/auth/', include('api.cuser.sso_urls')),
#path('api-auth/', include('rest_framework.urls')), #path('api-auth/', include('rest_framework.urls')),
path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), path('api/v1/swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), path('api/v1/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
@@ -51,6 +54,7 @@ urlpatterns = [
path('api/v1/cards/', include('api.cards.urls')), # Cards app path('api/v1/cards/', include('api.cards.urls')), # Cards app
path('api/v1/reports/', include('api.reports.urls')), # Reports app path('api/v1/reports/', include('api.reports.urls')), # Reports app
path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app
path('api/v1/rbac/', include('api.rbac.urls')), # RBAC app
] ]
# En producción, los archivos media son servidos por Nginx # En producción, los archivos media son servidos por Nginx
if settings.DEBUG: if settings.DEBUG:

View File

@@ -1,100 +1,244 @@
# permissions.py
from rest_framework import permissions from rest_framework import permissions
from api.cuser.models import CustomUser from rest_framework.exceptions import PermissionDenied
from rest_framework.authentication import TokenAuthentication
class IsSameOrganization(permissions.BasePermission):
"""
Permiso personalizado que solo permite acceder a usuarios de la misma organización
o a administradores/staff.
"""
def has_permission(self, request, view):
# Permite listar/crear solo si el usuario está autenticado
return request.user.is_authenticated
def has_object_permission(self, request, view, obj): # ---------------------------------------------------------------------------
# Permite operaciones sobre un objeto específico solo si: # Helpers centrales — toda la lógica de RBAC pasa por aquí
# - El objeto pertenece a la misma organización (acceso por usuario relacionado) # ---------------------------------------------------------------------------
return (getattr(obj, 'dirigido', None) and obj.dirigido.organizacion == request.user.organizacion)
class IsSameOrganizationAndAdmin(permissions.BasePermission): def is_internal_service_request(request):
""" """True si la petición proviene de un service account (Token auth + superuser).
Permiso personalizado que solo permite acceder a usuarios de la misma organización Misma lógica que IsInternalService, útil en get_queryset() y perform_* methods."""
o a administradores/staff. user = getattr(request, 'user', None)
""" if not user or not user.is_superuser:
def has_permission(self, request, view):
# Permite listar/crear solo si el usuario está autenticado
return request.user.is_authenticated
def has_object_permission(self, request, view, obj):
# Permite operaciones solo si el usuario es admin, Agente Aduanal o user y la organización coincide
allowed_groups = ['admin', 'Agente Aduanal', 'user']
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists()
if not user_in_group:
return False return False
if hasattr(obj, 'organizacion'): return isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication)
return obj.organizacion == request.user.organizacion
def get_org_context(user):
"""Retorna la organización activa para filtrado de datos.
Superusuarios usan active_organization; usuarios normales usan organizacion."""
if user.is_superuser:
return getattr(user, 'active_organization', None)
return getattr(user, 'organizacion', None)
def user_has_permission(user, codename):
"""Verifica si un usuario tiene un permiso RBAC por su codename.
Orden de evaluación:
1. is_superuser → True siempre
2. UserPermission deny explícito → False
3. UserPermission grant explícito → True
4. Algún UserRole en su org tiene el permiso → True
5. Denegar
"""
if user.is_superuser:
return True
org = getattr(user, 'organizacion', None)
if not org:
return False return False
class IsSameOrganizationDeveloper(permissions.BasePermission): from api.rbac.models import UserPermission, UserRole
"""
Permiso personalizado que solo permite acceder a usuarios de la misma organización
o a administradores/staff.
"""
def has_permission(self, request, view):
# Permite listar/crear solo si el usuario está autenticado
return request.user.is_authenticated
def has_object_permission(self, request, view, obj): try:
# Permite operaciones solo si el usuario es developer, Agente Aduanal o user y la organización coincide override = UserPermission.objects.get(user=user, permission__codename=codename)
allowed_groups = ['developer', 'Agente Aduanal', 'user'] return override.granted
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() except UserPermission.DoesNotExist:
if not user_in_group: pass
return False
if hasattr(obj, 'organizacion'): return UserRole.objects.filter(
return obj.organizacion == request.user.organizacion user=user,
role__organizacion=org,
role__permissions__codename=codename,
).exists()
def user_has_role(user, role_name):
"""Verifica si un usuario tiene un rol por nombre dentro de su organización.
Función puente durante la transición — lee desde UserRole en lugar de auth.Group."""
from api.rbac.models import UserRole
org = getattr(user, 'organizacion', None)
if not org:
return False return False
return UserRole.objects.filter(
user=user,
role__nombre=role_name,
role__organizacion=org,
).exists()
class IsOwnerOrOrgAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return (
obj == request.user or
request.user.is_staff or
request.user.groups.filter(name='admin').exists()
)
class IsSuperUser(permissions.BasePermission): # ---------------------------------------------------------------------------
def has_object_permission(self, request, view, obj): # Base compartida — aplica el requisito de org activa a superusuarios
return request.user.is_superuser # ---------------------------------------------------------------------------
class OrgScopedPermission(permissions.BasePermission):
"""Base para todas las clases de permiso con scope de organización.
Superusuario sin active_organization recibe 403, EXCEPTO service accounts
(Token auth + superuser) que pasan sin restricción de org."""
message = 'No tienes permiso para realizar esta acción.'
class HasStoragePermission(permissions.BasePermission):
"""
Permiso personalizado que permite el acceso a los usuarios que tienen permisos de almacenamiento.
"""
def has_permission(self, request, view): def has_permission(self, request, view):
# Permite el acceso si el usuario tiene el permiso 'can_access_storage' if not request.user.is_authenticated:
return request.user.has_perm('api.cuser.can_access_storage') return False
if request.user.is_superuser:
from rest_framework.authentication import TokenAuthentication
# Service account interno: Token auth + superuser → siempre permitido
if isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication):
return True
# Superuser JWT: requiere active_organization
if not getattr(request.user, 'active_organization', None):
return False
return True
# ---------------------------------------------------------------------------
# Clases de permiso
# ---------------------------------------------------------------------------
class IsSameOrganization(OrgScopedPermission):
"""Usuario autenticado con org activa. Cualquier rol pasa (incluyendo Importador)."""
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
# Permite operaciones sobre un objeto específico si el usuario tiene el permiso org = get_org_context(request.user)
return request.user.has_perm('api.cuser.can_access_storage') if not org:
return False
dirigido = getattr(obj, 'dirigido', None)
if dirigido:
return getattr(dirigido, 'organizacion', None) == org
return getattr(obj, 'organizacion', None) == org
class IsSameOrganizationAndInAllowedGroups(permissions.BasePermission):
""" class IsSameOrganizationAndAdmin(OrgScopedPermission):
Permite update/delete solo si el usuario está en TODOS los grupos permitidos """Usuario con rol admin, Agente Aduanal o user en su organización."""
y pertenece a la misma organización que el registro, o es superuser.
"""
allowed_groups = ['admin', 'Agente Aduanal', 'user']
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
user = request.user user = request.user
if not user.is_authenticated:
return False
if user.is_superuser: if user.is_superuser:
return True return True
if not hasattr(user, 'organizacion') or not user.organizacion: org = get_org_context(user)
if not org:
return False return False
# Debe tener los tres grupos asignados tiene_rol = (
for group in self.allowed_groups: user_has_role(user, 'admin') or
if not user.groups.filter(name=group).exists(): user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
)
if not tiene_rol:
return False return False
return obj.organizacion == user.organizacion return getattr(obj, 'organizacion', None) == org
class IsSameOrganizationDeveloper(OrgScopedPermission):
"""Usuario con rol developer, Agente Aduanal o user en su organización."""
def has_object_permission(self, request, view, obj):
user = request.user
if user.is_superuser:
return True
org = get_org_context(user)
if not org:
return False
tiene_rol = (
user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
)
if not tiene_rol:
return False
return getattr(obj, 'organizacion', None) == org
class IsOwnerOrOrgAdmin(OrgScopedPermission):
"""El propio usuario, staff de Django o usuario con rol admin en la org."""
def has_object_permission(self, request, view, obj):
user = request.user
return (
obj == user or
user.is_staff or
user.is_superuser or
user_has_role(user, 'admin')
)
class IsSuperUser(permissions.BasePermission):
"""Solo superusuarios de Django. No requiere org activa (para endpoints de gestión global)."""
message = 'No tienes permiso para realizar esta acción.'
def has_permission(self, request, view):
return request.user.is_authenticated and request.user.is_superuser
def has_object_permission(self, request, view, obj):
return request.user.is_superuser
class IsInternalService(permissions.BasePermission):
"""
Identifica llamadas internas de microservicio → backend.
Criterio: autenticación via Token (no JWT) + usuario superuser.
Esto garantiza que solo cuentas de servicio predefinidas pasan,
sin depender de flags manuales como is_staff que pueden no estar
configurados en producción.
"""
message = 'Acceso reservado para servicios internos.'
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
from rest_framework.authentication import TokenAuthentication
return (
isinstance(request.successful_authenticator, TokenAuthentication)
and request.user.is_superuser
)
class HasStoragePermission(OrgScopedPermission):
"""Usuarios con acceso a operaciones de almacenamiento (organizacion.view)."""
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
return user_has_permission(request.user, 'organizacion.view')
def has_object_permission(self, request, view, obj):
return user_has_permission(request.user, 'organizacion.view')
def require_permission(codename):
"""
Devuelve una clase de permiso DRF que exige el codename RBAC indicado.
Uso en permission_classes: require_permission('pedimentos.view')
Uso en get_permissions(): require_permission('pedimentos.create')()
"""
class _RbacPerm(OrgScopedPermission):
def has_permission(self, request, view):
if not super().has_permission(request, view):
return False
return user_has_permission(request.user, codename)
_RbacPerm.__name__ = f'HasPerm_{codename.replace(".", "_")}'
_RbacPerm.__qualname__ = _RbacPerm.__name__
return _RbacPerm
class IsSameOrganizationAndInAllowedGroups(OrgScopedPermission):
"""Usuario con permiso vucem.manage en su organización.
Reemplaza la lógica rota que requería 3 grupos simultáneamente."""
def has_object_permission(self, request, view, obj):
user = request.user
if user.is_superuser:
return True
org = get_org_context(user)
if not org:
return False
if not user_has_permission(user, 'vucem.manage'):
return False
return getattr(obj, 'organizacion', None) == org

42
core/redis_events.py Normal file
View File

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

View File

@@ -150,13 +150,14 @@ class PedimentoScrapper: # Clase me extrae datos de Pedimento
def _remesas(self, root: ET.Element) -> bool: def _remesas(self, root: ET.Element) -> bool:
""" """
Método para verificar si el pedimento tiene remesas. Método para verificar si el pedimento tiene remesas.
Busca identificadores con clave 'RC' (REMESAS DE CONSOLIDADO). Busca identificadores con clave 'PC' (PEDIMENTO CONSOLIDADO)
o 'RC' (REMESAS DE CONSOLIDADO).
Args: Args:
root: Elemento raíz del XML. root: Elemento raíz del XML.
Returns: Returns:
True si encuentra identificadores con clave 'RC', False en caso contrario. True si encuentra identificadores con clave 'PC' o 'RC', False en caso contrario.
""" """
namespaces = { namespaces = {
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', 'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
@@ -172,8 +173,8 @@ class PedimentoScrapper: # Clase me extrae datos de Pedimento
clave_elem = identificador.find('ns:claveIdentificador/ns:clave', namespaces) clave_elem = identificador.find('ns:claveIdentificador/ns:clave', namespaces)
clave = clave_elem.text if clave_elem is not None else None clave = clave_elem.text if clave_elem is not None else None
# Si encontramos una clave 'RC', el pedimento tiene remesas # PC (consolidado) o RC (remesas de consolidado) indican remesas
if clave == 'RC': if clave in ('PC', 'RC'):
return True return True
except Exception as e: except Exception as e:

View File

@@ -1,142 +1,179 @@
import logging import logging
from core.permissions import get_org_context, user_has_role, is_internal_service_request
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _is_internal_service(request):
return is_internal_service_request(request)
class FiltroPorOrganizacionMixin: class FiltroPorOrganizacionMixin:
model = None model = None
campo_usuario = 'user' campo_usuario = 'user'
campo_organizacion = 'organizacion' campo_organizacion = 'organizacion'
campo_rfc = 'rfc__id' campo_contribuyente = 'pedimento__contribuyente'
campo_contribuyente = 'pedimento__contribuyente' # solo si aplica
def get_queryset_filtrado(self): def get_queryset_filtrado(self):
user = self.request.user user = self.request.user
if not user.is_authenticated or not hasattr(user, self.campo_organizacion): if not user.is_authenticated:
return self.model.objects.none() return self.model.objects.none()
if user.is_superuser: if _is_internal_service(self.request):
return self.model.objects.all() return self.model.objects.all()
if (user.groups.filter(name='admin').exists() or user.groups.filter(name='developer').exists()) and user.is_authenticated and user.groups.filter(name='Agente Aduanal').exists(): org = get_org_context(user)
model_fields = [f.name for f in self.model._meta.get_fields()] if not org:
if self.campo_organizacion in model_fields:
filtro = {f"{self.campo_organizacion}": getattr(user, self.campo_organizacion)}
else:
return self.model.objects.none() return self.model.objects.none()
filtro = {self.campo_organizacion: org}
# Superuser y usuarios con rol operativo ven todo lo de su org activa
if user.is_superuser:
return self.model.objects.filter(**filtro) return self.model.objects.filter(**filtro)
if user.groups.filter(name='Importador').exists() and getattr(user, 'is_importador', False): if (
filtro = { user_has_role(user, 'admin') or
f"{self.campo_contribuyente}__{self.campo_rfc}": getattr(user, self.campo_rfc), user_has_role(user, 'developer') or
} user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
):
return self.model.objects.filter(**filtro)
# Importador: acceso filtrado por org + RFC como contribuyente
if user.is_importador:
filtro[f"{self.campo_contribuyente}__in"] = user.rfc.all()
return self.model.objects.filter(**filtro) return self.model.objects.filter(**filtro)
return self.model.objects.none() return self.model.objects.none()
# en core/mixins/organizacion.py o similar
class OrganizacionFiltradaMixin: class OrganizacionFiltradaMixin:
model = None # Puedes sobreescribir esto en la vista model = None
campo_organizacion = 'organizacion' campo_organizacion = 'organizacion'
campo_contribuyente = 'contribuyente' # solo si aplica campo_contribuyente = 'contribuyente'
def get_queryset_filtrado_por_organizacion(self): def get_queryset_filtrado_por_organizacion(self):
model = self.model or self.queryset.model model = self.model or self.queryset.model
user = self.request.user
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if not user.is_authenticated:
return model.objects.none() return model.objects.none()
if self.request.user.is_superuser: if _is_internal_service(self.request):
return model.objects.all() return model.objects.all()
org = self.request.user.organizacion org = get_org_context(user)
if not org:
return model.objects.none()
filtros_base = { filtros_base = {
f"{self.campo_organizacion}": org, self.campo_organizacion: org,
f"{self.campo_organizacion}__is_active": True, f'{self.campo_organizacion}__is_active': True,
f"{self.campo_organizacion}__is_verified": True, f'{self.campo_organizacion}__is_verified': True,
} }
grupos = self.request.user.groups.values_list('name', flat=True) if user.is_superuser:
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and (('admin' in grupos or 'developer' in grupos) and 'user' in grupos) :
if 'Agente Aduanal' in grupos:
return model.objects.filter(**filtros_base) return model.objects.filter(**filtros_base)
# if hasattr(model, self.campo_contribuyente): if (
if self.request.user.is_authenticated and 'Importador' in grupos: user_has_role(user, 'admin') or
filtros_base[f"{self.campo_contribuyente}__in"] = self.request.user.rfc.all() user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
):
return model.objects.filter(**filtros_base)
if user.is_importador:
filtros_base[f'{self.campo_contribuyente}__in'] = user.rfc.all()
return model.objects.filter(**filtros_base) return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
return model.objects.none() return model.objects.none()
class DocumentosFiltradosMixin: class DocumentosFiltradosMixin:
model = None model = None
campo_organizacion = 'organizacion' campo_organizacion = 'organizacion'
campo_contribuyente = 'pedimento' # solo si aplica campo_contribuyente = 'pedimento'
def get_queryset_filtrado_por_organizacion(self): def get_queryset_filtrado_por_organizacion(self):
model = self.model or self.queryset.model model = self.model or self.queryset.model
user = self.request.user
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if not user.is_authenticated:
return model.objects.none() return model.objects.none()
if self.request.user.is_superuser: if _is_internal_service(self.request):
return model.objects.all() return model.objects.all()
org = self.request.user.organizacion org = get_org_context(user)
if not org:
return model.objects.none()
filtros_base = { filtros_base = {
f"{self.campo_organizacion}": org.id, f'{self.campo_organizacion}': org.id,
f"{self.campo_organizacion}__is_active": True, f'{self.campo_organizacion}__is_active': True,
f"{self.campo_organizacion}__is_verified": True, f'{self.campo_organizacion}__is_verified': True,
} }
grupos = self.request.user.groups.values_list('name', flat=True) if user.is_superuser:
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos):
if 'Agente Aduanal' in grupos:
return model.objects.filter(**filtros_base) return model.objects.filter(**filtros_base)
if hasattr(model, self.campo_contribuyente): if (
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False): user_has_role(user, 'admin') or
filtros_base[f"{self.campo_contribuyente}__contribuyente__in"] = self.request.user.rfc.all() user_has_role(user, 'developer') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
):
return model.objects.filter(**filtros_base)
if user.is_importador:
filtros_base[f'{self.campo_contribuyente}__contribuyente__in'] = user.rfc.all()
return model.objects.filter(**filtros_base) return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
return model.objects.none() return model.objects.none()
class ProcesosPorOrganizacionMixin: class ProcesosPorOrganizacionMixin:
model = None # Puedes sobreescribir esto en la vista model = None
campo_organizacion = 'organizacion' campo_organizacion = 'organizacion'
campo_pedimento = 'pedimento' # solo si aplica campo_pedimento = 'pedimento'
def get_queryset_filtrado_por_organizacion(self): def get_queryset_filtrado_por_organizacion(self):
model = self.model or self.queryset.model model = self.model or self.queryset.model
user = self.request.user
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): if not user.is_authenticated:
return model.objects.none() return model.objects.none()
if self.request.user.is_superuser: if _is_internal_service(self.request):
return model.objects.all() return model.objects.all()
org = self.request.user.organizacion org = get_org_context(user)
filtros_base = { if not org:
f"{self.campo_organizacion}": org,
f"{self.campo_organizacion}__is_active": True,
f"{self.campo_organizacion}__is_verified": True,
}
grupos = self.request.user.groups.values_list('name', flat=True)
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos) :
if 'Agente Aduanal' in grupos:
return model.objects.filter(**filtros_base)
if hasattr(model, self.campo_pedimento):
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
filtros_base[f"{self.campo_pedimento}__contribuyente__in"] = self.request.user.rfc.all()
return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
return model.objects.none() return model.objects.none()
filtros_base = {
self.campo_organizacion: org,
f'{self.campo_organizacion}__is_active': True,
f'{self.campo_organizacion}__is_verified': True,
}
if user.is_superuser:
return model.objects.filter(**filtros_base)
if (
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 model.objects.filter(**filtros_base)
if user.is_importador:
filtros_base[f'{self.campo_pedimento}__contribuyente__in'] = user.rfc.all()
return model.objects.filter(**filtros_base)
return model.objects.none()

View File

@@ -0,0 +1,251 @@
"""
Migración one-shot: crea usuarios EFC en Keycloak (realm master) y los
vincula al tenant en el Hub via BD directa.
Estrategia:
- Crear usuario en Keycloak via admin REST API
- Insertar registro en hub.user_tenants
- Guardar keycloak_user_id en cuser_customuser
CÓMO USAR:
Dry-run (no toca nada):
docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py --dry-run
Migración real:
docker exec EFC_backend_dev python script/migrate_users_to_keycloak.py
CASOS ESPECIALES:
- Sin email: se omiten (no se pueden crear en KC sin email único)
- Email duplicado: se crea una sola vez en KC; el UUID se asigna a todos
los registros EFC con ese email
"""
import argparse
import logging
import os
import sys
import time
from collections import defaultdict
import django
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
import requests # noqa: E402
from django.db import transaction # noqa: E402
from api.cuser.models import CustomUser # noqa: E402
_hub_token_cache: dict = {}
def get_hub_admin_token(session: requests.Session) -> str:
"""Obtiene Bearer token del hubadmin. Acepta token pre-generado via HUB_BEARER_TOKEN."""
preset = os.getenv("HUB_BEARER_TOKEN", "")
if preset:
return preset
r = session.post(
f"{HUB_API_URL.rstrip('/')}/api/v1/auth/login",
json={"username": HUB_ADMIN_USER, "password": HUB_ADMIN_PASS},
timeout=10,
)
r.raise_for_status()
return r.json()["access_token"]
def register_user_in_hub(session: requests.Session, hub_token: str,
keycloak_user_id: str) -> bool:
"""Llama a POST /hub/user-tenants/add para vincular el usuario al tenant."""
r = session.post(
f"{HUB_API_URL.rstrip('/')}/api/v1/hub/user-tenants/add",
json={"keycloak_user_id": keycloak_user_id, "tenant_id": HUB_TENANT_ID, "role": "user"},
headers={"Authorization": f"Bearer {hub_token}"},
timeout=10,
)
if r.status_code in (200, 201):
return True
if r.status_code == 409:
return True # ya registrado
logger.error("Hub user-tenants/add %s: %s", r.status_code, r.text[:200])
return False
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("migrate")
# ── Configuración ──────────────────────────────────────────────────────────────
KC_URL = os.getenv("KEYCLOAK_URL", "http://hub-keycloak:8080")
KC_REALM = "master"
KC_ADMIN_USER = os.getenv("KEYCLOAK_ADMIN_USERNAME", "admin")
KC_ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
HUB_API_URL = os.getenv("HUB_URL", "http://hub-backend:8000")
HUB_ADMIN_USER = os.getenv("HUB_ADMIN_USERNAME", "hubadmin")
HUB_ADMIN_PASS = os.getenv("HUB_ADMIN_PASSWORD", "Localhost1234!")
HUB_TENANT_ID = int(os.getenv("HUB_TENANT_ID", "1"))
TENANT_SLUG = os.getenv("HUB_TENANT_SLUG", "efc")
TEMP_PASSWORD = "ChangeMe!Temp2025"
BATCH_SIZE = 20
def get_kc_admin_token(session: requests.Session) -> str:
"""Obtiene token de admin de Keycloak."""
r = session.post(
f"{KC_URL}/kcauth/realms/master/protocol/openid-connect/token",
data={
"client_id": "admin-cli",
"grant_type": "password",
"username": KC_ADMIN_USER,
"password": KC_ADMIN_PASS,
},
timeout=10,
)
r.raise_for_status()
return r.json()["access_token"]
def create_kc_user(session: requests.Session, token: str, user: CustomUser) -> str | None:
"""Crea usuario en Keycloak. Retorna UUID o None si falla."""
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
body = {
"username": user.username,
"email": user.email,
"firstName": user.first_name or "",
"lastName": user.last_name or "",
"enabled": True,
"credentials": [{"type": "password", "value": TEMP_PASSWORD, "temporary": True}],
}
r = session.post(
f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users",
json=body,
headers=headers,
timeout=15,
)
if r.status_code == 201:
# El ID viene en el header Location: .../users/<uuid>
location = r.headers.get("Location", "")
kc_id = location.split("/")[-1]
return kc_id if kc_id else None
if r.status_code == 409:
# Ya existe — buscar por email
search = session.get(
f"{KC_URL}/kcauth/admin/realms/{KC_REALM}/users?email={user.email}&exact=true",
headers=headers,
timeout=10,
)
users = search.json()
if users:
return users[0]["id"]
logger.warning("[%s] Conflicto 409 pero no se encontró por email", user.username)
return None
logger.error("[%s] KC %s: %s", user.username, r.status_code, r.text[:200])
return None
def run(dry_run: bool):
pending = list(
CustomUser.objects.filter(is_active=True, keycloak_user_id__isnull=True)
.order_by("date_joined")
)
sin_email = [u for u in pending if not (u.email or "").strip()]
con_email = [u for u in pending if (u.email or "").strip()]
by_email: dict[str, list] = defaultdict(list)
for u in con_email:
by_email[u.email.lower()].append(u)
dup_emails = {e: us for e, us in by_email.items() if len(us) > 1}
logger.info("═══════════════════════════════════════════")
logger.info("Pendientes : %d", len(pending))
logger.info("Sin email (omiten): %d", len(sin_email))
logger.info("Provisiones KC : %d emails únicos", len(by_email))
logger.info("Emails duplicados : %d grupos", len(dup_emails))
logger.info("Tenant Hub : %s", TENANT_SLUG)
logger.info("Keycloak URL : %s", KC_URL)
logger.info("Hub API URL : %s", HUB_API_URL)
logger.info("Hub Tenant ID : %d", HUB_TENANT_ID)
logger.info("═══════════════════════════════════════════")
if sin_email:
logger.warning("Omitidos sin email: %s", [u.username for u in sin_email])
if dup_emails:
for email, users in dup_emails.items():
logger.info("Duplicado %s%s", email, [u.username for u in users])
if dry_run:
logger.info("=== DRY-RUN completado — no se hizo ningún cambio ===")
return
ok = 0; failed = 0; i = 0
with requests.Session() as session:
kc_token = get_kc_admin_token(session)
hub_token = get_hub_admin_token(session)
for email, users in by_email.items():
i += 1
primary = users[0]
# Refrescar tokens cada 50 usuarios
if i % 50 == 0:
try:
kc_token = get_kc_admin_token(session)
hub_token = get_hub_admin_token(session)
except Exception as exc:
logger.error("No se pudo refrescar tokens: %s", exc)
sys.exit(1)
kc_id = create_kc_user(session, kc_token, primary)
if not kc_id:
failed += 1
continue
# Registrar en Hub via API
if not register_user_in_hub(session, hub_token, kc_id):
failed += 1
continue
# Guardar en EFC DB — solo el usuario principal (UNIQUE constraint)
# Los duplicados quedan con keycloak_user_id=NULL; comparten identidad KC
with transaction.atomic():
CustomUser.objects.filter(pk=primary.pk).update(keycloak_user_id=kc_id)
ok += 1
extra = f" (+{len(users)-1} dups)" if len(users) > 1 else ""
logger.info("[%d/%d] %-40s%s%s", i, len(by_email), email, kc_id[:8] + "...", extra)
if i % BATCH_SIZE == 0:
time.sleep(0.3)
logger.info("═══════════════════════════════════════════")
logger.info("Exitosos : %d", ok)
logger.info("Fallidos : %d", failed)
logger.info("Sin email: %d (omitidos)", len(sin_email))
if failed:
logger.warning("Hay fallidos — vuelve a correr el script, es idempotente.")
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--tenant-slug", default=TENANT_SLUG)
args = parser.parse_args()
if args.tenant_slug != TENANT_SLUG:
TENANT_SLUG = args.tenant_slug
run(args.dry_run)

View File

@@ -0,0 +1,90 @@
-- T2026-05-027 / Paso 1: detección de registros afectados (solo lectura)
-- Ejecutar ANTES de la migración como línea base y DESPUÉS de la corrección
-- (las queries deben regresar 0 filas al final).
--
-- 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
-- Confirmar con: SELECT id, nombre, descripcion FROM document_type ORDER BY id;
-- (a) Acuses de EDocument marcados descargados SIN documento tipo 4 en BD
SELECT e.id, e.numero_edocument, p.pedimento, p.id AS pedimento_id, e.organizacion_id
FROM edocs e
JOIN pedimento p ON p.id = e.pedimento_id
WHERE e.acuse_descargado = TRUE
AND NOT EXISTS (
SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id
AND d.document_type_id = 4
AND d.archivo ILIKE '%' || e.numero_edocument || '%'
);
-- (b) EDocuments marcados descargados SIN documento general
SELECT e.id, e.numero_edocument, p.pedimento
FROM edocs e
JOIN pedimento p ON p.id = e.pedimento_id
WHERE e.edocument_descargado = TRUE
AND NOT EXISTS (
SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id
AND d.archivo ILIKE '%' || e.numero_edocument || '%'
AND d.document_type_id NOT IN (4, 21, 22, 25, 26)
);
-- (c) Acuses de COVE marcados descargados SIN documento tipo 7
SELECT c.id, c.numero_cove, p.pedimento
FROM coves c
JOIN pedimento p ON p.id = c.pedimento_id
WHERE c.acuse_cove_descargado = TRUE
AND NOT EXISTS (
SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id
AND d.document_type_id = 7
AND d.archivo ILIKE '%' || c.numero_cove || '%'
);
-- (d) COVEs marcados descargados SIN documento general
SELECT c.id, c.numero_cove, p.pedimento
FROM coves c
JOIN pedimento p ON p.id = c.pedimento_id
WHERE c.cove_descargado = TRUE
AND NOT EXISTS (
SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id
AND d.archivo ILIKE '%' || c.numero_cove || '%'
AND d.document_type_id NOT IN (7, 19, 20, 23, 24)
);
-- (e) Pendientes con evidencia de error (documentos tipo 20/22/24/26)
SELECT 'cove_acuse_pendiente_con_error' AS categoria, c.id::text, c.numero_cove AS numero
FROM coves c
WHERE c.acuse_cove_descargado = FALSE
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
AND d.archivo ILIKE '%' || c.numero_cove || '%')
UNION ALL
SELECT 'cove_pendiente_con_error', c.id::text, c.numero_cove
FROM coves c
WHERE c.cove_descargado = FALSE
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
AND d.archivo ILIKE '%' || c.numero_cove || '%')
UNION ALL
SELECT 'edoc_pendiente_con_error', e.id::text, e.numero_edocument
FROM edocs e
WHERE e.edocument_descargado = FALSE
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
AND d.archivo ILIKE '%' || e.numero_edocument || '%')
UNION ALL
SELECT 'edoc_acuse_pendiente_con_error', e.id::text, e.numero_edocument
FROM edocs e
WHERE e.acuse_descargado = FALSE
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
AND d.archivo ILIKE '%' || e.numero_edocument || '%');
-- (f) Verificación puntual del caso reportado por QA (pedimento del documento de pruebas)
SELECT e.id, e.numero_edocument, e.edocument_descargado, e.acuse_descargado
FROM edocs e
WHERE e.pedimento_id = 'b4a6c3dd-5966-45a8-aa50-79b626ffd9c1';

View File

@@ -0,0 +1,21 @@
-- T2026-05-027 / Paso 2: backfill de estados (ejecutar DESPUÉS de aplicar la
-- migración de customs que agrega *_estado, *_intentos, ultimo_intento_at, ultimo_error).
-- Deriva el estado de 3 valores de los booleanos legados.
BEGIN;
-- Conteos de control: anotar y comparar contra las filas afectadas por cada UPDATE
SELECT COUNT(*) AS total_edocs FROM edocs;
SELECT COUNT(*) AS total_coves FROM coves;
UPDATE edocs SET
edocument_estado = CASE WHEN edocument_descargado THEN 'descargado' ELSE 'pendiente' END,
acuse_estado = CASE WHEN acuse_descargado THEN 'descargado' ELSE 'pendiente' END;
UPDATE coves SET
cove_estado = CASE WHEN cove_descargado THEN 'descargado' ELSE 'pendiente' END,
acuse_cove_estado = CASE WHEN acuse_cove_descargado THEN 'descargado' ELSE 'pendiente' END;
-- Validar que cada UPDATE afectó exactamente el total de su tabla antes de confirmar:
COMMIT;
-- ROLLBACK; -- usar en su lugar si los conteos no cuadran

View File

@@ -0,0 +1,85 @@
-- T2026-05-027 / Paso 3: corrección masiva de pendientes con evidencia de error.
-- Ejecutar DESPUÉS del backfill (02). Los registros 'pendiente' con documento de
-- error asociado (tipos 20/22/24/26) transicionan a 'error' — quedan visibles y
-- solo reprocesables de forma manual.
--
-- Los falsos "descargado" se corrigen con el comando (verifica también MinIO):
-- python manage.py reconciliar_descargas -- dry-run
-- python manage.py reconciliar_descargas --apply -- corrige
BEGIN;
-- Conteo de control (comparar contra filas afectadas por el UPDATE correspondiente)
SELECT COUNT(*) AS coves_acuse_error FROM coves c
WHERE c.acuse_cove_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
AND d.archivo ILIKE '%' || c.numero_cove || '%');
UPDATE coves c SET
acuse_cove_estado = 'error',
acuse_cove_descargado = FALSE,
ultimo_error = 'Corrección T2026-05-027: acuse con documento de error (tipo 24) sin reintento',
updated_at = NOW()
WHERE c.acuse_cove_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 24
AND d.archivo ILIKE '%' || c.numero_cove || '%');
SELECT COUNT(*) AS coves_error FROM coves c
WHERE c.cove_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
AND d.archivo ILIKE '%' || c.numero_cove || '%');
UPDATE coves c SET
cove_estado = 'error',
cove_descargado = FALSE,
ultimo_error = 'Corrección T2026-05-027: COVE con documento de error (tipo 20) sin reintento',
updated_at = NOW()
WHERE c.cove_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = c.pedimento_id AND d.document_type_id = 20
AND d.archivo ILIKE '%' || c.numero_cove || '%');
SELECT COUNT(*) AS edocs_error FROM edocs e
WHERE e.edocument_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
AND d.archivo ILIKE '%' || e.numero_edocument || '%');
UPDATE edocs e SET
edocument_estado = 'error',
edocument_descargado = FALSE,
ultimo_error = 'Corrección T2026-05-027: EDocument con documento de error (tipo 22) sin reintento',
updated_at = NOW()
WHERE e.edocument_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 22
AND d.archivo ILIKE '%' || e.numero_edocument || '%');
SELECT COUNT(*) AS edocs_acuse_error FROM edocs e
WHERE e.acuse_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
AND d.archivo ILIKE '%' || e.numero_edocument || '%');
UPDATE edocs e SET
acuse_estado = 'error',
acuse_descargado = FALSE,
ultimo_error = 'Corrección T2026-05-027: acuse con documento de error (tipo 26) sin reintento',
updated_at = NOW()
WHERE e.acuse_estado = 'pendiente'
AND EXISTS (SELECT 1 FROM document d
WHERE d.pedimento_id = e.pedimento_id AND d.document_type_id = 26
AND d.archivo ILIKE '%' || e.numero_edocument || '%');
COMMIT;
-- ROLLBACK; -- usar en su lugar si los conteos no cuadran
-- Censo final: distribución de estados (no debe existir valor fuera del catálogo)
SELECT 'edocs' AS tabla, edocument_estado AS estado, COUNT(*) FROM edocs GROUP BY 2
UNION ALL SELECT 'edocs_acuse', acuse_estado, COUNT(*) FROM edocs GROUP BY 2
UNION ALL SELECT 'coves', cove_estado, COUNT(*) FROM coves GROUP BY 2
UNION ALL SELECT 'coves_acuse', acuse_cove_estado, COUNT(*) FROM coves GROUP BY 2
ORDER BY tabla, estado;