Compare commits

...

19 Commits

Author SHA1 Message Date
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
9bbed42cf3 Merge pull request 'feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo' (#29) from feature/background-tasks into main
Reviewed-on: #29
2026-05-19 15:02:24 +00:00
1966218081 feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo 2026-05-19 08:59:56 -06:00
b57ce83dc5 Merge pull request 'feature/T2026-05-016-y-T2026-05-031' (#28) from feature/T2026-05-016-y-T2026-05-031 into main
Reviewed-on: #28
2026-05-18 18:05:26 +00:00
Dulce
c2ae752932 fix/T2025-09-007 corregir documentos duplicados 2026-05-18 11:55:46 -06:00
Dulce
8cc0b9f573 feature/T2026-05-016 implementar cargas de tareas en background e implementar y corregir auditoria para datastages 2026-05-18 11:54:46 -06:00
Dulce
3a636c14ae T2026-05-030 2026-05-18 11:51:30 -06:00
Dulce
63f051c566 feature/T2026-05-031 agregar multiples rfc's a un usuario 2026-05-18 11:47:41 -06:00
c890e79394 Merge pull request 'feature/implementacion de gestor de informacion y archivos minIO' (#27) from feature/minio-implementation into main
Reviewed-on: #27
2026-04-22 18:02:58 +00:00
Dulce
39504e196c feature/implementacion de gestor de informacion y archivos minIO 2026-04-22 11:10:05 -06:00
69d07f2713 Merge pull request 'filtros de pedimento completo' (#26) from filtros-doctype into main
Reviewed-on: #26
2026-03-27 14:30:11 +00:00
Dulce
27c8d24a56 filtros de pedimento completo 2026-03-27 08:17:29 -06:00
627d78f4b8 Merge pull request 'modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado' (#25) from formato-pedimento-completo into main
Reviewed-on: #25
2026-03-26 18:00:07 +00:00
Dulce
4c7eb22b28 modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado 2026-03-26 11:44:58 -06:00
30b6d73567 Merge pull request 'primeros 2 difitos' (#24) from pedimento-app-patente into main
Reviewed-on: #24
2026-03-23 22:29:34 +00:00
32aff7649e Merge pull request 'cambiar forma de agregar aduana al pedimento_app, ahora se incluyen siempre 3 digitos de aduana' (#23) from pedimento-app-patente into main
Reviewed-on: #23
2026-03-13 18:51:36 +00:00
68 changed files with 7606 additions and 2320 deletions

View File

@@ -8,10 +8,9 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
get_org_context,
require_permission,
user_has_permission,
)
from api.organization.models import UsoAlmacenamiento, Organizacion
@@ -34,7 +33,7 @@ class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin)
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
"""
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document
my_tags = ['Cards']
@@ -100,7 +99,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
View para obtener información de uso de servicios relacionados con pedimentos.
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
"""
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document
my_tags = ['Cards']
@@ -140,29 +139,17 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
return None
# Si es super usuario, devuelve todos los procesos
if self.request.user.is_superuser:
return ProcesamientoPedimento.objects.all()
org = get_org_context(self.request.user)
if not org:
return ProcesamientoPedimento.objects.none()
# Si es Administrador de la organizacion devuelve todos los servicios de la organizacion
if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
if self.request.user.is_importador:
return ProcesamientoPedimento.objects.filter(
pedimento__organizacion=org,
pedimento__contribuyente__in=self.request.user.rfc.all(),
)
# Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion
if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
return self.request.user.organizacion.procesamiento_pedimentos.all()
if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
return self.request.user.organizacion.procesamiento_pedimentos.all()
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos
if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists():
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc)
# Si es parte de una organización, filtrar por esa organización
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
def get(self, request):
queryset = self.get_queryset()
@@ -193,12 +180,21 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
Endpoint para análisis de actividades de usuario.
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
"""
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = UserActivity
campo_organizacion = 'user__organizacion'
my_tags = ['Cards']
def get_queryset_importador(self):
# Importadores solo ven sus propias actividades
user = self.request.user
org = get_org_context(user)
if not org:
return UserActivity.objects.none()
return UserActivity.objects.filter(user__organizacion=org, user=user)
@swagger_auto_schema(
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
manual_parameters=[
@@ -253,6 +249,8 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
}
)
def get_queryset(self):
if self.request.user.is_importador:
return self.get_queryset_importador()
return self.get_queryset_filtrado()
def get(self, request):
@@ -289,11 +287,20 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
Endpoint para análisis de logs de peticiones.
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
"""
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = RequestLog
campo_organizacion = 'user__organizacion'
my_tags = ['Cards']
def get_queryset_importador(self):
# Importadores solo ven sus propios logs
user = self.request.user
org = get_org_context(user)
if not org:
return RequestLog.objects.none()
return RequestLog.objects.filter(user__organizacion=org, user=user)
@swagger_auto_schema(
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
manual_parameters=[
@@ -345,6 +352,8 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
}
)
def get_queryset(self):
if self.request.user.is_importador:
return self.get_queryset_importador()
return self.get_queryset_filtrado()
def get(self, request):
@@ -376,7 +385,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin):
View que obtiene los ultimos 10 documentos agregados.
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
"""
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('cards.view')]
model = Document
my_tags = ['Cards']

View File

@@ -13,7 +13,7 @@ class CustomUserCreationForm(UserCreationForm):
class CustomUserChangeForm(UserChangeForm):
class Meta:
model = CustomUser
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture')
fields = ('username', 'email', 'first_name', 'last_name', 'organizacion', 'profile_picture', 'is_importador', 'rfc')
class CustomUserAdmin(UserAdmin):
@@ -25,11 +25,12 @@ class CustomUserAdmin(UserAdmin):
list_filter = ('is_staff', 'is_active', 'organizacion')
search_fields = ('username', 'email', 'first_name', 'last_name')
ordering = ('username',)
filter_horizontal = ('rfc', 'groups', 'user_permissions')
# Fieldsets para editar un usuario
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', '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')}),
('Fechas importantes', {'fields': ('last_login', 'date_joined')}),
)

View File

@@ -11,8 +11,19 @@ class CustomUser(AbstractUser):
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users')
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
# Contexto de trabajo activo para superusuarios. Filtra datos igual que un usuario normal.
# Sin este campo activo, el superuser no puede consultar datos — debe hacer switch primero.
active_organization = models.ForeignKey(
'organization.Organizacion',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='superusers_activos',
help_text="Solo superusuarios: organización activa para contexto de trabajo",
)
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
rfc = models.ForeignKey('customs.Importador', on_delete=models.SET_NULL, null=True, blank=True, related_name='users', help_text="RFC associated with the user if they are an importer")
rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario")
def __str__(self):
return self.username

View File

@@ -2,28 +2,62 @@
from rest_framework import serializers
from .models import CustomUser
from django.contrib.auth.models import Group
from api.customs.models import Importador
class CustomUserSerializer(serializers.ModelSerializer):
"""
Serializer for the CustomUser model.
"""
password = serializers.CharField(write_only=True)
password = serializers.CharField(write_only=True, required=False)
groups = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all(), many=True, required=False)
rfc = serializers.CharField(max_length=20, required=False, allow_blank=True)
rfc = serializers.PrimaryKeyRelatedField(
queryset=Importador.objects.all(),
many=True,
required=False,
pk_field=serializers.CharField(),
)
class Meta:
model = CustomUser
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'password', 'profile_picture', 'organizacion', 'is_importador', 'rfc', 'is_active', 'is_superuser', 'groups']
read_only_fields = ['id', 'organizacion', 'is_superuser']
def validate_password(self, value):
if not value or not value.strip():
raise serializers.ValidationError("La contraseña no puede estar vacía o contener solo espacios.")
return value
def validate(self, attrs):
# En create, la contraseña es obligatoria
if self.instance is None and not attrs.get('password'):
raise serializers.ValidationError({"password": "Este campo es requerido."})
return attrs
def create(self, validated_data):
groups = validated_data.pop('groups', [])
rfcs = validated_data.pop('rfc', [])
password = validated_data.pop('password')
user = CustomUser(**validated_data)
user.set_password(password)
user.save()
if groups:
user.groups.set(groups)
if rfcs:
user.rfc.set(rfcs)
return user
def update(self, instance, validated_data):
groups = validated_data.pop('groups', None)
rfcs = validated_data.pop('rfc', None)
password = validated_data.pop('password', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if password:
instance.set_password(password)
instance.save()
if groups is not None:
instance.groups.set(groups)
if rfcs is not None:
instance.rfc.set(rfcs)
return instance

View File

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

View File

@@ -34,6 +34,7 @@ class Pedimento(models.Model):
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente")
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")

View File

@@ -47,55 +47,31 @@ class PartidaSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField()
def get_documentos(self, obj):
"""
Busca documentos en la tabla `document` que coincidan EXACTAMENTE con:
'documents/vu_PT_{pedimentoApp}_{numero}' al inicio del nombre del archivo.
"""
if not obj or not getattr(obj, 'pedimento', None):
return []
if not obj or not getattr(obj, 'numero_partida', None):
return []
try:
pedimentoApp = str(obj.pedimento.pedimento_app).strip()
pedimento_app = str(obj.pedimento.pedimento_app).strip()
numero = str(obj.numero_partida).strip()
# Incluir pedimento_app en el patrón para evitar falsos positivos
# entre partidas con números cortos (1 matchearía 10, 100, etc.)
patron = f"vu_PT_{pedimento_app}_{numero}_"
# Construir el patrón exacto de búsqueda
patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml'
# Buscar documentos que empiecen EXACTAMENTE con ese patrón
# 17 = REQUEST partida, 18 = ERROR partida
qs = Document.objects.filter(
archivo=patron_exacto
)
pedimento=obj.pedimento,
archivo__icontains=patron,
).exclude(document_type_id__in=[17, 18])
# Opción 2: Si puede tener diferentes extensiones
# patron_base = f'documents/vu_PT_{pedimentoApp}_{numero}'
# qs = Document.objects.filter(
# archivo__startswith=patron_base
# ).filter(
# archivo__in=[
# f'{patron_base}.xml',
# f'{patron_base}.pdf',
# f'{patron_base}.zip'
# ]
# )
# Filtro adicional por pedimento si el modelo Document tiene este campo
if hasattr(Document, 'pedimento'):
qs = qs.filter(pedimento=obj.pedimento)
# Filtro por organización
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
#return []
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return []
class Meta:
model = Partida
@@ -208,10 +184,11 @@ class EDocumentSerializer(serializers.ModelSerializer):
numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip()
# excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
).exclude(document_type_id__in=[21, 25])
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
@@ -263,10 +240,11 @@ class CoveSerializer(serializers.ModelSerializer):
try:
numero = str(obj.numero_cove).strip()
# Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend
qs = Document.objects.filter(
pedimento=obj.pedimento,
archivo__icontains=numero,
)
).exclude(document_type_id__in=[19, 23])
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:

View File

@@ -27,6 +27,9 @@ def trigger_celery_task_on_create(sender, instance, created, **kwargs):
logger.info("NO es creación de pedimento, no se crea procesamiento.")
return
if not instance.consultar_vucem:
return
def crear_procesamiento():
import logging
logger = logging.getLogger('api.customs.async_operations')
@@ -87,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)])
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)])
pedimento_id = str(instance.pedimento.id)
def enqueue_cove_tasks():
crear_procesamiento_cove.apply_async(args=[pedimento_id])
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_cove_tasks)
@receiver(post_save, sender=EDocument)
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
@@ -96,5 +102,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)])
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)])
pedimento_id = str(instance.pedimento.id)
def enqueue_edocument_tasks():
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_edocument_tasks)

View File

@@ -1,3 +1,4 @@
from .microservice import *
from .internal_services import *
from .bulk_upload import *
from .microservice_v2 import *

View File

@@ -6,6 +6,8 @@ from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocumen
from core.utils import xml_controller
import requests
from core.utils import xml_remesas_controller
import logging
logger = logging.getLogger(__name__)
def obtener_pedimentos(organizacion_id):
return Pedimento.objects.filter(organizacion_id=organizacion_id)
@@ -35,23 +37,31 @@ def auditor_descargas(pedimento, servicio, related_name, variable, mensaje):
pedimento_id = pedimento.id
docs = getattr(pedimento, related_name).all()
print(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
logger.info(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
# Si no hay documentos, marcar como completado
if not docs.exists():
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
logger.info(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
else:
all_docs = all(getattr(doc, variable) for doc in docs)
if all_docs:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
logger.info(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
else:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso"
print(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
logger.info(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
if proceso:
print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
logger.info(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
else:
print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
logger.info(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
## Auditar pedimentos
@@ -121,44 +131,66 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
@shared_task
def crear_partidas(organizacion_id):
from api.customs.models import Partida
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
pedimentos_procesados = 0
total_partidas_agregadas = 0
print(f"Iniciando procesamiento de {total_pedimentos} pedimentos para organización {organizacion_id}")
completados = []
con_pendientes = []
sin_datos = []
errores = []
for pedimento in pedimentos:
pedimentos_procesados += 1
partidas_agregadas_pedimento = 0
# Validar que numero_partidas no sea None y sea mayor que 0
if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0:
partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes:
print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
try:
if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
sin_datos.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'razon': f'numero_partidas inválido ({pedimento.numero_partidas})',
})
continue
for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida
partida, created = Partida.objects.get_or_create(
Partida.objects.get_or_create(
pedimento=pedimento,
numero_partida=i,
organizacion_id=organizacion_id
defaults={'organizacion_id': organizacion_id}
)
if created:
partidas_agregadas_pedimento += 1
total_partidas_agregadas += 1
print(f" → Partidas agregadas para pedimento {pedimento.id}: {partidas_agregadas_pedimento}")
else:
print(f"Pedimento {pedimento.id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
else:
print(f"Pedimento {pedimento.id} omitido - numero_partidas: {pedimento.numero_partidas} (inválido)")
partidas = list(pedimento.partidas.order_by('numero_partida'))
no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
print(f"\n=== RESUMEN ===")
print(f"Pedimentos procesados: {pedimentos_procesados}")
print(f"Total de partidas agregadas: {total_partidas_agregadas}")
print(f"Procesamiento completado para organización {organizacion_id}")
if not no_descargadas:
completados.append(str(pedimento.id))
else:
con_pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'total_partidas': len(partidas),
'descargadas': len(partidas) - len(no_descargadas),
'no_descargadas': no_descargadas,
})
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}")
return {
'organizacion_id': str(organizacion_id),
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(con_pendientes),
'sin_datos': len(sin_datos),
'con_errores': len(errores),
'detalle_pendientes': con_pendientes,
'detalle_sin_datos': sin_datos,
'detalle_errores': errores,
}
@shared_task
def crear_partidas_por_pedimento(pedimento_id):
@@ -169,6 +201,7 @@ def crear_partidas_por_pedimento(pedimento_id):
return
print(f"Procesando pedimento individual {pedimento_id}...")
logger.info(f"Procesando pedimento individual {pedimento_id}...")
partidas_agregadas = 0
# Validar que numero_partidas no sea None y sea mayor que 0
@@ -176,6 +209,7 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes:
print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
logger.info(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida
@@ -188,62 +222,165 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_agregadas += 1
print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
logger.info(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
else:
print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
logger.info(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
else:
print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
logger.info(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label):
"""
Itera todos los pedimentos de una organización auditando el campo `variable`
en la relación `related_name`. Retorna un resumen estructurado por pedimento.
"""
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
completados = []
pendientes = []
errores = []
for pedimento in pedimentos:
try:
docs = list(getattr(pedimento, related_name).all())
total = len(docs)
faltantes = [
getattr(doc, 'numero_cove', None) or getattr(doc, 'numero_edocument', None)
for doc in docs if not getattr(doc, variable)
]
if total == 0 or len(faltantes) == 0:
nuevo_estado = 3
completados.append(str(pedimento.id))
else:
nuevo_estado = 4
pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
f'faltantes_{label}': faltantes,
'total': total,
'descargados': total - len(faltantes),
})
modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado)
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando pedimento {pedimento.id} [{label}]: {e}")
return {
'organizacion_id': str(organizacion_id),
'auditoria': label,
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
# Auditar coves
@shared_task
def auditar_coves(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
return _auditar_organizacion(
organizacion_id,
servicio=8,
related_name='coves',
variable='cove_descargado',
mensaje='COVE'
label='cove',
)
@shared_task
def auditar_acuse_cove(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
return _auditar_organizacion(
organizacion_id,
servicio=9,
related_name='coves',
variable='acuse_cove_descargado',
mensaje='acuse de COVE'
label='acuse_cove',
)
# Revisa si el pedimento completo todos sus acuse coves
# Auditar edocuments
@shared_task
def auditar_edocuments(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
return _auditar_organizacion(
organizacion_id,
servicio=7,
related_name='documentos',
variable='edocument_descargado',
mensaje='EDocument'
label='edocument',
)
@shared_task
def auditar_acuse(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
return _auditar_organizacion(
organizacion_id,
servicio=6,
related_name='documentos',
variable='acuse_descargado',
mensaje='acuse'
label='acuse',
)
@shared_task
def auditar_remesas(organizacion_id):
"""
Audita el estado de descarga de remesas para todos los pedimentos de una organización.
A diferencia de coves/edocuments, las remesas no tienen campo booleano propio —
se verifica la existencia de un documento de tipo 3 (Remesa) en el pedimento.
"""
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
completados = []
pendientes = []
errores = []
for pedimento in pedimentos:
try:
if not pedimento.remesas:
# El pedimento no declara remesas — no aplica, marcar como completado
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
elif pedimento.documents.filter(document_type=3).exists():
# Documento de remesa ya descargado
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
else:
# Tiene remesas declaradas pero el documento aún no existe
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=4)
pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
})
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando remesa de pedimento {pedimento.id}: {e}")
return {
'organizacion_id': str(organizacion_id),
'auditoria': 'remesa',
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
@shared_task
def auditar_cove_por_pedimento(pedimento_id):
try:
print(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
logger.info(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
from api.customs.models import Pedimento
pedimento = Pedimento.objects.get(id=pedimento_id)
auditor_descargas(

View File

@@ -1,6 +1,8 @@
# auditoria_xml.py
import xml.etree.ElementTree as ET
from datetime import datetime
import logging
logger = logging.getLogger('api.customs.auditoria_xml')
def extraer_info_pedimento_xml(xml_content):
"""
@@ -13,8 +15,10 @@ def extraer_info_pedimento_xml(xml_content):
# Buscar el namespace (puede variar)
namespaces = {
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
's': 'http://schemas.xmlsoap.org/soap/envelope/',
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta'
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta',
}
resultado = {}
@@ -181,10 +185,37 @@ def extraer_info_pedimento_xml(xml_content):
if edocs_encontrados:
resultado['edocuments_en_xml'] = edocs_encontrados
# Verificar si hay error en la respuesta
# Verificar si hay error en la respuesta — 3 variantes según el servicio VUCEM:
# 1) Remesas/pedimentos: <ns3:tieneError> en namespace oxml/respuesta
# 2) eDocuments: <TieneError> en namespace tempuri.org, mensaje en <Errores>
# 3) Acuses: <error> sin namespace dentro de responseConsultaAcuses
tiene_error = root.find('.//ns3:tieneError', namespaces)
if tiene_error is not None:
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
if resultado['tiene_error']:
mensaje = root.find('.//ns3:error/ns3:mensaje', namespaces)
if mensaje is not None and mensaje.text:
resultado['error_mensaje'] = mensaje.text.strip()
else:
# Variante eDocuments (tempuri.org)
tiene_error_edoc = root.find('.//{http://tempuri.org/}TieneError')
if tiene_error_edoc is not None:
resultado['tiene_error'] = tiene_error_edoc.text.lower() == 'true'
if resultado['tiene_error']:
errores_elem = root.find('.//{http://tempuri.org/}Errores')
if errores_elem is not None and errores_elem.text:
resultado['error_mensaje'] = errores_elem.text.strip()
else:
# Variante acuses: <error> sin namespace
error_acuses = root.find('.//error')
if error_acuses is not None and error_acuses.text is not None:
resultado['tiene_error'] = error_acuses.text.lower() == 'true'
if resultado['tiene_error']:
descripciones = root.findall('.//mensajeErrores/descripcion')
if descripciones:
resultado['error_mensaje'] = ' | '.join(
d.text.strip() for d in descripciones if d.text
)
return resultado

View File

@@ -1,5 +1,4 @@
from celery import shared_task
from django.core.files.base import ContentFile
from django.utils import timezone
import os
import zipfile
@@ -27,16 +26,27 @@ def normalize_filename(filename):
return filename
def extract_django_suffix(filename):
"""
Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos.
"""
name_without_ext = os.path.splitext(filename)[0]
match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
if match:
return match.group(1)
return None
def get_clean_base_filename(filename):
"""
Obtiene el nombre base limpio sin el sufijo de Django.
Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
"""
normalized = normalize_filename(filename)
name_without_ext, ext = os.path.splitext(normalized)
django_suffix = extract_django_suffix(name_without_ext)
if django_suffix:
base_name = name_without_ext[:-8]
base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else:
base_name = name_without_ext
@@ -45,17 +55,6 @@ def get_clean_base_filename(filename):
return base_name.lower().strip('_')
def extract_django_suffix(filename):
"""
Extrae el sufijo único que Django añade a los archivos.
"""
name_without_ext = os.path.splitext(filename)[0]
match = re.search(r'_([a-zA-Z0-9]{7})$', name_without_ext)
if match:
return match.group(1)
return None
def is_same_document(existing_doc, new_filename):
"""
Compara si un documento existente y un nuevo archivo son el mismo documento.
@@ -615,8 +614,6 @@ def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
tiene_nomenclatura_especial = True
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento)
django_file = ContentFile(file_content, name=file_name)
# Buscar documento existente
existing_documents = Document.objects.filter(
pedimento_id=existing_pedimento.id,
@@ -630,51 +627,53 @@ def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
break
if existing_document:
# Actualizar documento existente
# try:
# if existing_document.archivo and os.path.exists(existing_document.archivo.path):
# os.remove(existing_document.archivo.path)
# except (ValueError, OSError):
# pass
# existing_document.archivo = django_file
# existing_document.size = len(file_content)
# existing_document.extension = extension
# existing_document.updated_at = timezone.now()
# existing_document.save()
# doc = Document.objects.get(id=existing_document.id)
# doc.archivo.delete(save=False) # Eliminar el archivo anterior
# doc.delete() # Eliminar el registro para crear uno nuevo (evita problemas con archivos en Django)
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento actualizado",
"accion": "Documento ya existente, omitido",
"documento": file_name
})
documents_created += 1
else:
# Crear nuevo documento
# Crear registro sin archivo primero
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=existing_pedimento.id,
document_type=document_type,
fuente_id=fuente.id,
archivo=django_file,
size=len(file_content),
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
)
from api.utils.storage_service import storage_service
ruta = storage_service.save_document_from_path(
file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=existing_pedimento.pedimento_app,
metadata={
'pedimento_id': str(existing_pedimento.id),
'document_id': str(document.id),
'source': 'bulk_upload_async'
}
)
if ruta:
document.archivo = ruta
document.save()
documents_created += 1
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento creado",
"documento": file_name
})
documents_created += 1
else:
document.delete()
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Error al guardar {file_name} en almacenamiento"
})
except Exception as e:
failed_records.append({

View File

@@ -1,6 +1,14 @@
from celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller
from api.customs.tasks.microservice import (
procesar_cove_individual,
procesar_acuse_individual,
procesar_acuse_cove_individual,
procesar_edoc_individual,
procesar_partida_individual,
procesar_remesa_individual,
)
@shared_task
def crear_procesamiento_remesa(pedimento_id):
@@ -11,7 +19,7 @@ def crear_procesamiento_remesa(pedimento_id):
if pedimento.remesas:
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=5, # ID del servicio de remesas
servicio_id=5,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -19,10 +27,11 @@ def crear_procesamiento_remesa(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=5,
organizacion=pedimento.organizacion
)
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_partida(pedimento_id):
@@ -32,7 +41,7 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=4, # ID del servicio de partidas
servicio_id=4,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -40,10 +49,11 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=4,
organizacion=pedimento.organizacion
)
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_cove(pedimento_id):
@@ -54,7 +64,7 @@ def crear_procesamiento_cove(pedimento_id):
if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=8, # ID del servicio de Coves
servicio_id=8,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -62,10 +72,11 @@ def crear_procesamiento_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=8,
organizacion=pedimento.organizacion
)
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_acuse(pedimento_id):
@@ -73,10 +84,10 @@ def crear_procesamiento_acuse(pedimento_id):
logger = logging.getLogger('api.customs.async_operations')
pedimento = Pedimento.objects.get(id=pedimento_id)
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
if pedimento.coves.exists():
if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=6, # ID del servicio de Acuse Cove
servicio_id=6,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -84,10 +95,11 @@ def crear_procesamiento_acuse(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=6,
organizacion=pedimento.organizacion
)
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_acuse_cove(pedimento_id):
@@ -98,7 +110,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=9, # ID del servicio de Acuse Cove
servicio_id=9,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -106,10 +118,11 @@ def crear_procesamiento_acuse_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=9,
organizacion=pedimento.organizacion
)
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_edocument(pedimento_id):
@@ -120,7 +133,7 @@ def crear_procesamiento_edocument(pedimento_id):
if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento,
servicio_id=7, # ID del servicio de EDocument
servicio_id=7,
organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4]
).exists()
@@ -128,10 +141,11 @@ def crear_procesamiento_edocument(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create(
pedimento=pedimento,
estado_id=1, # Estado "pendiente"
estado_id=1,
servicio_id=7,
organizacion=pedimento.organizacion
)
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task
def crear_procesamiento_pedimento_completo(organizacion_id):

View File

@@ -1,3 +1,4 @@
from api.organization.models import Organizacion
from celery import group
from celery import shared_task, group
from api.customs.models import *
@@ -8,17 +9,37 @@ import requests
from config.settings import SERVICE_API_URL_V2
from datetime import datetime
import json
import logging
import uuid
# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero
TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23')
logger = logging.getLogger('api.customs.microservice_v2')
def credenciales_to_dict(credenciales):
if not credenciales:
return {}
key_value = None
if credenciales.key:
if hasattr(credenciales.key, 'url'):
key_value = credenciales.key.url
else:
key_value = str(credenciales.key)
cer_value = None
if credenciales.cer:
if hasattr(credenciales.cer, 'url'):
cer_value = credenciales.cer.url
else:
cer_value = str(credenciales.cer)
return {
"id": str(credenciales.id),
"user": credenciales.usuario,
"password": credenciales.password,
"efirma": credenciales.efirma,
"key": credenciales.key.url if credenciales.key else None,
"cer": credenciales.cer.url if credenciales.cer else None,
"key": key_value,
"cer": cer_value,
"is_active": credenciales.is_active,
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
}
@@ -70,12 +91,18 @@ def procesar_coves_pedimento(pedimento_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/coves",
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
def procesar_acuse_coves_pedimento(pedimento_id):
@@ -93,12 +120,18 @@ def procesar_acuse_coves_pedimento(pedimento_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
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
def procesar_edocs_pedimento(pedimento_id):
@@ -116,12 +149,18 @@ def procesar_edocs_pedimento(pedimento_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/edoc/",
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
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
def procesar_acuses_pedimento(pedimento_id):
@@ -139,12 +178,18 @@ def procesar_acuses_pedimento(pedimento_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
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
def procesar_partidas_pedimento(pedimento_id):
@@ -156,18 +201,31 @@ def procesar_partidas_pedimento(pedimento_id):
).first()
credenciales_dict = credenciales_to_dict(credenciales)
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)],
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
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
def procesar_remesas_pedimento(pedimento_id):
@@ -184,12 +242,18 @@ def procesar_remesas_pedimento(pedimento_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
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
def procesar_pedimento_completo_individual(pedimento_id):
@@ -204,13 +268,19 @@ def procesar_pedimento_completo_individual(pedimento_id):
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
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
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
raise
@shared_task
def procesar_pedimentos_completos(organizacion_id):
@@ -249,23 +319,41 @@ def procesar_pedimentos_completos(organizacion_id):
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
dataJson = json.dumps(payload)
try:
response = requests.post(
url,
data=dataJson,
headers={"Content-Type": "application/json"}
headers={"Content-Type": "application/json"},
timeout=60
)
# Aquí puedes continuar con el resto de tu lógica
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
continue
@shared_task
def procesar_remesas(organizacion_id):
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos:
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa
# Convertir el pedimento a JSON usando el serializer
logger.info(f"pedimento >>>> {pedimento}")
try:
# if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada
# logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.")
# continue
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
if not credencial_importador:
logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.")
continue
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
if not credenciales:
logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.")
continue
credenciales_dict = credenciales_to_dict(credenciales)
@@ -274,15 +362,17 @@ def procesar_remesas(organizacion_id):
"credencial": credenciales_dict
}
response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas",
f"{SERVICE_API_URL_V2}/services/remesas/",
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()
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
except Exception as e:
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
@shared_task
def procesar_coves(organizacion_id):
@@ -305,14 +395,18 @@ def procesar_coves(organizacion_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/coves",
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
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task
def procesar_acuse_coves(organizacion_id):
@@ -336,14 +430,18 @@ def procesar_acuse_coves(organizacion_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
headers={"Content-Type": "application/json"},
timeout=60
)
# Aquí puedes continuar con el resto de tu lógica
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task
def procesar_acuses(organizacion_id):
@@ -367,14 +465,18 @@ def procesar_acuses(organizacion_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
headers={"Content-Type": "application/json"},
timeout=60
)
# Aquí puedes continuar con el resto de tu lógica
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task
def procesar_edocs(organizacion_id):
@@ -398,14 +500,18 @@ def procesar_edocs(organizacion_id):
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
headers={"Content-Type": "application/json"},
timeout=60
)
# Aquí puedes continuar con el resto de tu lógica
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task
def procesar_partidas(organizacion_id):
@@ -415,27 +521,40 @@ def procesar_partidas(organizacion_id):
).distinct()
for pedimento in pedimentos:
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas
# Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
if not partidas_pendientes:
continue
pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first()
credenciales_dict = credenciales_to_dict(credenciales)
payload = {
"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,
"credencial": credenciales_dict
}
try:
response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/",
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
print(f"Servicio 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}"
)
continue
@shared_task
def documentos_con_errores(organizacion_id):
@@ -507,6 +626,37 @@ def ejecutar_todos_por_organizacion(organizacion_id):
procesar_pedimentos_completos.delay(organizacion_id)
procesar_remesas.delay(organizacion_id)
def ejecutar_basicos_organizacion(organizacion_id):
# solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres
procesar_coves.delay(organizacion_id)
procesar_acuse_coves.delay(organizacion_id)
procesar_edocs.delay(organizacion_id)
procesar_acuses.delay(organizacion_id)
# procesar_partidas.delay(organizacion_id)
# procesar_pedimentos_completos.delay(organizacion_id)
# procesar_remesas.delay(organizacion_id)
@shared_task
def process_organization_batch(org_id):
"""
Procesa todos los tipos de documentos pendientes para una organización.
"""
ejecutar_basicos_organizacion(org_id)
@shared_task
def process_all_organizations():
"""
Envía una tarea por organización activa a la cola org_processing.
"""
active_orgs = Organizacion.objects.filter(
is_active=True,
is_verified=True,
apply_auto_download=True,
)
for org in active_orgs:
process_organization_batch.apply_async(
args=[str(org.id)],
queue='org_processing'
)
return f"Dispatched {active_orgs.count()} organizations"

View File

@@ -3,7 +3,12 @@ from django.urls import reverse
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from unittest.mock import patch
from io import BytesIO
import zipfile
from api.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
User = get_user_model()
@@ -75,3 +80,147 @@ class CustomsViewsTests(APITestCase):
self.client.force_authenticate(user=self.admin)
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# ---------------------------------------------------------------------------
# Tests de integración para bulk-create (ViewSetPedimento.bulk_create)
# Verifica que al re-cargar un pedimento existente sus documentos se actualicen
# ---------------------------------------------------------------------------
class BulkCreateDocumentReplaceTests(APITestCase):
"""Verifica que bulk-create actualiza los documentos de pedimentos existentes
en vez de ignorarlos, y que no quedan archivos residuales en el storage."""
PEDIMENTO_APP = "24-01-3420-1234567"
def setUp(self):
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgBulkCreate",
licencia=self.licencia,
is_active=True,
is_verified=True,
)
self.user = User.objects.create_user(
username="bulkcreateuser", password="pass", organizacion=self.org
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org,
pedimento="1234567",
pedimento_app=self.PEDIMENTO_APP,
)
from api.record.models import DocumentType, Fuente
self.doc_type = DocumentType.objects.get_or_create(nombre="Pedimento")[0]
# bulk_create usa fuente_id=4 hardcodeado; debe existir en la DB de test
Fuente.objects.get_or_create(id=4, defaults={"nombre": "Bulk Create"})
self.url = reverse("Pedimento-bulk-create")
self.client.force_authenticate(user=self.user)
def _make_zip(self, files_dict):
"""Crea un ZIP en memoria. files_dict = {nombre_archivo: contenido_bytes}"""
buf = BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, content in files_dict.items():
zf.writestr(name, content)
buf.seek(0)
return SimpleUploadedFile(
f"{self.PEDIMENTO_APP}.zip", buf.read(), content_type="application/zip"
)
def _post_zip(self, files_dict):
return self.client.post(
self.url,
{"contribuyente": "XAXX010101000", "archivos": [self._make_zip(files_dict)]},
format="multipart",
)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_not_duplicated(self, mock_st):
"""Re-subir un pedimento existente NO debe crear un segundo Pedimento."""
mock_st.save_document_from_path.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
self._post_zip({"informe.pdf": b"contenido"})
self.assertEqual(
Pedimento.objects.filter(
organizacion=self.org, pedimento_app=self.PEDIMENTO_APP
).count(),
1,
)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_document_replaced_not_duplicated(self, mock_st):
"""Documento existente con el mismo nombre base se reemplaza, no se duplica."""
from api.record.models import Document
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
old_doc = Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
new_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
mock_st.save_document_from_path.return_value = new_path
mock_st.delete_file.return_value = True
self._post_zip({"informe.pdf": b"contenido actualizado"})
docs = Document.objects.filter(pedimento=self.pedimento)
# Sin duplicados
self.assertEqual(docs.count(), 1)
# Mismo registro
self.assertEqual(docs.first().id, old_doc.id)
# Archivo actualizado
old_doc.refresh_from_db()
self.assertEqual(old_doc.archivo.name, new_path)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_stale_file_deleted_from_storage(self, mock_st):
"""Al reemplazar un documento, el archivo viejo debe eliminarse del storage."""
from api.record.models import Document
old_path = f"org_1/documents/{self.PEDIMENTO_APP}/informe_a1b2c3d4.pdf"
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
mock_st.save_document_from_path.return_value = f"org_1/documents/{self.PEDIMENTO_APP}/informe_b5c6d7e8.pdf"
mock_st.delete_file.return_value = True
self._post_zip({"informe.pdf": b"contenido"})
# delete_file debe haberse llamado con la ruta del archivo viejo
mock_st.delete_file.assert_called()
called_arg = str(mock_st.delete_file.call_args[0][0])
self.assertIn("informe_a1b2c3d4", called_arg)
@patch("api.customs.views.storage_service")
def test_existing_pedimento_new_file_added(self, mock_st):
"""Archivo nuevo en el ZIP se añade al pedimento existente."""
from api.record.models import Document
mock_st.save_document_from_path.return_value = "org_1/documents/ped/nuevo_b5c6d7e8.pdf"
self._post_zip({"nuevo_documento.pdf": b"contenido nuevo"})
self.assertGreaterEqual(
Document.objects.filter(pedimento=self.pedimento).count(), 1
)
@patch("api.customs.views.storage_service")
def test_already_existing_count_in_response(self, mock_st):
"""La respuesta debe indicar que el pedimento ya existía (already_existing_count >= 1)."""
mock_st.save_document_from_path.return_value = "org_1/documents/ped/f_a1b2c3d4.pdf"
response = self._post_zip({"archivo.pdf": b"contenido"})
self.assertIn(response.status_code, [status.HTTP_200_OK, status.HTTP_207_MULTI_STATUS, status.HTTP_201_CREATED])
data = response.json()
self.assertGreaterEqual(data.get("already_existing_count", 0), 1)

View File

@@ -39,6 +39,7 @@ from .views_auditor import (
auditar_acuse_cove_endpoint,
auditar_edocuments_endpoint,
auditar_acuse_endpoint,
auditar_remesas_endpoint,
auditar_cove_pedimento_endpoint,
auditar_acuse_cove_pedimento_endpoint,
auditar_edocument_pedimento_endpoint,
@@ -61,6 +62,7 @@ from .views_auditor import (
auditor_obtener_peticion_edocument_vu,
auditor_obtener_respuesta_edocument_vu,
auditar_pedimento_endpoint,
procesar_pedimento_completo_endpoint,
)
urlpatterns = [
@@ -72,12 +74,14 @@ urlpatterns = [
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
path('auditor/auditar-remesas/', auditar_remesas_endpoint, name='auditar-remesas'),
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
path('auditor/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'),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,8 @@ from django.db import models
# Create your models here.
class DataStage(models.Model):
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='datastages', null=True, blank=True)
archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
# archivo = models.FileField(upload_to='datastages/', blank=False, null=False)
archivo = models.CharField(max_length=500, blank=True, null=True)
contribuyente = models.CharField(max_length=100, blank=False, null=False)
procesado = models.BooleanField(default=False)
@@ -84,6 +85,8 @@ class Registro501(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro501'
@@ -103,6 +106,8 @@ class Registro502(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True)
patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro502'
@@ -119,6 +124,8 @@ class Registro503(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro503s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro503'
@@ -135,6 +142,8 @@ class Registro504(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro504s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro504'
@@ -164,6 +173,8 @@ class Registro505(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True)
patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro505'
@@ -180,6 +191,8 @@ class Registro506(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro506s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro506'
@@ -198,6 +211,8 @@ class Registro507(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro507s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro507'
@@ -222,6 +237,8 @@ class Registro508(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro508s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro508'
@@ -240,6 +257,8 @@ class Registro509(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro509s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro509'
@@ -260,6 +279,8 @@ class Registro510(models.Model):
forma_pago = models.CharField(max_length=3, null=True, blank=True)
importe_pago = models.CharField(max_length=12, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro510'
@@ -277,6 +298,8 @@ class Registro511(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro511s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro511'
@@ -300,6 +323,8 @@ class Registro512(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro512s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro512'
@@ -362,6 +387,8 @@ class Registro551(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True)
entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro551'
@@ -380,6 +407,8 @@ class Registro552(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro552s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro552'
@@ -401,6 +430,8 @@ class Registro553(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro553'
@@ -420,6 +451,8 @@ class Registro554(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro554s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro554'
@@ -445,6 +478,8 @@ class Registro555(models.Model):
created_by = models.IntegerField(null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro555'
@@ -464,6 +499,8 @@ class Registro556(models.Model):
fraccion = models.CharField(max_length=8, null=True, blank=True)
secuencia_fraccion = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro556'
@@ -483,6 +520,8 @@ class Registro557(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro557'
@@ -501,6 +540,8 @@ class Registro558(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro558'
@@ -521,6 +562,8 @@ class RegistroSel(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro_sel', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro_sel'
@@ -545,6 +588,8 @@ class Registro701(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro701s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro701'
@@ -563,6 +608,8 @@ class Registro702(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True)
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro702s', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = 'registro702'

View File

@@ -1,12 +1,86 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers
from .models import DataStage
from api.organization.models import Organizacion
class DataStageSerializer(serializers.ModelSerializer):
archivo = serializers.FileField(write_only=True, required=False, allow_null=True)
download_url = serializers.SerializerMethodField(read_only=True)
organizacion = serializers.PrimaryKeyRelatedField(required=False, allow_null=True, queryset=Organizacion.objects.all())
class Meta:
model = DataStage
fields = '__all__'
read_only_fields = ('id', 'created_at', 'updated_at')
# extra_kwargs = {'archivo': {'read_only': True},}
def get_download_url(self, obj):
"""Retorna URL de descarga según dónde esté el archivo"""
if not obj.archivo:
return None
if storage_service.is_minio_path(obj.archivo):
return storage_service.get_file_url(obj.archivo)
else:
request = self.context.get('request')
if request:
return request.build_absolute_uri(
f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
)
return f"/api/v1/datastage/datastages/{obj.id}/download-datastage/"
def create(self, validated_data):
"""Override para manejar la subida del archivo a MinIO"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion')
datastage = super().create(validated_data)
print(f"ENDPOINT DE CREATE >>>>")
# guardarlo en MinIO
if archivo_file:
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id if organizacion else datastage.organizacion.id,
metadata={
'datastage_id': str(datastage.id),
'nombre': datastage.nombre if hasattr(datastage, 'nombre') else ''
}
)
if ruta:
datastage.archivo = ruta
datastage.save()
else:
# eliminar el registro creado
datastage.delete()
raise serializers.ValidationError({"archivo": "Error al guardar el archivo en el almacenamiento"})
return datastage
def update(self, instance, validated_data):
"""Override para manejar actualización de archivo"""
archivo_file = validated_data.pop('archivo', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
# Si hay nuevo archivo, reemplazarlo
if archivo_file:
if instance.archivo:
storage_service.delete_file(instance.archivo)
ruta = storage_service.save_datastage(
file=archivo_file,
organizacion_id=organizacion.id,
metadata={
'datastage_id': str(instance.id),
'updated': 'true'
}
)
if ruta:
instance.archivo = ruta
instance.save()
else:
raise serializers.ValidationError({"archivo": "Error al guardar el nuevo archivo"})
return instance

View File

@@ -1,3 +1,4 @@
import tempfile
from celery import group
from celery import shared_task
import logging
@@ -6,81 +7,132 @@ from django.utils import timezone
import os
import zipfile
import re
from api.utils.storage_service import storage_service
logger = logging.getLogger(__name__)
@shared_task
def procesar_datastage_task(datastage_id, user_organizacion_id=None):
import traceback
tmp_path = None
try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage
from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen
# Obtener datastage
try:
datastage = DataStage.objects.get(id=datastage_id)
except DataStage.DoesNotExist:
return {'error': f'DataStage {datastage_id} no encontrado'}
# Validar archivo
if not datastage.archivo:
print("DataStage no tiene archivo asociado")
return {'detail': 'No hay archivo asociado a este DataStage.'}
file_path = datastage.archivo.path
if not os.path.exists(file_path):
return {'detail': 'El archivo no existe en el servidor.'}
if not file_path.endswith('.zip'):
ruta_archivo = str(datastage.archivo)
if not ruta_archivo.lower().endswith('.zip'):
return {'detail': 'El archivo no es un .zip.'}
documentos_encontrados = []
registros_cargados = {}
registros_por_archivo = {}
errores_por_archivo = {}
errores_pedimento = []
# Descargar archivo
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
print(f"No se pudo descargar: {ruta_archivo}")
return {'detail': f'No se pudo descargar el archivo: {ruta_archivo}'}
file_path = tmp_path
# Obtener organización
user_organizacion = None
if user_organizacion_id:
try:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
except Organizacion.DoesNotExist:
print(f"Organización no encontrada: {user_organizacion_id}")
def to_snake_case(name):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower()
# Lanzar una subtarea por cada archivo ASC
# Leer ZIP y lanzar subtareas
subtasks = []
with zipfile.ZipFile(file_path, 'r') as zip_ref:
for asc_name in zip_ref.namelist():
namelist = zip_ref.namelist()
for asc_name in namelist:
if asc_name.endswith('.asc'):
subtasks.append(procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name))
subtasks.append(
procesar_archivo_asc_task.s(datastage_id, user_organizacion_id, asc_name)
)
if subtasks:
job = group(subtasks).apply_async()
print(f"Grupo de tareas lanzado: {job.id}")
return {
'group_id': job.id,
'subtask_ids': [t.id for t in job.results],
'detail': 'Procesamiento lanzado. Monitorea el estado de cada subtask_id.'
'detail': f'Procesamiento lanzado. {len(subtasks)} archivos .ASC en cola.'
}
print("No se encontraron archivos .ASC")
return {'detail': 'No se encontraron archivos .asc'}
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
finally:
# Limpiar temporal
if tmp_path and os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except Exception as e:
print(f"No se pudo eliminar temporal: {e}")
@shared_task
def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
import traceback
"""
Procesa un archivo .ASC individual dentro del ZIP
"""
tmp_path = None
try:
logger = logging.getLogger(__name__)
from api.datastage.models import DataStage
from api.organization.models import Organizacion
from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.apps import apps
import zipfile
import re
import datetime
# Obtener datastage
datastage = DataStage.objects.get(id=datastage_id)
user_organizacion = None
if user_organizacion_id:
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
file_path = datastage.archivo.path
ruta_archivo = str(datastage.archivo)
# Descargar archivo
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta_archivo, tmp_path)
if not success:
return {'errores': [f'No se pudo descargar el archivo: {ruta_archivo}']}
file_path = tmp_path
def to_snake_case(name):
s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name)
s2 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1)
return s2.replace('__', '_').lower()
objects_to_create = []
with zipfile.ZipFile(file_path, 'r') as zip_ref:
if asc_name not in zip_ref.namelist():
print(f"{asc_name} no encontrado en el ZIP")
return {'errores': [f'{asc_name} no encontrado en el zip']}
# Determinar modelo
match = re.match(r'.*_(\d+)\.asc$', asc_name)
if match:
registro_key = match.group(1)
@@ -96,71 +148,86 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
Model = apps.get_model('datastage', model_name)
except LookupError:
return {'errores': [f"No existe el modelo para {model_name}"]}
# Procesar archivo
with zip_ref.open(asc_name) as asc_file:
first = True
field_names = []
field_names_snake = []
objects_to_create = []
errores_pedimento = []
line_count = 0
for line in asc_file:
line_decoded = None
line_count += 1
try:
line_decoded = line.decode('utf-8').strip()
except UnicodeDecodeError:
try:
line_decoded = line.decode('latin-1').strip()
except Exception as e:
continue
except Exception as e:
except Exception:
continue
if not line_decoded:
continue
if first:
field_names = [f for f in line_decoded.split('|')]
field_names = line_decoded.split('|')
# Eliminar columnas vacías del final (líneas terminan con |)
while field_names and field_names[-1] == '':
field_names.pop()
field_names_snake = [to_snake_case(f) for f in field_names]
first = False
continue
values = line_decoded.split('|')
while values and values[-1] == '':
values.pop()
if len(values) == len(field_names_snake) + 1 and values[-1] == '':
values = values[:-1]
if len(values) < len(field_names_snake):
values += [None] * (len(field_names_snake) - len(values))
if len(values) != len(field_names_snake):
logger.debug(
"%s línea %d: esperados %d campos, recibidos %d — se omite",
asc_name, line_count, len(field_names_snake), len(values)
)
continue
data = dict(zip(field_names_snake, values))
if hasattr(Model, 'organizacion_id'):
data['organizacion_id'] = user_organizacion.id if user_organizacion else None
if hasattr(Model, 'datastage_id'):
data['datastage_id'] = datastage.id
# Limpiar campos de fecha vacíos ('') a None
# Parsear y normalizar todos los campos de fecha/datetime
for field in Model._meta.get_fields():
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]:
if data.get(field.name) == "":
if not hasattr(field, 'get_internal_type'):
continue
field_type = field.get_internal_type()
val = data.get(field.name)
if val == '' or val is None:
data[field.name] = None
# Convertir fecha_pago_real a timezone-aware si existe
if 'fecha_pago_real' in data and data['fecha_pago_real']:
from django.utils import timezone
import datetime
fecha_val = data['fecha_pago_real']
if isinstance(fecha_val, str):
try:
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S')
except ValueError:
try:
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d')
except Exception:
continue
if field_type == 'DateTimeField' and isinstance(val, str):
dt = None
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
try:
dt = datetime.datetime.strptime(val, fmt)
break
except ValueError:
continue
if dt and timezone.is_naive(dt):
dt = timezone.make_aware(dt)
if dt:
data['fecha_pago_real'] = dt
elif isinstance(fecha_val, datetime.datetime) and timezone.is_naive(fecha_val):
data['fecha_pago_real'] = timezone.make_aware(fecha_val)
data[field.name] = dt
# Filtrar data para solo incluir campos válidos del modelo
valid_fields = set()
for f in Model._meta.get_fields():
if hasattr(f, 'name'):
valid_fields.add(f.name)
if hasattr(f, 'attname'):
valid_fields.add(f.attname)
data = {k: v for k, v in data.items() if k in valid_fields}
try:
obj = Model(**data)
objects_to_create.append(obj)
# Si es Registro501, crear Pedimento
if model_name == 'Registro501':
organizacion_instance = None
@@ -169,7 +236,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
try:
organizacion_instance = Organizacion.objects.get(id=org_id)
except Exception as org_exc:
logger.warning(f"No se encontró la organización con id {org_id}: {org_exc}")
print(f"No se encontró la organización con id {org_id}: {org_exc}")
if not organizacion_instance:
organizacion_instance = user_organizacion
fecha_pago_raw = data.get('fecha_pago_real')
@@ -198,7 +265,7 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
pedimento_app = f"{year[-2:]}-{str(aduana).zfill(2)[:2]}-{str(patente).zfill(4)[-4:]}-{str(pedimento_num).zfill(7)[-7:]}"
# logger.info(f"pedimento_app >>>> {pedimento_app}")
except Exception as ped_app_exc:
logger.warning(f"No se pudo generar pedimento_app: {ped_app_exc}")
print(f"No se pudo generar pedimento_app: {ped_app_exc}")
tipo_operacion_val = data.get('tipo_operacion')
tipo_operacion = TipoOperacion.objects.filter(id=int(tipo_operacion_val)).first() if tipo_operacion_val else None
regimen = Regimen.objects.filter(claveped=data.get('clave_documento', '').strip(), tipo=tipo_operacion.id if tipo_operacion else None).first() if tipo_operacion else None
@@ -230,18 +297,23 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
"importe_pedimento": data.get('importe_pedimento', 0.0),
"existe_expediente": data.get('existe_expediente', False),
"remesas": data.get('remesas', False),
"consultar_vucem": True,
}
try:
Pedimento.objects.create(**pedimento_data)
except Exception as ped_exc:
pass
logger.warning("No se pudo crear Pedimento %s: %s", pedimento_app, ped_exc)
except Exception as e:
logger.error("%s línea %d: error creando objeto %s: %s", asc_name, line_count, model_name, e)
continue
# Bulk create
if objects_to_create:
try:
Model.objects.bulk_create(objects_to_create, batch_size=1000)
except Exception as e:
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
return {'archivo': asc_name, 'error': str(e)}
return {
'archivo': asc_name,
'insertados': len(objects_to_create)
@@ -250,32 +322,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
import traceback
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
detalles = {}
for key in ['502', '503', '504']:
model_name = f'Registro{key}'
asc_file = None
encabezado = None
errores = []
for asc_name in registros_por_archivo:
if asc_name.endswith(f'_{key}.asc'):
asc_file = asc_name
break
if asc_file:
finally:
# Limpiar temporal
if tmp_path and os.path.exists(tmp_path):
try:
with zipfile.ZipFile(file_path, 'r') as zip_ref:
with zip_ref.open(asc_file) as f:
for line in f:
try:
encabezado = line.decode('utf-8').strip()
except UnicodeDecodeError:
encabezado = line.decode('latin-1').strip()
break
os.unlink(tmp_path)
except Exception as e:
encabezado = f'Error leyendo encabezado: {e}'
errores = errores_por_archivo.get(asc_file, [])
detalles[model_name] = {
'archivo': asc_file,
'encabezado': encabezado,
'errores': errores
}
return {'registros_cargados': registros_cargados, 'errores_pedimento': errores_pedimento}
print(f"No se pudo eliminar temporal: {e}")

View File

@@ -1,3 +1,8 @@
import atexit
import tempfile
from api.utils.storage_service import storage_service
from config import settings
from rest_framework.pagination import PageNumberPagination
from api.customs.models import Pedimento, TipoOperacion, Regimen
from django.shortcuts import render
@@ -7,135 +12,147 @@ from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import FileResponse, Http404
import os
from .models import DataStage
from .serializer import DataStageSerializer
from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
from core.permissions import get_org_context, is_internal_service_request, require_permission
# Create your views here.
class DataStagePagination(PageNumberPagination):
page_size = 20 # Valor por defecto
page_size_query_param = 'page_size'
max_page_size = 1000
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
"""
ViewSet for managing DataStage instances.
Provides CRUD operations for DataStage.
"""
serializer_class = DataStageSerializer
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
model = DataStage
my_tags = ['DataStage']
pagination_class = DataStagePagination
def get_permissions(self):
perms = {
'list': 'datastage.view',
'retrieve': 'datastage.view',
'create': 'datastage.create',
'update': 'datastage.create',
'partial_update': 'datastage.create',
'destroy': 'datastage.delete',
'procesar': 'datastage.process',
'download_datastage': 'datastage.view',
'task_status': 'datastage.view',
}
codename = perms.get(self.action, 'datastage.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self):
if self.request.user.is_superuser:
if is_internal_service_request(self.request):
return DataStage.objects.all().order_by('-created_at')
if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists():
return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at')
return self.get_queryset_filtrado_por_organizacion().order_by('-created_at')
org = get_org_context(self.request.user)
if not org:
return DataStage.objects.none()
return DataStage.objects.filter(organizacion=org).order_by('-created_at')
def perform_create(self, serializer):
"""
Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado.
"""
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("Usuario no autenticado o sin organización")
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()
org = get_org_context(self.request.user)
datastage = serializer.save(organizacion=org)
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):
"""
Método helper para disparar el procesamiento.
"""
from api.datastage.tasks import procesar_datastage_task
user_organizacion = getattr(self.request.user, 'organizacion', None)
user_organizacion_id = user_organizacion.id if user_organizacion else None
org = get_org_context(self.request.user)
datastage.procesado = True
datastage.save()
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
procesar_datastage_task.delay(datastage.id, org.id if org else None)
def perform_update(self, serializer):
"""
Override to ensure organization is set on update.
"""
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("Usuario no autenticado o sin organización")
if self.request.user.is_superuser:
# Allow superuser to update without organization
if is_internal_service_request(self.request):
serializer.save()
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():
serializer.save(organizacion=self.request.user.organizacion)
return
raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage")
def perform_destroy(self, instance):
if instance.archivo:
storage_service.delete_file(instance.archivo)
instance.delete()
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
def download_datastage(self, request, pk=None):
"""
Endpoint para descargar el archivo asociado a un DataStage.
Soporta tanto archivos en MinIO como archivos locales antiguos.
"""
try:
datastage = self.get_object()
if not datastage.archivo:
raise Http404("No hay archivo asociado a este DataStage.")
file_path = datastage.archivo.path
if not os.path.exists(file_path):
raise Http404("El archivo no existe en el servidor.")
response = FileResponse(open(file_path, 'rb'), as_attachment=True, filename=os.path.basename(file_path))
# Detectar si es ruta de MinIO o local
is_minio_path = datastage.archivo.startswith('org_')
if is_minio_path:
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(datastage.archivo, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo de MinIO")
filename = os.path.basename(datastage.archivo)
response = FileResponse(
open(tmp_path, 'rb'),
as_attachment=True,
filename=filename
)
import atexit
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
else:
file_path = os.path.join(settings.MEDIA_ROOT, str(datastage.archivo))
if not os.path.exists(file_path):
raise Http404(f"El archivo no existe: {file_path}")
filename = os.path.basename(file_path)
response = FileResponse(
open(file_path, 'rb'),
as_attachment=True,
filename=filename
)
return response
except Exception as e:
return Response({'detail': str(e)}, status=404)
def perform_destroy(self, instance):
"""
Al eliminar un DataStage, también eliminar su archivo asociado.
"""
if instance.archivo:
storage_service.delete_file(instance.archivo)
instance.delete()
@action(detail=True, methods=['post'], url_path='procesar')
def procesar(self, request, pk=None):
"""
Endpoint para procesar el DataStage de forma asíncrona usando Celery.
"""
# ojo aqui
from api.datastage.tasks import procesar_datastage_task
datastage = self.get_object()
user_organizacion = getattr(self.request.user, 'organizacion', None)
user_organizacion_id = user_organizacion.id if user_organizacion else None
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
org = get_org_context(self.request.user)
task = procesar_datastage_task.delay(datastage.id, org.id if org else None)
return Response({
'task_id': 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:
return UserActivity.objects.none()
# Los usuarios normales solo ven su propia actividad
if self.request.user.is_staff:
if self.request.user.is_superuser:
return UserActivity.objects.all()
return UserActivity.objects.filter(user=self.request.user)

View File

View File

View File

@@ -0,0 +1,472 @@
import os
import time
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
from django.core.management.base import BaseCommand
from django.conf import settings
from minio import Minio
from api.record.models import Document
from api.datastage.models import DataStage
from api.vucem.models import Vucem
from api.reports.models import ReportDocument
class Command(BaseCommand):
help = 'Migra archivos existentes del sistema local a MinIO (versión optimizada)'
def add_arguments(self, parser):
parser.add_argument('--dry-run', action='store_true', help='Solo muestra lo que se migraría')
parser.add_argument('--model', type=str, help='Document, DataStage, Vucem, ReportDocument')
parser.add_argument('--limit', type=int, help='Límite de registros')
parser.add_argument('--batch-size', type=int, default=200, help='Tamaño del lote (default: 200)')
parser.add_argument('--workers', type=int, default=3, help='Número de workers (default: 3)')
parser.add_argument('--offset', type=int, default=0, help='Offset inicial (para reanudar)')
def __init__(self):
super().__init__()
self.client = None
self.bucket_name = None
def _init_minio_client(self):
"""Inicializa el cliente MinIO"""
if self.client is None:
self.client = Minio(
endpoint=os.getenv('MINIO_ENDPOINT', 'minio:9000'),
access_key=os.getenv('MINIO_ACCESS_KEY'),
secret_key=os.getenv('MINIO_SECRET_KEY'),
secure=os.getenv('MINIO_SECURE', 'false').lower() == 'true'
)
self.bucket_name = os.getenv('MINIO_BUCKET_NAME', 'efc-backend-dev')
def handle(self, *args, **options):
dry_run = options.get('dry_run', False)
model_filter = options.get('model')
limit = options.get('limit')
batch_size = options.get('batch_size', 200)
workers = options.get('workers', 3)
offset = options.get('offset', 0)
self.stdout.write(self.style.WARNING('=' * 60))
self.stdout.write(self.style.WARNING('INICIANDO MIGRACIÓN A MINIO (OPTIMIZADA)'))
self.stdout.write(self.style.WARNING(f'Batch size: {batch_size} | Workers: {workers} | Offset: {offset}'))
if dry_run:
self.stdout.write(self.style.WARNING('MODO: DRY RUN (sin cambios)'))
self.stdout.write(self.style.WARNING('=' * 60))
results = {}
if not model_filter or model_filter.lower() == 'document':
results['Document'] = self.migrate_documents(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'datastage':
results['DataStage'] = self.migrate_datastage(dry_run, limit, batch_size, workers, offset)
if not model_filter or model_filter.lower() == 'vucem':
results['Vucem'] = self.migrate_vucem(dry_run, limit, workers)
if not model_filter or model_filter.lower() == 'reportdocument':
results['ReportDocument'] = self.migrate_reports(dry_run, limit, batch_size, workers, offset)
# Resumen final
self.stdout.write('\n' + '=' * 60)
self.stdout.write(self.style.SUCCESS('RESUMEN DE MIGRACIÓN'))
self.stdout.write('=' * 60)
total_migrados = 0
total_no_encontrados = 0
total_errores = 0
for model_name, stats in results.items():
self.stdout.write(f"\n📁 {model_name}:")
self.stdout.write(f" ✅ Migrados: {stats['migrated']}")
self.stdout.write(f" ⚠️ No encontrados: {stats['not_found']}")
self.stdout.write(f" ❌ Errores: {stats['errors']}")
total_migrados += stats['migrated']
total_no_encontrados += stats['not_found']
total_errores += stats['errors']
self.stdout.write('\n' + '-' * 40)
self.stdout.write(f"📊 TOTAL Migrados: {total_migrados}")
self.stdout.write(f"📊 TOTAL No encontrados: {total_no_encontrados}")
self.stdout.write(f"📊 TOTAL Errores: {total_errores}")
if dry_run:
self.stdout.write('\n' + self.style.WARNING('⚠️ MODO DRY RUN - No se realizaron cambios'))
def get_local_file_path(self, path_str):
"""Obtiene la ruta completa del archivo local"""
return Path(settings.MEDIA_ROOT) / path_str
def migrate_documents(self, dry_run, limit, batch_size, workers, offset):
"""Migra documentos del modelo Document"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Document.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📄 Procesando {total} documentos...")
if total == 0:
return stats
start_time = time.time()
processed = 0
# Procesar en lotes
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
# Preparar items para workers
items = []
for doc in batch_docs:
path_str = str(doc.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
pedimento_app = doc.pedimento.pedimento_app if doc.pedimento else 'unknown'
items.append({
'doc': doc,
'local_path': local_path,
'path_str': path_str,
'pedimento_app': pedimento_app
})
# Procesar en paralelo
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_document, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_document(self, item):
"""Sube un documento directamente a MinIO"""
try:
doc = item['doc']
local_path = item['local_path']
pedimento_app = item['pedimento_app']
filename = local_path.name
# Generar ruta MinIO
object_name = f"org_{doc.organizacion_id}/documents/{pedimento_app}/{filename}"
# Subir directamente a MinIO
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
# Actualizar base de datos
doc.archivo = object_name
doc.save(update_fields=['archivo'])
return {'success': True, 'doc_id': doc.id}
except Exception as e:
return {'success': False, 'doc_id': doc.id, 'error': str(e)}
def migrate_datastage(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo DataStage"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = DataStage.objects.exclude(archivo='').exclude(archivo__isnull=True)
queryset = queryset.exclude(archivo__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📦 Procesando {total} archivos DataStage...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for ds in batch_docs:
path_str = str(ds.archivo)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'ds': ds, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_datastage, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_datastage(self, item):
"""Sube un DataStage directamente a MinIO"""
try:
ds = item['ds']
local_path = item['local_path']
filename = local_path.name
object_name = f"org_{ds.organizacion_id}/datastage/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
ds.archivo = object_name
ds.save(update_fields=['archivo'])
return {'success': True, 'id': ds.id}
except Exception as e:
return {'success': False, 'id': ds.id, 'error': str(e)}
def migrate_vucem(self, dry_run, limit, workers):
"""Migra archivos key y cer del modelo Vucem"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = Vucem.objects.all()
if limit:
queryset = queryset[:limit]
total = queryset.count() * 2
self.stdout.write(f"\n🔐 Procesando {queryset.count()} registros VUCEM (key + cer)...")
if total == 0:
return stats
items = []
for vucem in queryset:
if vucem.key and not str(vucem.key).startswith('org_'):
path_str = str(vucem.key)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'key'})
else:
stats['not_found'] += 1
if vucem.cer and not str(vucem.cer).startswith('org_'):
path_str = str(vucem.cer)
local_path = self.get_local_file_path(path_str)
if local_path.exists():
items.append({'vucem': vucem, 'local_path': local_path, 'tipo': 'cer'})
else:
stats['not_found'] += 1
if dry_run:
stats['migrated'] = len(items)
self.stdout.write(f" 📝 [DRY RUN] Se migrarían {len(items)} archivos")
return stats
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_vucem, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
self.stdout.write(self.style.SUCCESS(f"{result['tipo']} migrado: {result['id']}"))
else:
stats['errors'] += 1
return stats
def _upload_vucem(self, item):
"""Sube un archivo VUCEM directamente a MinIO"""
try:
vucem = item['vucem']
local_path = item['local_path']
tipo = item['tipo']
filename = local_path.name
if tipo == 'key':
object_name = f"org_{vucem.organizacion_id}/vucem_keys/{filename}"
vucem.key = object_name
vucem.save(update_fields=['key'])
else:
object_name = f"org_{vucem.organizacion_id}/vucem_certs/{filename}"
vucem.cer = object_name
vucem.save(update_fields=['cer'])
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
return {'success': True, 'id': vucem.id, 'tipo': tipo}
except Exception as e:
return {'success': False, 'id': vucem.id, 'tipo': tipo, 'error': str(e)}
def migrate_reports(self, dry_run, limit, batch_size, workers, offset):
"""Migra archivos del modelo ReportDocument"""
self._init_minio_client()
stats = {'migrated': 0, 'not_found': 0, 'errors': 0}
queryset = ReportDocument.objects.exclude(file='').exclude(file__isnull=True)
queryset = queryset.exclude(file__startswith='org_')
queryset = queryset.order_by('created_at')
if offset:
queryset = queryset[offset:]
if limit:
queryset = queryset[:limit]
total = queryset.count()
self.stdout.write(f"\n📊 Procesando {total} reportes...")
if total == 0:
return stats
start_time = time.time()
processed = 0
for batch_start in range(0, total, batch_size):
batch = queryset[batch_start:batch_start + batch_size]
batch_docs = list(batch)
if dry_run:
stats['migrated'] += len(batch_docs)
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
continue
items = []
for report in batch_docs:
path_str = str(report.file)
local_path = self.get_local_file_path(path_str)
if not local_path.exists():
stats['not_found'] += 1
continue
items.append({'report': report, 'local_path': local_path})
if items:
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {executor.submit(self._upload_report, item): item for item in items}
for future in as_completed(futures):
result = future.result()
if result['success']:
stats['migrated'] += 1
else:
stats['errors'] += 1
processed += len(batch_docs)
self._print_progress(processed, total, start_time, stats)
total_time = time.time() - start_time
self.stdout.write(f"\n ✅ Completado en {total_time/60:.1f} minutos")
return stats
def _upload_report(self, item):
"""Sube un reporte directamente a MinIO"""
try:
report = item['report']
local_path = item['local_path']
filename = local_path.name
filters = report.filters or {}
org_id = filters.get('organizacion_id', 'unknown')
object_name = f"org_{org_id}/reports/{filename}"
self.client.fput_object(
bucket_name=self.bucket_name,
object_name=object_name,
file_path=str(local_path)
)
report.file = object_name
report.save(update_fields=['file'])
return {'success': True, 'id': report.id}
except Exception as e:
return {'success': False, 'id': report.id, 'error': str(e)}
def _print_progress(self, processed, total, start_time, stats):
"""Imprime el progreso actual"""
elapsed = time.time() - start_time
rate = processed / elapsed if elapsed > 0 else 0
pct = processed * 100 / total if total > 0 else 0
self.stdout.write(
f" 📊 {processed}/{total} ({pct:.1f}%) | "
f"{rate:.0f} docs/seg | "
f"{stats['migrated']} | "
f"⚠️ {stats['not_found']} | "
f"{stats['errors']}"
)

View File

@@ -4,31 +4,43 @@ from django.dispatch import receiver
from api.notificaciones.models import Notificacion
from api.record.models import Document
@receiver(post_save, sender=Document)
def trigger_notificacion(sender, instance, created, **kwargs):
if created:
if not created:
return
from api.cuser.models import CustomUser
from api.customs.models import Pedimento
from api.notificaciones.models import TipoNotificacion
from core.permissions import user_has_permission
# Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos)
tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"})
tipo_info, _ = TipoNotificacion.objects.get_or_create(
tipo='info',
defaults={'descripcion': 'Notificación informativa'},
)
mensaje = (
f"Se agregó el documento {instance.archivo} "
f"al pedimento {instance.pedimento.pedimento}\n"
f"{instance.document_type.nombre}"
)
usuarios_org = CustomUser.objects.filter(
organizacion=instance.organizacion,
is_active=True,
).prefetch_related('rfc')
# Notificar a todos los usuarios de la organización
usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion)
for usuario in usuarios_org:
# Notificar solo a importadores cuyo RFC coincide
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()):
if usuario.rfc == instance.pedimento.contribuyente:
if not user_has_permission(usuario, 'notificaciones.receive'):
continue
# Importadores: solo si el pedimento corresponde a uno de sus RFC
if usuario.is_importador:
if instance.pedimento.contribuyente not in usuario.rfc.all():
continue
Notificacion.objects.create(
tipo=tipo_info,
dirigido=usuario,
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
)
# Notificar a otros roles (no importadores)
elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()):
Notificacion.objects.create(
tipo=tipo_info,
dirigido=usuario,
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
mensaje=mensaje,
)

View File

@@ -1,39 +1,36 @@
from django.shortcuts import render
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from rest_framework.exceptions import PermissionDenied
from .models import Notificacion, TipoNotificacion
from .serializers import NotificacionSerializer, TipoNotificacionSerializer
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
# Create your views here.
from core.permissions import require_permission
class TipoNotificacionViewSet(viewsets.ModelViewSet):
queryset = TipoNotificacion.objects.all()
serializer_class = TipoNotificacionSerializer
http_method_names = ['get']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated]
my_tags = ['Notificaciones']
def get_queryset(self):
return self.queryset.order_by('tipo')
class NotificacionViewSet(viewsets.ModelViewSet):
queryset = Notificacion.objects.all()
serializer_class = NotificacionSerializer
http_method_names = ['get', 'post', 'put', 'patch', 'delete']
filterset_fields = ['visto']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
my_tags = ['Notificaciones']
def get_permissions(self):
if self.action in ('list', 'retrieve'):
return [IsAuthenticated(), require_permission('notificaciones.view')()]
return [IsAuthenticated()]
def get_queryset(self):
# Evita error en generación de esquema Swagger
if getattr(self, 'swagger_fake_view', False):
return Notificacion.objects.none()
user = self.request.user
@@ -45,6 +42,6 @@ class NotificacionViewSet(viewsets.ModelViewSet):
if not self.request.user.is_authenticated:
raise PermissionDenied("Usuario no autenticado")
if self.request.user.is_superuser:
# Allow superusers and admins to create notifications for any user
serializer.save()
return
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")

View File

@@ -1,18 +1,22 @@
from django.contrib import admin
from .models import Organizacion
# Register your models here.
@admin.register(Organizacion)
class OrganizacionAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento')
list_display = ('nombre', 'rfc', 'email', 'telefono', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
search_fields = ('nombre', 'rfc', 'email')
list_filter = ('is_active', 'is_verified')
list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
ordering = ('nombre',)
# class UsuarioOrganizacionAdmin(admin.ModelAdmin):
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified')
# search_fields = ('email', 'telefono', 'puesto')
# list_filter = ('is_active', 'is_verified')
# ordering = ('email',)
admin.site.register(Organizacion)
# admin.site.register(UsuarioOrganizacion)
autocomplete_fields = ('owner',)
readonly_fields = ('created_at', 'updated_at')
fieldsets = (
(None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
('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

@@ -40,8 +40,19 @@ class Organizacion(models.Model):
estado = models.CharField(max_length=50)
ciudad = models.CharField(max_length=50)
# Administrador maestro: acceso total a su org, no puede ser removido de su rol por otros admins.
# on_delete=PROTECT: no se puede eliminar el usuario sin reasignar el ownership primero.
owner = models.ForeignKey(
'cuser.CustomUser',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='organizaciones_que_administra',
)
is_active = models.BooleanField(default=True)
is_verified = models.BooleanField(default=False)
apply_auto_download = models.BooleanField(default=False)
inicio = models.DateField(null=True, blank=True)
vencimiento = models.DateField(null=True, blank=True)

View File

@@ -1,8 +1,28 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import Organizacion, UsoAlmacenamiento
@receiver(post_save, sender=Organizacion)
def crear_uso_almacenamiento(sender, instance, created, **kwargs):
if created:
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0)
@receiver(post_save, sender=Organizacion)
def crear_roles_default(sender, instance, created, **kwargs):
"""Al crear una organización nueva, genera automáticamente los 5 roles por defecto
con sus permisos. Depende de que el catálogo RolePermission ya exista (post-migration)."""
if not created:
return
try:
from api.rbac.roles import crear_roles_para_organizacion
crear_roles_para_organizacion(instance)
except Exception:
# Si la app rbac aún no está migrada (ej. primer deploy), no bloquear la creación de org
import logging
logging.getLogger(__name__).warning(
'No se pudieron crear roles para org %s — verifica que rbac esté migrado.',
instance.id,
)

View File

@@ -9,7 +9,10 @@ from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
IsSuperUser,
get_org_context,
is_internal_service_request,
user_has_permission,
)
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
from .models import Organizacion, UsoAlmacenamiento
@@ -32,22 +35,20 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr
my_tags = ['Organizaciones']
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()
if self.request.user.is_superuser:
# Superuser can see all organizations
if is_internal_service_request(self.request):
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():
# Importers can only see their own organization
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)
org = get_org_context(user)
if not org:
return Organizacion.objects.none()
# Superuser ve solo su org activa, no todas
return Organizacion.objects.filter(id=org.id)
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
"""
Vista para consultar el uso de almacenamiento
@@ -60,31 +61,26 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
my_tags = ['Uso de Almacenamiento']
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()
if self.request.user.is_superuser:
# Superuser can see all storage usage
if is_internal_service_request(self.request):
return UsoAlmacenamiento.objects.all()
if (self.request.user.groups.filter(name='developer').exists() or
self.request.user.groups.filter(name='admin').exists() or
self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
# Developers, Admins, and Users can see their organization's storage usage
return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion)
org = get_org_context(self.request.user)
if not org:
return UsoAlmacenamiento.objects.none()
if self.request.user.groups.filter(name='importador').exists():
# Importers can only see their own organization's storage usage
if self.request.user.is_importador:
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'])
def mi_organizacion(self, request):
"""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
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

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

@@ -17,6 +17,14 @@ class DocumentSerializer(serializers.ModelSerializer):
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
def get_pedimento_numero(self, obj):
# Si es un diccionario (durante create)
if isinstance(obj, dict):
pedimento = obj.get('pedimento')
if pedimento and hasattr(pedimento, 'pedimento_app'):
return pedimento.pedimento_app
return None
# Si es una instancia del modelo (durante retrieve/list)
if obj.pedimento:
return obj.pedimento.pedimento_app
return None
@@ -28,9 +36,19 @@ class DocumentSerializer(serializers.ModelSerializer):
return value
def get_fuente_nombre(self, obj):
# Método 1: Si la fuente está precargada con select_related
"""Obtiene el nombre de la fuente de forma segura"""
if isinstance(obj, dict):
fuente = obj.get('fuente')
if fuente and hasattr(fuente, 'nombre'):
return fuente.nombre
return "Desconocido"
try:
if obj.fuente:
return obj.fuente.nombre
except AttributeError:
pass
return "Desconocido"
class FuenteSerializer(serializers.ModelSerializer):

View File

@@ -1,12 +1,16 @@
from django.urls import reverse
from django.test import TestCase
from rest_framework.test import APITestCase, APIClient
from rest_framework import status
from django.core.files.uploadedfile import SimpleUploadedFile
from unittest.mock import patch, MagicMock
from api.organization.models import Organizacion, UsoAlmacenamiento
from api.cuser.models import CustomUser
from api.customs.models import Pedimento
from .models import Document
from api.licence.models import Licencia
from api.customs.views import is_same_document, get_clean_base_filename
from .models import Document, DocumentType
import io
class DocumentViewSetTests(APITestCase):
@@ -95,3 +99,177 @@ class DocumentViewSetTests(APITestCase):
url = reverse('descargar-documento', args=[doc.id])
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
# ---------------------------------------------------------------------------
# Tests unitarios para las funciones helper de comparación de documentos
# ---------------------------------------------------------------------------
class DocumentNameHelperTests(TestCase):
"""Verifica que get_clean_base_filename e is_same_document manejan
correctamente el sufijo UUID de 8 chars que añade storage_service."""
def test_strips_uuid_suffix(self):
self.assertEqual(get_clean_base_filename('informe_a1b2c3d4.pdf'), 'informe')
def test_no_suffix_unchanged(self):
self.assertEqual(get_clean_base_filename('informe.pdf'), 'informe')
def test_is_same_document_matches_stored_uuid_name(self):
"""El archivo guardado tiene sufijo, el nuevo no — deben coincidir."""
doc = MagicMock()
doc.archivo.name = 'org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertTrue(is_same_document(doc, 'informe.pdf'))
def test_is_same_document_different_name_no_match(self):
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertFalse(is_same_document(doc, 'otro.pdf'))
def test_is_same_document_different_extension_no_match(self):
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/informe_a1b2c3d4.pdf'
doc.extension = 'pdf'
self.assertFalse(is_same_document(doc, 'informe.xml'))
def test_both_clean_names_equal(self):
"""Dos archivos con UUID distintos pero mismo nombre base deben coincidir."""
doc = MagicMock()
doc.archivo.name = 'org_1/documents/ped/pedimento_a1b2c3d4.xml'
doc.extension = 'xml'
self.assertTrue(is_same_document(doc, 'pedimento_b5c6d7e8.xml'))
# ---------------------------------------------------------------------------
# Tests de integración para bulk-upload (DocumentViewSet.bulk_upload)
# ---------------------------------------------------------------------------
class BulkUploadReplaceTests(APITestCase):
"""Verifica que bulk-upload reemplaza documentos existentes en vez de duplicar
y que no quedan archivos residuales en el storage."""
def setUp(self):
self.licencia = Licencia.objects.create(nombre="Lic100GB", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgBulkUpload",
licencia=self.licencia,
is_active=True,
is_verified=True,
)
self.user = CustomUser.objects.create_user(
username="bulkuploaduser", password="pass", organizacion=self.org
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org,
pedimento="1234567",
pedimento_app="24-01-3420-1234567",
)
self.doc_type = DocumentType.objects.get_or_create(nombre="Documento General")[0]
self.url = reverse("Document-bulk-upload")
self.client.force_authenticate(user=self.user)
def _post_file(self, filename, content=b"contenido de prueba"):
archivo = SimpleUploadedFile(filename, content, content_type="application/pdf")
return self.client.post(
self.url,
{"pedimento_id": str(self.pedimento.id), "files": [archivo]},
format="multipart",
)
@patch("api.record.views.storage_service")
def test_new_file_creates_document(self, mock_st):
"""Subir un archivo nuevo crea exactamente un Document."""
mock_st.save_document.return_value = "org_1/documents/ped/informe_a1b2c3d4.pdf"
response = self._post_file("informe.pdf")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 1)
mock_st.delete_file.assert_not_called()
@patch("api.record.views.storage_service")
def test_duplicate_replaces_not_creates(self, mock_st):
"""Re-subir el mismo archivo debe actualizar el Document existente,
no crear uno nuevo."""
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
old_doc = Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
new_path = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
mock_st.save_document.return_value = new_path
mock_st.delete_file.return_value = True
response = self._post_file("informe.pdf", b"contenido actualizado")
self.assertIn(response.status_code, [status.HTTP_201_CREATED, status.HTTP_207_MULTI_STATUS])
docs = Document.objects.filter(pedimento=self.pedimento)
# Un único Document — sin duplicados
self.assertEqual(docs.count(), 1)
# Es el mismo registro (mismo UUID)
self.assertEqual(docs.first().id, old_doc.id)
# El campo archivo fue actualizado
old_doc.refresh_from_db()
self.assertEqual(old_doc.archivo.name, new_path)
@patch("api.record.views.storage_service")
def test_replace_deletes_old_storage_file(self, mock_st):
"""Al reemplazar, delete_file debe llamarse con la ruta del archivo viejo."""
old_path = "org_1/documents/24-01-3420-1234567/informe_a1b2c3d4.pdf"
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo=old_path,
size=500,
extension="pdf",
)
mock_st.save_document.return_value = "org_1/documents/24-01-3420-1234567/informe_b5c6d7e8.pdf"
mock_st.delete_file.return_value = True
self._post_file("informe.pdf")
mock_st.delete_file.assert_called_once_with(old_path)
@patch("api.record.views.storage_service")
def test_different_filename_creates_new_document(self, mock_st):
"""Archivo con nombre diferente debe crear un Document adicional."""
Document.objects.create(
organizacion=self.org,
pedimento=self.pedimento,
document_type=self.doc_type,
archivo="org_1/documents/ped/informe_a1b2c3d4.pdf",
size=500,
extension="pdf",
)
mock_st.save_document.return_value = "org_1/documents/ped/otro_b5c6d7e8.pdf"
self._post_file("otro.pdf")
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
mock_st.delete_file.assert_not_called()
@patch("api.record.views.storage_service")
def test_multiple_files_no_cross_replacement(self, mock_st):
"""Subir dos archivos distintos en la misma petición crea dos Documents."""
mock_st.save_document.side_effect = [
"org_1/documents/ped/a_a1b2c3d4.pdf",
"org_1/documents/ped/b_a1b2c3d4.pdf",
]
archivos = [
SimpleUploadedFile("a.pdf", b"contenido a", content_type="application/pdf"),
SimpleUploadedFile("b.pdf", b"contenido b", content_type="application/pdf"),
]
self.client.post(
self.url,
{"pedimento_id": str(self.pedimento.id), "files": archivos},
format="multipart",
)
self.assertEqual(Document.objects.filter(pedimento=self.pedimento).count(), 2)
mock_st.delete_file.assert_not_called()

View File

@@ -24,12 +24,15 @@ from rest_framework.decorators import action
from datetime import timedelta
from django.utils import timezone
from django.db.models import Q
from api.utils.storage_service import storage_service
from rest_framework.authentication import TokenAuthentication
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
get_org_context,
require_permission,
user_has_permission,
IsInternalService,
)
import logging
@@ -141,26 +144,51 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"""
ViewSet for Document model.
"""
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
model = Document
pagination_class = CustomPagination
serializer_class = DocumentSerializer
# Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado)
filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento', 'created_at']
# filterset_fields = ['extension', 'size', 'pedimento', 'pedimento__pedimento']
# Puedes filtrar por pedimento usando: /api/record/documents/?pedimento=<id> o /api/record/documents/?pedimento__pedimento=<numero>
# Ejemplo: /api/record/documents/?pedimento_numero=12345678
my_tags = ['Documents']
def get_queryset(self):
queryset = self.get_queryset_filtrado_por_organizacion()
def get_permissions(self):
# Service account (Token + superuser): acceso directo sin RBAC de org
if (self.request.user.is_authenticated and self.request.user.is_superuser and
isinstance(getattr(self.request, 'successful_authenticator', None), TokenAuthentication)):
return [IsAuthenticated(), IsInternalService()]
perms = {
'list': 'documentos.view',
'retrieve': 'documentos.view',
'create': 'documentos.upload',
'update': 'documentos.upload',
'partial_update': 'documentos.upload',
'destroy': 'documentos.delete',
'vu_documentos_errores': 'documentos.view',
'bulk_delete': 'documentos.delete',
'bulk_delete_partidas_vu': 'documentos.delete',
'bulk_delete_coves_vu': 'documentos.delete',
'bulk_delete_edocs_vu': 'documentos.delete',
'bulk_upload': 'documentos.upload',
'bulk_upload_vu': 'documentos.upload',
'create_vu_record': 'documentos.upload',
}
codename = perms.get(self.action, 'documentos.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self):
user = self.request.user
if user.is_superuser and isinstance(
getattr(self.request, 'successful_authenticator', None), TokenAuthentication
):
queryset = Document.objects.all()
else:
if not user_has_permission(user, 'documentos.view'):
return Document.objects.none()
queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo')
if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos':
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10'])
queryset = queryset.exclude(document_type_id__in=['1','2','3','4','5','6','7','8','9','10','25','23','21','19','17','15','13','16'])
# Filtro personalizado por document_type
# document_type = self.request.query_params.get('document_type')
# if document_type:
@@ -252,14 +280,34 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
try:
# Guardar documento y actualizar espacio atómicamente
documento = serializer.save(
pedimento = serializer.validated_data.get('pedimento')
pedimento_app = pedimento.pedimento_app if pedimento else None
documento = Document.objects.create(
document_type=document_type,
organizacion=organizacion,
pedimento=pedimento,
size=archivo.size,
extension=archivo.name.split('.')[-1].lower()
)
ruta = storage_service.save_document(
file=archivo,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={'source': 'document_create'}
)
if ruta:
documento.archivo = ruta
documento.save()
# si no agrego esto, el proceso no retorna todos los campos necesarios como id, si lo agrega a minIO pero no
# actualiza su status.
serializer.instance = documento
else:
documento.delete()
raise ValidationError({"archivo": "Error al guardar el archivo"})
except Exception as e:
# Guardar documento y actualizar espacio atómicamente
documento = serializer.save(
@@ -300,17 +348,45 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
}, code=status.HTTP_400_BAD_REQUEST)
# Actualizar documento y espacio
serializer.save(size=new_file.size)
if instance.archivo:
ruta_anterior = str(instance.archivo)
storage_service.delete_file(ruta_anterior)
pedimento = instance.pedimento
pedimento_app = pedimento.pedimento_app if pedimento else None
ruta = storage_service.save_document(
file=new_file,
organizacion_id=organizacion.id,
pedimento_app=pedimento_app,
metadata={'source': 'document_update'}
)
if ruta:
instance.archivo = ruta
instance.size = new_file.size
instance.extension = new_file.name.split('.')[-1].lower()
instance.save()
uso.espacio_utilizado = nuevo_espacio_utilizado
uso.save()
else:
raise ValidationError({"archivo": "Error al actualizar el archivo"})
else:
serializer.save()
def perform_destroy(self, instance):
from api.utils.storage_service import storage_service
if instance.archivo:
ruta = str(instance.archivo)
storage_service.delete_file(ruta)
# Restar el espacio al eliminar
uso = UsoAlmacenamiento.objects.get(organizacion=instance.organizacion)
uso.espacio_utilizado -= instance.size
uso.save()
instance.delete()
@action(detail=False, methods=['get'], url_path='vu-documentos-errores')
@@ -508,11 +584,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -700,12 +775,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
for doc in existing_documents:
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
@@ -899,12 +974,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
pass
# Eliminar los documentos
for doc in existing_documents:
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
@@ -1099,13 +1174,11 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Eliminar los documentos
archivos_eliminados = 0
for doc in existing_documents:
try:
# Eliminar archivo físico
if doc.archivo and doc.archivo.storage.exists(doc.archivo.name):
doc.archivo.delete(save=False) # save=False para no intentar guardar el modelo
if doc.archivo:
ruta = str(doc.archivo)
storage_service.delete_file(ruta)
# Eliminar registro de la base de datos
doc.delete()
archivos_eliminados += 1
except Exception as e:
@@ -1233,15 +1306,21 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
status=status.HTTP_403_FORBIDDEN
)
# Usar tipo de documento por defecto siempre
document_type, created = DocumentType.objects.get_or_create(
# Usar tipo de documento indicado o "Documento General" por defecto
document_type_id_param = request.data.get('document_type_id')
if document_type_id_param:
try:
document_type = DocumentType.objects.get(id=int(document_type_id_param))
except (DocumentType.DoesNotExist, ValueError):
return Response(
{"error": f"Tipo de documento con ID '{document_type_id_param}' no encontrado"},
status=status.HTTP_400_BAD_REQUEST
)
else:
document_type, _ = DocumentType.objects.get_or_create(
nombre="Documento General",
defaults={'descripcion': "Documento general sin tipo específico"}
)
if created:
print(f"✅ DocumentType creado: {document_type.nombre} (ID: {document_type.id})")
else:
print(f"♻️ DocumentType existente: {document_type.nombre} (ID: {document_type.id})")
uploaded_documents = []
failed_files = []
@@ -1279,6 +1358,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"codigo": "bulk_storage_limit_exceeded"
}, status=status.HTTP_400_BAD_REQUEST)
# Cargar documentos existentes del pedimento para detectar y reemplazar duplicados
existing_docs = list(Document.objects.filter(
pedimento_id=pedimento_id,
organizacion=organizacion
))
# Procesar cada archivo
espacio_usado_temp = espacio_inicial
@@ -1293,15 +1378,59 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
# Crear el documento
# Detectar si ya existe un documento con el mismo nombre base + extensión.
# storage_service agrega un sufijo UUID de 8 chars al guardar, hay que ignorarlo.
new_name_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(file.name)[0]).lower().strip('_')
existing_doc = None
for doc in existing_docs:
if doc.archivo:
doc_basename = os.path.basename(doc.archivo.name)
doc_base = re.sub(r'_[a-zA-Z0-9]{8}$', '', os.path.splitext(doc_basename)[0]).lower().strip('_')
doc_ext = (doc.extension or '').lower()
if new_name_base == doc_base and extension == doc_ext:
existing_doc = doc
break
if existing_doc:
# Reemplazar archivo del documento existente
if existing_doc.archivo:
storage_service.delete_file(existing_doc.archivo.name)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload_replace'}
)
if ruta:
existing_doc.archivo = ruta
existing_doc.size = file.size
existing_doc.extension = extension
existing_doc.document_type = document_type
existing_doc.save()
else:
raise Exception(f"Error al guardar archivo: {file.name}")
document = existing_doc
else:
# Crear nuevo documento
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
extension=extension
)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload'}
)
if ruta:
document.archivo = ruta
document.save()
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
# Actualizar espacio usado
espacio_usado_temp += file.size
@@ -1312,7 +1441,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"filename": file.name,
"size": file.size,
"extension": extension,
"document_type": document_type.nombre
"document_type": document.document_type.nombre if document.document_type else None
})
except Exception as e:
@@ -1586,12 +1715,24 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
archivo=file,
size=file.size,
fuente_id=7,
extension=extension
)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload'}
)
if ruta:
document.archivo = ruta
document.save()
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
# Actualizar espacio usado
espacio_usado_temp += file.size
total_space_used += file.size
@@ -1644,8 +1785,267 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
return Response(response_data, status=response_status)
@action(detail=False, methods=['post'], url_path='create-vu-record', parser_classes=[MultiPartParser])
def create_vu_record(self, request):
"""
Crea un registro (Partida/Cove/EDocument) en su tabla correspondiente
y sube sus archivos con la nomenclatura VU.
FormData:
- pedimento_id : UUID del pedimento
- tab_seccion : 'partida' | 'cove' | 'edoc'
- numero : número del registro a crear
- files : archivos (nombre con flag de sección: .xml.general, .pdf.acuse, etc.)
"""
pedimento_id = request.data.get('pedimento_id')
tab_seccion = request.data.get('tab_seccion')
numero = request.data.get('numero', '').strip()
files = request.FILES.getlist('files')
if not pedimento_id:
return Response({"error": "Se requiere 'pedimento_id'"}, status=status.HTTP_400_BAD_REQUEST)
if tab_seccion not in ('partida', 'cove', 'edoc'):
return Response({"error": "tab_seccion debe ser 'partida', 'cove' o 'edoc'"}, status=status.HTTP_400_BAD_REQUEST)
if not numero:
return Response({"error": "Se requiere 'numero'"}, status=status.HTTP_400_BAD_REQUEST)
if not files:
return Response({"error": "Se requiere al menos un archivo"}, status=status.HTTP_400_BAD_REQUEST)
if not request.user.is_authenticated:
return Response({"error": "Usuario no autenticado"}, status=status.HTTP_401_UNAUTHORIZED)
from api.customs.models import Pedimento as PedimentoModel, Partida, Cove, EDocument
try:
pedimento = PedimentoModel.objects.get(id=pedimento_id)
except PedimentoModel.DoesNotExist:
return Response({"error": "Pedimento no encontrado"}, status=status.HTTP_404_NOT_FOUND)
organizacion = pedimento.organizacion
if not request.user.is_superuser:
if not hasattr(request.user, 'organizacion') or request.user.organizacion != organizacion:
return Response({"error": "Sin permisos para este pedimento"}, status=status.HTTP_403_FORBIDDEN)
# Validar número entero para partida
numero_int = None
if tab_seccion == 'partida':
try:
numero_int = int(numero)
except ValueError:
return Response({"error": "El número de partida debe ser un entero"}, status=status.HTTP_400_BAD_REQUEST)
uploaded_documents = []
failed_files = []
errors = []
total_space_used = 0
expediente_obj = None
try:
with transaction.atomic():
uso = UsoAlmacenamiento.objects.select_for_update().get_or_create(
organizacion=organizacion,
defaults={'espacio_utilizado': 0}
)[0]
max_bytes = organizacion.licencia.almacenamiento * 1024 ** 3
total_files_size = sum(f.size for f in files)
if uso.espacio_utilizado + total_files_size > max_bytes:
espacio_faltante = (uso.espacio_utilizado + total_files_size) - max_bytes
return Response({
"error": "Espacio de almacenamiento insuficiente",
"espacio_faltante_gb": round(espacio_faltante / (1024 ** 3), 2),
}, status=status.HTTP_400_BAD_REQUEST)
# Verificar unicidad y crear registro
if tab_seccion == 'partida':
if Partida.objects.filter(pedimento=pedimento, numero_partida=numero_int).exists():
return Response(
{"error": f"La partida {numero} ya existe para este pedimento"},
status=status.HTTP_409_CONFLICT
)
expediente_obj = Partida.objects.create(
pedimento=pedimento,
organizacion=organizacion,
numero_partida=numero_int
)
elif tab_seccion == 'cove':
if Cove.objects.filter(pedimento=pedimento, numero_cove=numero).exists():
return Response(
{"error": f"El COVE {numero} ya existe para este pedimento"},
status=status.HTTP_409_CONFLICT
)
expediente_obj = Cove.objects.create(
pedimento=pedimento,
organizacion=organizacion,
numero_cove=numero
)
elif tab_seccion == 'edoc':
if EDocument.objects.filter(pedimento=pedimento, numero_edocument=numero).exists():
return Response(
{"error": f"El EDocument {numero} ya existe para este pedimento"},
status=status.HTTP_409_CONFLICT
)
expediente_obj = EDocument.objects.create(
pedimento=pedimento,
organizacion=organizacion,
numero_edocument=numero
)
espacio_usado_temp = uso.espacio_utilizado
uploaded_secciones = set()
for file in files:
try:
if not file.name:
failed_files.append("archivo_sin_nombre")
errors.append("Archivo sin nombre detectado")
continue
filename = file.name
if '.' in filename:
base = '.'.join(filename.split('.')[:-1])
secciones = filename.split('.')[-1]
else:
base = filename
secciones = ''
file.name = base
extension = file.name.split('.')[-1].lower() if '.' in file.name else ''
if tab_seccion == 'partida':
nuevo_nombre = f"vu_PT_{pedimento.pedimento_app}_{numero}.{extension}"
document_type, _ = DocumentType.objects.get_or_create(
nombre="Pedimento Partida",
defaults={'descripcion': "Tag para saber que el archivo guarda una partida"}
)
elif tab_seccion == 'cove':
if secciones == 'acuse':
nuevo_nombre = f"vu_AC_COVE_{pedimento.pedimento_app}_{numero}.{extension}"
document_type, _ = DocumentType.objects.get_or_create(
nombre="Acuse Cove",
defaults={'descripcion': "Tag para saber que el archivo guarda un acuse de cove"}
)
else:
nuevo_nombre = f"vu_COVE_{pedimento.pedimento_app}_{numero}.{extension}"
document_type, _ = DocumentType.objects.get_or_create(
nombre="Cove",
defaults={'descripcion': "Tag para saber que el archivo guarda un cove"}
)
elif tab_seccion == 'edoc':
if secciones == 'acuse':
nuevo_nombre = f"vu_AC_{pedimento.pedimento_app}_{numero}.{extension}"
document_type, _ = DocumentType.objects.get_or_create(
nombre="Pedimento Acuse",
defaults={'descripcion': "Tag para saber que el documento es un Acuse"}
)
else:
nuevo_nombre = f"vu_ED_{pedimento.pedimento_app}_{numero}.{extension}"
document_type, _ = DocumentType.objects.get_or_create(
nombre="Pedimento EDocument",
defaults={'descripcion': "Tag para saber que el documento es un EDocument"}
)
file.name = nuevo_nombre
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_id,
document_type=document_type,
size=file.size,
extension=extension
)
ruta = storage_service.save_document(
file=file,
organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app,
metadata={'source': 'create_vu_record'}
)
if ruta:
document.archivo = ruta
document.save()
else:
document.delete()
raise Exception(f"Error al guardar archivo: {file.name}")
espacio_usado_temp += file.size
total_space_used += file.size
uploaded_secciones.add(secciones)
uploaded_documents.append({
"id": str(document.id),
"filename": file.name,
"size": file.size,
"extension": extension,
"document_type": document_type.nombre
})
except Exception as e:
failed_files.append(file.name)
errors.append(f"Error al procesar {file.name}: {str(e)}")
continue
# Actualizar flags de descarga según secciones subidas exitosamente
if tab_seccion == 'partida':
if uploaded_secciones:
expediente_obj.descargado = True
expediente_obj.save(update_fields=['descargado'])
elif tab_seccion == 'cove':
update_fields = []
if 'general' in uploaded_secciones:
expediente_obj.cove_descargado = True
update_fields.append('cove_descargado')
if 'acuse' in uploaded_secciones:
expediente_obj.acuse_cove_descargado = True
update_fields.append('acuse_cove_descargado')
if update_fields:
expediente_obj.save(update_fields=update_fields)
elif tab_seccion == 'edoc':
update_fields = []
if 'general' in uploaded_secciones:
expediente_obj.edocument_descargado = True
update_fields.append('edocument_descargado')
if 'acuse' in uploaded_secciones:
expediente_obj.acuse_descargado = True
update_fields.append('acuse_descargado')
if update_fields:
expediente_obj.save(update_fields=update_fields)
uso.espacio_utilizado = espacio_usado_temp
uso.save()
except Exception as e:
return Response(
{"error": f"Error durante el procesamiento: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
space_used_mb = round(total_space_used / (1024 * 1024), 2)
response_data = {
"uploaded_count": len(uploaded_documents),
"uploaded_documents": uploaded_documents,
"space_used_mb": space_used_mb,
"pedimento_id": str(pedimento_id),
"expediente_id": str(expediente_obj.id),
"tab_seccion": tab_seccion,
"numero": numero,
}
if failed_files:
response_data.update({
"message": f"Registro creado pero algunos archivos fallaron",
"failed_files": failed_files,
"errors": errors
})
response_status = status.HTTP_207_MULTI_STATUS
else:
response_data["message"] = f"{tab_seccion.capitalize()} {numero} creado exitosamente"
response_status = status.HTTP_201_CREATED
return Response(response_data, status=response_status)
class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
serializer_class = DocumentSerializer
model = Document
my_tags = ['Documents']
@@ -1654,29 +2054,48 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
return self.get_queryset_filtrado_por_organizacion()
def get(self, request, pk):
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
import tempfile
import os
from api.utils.storage_service import storage_service
try:
doc = Document.objects.get(pk=pk)
except Document.DoesNotExist:
raise Http404("Documento no encontrado")
# Verifica que el usuario pertenece a la organización del documento
if self.request.user.is_superuser:
return FileResponse(doc.archivo.open('rb'))
if doc.organizacion != request.user.organizacion:
org = get_org_context(request.user)
if doc.organizacion != org:
raise Http404("No autorizado")
return FileResponse(doc.archivo.open('rb'))
if not doc.archivo:
raise Http404("Documento sin archivo asociado")
ruta = str(doc.archivo)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
import atexit
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
class BulkDownloadZipView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents']
def post(self, request):
import tempfile
import os
from api.utils.storage_service import storage_service
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
return Response({"error": "Usuario no autenticado o sin organización"}, status=401)
@@ -1695,25 +2114,90 @@ class BulkDownloadZipView(APIView):
return Response({"error": "Uno o más documentos no existen o no pertenecen a su organización."}, status=404)
buffer = BytesIO()
missing_files = []
temp_files = [] # Para limpiar después
files_found = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# Usar solo el nombre del archivo sin descripcion
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
zip_name = f"{file_name}.{ext}"
doc.archivo.open('rb')
zip_file.writestr(zip_name, doc.archivo.read())
doc.archivo.close()
if not doc.archivo:
missing_files.append(f"{doc.id} (sin archivo)")
continue
ruta = str(doc.archivo)
# ============ DETECTAR TIPO DE RUTA ============
is_minio = ruta.startswith('org_')
if is_minio:
# Verificar en MinIO
if not storage_service.file_exists(ruta):
missing_files.append(f"{doc.id} ({ruta})")
continue
else:
# Verificar en sistema local
from pathlib import Path
from django.conf import settings
full_path = Path(settings.MEDIA_ROOT) / ruta
if not full_path.exists():
missing_files.append(f"{doc.id} ({ruta})")
continue
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
if is_minio:
success = storage_service.download_file(ruta, tmp_path)
else:
import shutil
full_path = Path(settings.MEDIA_ROOT) / ruta
try:
shutil.copy2(full_path, tmp_path)
success = True
except Exception as e:
success = False
if not success:
missing_files.append(f"{doc.id} ({ruta})")
continue
files_found.append(f"{doc.id} ({ruta})")
file_name = slugify(ruta.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = ruta.split('.')[-1] if '.' in ruta else ''
zip_name = f"{file_name}.{ext}" if ext else file_name
with open(tmp_path, 'rb') as f:
zip_file.writestr(zip_name, f.read())
buffer.seek(0)
safe_name = slugify(pedimento_nombre)
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={safe_name or "documentos"}.zip'
if missing_files:
response['X-Missing-Files'] = ', '.join(missing_files[:5]) # Primeros 5
response['Access-Control-Expose-Headers'] = 'X-Missing-Files'
return response
except Exception as e:
return Response(
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class GetFuenteView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
serializer_class = FuenteSerializer
my_tags = ['Fuente Documentos']
@@ -1728,7 +2212,7 @@ class GetFuenteView(APIView):
return Response(serializer.data, status=200)
class DocumentTypeView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
serializer_class = DocumentTypeSerializer
my_tags = ['Tipo de Documentos']
@@ -1745,7 +2229,7 @@ class DocumentTypeView(APIView):
return Response(serializer.data, status=200)
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents']
def post(self, request):
@@ -1753,6 +2237,10 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
Descarga todos los documentos de un pedimento (o filtrados) en un ZIP.
Body: { "pedimento_id": "<uuid>" }
"""
import tempfile
import os
from api.utils.storage_service import storage_service
pedimento_id = request.data.get('pedimento_id')
if not pedimento_id:
return Response({"error": "Falta pedimento_id"}, status=status.HTTP_400_BAD_REQUEST)
@@ -1774,52 +2262,76 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
if not docs.exists():
return Response({"error": "No hay documentos para este pedimento"}, status=status.HTTP_404_NOT_FOUND)
# 1. Crear un único buffer y ZIP para todos los archivos
buffer = BytesIO()
missing_files = [] # opcional: para informar después
missing_files = []
files_found = []
temp_files = []
try:
with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for doc in docs:
# 2. Validaciones
if not doc.archivo.name:
logger.warning("Documento %s no tiene archivo asociado", doc.id)
if not doc.archivo:
missing_files.append(f"{doc.id} (sin archivo)")
continue
if not default_storage.exists(doc.archivo.name):
logger.warning("Archivo no encontrado en disco: %s", doc.archivo.path)
missing_files.append(f"{doc.id} ({doc.archivo.name})")
ruta = str(doc.archivo)
if not storage_service.file_exists(ruta):
missing_files.append(f"{doc.id} ({ruta})")
continue
files_found.append(f"{doc.id} ({doc.archivo.name})")
with tempfile.NamedTemporaryFile(delete=False, suffix='.tmp') as tmp:
tmp_path = tmp.name
temp_files.append(tmp_path)
# 3. Nombre seguro para dentro del ZIP
file_name = slugify(doc.archivo.name.rsplit('/', 1)[-1].rsplit('.', 1)[0])
ext = doc.archivo.name.split('.')[-1]
name_inside_zip = f"{file_name}.{ext}"
success = storage_service.download_file(ruta, tmp_path)
# 4. Escribir el archivo dentro del ZIP
with doc.archivo.open('rb') as f:
if not success:
missing_files.append(f"{doc.id} ({ruta})")
continue
files_found.append(f"{doc.id} ({ruta})")
nombre_base = ruta.rsplit('/', 1)[-1]
file_name = slugify(nombre_base.rsplit('.', 1)[0])
ext = nombre_base.split('.')[-1] if '.' in nombre_base else ''
name_inside_zip = f"{file_name}.{ext}" if ext else file_name
with open(tmp_path, 'rb') as f:
zip_file.writestr(name_inside_zip, f.read())
# 5. Preparar respuesta
buffer.seek(0)
zip_name = slugify(f"expediente_{pedimento.pedimento_app}")
response = HttpResponse(buffer, content_type='application/zip')
response['Content-Disposition'] = f'attachment; filename={zip_name or "documentos"}.zip'
if not files_found:
return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
return Response(
{"error": f"No se encontraron documentos descargables para el pedimento: {pedimento.pedimento_app}"},
status=status.HTTP_404_NOT_FOUND
)
# (Opcional) cabecera personalizada si faltaron archivos
# if missing_files:
# response['X-Missing-Files'] = ', '.join(missing_files)
# return Response({"error": f"No hay documentos para este pedimento: {pedimento.pedimento_app}"}, status=status.HTTP_404_NOT_FOUND)
if missing_files:
response['X-Missing-Files-Count'] = str(len(missing_files))
response['Access-Control-Expose-Headers'] = 'X-Missing-Files-Count'
return response
except Exception as e:
return Response(
{"error": f"Error al crear el archivo ZIP: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
finally:
for tmp_path in temp_files:
try:
if os.path.exists(tmp_path):
os.unlink(tmp_path)
except Exception as e:
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class MultiPedimentoZipDownloadView(APIView):
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)]
permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents']
def post(self, request):
@@ -1888,7 +2400,7 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"""
ViewSet for Document model.
"""
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
permission_classes = [IsAuthenticated, require_permission('documentos.view')]
model = Document
pagination_class = CustomPagination
@@ -1902,60 +2414,55 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
my_tags = ['Documents']
def get_queryset(self):
if not user_has_permission(self.request.user, 'documentos.view'):
return Document.objects.none()
queryset = self.get_queryset_filtrado_por_organizacion()
pedimento_id = self.request.query_params.get('pedimento')
# Obtener el pedimento primero para usar su organización
# Validar que el pedimento existe
from api.customs.models import Pedimento
try:
pedimento = Pedimento.objects.get(id=pedimento_id)
except Pedimento.DoesNotExist:
return Response(
{"error": "Pedimento no encontrado"},
status=status.HTTP_404_NOT_FOUND
)
return Document.objects.none() # Retornar queryset vacío
# Tipos de documento permitidos (fijos en código, Pedimento completo y remesas)
TIPOS_PERMITIDOS = ['2', '3'] # <-- Ajusta aquí tus tipos
# Filtrar SOLO por pedimento
queryset = queryset.filter(pedimento_id=pedimento_id)
# Tipos de documento permitidos (fijos: 2 y 3)
TIPOS_PERMITIDOS = ['2', '3']
tipo_documento = self.request.query_params.get('document_type')
if tipo_documento:
if tipo_documento == '2':
queryset = queryset.filter(archivo__startswith=f'documents/vu_PC_{pedimento.pedimento_app}.xml')
elif tipo_documento == '3':
queryset = queryset.filter(archivo__startswith=f'documents/vu_RM_{pedimento.pedimento_app}.xml')
# Si se especifica tipo, filtrar por ese tipo (si está en permitidos)
if tipo_documento in TIPOS_PERMITIDOS:
queryset = queryset.filter(document_type_id=tipo_documento)
else:
queryset = queryset.filter(archivo__startswith=f'documents/NOTFOUND_{pedimento.pedimento_app}.xml')
else:
# Filtrar por tipos permitidos
# queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
queryset = queryset.filter(
Q(archivo__startswith=f'documents/vu_PC_{pedimento.pedimento_app}.xml') |
Q(archivo__startswith=f'documents/vu_RM_{pedimento.pedimento_app}.xml')
)
# Si no se especifica, filtrar por los tipos permitidos
queryset = queryset.filter(document_type_id__in=TIPOS_PERMITIDOS)
# Filtros adicionales
buscar_archivo = self.request.query_params.get('archivo__icontains')
if buscar_archivo:
queryset = queryset.filter(archivo__icontains=buscar_archivo)
created_at__date = self.request.query_params.get('created_at__date')
if created_at__date:
queryset = queryset.filter(created_at=created_at__date)
queryset = queryset.filter(created_at__date=created_at__date)
# Filtro adicional por pedimento_numero si se proporciona
pedimento_numero = self.request.query_params.get('pedimento_numero')
if pedimento_numero:
queryset = queryset.filter(pedimento__pedimento_app=pedimento_numero)
return queryset
class TriggerPedimentoCompletoView(APIView):
"""
Endpoint interno para disparar la descarga de pedimento completo
en el microservicio FastAPI. Reenvía el payload tal cual y devuelve
la respuesta del microservicio (normalmente un `task_id`).
"""
# permission_classes = [IsAuthenticated]
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
permission_classes = [IsAuthenticated, require_permission('pedimentos.process')]
my_tags = ['Microservice - Pedimento Completo']

View File

@@ -16,7 +16,8 @@ class ReportDocument(models.Model):
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='report_documents')
filters = models.JSONField(blank=True, null=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
file = models.FileField(upload_to='reports/', blank=True, null=True)
# file = models.FileField(upload_to='reports/', blank=True, null=True)
file = models.CharField(max_length=500, blank=True, null=True)
report_type = models.CharField(max_length=30, choices=TYPE_REPORT, default='cumplimiento')
error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@@ -1,6 +1,8 @@
import tempfile
from api.utils.storage_service import storage_service
from celery import shared_task
from api.organization.models import Organizacion
from django.core.files.base import ContentFile
from django.utils import timezone
from api.reports.models import ReportDocument
from api.customs.models import Pedimento, Cove, EDocument, Partida
@@ -10,6 +12,7 @@ from api.record.models import Document
import csv
import os
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
@shared_task
def generate_report_document(report_id):
@@ -46,15 +49,19 @@ def generate_report_document(report_id):
filename = f"{filename}.csv" if not filename.endswith('.csv') else filename
else:
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w', newline='', encoding='utf-8') as f:
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as f:
tmp_path = f.name
# Escribir CSV en archivo temporal
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
headers = [
'aduana', 'patente', 'regimen', 'pedimento', 'pedimento_app', 'clave_pedimento',
'tipo_operacion_id', 'contribuyente_id', 'tipo_documento', 'numero_documento', 'estado', 'acuse_estado'
]
writer.writerow(headers)
for ped in pedimentos:
for cove in Cove.objects.filter(pedimento=ped):
writer.writerow([
@@ -74,12 +81,43 @@ def generate_report_document(report_id):
ped.clave_pedimento, ped.tipo_operacion_id, ped.contribuyente_id,
'PARTIDA', partida.numero_partida, partida.descargado, ''
])
# Guardar el archivo en el modelo
with open(file_path, 'rb') as f:
report.file.save(filename, ContentFile(f.read()), save=True)
# ============ NUEVO: Guardar en MinIO ============
# Leer archivo temporal
with open(tmp_path, 'rb') as f:
file_content = f.read()
# Crear UploadedFile
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
)
# Guardar en storage
ruta = storage_service.save_report(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
metadata={
'report_id': str(report.id),
'report_type': 'cumplimiento',
'user_id': str(report.user.id) if report.user else None
}
)
if ruta:
report.file = ruta
report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
# Limpiar temporal
os.unlink(tmp_path)
report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at'])
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e:
report.status = 'error'
report.error_message = str(e)
@@ -88,8 +126,8 @@ def generate_report_document(report_id):
@shared_task
def generate_report_control_pedimento(report_id):
report = None
try:
report = ReportDocument.objects.get(id=report_id)
report.status = 'processing'
report.save(update_fields=['status'])
@@ -183,8 +221,9 @@ def generate_report_control_pedimento(report_id):
# 4. GENERAR CSV CON DETALLES
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
file_path = os.path.join(settings.MEDIA_ROOT, 'reports', filename)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv', encoding='utf-8', newline='') as tmp:
tmp_path = tmp.name
todas_las_filas = []
@@ -239,7 +278,7 @@ def generate_report_control_pedimento(report_id):
todas_las_filas.append(fila)
# 5. ESCRIBIR ARCHIVO CSV
with open(file_path, 'w', newline='', encoding='utf-8') as f:
with open(tmp_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
# SECCIÓN DE TOTALES
@@ -269,14 +308,39 @@ def generate_report_control_pedimento(report_id):
writer.writerow(fila)
with open(file_path, 'rb') as f:
report.file.save(filename, ContentFile(f.read()), save=True)
with open(tmp_path, 'rb') as f:
file_content = f.read()
uploaded_file = SimpleUploadedFile(
name=filename,
content=file_content,
content_type='text/csv'
)
ruta = storage_service.save_report(
file=uploaded_file,
organizacion_id=filters.get('organizacion_id'),
metadata={
'report_id': str(report.id),
'report_type': 'control_pedimento',
'user_id': str(report.user.id) if report.user else None
}
)
os.unlink(tmp_path)
if ruta:
report.file = ruta
report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
report.finished_at = timezone.now()
report.save(update_fields=['status', 'file', 'finished_at'])
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
except Exception as e:
if report:
report.status = 'error'
report.error_message = str(e)
report.finished_at = timezone.now()

View File

@@ -1,57 +1,31 @@
from warnings import filters
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from api.customs.models import Pedimento, Cove, EDocument, Partida
from api.record.models import Document
from api.organization.models import Organizacion
from django.db.models import Count, Q
# Registrar endpoint en urls.py:
# path('dashboard/summary/', dashboard_summary)
import csv
import io
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from .serializers import ExportModelSerializer
from rest_framework.response import Response
from django.http import HttpResponse
import openpyxl
from django.apps import apps
from rest_framework import status
from django.shortcuts import render
from rest_framework import viewsets
from .serializers import ExportModelSerializer
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
from rest_framework.permissions import IsAuthenticated
import csv
import io
import openpyxl
from django.http import HttpResponse
from django.apps import apps
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
from .serializers import ExportModelSerializer
import uuid
import datetime
import zipfile
import openpyxl
from django.apps import apps
from django.db import models
from django.db.models import Count, Q
from django.http import HttpResponse
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from api.customs.models import Cove, EDocument, Partida, Pedimento
from api.organization.models import Organizacion
from api.record.models import Document
from core.permissions import (
get_org_context,
require_permission,
user_has_permission,
)
from .serializers import ExportModelSerializer
def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
model = apps.get_model(module, model_name)
@@ -110,7 +84,11 @@ def export_model_to_excel(request, model_name, fields, module='datastage', filte
class ExportDataStageView(APIView):
my_tags = ['Reportes-DataStage']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
def get_permissions(self):
if self.request.method == 'GET':
return [IsAuthenticated(), require_permission('reportes.view')()]
return [IsAuthenticated(), require_permission('reportes.export')()]
# Constantes para partición
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
@@ -135,6 +113,27 @@ class ExportDataStageView(APIView):
else:
return str(value)
def get(self, request, *args, **kwargs):
"""Retorna RFCs distintos de Registro501 para la organización activa del usuario."""
try:
Registro501 = apps.get_model('datastage', 'Registro501')
org = get_org_context(request.user)
if not org:
return Response({'error': 'Sin organización activa'}, status=status.HTTP_403_FORBIDDEN)
qs = Registro501.objects.filter(organizacion=org)
rfcs = (
qs.exclude(rfc__isnull=True)
.exclude(rfc='')
.values_list('rfc', flat=True)
.distinct()
.order_by('rfc')
)
return Response({'rfcs': list(rfcs)})
except LookupError:
return Response({'rfcs': []})
@swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
def post(self, request, *args, **kwargs):
"""
@@ -148,6 +147,23 @@ class ExportDataStageView(APIView):
else:
return self.handle_simple_export(request)
def _resolve_org_filter(self, global_filters, user):
"""
Devuelve los global_filters asegurando que siempre haya una organización.
La org se obtiene de active_organization (superuser) o del campo organizacion (usuario normal).
Retorna (filters_dict, error_response_or_None).
"""
filters = dict(global_filters or {})
if not filters.get('organizacion'):
org = get_org_context(user)
if not org:
return None, Response(
{'error': 'Sin organización activa'},
status=status.HTTP_403_FORBIDDEN,
)
filters['organizacion'] = str(org.id)
return filters, None
def handle_simple_export(self, request):
"""Maneja exportación simple de DataStage (un solo modelo)"""
model_name = request.data.get('model')
@@ -159,6 +175,10 @@ class ExportDataStageView(APIView):
if not model_name or not fields:
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
global_filters, err = self._resolve_org_filter(global_filters, request.user)
if err:
return err
try:
model = apps.get_model(module, model_name)
filters = self.apply_global_filters_to_model(global_filters, model, request.user)
@@ -190,18 +210,16 @@ class ExportDataStageView(APIView):
if not models_data:
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
global_filters, err = self._resolve_org_filter(global_filters, request.user)
if err:
return err
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
if export_type == 'excel':
# Siempre usar el método particionado inteligente para Excel
return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
else:
# Para CSV, podemos mantener la lógica actual o mejorarla
total_estimated_records = self.estimate_total_records(models_data, global_filters, related_keys, request.user)
if total_estimated_records > self.MAX_RECORDS_PER_FILE:
return self.export_datastage_multiple_partitioned_csv(request, models_data, global_filters, related_keys)
else:
return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys)
return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
def estimate_total_records(self, models_data, global_filters, related_keys, user):
"""Estima el total de registros para todos los modelos"""
@@ -282,17 +300,11 @@ class ExportDataStageView(APIView):
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try:
zip_buffer = io.BytesIO()
# 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido
from api.organization.models import Organizacion
organizaciones = Organizacion.objects.all()
org_mapping = {str(org.id): org.nombre for org in organizaciones}
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
# 1. Recopilar todos los datos de cada modelo
all_models_data = {} # Ahora será una lista por clave
# 1. Recopilar todos los datos FUERA del contexto ZIP
all_models_data = {}
model_field_mappings = {}
for model_data in models_data:
@@ -302,8 +314,6 @@ class ExportDataStageView(APIView):
if not model_name or not fields:
continue
# Normalizar nombres de campo entrantes: si se pasó "Organizacion"
# (cualquier capitalización), usar el campo real de la BD `organizacion_id`.
normalized_fields = []
for f in fields:
try:
@@ -320,13 +330,11 @@ class ExportDataStageView(APIView):
fields = normalized_fields
# Asegurar que tenemos los campos de relación
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
for field in required_fields:
if field not in fields:
fields.append(field)
# 🔥 Añadir organizacion_id a los campos si no está y existe en el modelo
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
fields.append('organizacion_id')
@@ -339,233 +347,182 @@ class ExportDataStageView(APIView):
else:
queryset = model.objects.none()
total_records = queryset.count()
if total_records == 0:
if queryset.count() == 0:
continue
# Determinar campos de relación disponibles en este modelo
relation_fields = []
for field_name in ['seccion_aduanera', 'patente', 'pedimento']:
if field_name in fields:
relation_fields.append(field_name)
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
if not relation_fields:
# Si no hay campos de relación, usar un identificador único
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
# Guardar mapeo de campos para este modelo
if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields
# Procesar cada registro
for record in queryset:
# Crear clave de relación
key_parts = []
for rel_field in relation_fields:
if rel_field in record and record[rel_field] is not None:
key_parts.append(str(record[rel_field]))
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
if not key_parts:
# Si no hay campos de relación, usar un hash del registro
import hashlib
record_str = str(sorted(record.items()))
key = hashlib.md5(record_str.encode()).hexdigest()[:10]
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
else:
key = "_".join(key_parts)
# 🔥 PROCESAR CAMPO organizacion_id para convertirlo a nombre
processed_record = {}
for field_name, value in record.items():
# Convertir organizacion_id a nombre
if field_name == 'organizacion_id' and value:
org_id_str = str(value)
# Usar el nombre de la organización si está en el mapeo
if org_id_str in org_mapping:
processed_value = org_mapping[org_id_str]
else:
# Si no se encuentra, intentar obtener de la base de datos
try:
org = Organizacion.objects.filter(id=value).first()
processed_value = org.nombre if org else str(value)
# Actualizar mapeo para futuras referencias
processed_value = org.nombre if org else org_id_str
org_mapping[org_id_str] = processed_value
except:
processed_value = str(value)
except Exception:
processed_value = org_id_str
else:
processed_value = value
# Agregar prefijo del modelo a los campos para evitar colisiones
if field_name in relation_fields:
prefixed_field_name = field_name
else:
prefixed_field_name = f"{model_name}_{field_name}"
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
# 🔥 CORRECIÓN: Ahora almacenamos una LISTA de registros por clave
if key not in all_models_data:
all_models_data[key] = {
'relation_fields': {}, # Campos de relación compartidos
'model_records': {} # Diccionario de listas por modelo
}
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
# Guardar campos de relación (solo una vez, ya que son los mismos)
for rel_field in relation_fields:
if rel_field in record:
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
# 🔥 GUARDAR COMO LISTA: Crear lista si no existe
if model_name not in all_models_data[key]['model_records']:
all_models_data[key]['model_records'][model_name] = []
# Agregar este registro a la lista del modelo
all_models_data[key]['model_records'][model_name].append(processed_record)
except LookupError:
continue
# Si no hay datos, retornar error
# 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
if not all_models_data:
return Response({'error': 'No se encontraron datos para exportar'}, status=status.HTTP_404_NOT_FOUND)
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)
output.seek(0)
resp = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"'
return resp
# 2. Crear estructura de filas combinadas
# Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave
# 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
combined_rows = []
for key, data in all_models_data.items():
relation_fields = data['relation_fields']
relation_fields_data = data['relation_fields']
model_records = data['model_records']
# 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave
# Encontrar el modelo con más registros para esta clave
max_records_per_key = 1
for model_name, records in model_records.items():
if len(records) > max_records_per_key:
max_records_per_key = len(records)
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
# 🔗 CREAR UNA FILA POR CADA COMBINACIÓN
for i in range(max_records_per_key):
row_data = {}
# Campos de relación (mismos para todas las filas con esta clave)
for rel_field, rel_value in relation_fields.items():
for rel_field, rel_value in relation_fields_data.items():
row_data[rel_field] = self.safe_excel_value(rel_value)
# Datos de cada modelo
for model_name, records in model_records.items():
# Si hay un registro en esta posición i
if i < len(records):
record = records[i]
# 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
else:
# Si no hay más registros para este modelo, poner campos vacíos
for field_name in model_field_mappings.get(model_name, []):
if field_name in ['seccion_aduanera', 'patente', 'pedimento', 'organizacion_id']:
# Los campos de relación ya están llenados o transformados
continue
prefixed_field_name = f"{model_name}_{field_name}"
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
row_data[prefixed_field_name] = ''
combined_rows.append(row_data)
# 3. Determinar todos los campos únicos para los encabezados
# 4. Encabezados ordenados
all_fields_set = set()
# Campos de relación primero
common_relation_fields = ['seccion_aduanera', 'patente', 'pedimento']
# Agregar todos los campos de todas las filas
for row in combined_rows:
all_fields_set.update(row.keys())
# Ordenar campos: relación primero, luego alfabéticamente
all_fields = []
for rel_field in common_relation_fields:
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
if rel_field in all_fields_set:
all_fields.append(rel_field)
all_fields_set.remove(rel_field)
all_fields_set.discard(rel_field)
# 🔥 Mover organizacion_nombre cerca de los campos de relación
org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()]
for org_field in sorted(org_fields):
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.remove(org_field)
all_fields_set.discard(org_field)
# Agregar el resto de campos ordenados alfabéticamente
all_fields.extend(sorted(all_fields_set))
total_records = len(combined_rows)
# 5. Filas de título y fecha de generación
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
title_row = ["Reporte Datastage"]
date_row = [f"Generado: {now_str}"]
# 4. Manejar particionado
from django.core.paginator import Paginator
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
for page_num in paginator.page_range:
page = paginator.page(page_num)
# Crear nuevo workbook para cada partición
current_wb = openpyxl.Workbook()
current_ws = current_wb.active
# Nombre de hoja limitado a 31 caracteres
sheet_name = f"Datastage_p{page_num}"
if len(sheet_name) > 31:
sheet_name = sheet_name[:31]
current_ws.title = sheet_name
# Escribir encabezados
current_ws.append(all_fields)
# Escribir datos de esta página
for row_data in page.object_list:
row_values = [row_data.get(field, '') for field in all_fields]
current_ws.append(row_values)
# Autoajustar anchos de columna
for column in current_ws.columns:
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
column_letter = column[0].column_letter
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:
except Exception:
pass
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
adjusted_width = min(max_length + 2, 50)
current_ws.column_dimensions[column_letter].width = adjusted_width
# 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
from django.core.paginator import Paginator
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
# Guardar archivo en ZIP
if paginator.num_pages == 1:
wb = openpyxl.Workbook()
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
output = io.BytesIO()
wb.save(output)
output.seek(0)
resp = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"'
return resp
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for page_num in paginator.page_range:
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)
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
print(f"Total de claves únicas: {len(all_models_data)}")
print(f"Total de filas expandidas: {total_records}")
zip_buffer.seek(0)
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
return response
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
return resp
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -782,10 +739,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
print(f"Total de claves únicas: {len(all_models_data)}")
print(f"Total de filas expandidas: {total_records}")
zip_buffer.seek(0)
@@ -795,12 +748,11 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel_test_2(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try:
@@ -1009,8 +961,8 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@@ -1126,8 +1078,6 @@ class ExportDataStageView(APIView):
part_buffer.seek(0)
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue())
# Información de depuración (opcional)
print(f"Creada partición {page_num} con {len(page.object_list)} registros")
zip_buffer.seek(0)
@@ -1137,8 +1087,8 @@ class ExportDataStageView(APIView):
except Exception as e:
import traceback
error_details = traceback.format_exc()
print(f"Error en exportación: {error_details}")
import logging
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys):
@@ -1265,6 +1215,144 @@ class ExportDataStageView(APIView):
except Exception as e:
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel)."""
import hashlib
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from api.organization.models import Organizacion
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
all_models_data = {}
model_field_mappings = {}
for model_data in models_data:
model_name = model_data.get('model')
fields = model_data.get('fields', [])
if not model_name or not fields:
continue
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 ['seccion_aduanera', 'patente', 'pedimento']:
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 = self.apply_related_filters(global_filters, model, related_keys, request.user)
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
if queryset.count() == 0:
continue
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
if not relation_fields:
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields
for record in queryset:
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
processed_record = {}
for field_name, value in record.items():
if field_name == 'organizacion_id' and value:
org_id_str = str(value)
processed_value = org_mapping.get(org_id_str, org_id_str)
else:
processed_value = value
if field_name in relation_fields:
prefixed = field_name
else:
prefixed = f"{model_name}_{field_name}"
if field_name == 'organizacion_id':
prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre')
processed_record[prefixed] = self.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
# Sin datos → CSV con mensaje, no error HTTP
if not all_models_data:
buf = io.StringIO()
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"'
return resp
# Construir filas planas
combined_rows = []
for key, data in all_models_data.items():
relation_fields_data = data['relation_fields']
model_records = data['model_records']
max_records = max((len(recs) for recs in model_records.values()), default=1)
for i in range(max_records):
row_data = {}
for rel_field, rel_value in relation_fields_data.items():
row_data[rel_field] = self.safe_excel_value(rel_value)
for mn, records in model_records.items():
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)
# Encabezados: campos de relación primero, luego org, luego el resto
all_fields_set = set()
for row in combined_rows:
all_fields_set.update(row.keys())
all_fields = []
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
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))
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])
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"'
return resp
except Exception as e:
logger.error("Error en exportación CSV combinada: %s", traceback.format_exc())
return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
zip_buffer = io.BytesIO()
@@ -1472,8 +1560,13 @@ class ExportDataStageView(APIView):
def get_related_keys_from_filters(self, global_filters, models_data, user):
"""
Obtiene patentes, pedimentos y datastages que cumplen EXACTAMENTE con TODOS los filtros globales
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model
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(),
@@ -1481,40 +1574,34 @@ class ExportDataStageView(APIView):
'datastage_ids': set()
}
# Si no hay filtros, retornar vacío
# 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. Un modelo sin 'rfc' no puede
# ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de
# otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas.
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
# ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
filters = self.apply_global_filters_to_model(global_filters, model, user)
if not filters:
continue
if filters:
# EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR
queryset = model.objects.filter(**filters)
total = queryset.count()
# VERIFICACIÓN ESPECIAL PARA RFC
if 'rfc' in filters:
rfc_value = filters['rfc']
# Doble verificación: contar registros con ese RFC exacto
rfc_exact_count = queryset.filter(rfc=rfc_value).count()
if rfc_exact_count != total:
try:
other_rfcs = queryset.exclude(rfc=rfc_value).values_list('rfc', flat=True).distinct()[:5]
except:
pass
# Obtener registros
records = queryset.values('patente', 'pedimento', 'datastage_id')
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
all_records_with_filters.extend(list(records))
except LookupError:
@@ -1585,9 +1672,17 @@ class ExportDataStageView(APIView):
filters = {}
model_fields = [f.name for f in model._meta.get_fields()]
# 1. Organización
# 1. Organización — convertir a UUID igual que apply_global_filters_to_model
if 'organizacion' in model_fields and global_filters.get('organizacion'):
filters['organizacion'] = global_filters['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
# 2. RFC (¡ESTO ES LO QUE FALTA!)
if 'rfc' in model_fields and global_filters.get('rfc'):
@@ -1741,7 +1836,11 @@ class ExportDataStageView(APIView):
class ExportModelView(APIView):
my_tags = ['Reportes']
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
def get_permissions(self):
if self.request.method == 'GET':
return [IsAuthenticated(), require_permission('reportes.view')()]
return [IsAuthenticated(), require_permission('reportes.export')()]
@swagger_auto_schema(
manual_parameters=[
@@ -1779,6 +1878,8 @@ class ExportModelView(APIView):
model_name = request.data.get('model')
fields = request.data.get('fields')
filters = request.data.get('filters', {})
org = get_org_context(request.user)
filters['organizacion__id'] = org.id if org else None
export_type = request.data.get('type', 'csv')
module = request.data.get('module', 'datastage')
@@ -1790,40 +1891,12 @@ class ExportModelView(APIView):
else:
return export_model_to_csv(request, model_name, fields, module, filters)
# Create your views here.
class ExportModelView(APIView):
my_tags = ['Reportes']
permission_classes = [IsAuthenticated & (
IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
@swagger_auto_schema(request_body=ExportModelSerializer, esponses={200: 'Archivo generado (Excel o CSV)'})
def post(self, request, *args, **kwargs):
model_name = request.data.get('model')
fields = request.data.get('fields')
filters = request.data.get('filters', {})
filters['organizacion__id'] = self.request.user.organizacion.id if hasattr(request.user, 'organizacion') and request.user.organizacion else None
export_type = request.data.get('type', 'csv')
if not model_name or not fields:
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
module = request.data.get('module', 'datastage')
if export_type == 'excel':
return export_model_to_excel(request, model_name, fields, module, filters)
else:
return export_model_to_csv(request, model_name, fields, module, filters)
# Resumen general para dashboard
@api_view(['GET'])
@permission_classes([
IsAuthenticated
])
@permission_classes([IsAuthenticated, require_permission('reportes.view')])
def dashboard_summary(request):
org_id = request.query_params.get('organizacion_id')
filters = {}
user = request.user
@@ -1837,18 +1910,16 @@ def dashboard_summary(request):
fecha_pago_lte = request.query_params.get('fecha_pago__lte')
contribuyente__rfc = request.query_params.get('contribuyente__rfc')
# Si no se especifica organización y el usuario tiene organización, usarla
if not org_id and hasattr(user, 'organizacion') and user.organizacion:
org_id = user.organizacion.id
# Si no es superusuario, filtrar por organización
if org_id and not getattr(user, 'is_superuser', False):
filters['organizacion_id'] = org_id
org = get_org_context(user)
if not org:
return Response({'error': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
filters['organizacion_id'] = org.id
# Si el usuario pertenece al grupo Importador, filtrar por RFC
if user.groups.filter(name='Importador').exists():
rfc = getattr(user, 'rfc', None)
if rfc:
filters['contribuyente__rfc'] = rfc
# Importador: filtrar solo por sus RFC asignados
if user.is_importador:
rfcs = list(user.rfc.values_list('rfc', flat=True))
if rfcs:
filters['contribuyente__rfc__in'] = rfcs
if pedimento_app:
filters['pedimento_app'] = pedimento_app

View File

@@ -1,9 +1,13 @@
from api.reports.models import ReportDocument
from api.reports.tasks.report_document import generate_report_document, generate_report_control_pedimento
from django.http import FileResponse
from api.utils.storage_service import storage_service
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
import tempfile
import os
import atexit
@api_view(['GET'])
@permission_classes([IsAuthenticated])
@@ -71,7 +75,9 @@ def table_summary(request):
"report_id": report.id,
"status": report.status,
"created_at": report.created_at,
"download_url": report.file.url if report.file else None
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
}, status=202)
@api_view(['GET'])
@@ -85,7 +91,9 @@ def report_document_status(request, report_id):
"created_at": report.created_at,
"finished_at": report.finished_at,
"error_message": report.error_message,
"download_url": report.file.url if report.file else None
# "download_url": report.file.url if report.file else None
"download_url": storage_service.get_file_url(report.file) if report.file else None
}
return Response(data)
except ReportDocument.DoesNotExist:
@@ -103,7 +111,8 @@ def report_document_list(request):
"created_at": r.created_at,
"finished_at": r.finished_at,
"error_message": r.error_message,
"download_url": r.file.url if r.file else None
# "download_url": r.file.url if r.file else None
"download_url": storage_service.get_file_url(r.file) if r.file else None
}
for r in reports
]
@@ -116,8 +125,22 @@ def report_document_download(request, report_id):
report = ReportDocument.objects.get(id=report_id, user=request.user)
if not report.file:
return Response({"error": "El archivo aún no está disponible"}, status=404)
response = FileResponse(report.file.open('rb'), as_attachment=True, filename=report.file.name)
ruta = str(report.file)
with tempfile.NamedTemporaryFile(delete=False, suffix='.csv') as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
return Response({"error": "No se pudo descargar el archivo"}, status=500)
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'),as_attachment=True,filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
except ReportDocument.DoesNotExist:
return Response({"error": "Reporte no encontrado"}, status=404)

View File

@@ -1,53 +1,54 @@
from django.shortcuts import render
from rest_framework import viewsets, filters
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
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 .serializers import TaskSerializer
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):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
# Task se relaciona con pedimento, que tiene contribuyente
campo_contribuyente = 'pedimento__contribuyente'
queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = TaskFilter
pagination_class = TaskPagination
ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero
ordering = ['-timestamp']
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')()]
"""
Filtra las tareas según la organización del usuario.
Superusuarios pueden ver todas las tareas.
"""
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador
# if user.is_superuser:
# return self.queryset
# # return self.queryset.filter(organizacion_id=user.organizacion.id)
# else:
# return self.queryset.filter(organizacion_id=user.organizacion.id)
return queryset
def get_queryset(self):
user = self.request.user
# Service account (Token + superuser): sin filtro de org, accede a todas las tasks
if user.is_superuser and isinstance(
getattr(self.request, 'successful_authenticator', None), TokenAuthentication
):
return Task.objects.select_related('pedimento', 'servicio').all()
if not user_has_permission(user, 'pedimentos.view'):
return Task.objects.none()
return self.get_queryset_filtrado_por_organizacion()
from rest_framework.views import APIView
@@ -57,42 +58,114 @@ from celery.result import AsyncResult
class TaskStatusView(APIView):
"""
Vista para consultar el estado de tareas de Celery.
"""
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):
"""
Consulta el estado de una tarea de Celery.
Consulta el estado de una tarea.
Returns:
- PENDING: La tarea está esperando ser procesada
- STARTED: La tarea ha sido iniciada
- SUCCESS: La tarea se completó exitosamente
- FAILURE: La tarea falló
- RETRY: La tarea está reintentando
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:
PENDING — en cola o aún no registrada
STARTED — worker ejecutando
SUCCESS — completada sin errores
FAILURE — terminó con error
RETRY — el worker la está reintentando
"""
try:
task_result = AsyncResult(task_id)
# 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': task_result.state,
'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)
state = task_result.state
response_data = {
'task_id': task_id,
'status': state,
'ready': task_result.ready(),
'successful': task_result.successful() if task_result.ready() else None,
}
if task_result.ready() and task_result.successful():
try:
response_data['result'] = task_result.result
except Exception:
pass
if state == 'SUCCESS':
result = task_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)}'
if task_result.state == 'FAILURE':
elif state == 'FAILURE':
response_data['error'] = str(task_result.info)
if task_result.state == 'STARTED':
elif state == 'STARTED':
response_data['info'] = str(task_result.info) if task_result.info else None
return Response(response_data, status=status.HTTP_200_OK)

143
api/utils/minio_client.py Normal file
View File

@@ -0,0 +1,143 @@
# backend/utils/minio_client.py
from datetime import timedelta
import os
from minio import Minio
from minio.error import S3Error
from django.conf import settings
from typing import Optional, BinaryIO
import logging
logger = logging.getLogger(__name__)
class MinIOClient:
"""Cliente singleton para MinIO con operaciones avanzadas"""
_instance = None
_client = None
_bucket_name = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if self._client is None and settings.STORAGE_BACKEND == 'minio':
self._initialize_client()
def _initialize_client(self):
"""Inicializa el cliente de MinIO"""
try:
endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
access_key = os.getenv('MINIO_ACCESS_KEY')
secret_key = os.getenv('MINIO_SECRET_KEY')
secure = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
self._client = Minio(
endpoint=endpoint,
access_key=access_key,
secret_key=secret_key,
secure=secure
)
self._bucket_name = os.environ.get('MINIO_BUCKET_NAME', 'efc-backend-dev')
# Asegurar que el bucket existe
if not self._client.bucket_exists(self._bucket_name):
self._client.make_bucket(self._bucket_name)
except Exception as e:
raise
def upload_file(
self,
object_name: str,
file_path: str = None,
file_data: BinaryIO = None,
content_type: str = None,
metadata: dict = None
) -> bool:
"""
Sube un archivo a MinIO
Args:
object_name: Ruta del objeto en el bucket (ej: 'documents/archivo.xml')
file_path: Ruta local del archivo (opcional)
file_data: Datos del archivo en memoria (opcional)
content_type: MIME type del archivo
metadata: Metadatos adicionales
Returns:
bool: True si se subió correctamente
"""
try:
if file_path:
self._client.fput_object(
bucket_name=self._bucket_name,
object_name=object_name,
file_path=file_path,
content_type=content_type,
metadata=metadata
)
elif file_data:
self._client.put_object(
bucket_name=self._bucket_name,
object_name=object_name,
data=file_data,
length=-1,
part_size=10*1024*1024, # 10MB
content_type=content_type,
metadata=metadata
)
else:
raise ValueError("You must provide file_path or file_data")
return True
except S3Error as e:
return False
def get_file_url(self, object_name: str, expires: int = 3600) -> Optional[str]:
"""Genera una URL firmada para acceder al archivo"""
try:
url = self._client.presigned_get_object(
bucket_name=self._bucket_name,
object_name=object_name,
expires=timedelta(seconds=expires)
)
# Reemplazar endpoint interno por público si está configurado
public_endpoint = os.getenv('MINIO_PUBLIC_ENDPOINT')
if public_endpoint and url:
internal_endpoint = os.getenv('MINIO_ENDPOINT', 'minio:9000')
url = url.replace(internal_endpoint, public_endpoint)
return url
except S3Error as e:
return None
def delete_file(self, object_name: str) -> bool:
"""Elimina un archivo del bucket"""
try:
self._client.remove_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error as e:
return False
def file_exists(self, object_name: str) -> bool:
"""Verifica si un archivo existe en el bucket"""
try:
self._client.stat_object(
bucket_name=self._bucket_name,
object_name=object_name
)
return True
except S3Error:
return False
# Singleton para uso global
minio_client = MinIOClient()

View File

@@ -0,0 +1,628 @@
# backend/utils/storage_service.py
import os
import logging
import mimetypes
import shutil
from uuid import uuid4
from typing import Optional, Union, Literal
from pathlib import Path
from enum import Enum
from django.core.files.uploadedfile import UploadedFile
from django.conf import settings
from .minio_client import minio_client
logger = logging.getLogger(__name__)
class StorageCategory(str, Enum):
"""Categorías de almacenamiento disponibles"""
DOCUMENTS = "documents"
DATASTAGE = "datastage"
REPORTS = "reports"
VUCEM_CERTS = "vucem_certs"
VUCEM_KEYS = "vucem_keys"
class StorageService:
"""
Servicio para gestionar el almacenamiento de archivos.
Estructura aislada por organización:
org_{id}/
├── documents/{pedimento_app o unknown}/
├── datastage/
├── reports/
├── vucem_certs/
└── vucem_keys/
"""
def __init__(self):
self.client = minio_client
self.storage_backend = getattr(settings, 'STORAGE_BACKEND', 'local')
self.local_media_root = getattr(settings, 'MEDIA_ROOT', 'media')
self.debug = getattr(settings, 'DEBUG', False)
def _generate_filename(self, original_filename: str) -> str:
"""Genera un nombre de archivo único para evitar colisiones"""
name, ext = os.path.splitext(original_filename)
unique_id = str(uuid4())[:8]
return f"{name}_{unique_id}{ext}"
def _get_content_type(self, filename: str) -> Optional[str]:
"""Determina el content-type basado en la extensión del archivo"""
content_type, _ = mimetypes.guess_type(filename)
return content_type
def _sanitize_folder_name(self, name: str) -> str:
"""
Sanitizar nombres de carpetas reemplazando caracteres problematicos.
Los guiones (-) son validos.
"""
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
name = name.replace(char, '_')
return name
def _build_base_path(self, organizacion_id: Union[int, str]) -> str:
"""Construye la ruta base para una organización"""
return f"org_{organizacion_id}"
def _build_document_path(
self,
organizacion_id: Union[int, str],
filename: str,
pedimento_app: Optional[str] = None
) -> str:
"""
Construye ruta para DOCUMENTS:
org_{id}/documents/{pedimento_app o unknown}/archivo
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
return f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
def _build_generic_path(
self,
organizacion_id: Union[int, str],
filename: str,
category: StorageCategory,
subfolder: Optional[str] = None
) -> str:
"""
Construye ruta para categorías genéricas:
org_{id}/{category}/{subfolder}/{archivo}
o
org_{id}/{category}/{archivo}
"""
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(filename)
if subfolder:
safe_subfolder = self._sanitize_folder_name(subfolder)
return f"{base}/{category.value}/{safe_subfolder}/{safe_filename}"
else:
return f"{base}/{category.value}/{safe_filename}"
def _save_file(
self,
file: UploadedFile,
object_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""Guarda el archivo según el backend configurado"""
meta = metadata or {}
meta['original_filename'] = file.name
content_type = self._get_content_type(file.name)
if self.storage_backend == 'minio':
return self._save_to_minio(file, object_path, content_type, meta)
else:
return self._save_to_local(file, object_path)
def _save_to_minio(
self,
file: UploadedFile,
object_path: str,
content_type: Optional[str],
metadata: dict
) -> Optional[str]:
"""Guarda archivo en MinIO"""
try:
file.seek(0)
success = self.client.upload_file(
object_name=object_path,
file_data=file,
content_type=content_type,
metadata=metadata
)
if success:
return object_path
else:
return None
except Exception as e:
return None
def _save_to_local(self, file: UploadedFile, object_path: str) -> Optional[str]:
"""Guarda archivo en sistema local"""
try:
full_path = Path(self.local_media_root) / object_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with open(full_path, 'wb+') as destination:
for chunk in file.chunks():
destination.write(chunk)
return object_path
except Exception as e:
return None
def save_document(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento en la categoría 'documents'.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización (obligatorio)
pedimento_app: Identificador del pedimento (opcional, ej: '24-23-1653-4003611')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_document(file, 123, '24-23-1653-4003611')
'org_123/documents/24-23-1653-4003611/documento_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
object_path = self._build_document_path(organizacion_id, file.name, pedimento_app)
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown'
})
return self._save_file(file, object_path, meta)
def save_document_from_path(
self,
file_path: str,
file_name: str,
organizacion_id: Union[int, str],
pedimento_app: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un documento desde una ruta de archivo en disco.
Útil para archivos temporales ya extraídos.
Args:
file_path: Ruta completa del archivo en disco
file_name: Nombre del archivo
organizacion_id: ID de la organización
pedimento_app: Identificador del pedimento (opcional)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
"""
if not file_path or not os.path.exists(file_path):
return None
if not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file_name)
if pedimento_app:
subfolder = self._sanitize_folder_name(pedimento_app)
else:
subfolder = "unknown"
object_path = f"{base}/{StorageCategory.DOCUMENTS.value}/{subfolder}/{safe_filename}"
# Metadatos
meta = metadata or {}
meta.update({
'category': StorageCategory.DOCUMENTS.value,
'organizacion_id': str(organizacion_id),
'pedimento_app': pedimento_app if pedimento_app else 'unknown',
'original_filename': file_name
})
content_type = self._get_content_type(file_name)
# Guardar según backend
if self.storage_backend == 'minio':
try:
self.client._client.fput_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=file_path,
content_type=content_type,
metadata=meta
)
return object_path
except Exception as e:
return None
else:
try:
dest_path = Path(self.local_media_root) / object_path
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(file_path, dest_path)
return object_path
except Exception as e:
return None
def save_datastage(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en la categoría 'datastage' (.zip, .jar, .rar, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de datastage
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
save_datastage(file, 123)
'org_123/datastage/proceso_a1b2c3d4.zip'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.DATASTAGE, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.DATASTAGE.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_report(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
subfolder: Optional[str] = None,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un reporte en la categoría 'reports' (.pdf, .xlsx, etc.)
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
subfolder: Subcarpeta opcional dentro de reports (ej: 'mensuales', '2025')
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_report(file, 123, '2025/enero')
'org_123/reports/2025/enero/reporte_x1y2z3w4.pdf'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.REPORTS, subfolder
)
meta = metadata or {}
meta.update({
'category': StorageCategory.REPORTS.value,
'organizacion_id': str(organizacion_id)
})
if subfolder:
meta['subfolder'] = subfolder
return self._save_file(file, object_path, meta)
def save_vucem_cert(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un certificado VUCEM en la categoría 'vucem_certs'.
Args:
file: Archivo de certificado
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_cert(file, 123)
'org_123/vucem_certs/certificado_a1b2c3d4.cer'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_CERTS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_CERTS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_vucem_key(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda una llave VUCEM en la categoría 'vucem_keys'.
Args:
file: Archivo de llave
organizacion_id: ID de la organización
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_vucem_key(file, 123)
'org_123/vucem_keys/llave_a1b2c3d4.key'
"""
if not file or not organizacion_id:
return None
object_path = self._build_generic_path(
organizacion_id, file.name, StorageCategory.VUCEM_KEYS
)
meta = metadata or {}
meta.update({
'category': StorageCategory.VUCEM_KEYS.value,
'organizacion_id': str(organizacion_id)
})
return self._save_file(file, object_path, meta)
def save_custom(
self,
file: UploadedFile,
organizacion_id: Union[int, str],
custom_path: str,
metadata: Optional[dict] = None
) -> Optional[str]:
"""
Guarda un archivo en una ruta personalizada dentro de la organización.
Args:
file: Archivo a guardar
organizacion_id: ID de la organización
custom_path: Ruta personalizada (se antepone org_{id}/)
metadata: Metadatos adicionales
Returns:
str: Ruta guardada o None si hay error
Ejemplo:
>>> save_custom(file, 123, 'temp/procesando/archivo.xml')
'org_123/temp/procesando/archivo_a1b2c3d4.xml'
"""
if not file or not organizacion_id:
return None
base = self._build_base_path(organizacion_id)
safe_filename = self._generate_filename(file.name)
# Combinar custom_path con el nombre del archivo
if custom_path.endswith('/'):
object_path = f"{base}/{custom_path}{safe_filename}"
else:
object_path = f"{base}/{custom_path}/{safe_filename}"
meta = metadata or {}
meta.update({
'organizacion_id': str(organizacion_id),
'custom_path': custom_path
})
return self._save_file(file, object_path, meta)
def get_file_url(self, object_path: str, expires: int = 3600) -> Optional[str]:
"""
Obtiene una URL para acceder al documento.
En desarrollo, reemplaza 'minio' por 'localhost' para acceso desde el navegador.
"""
if not object_path:
return None
if self.storage_backend == 'minio':
url = self.client.get_file_url(object_path, expires)
# En desarrollo, reemplazar 'minio:9000' por 'localhost:9000'
if url and self.debug:
url = url.replace('minio:9000', 'localhost:9000')
return url
else:
return f"{settings.MEDIA_URL}{object_path}"
def delete_file(self, object_path: str) -> bool:
"""Elimina un archivo"""
if self.storage_backend == 'minio':
return self.client.delete_file(object_path)
else:
try:
full_path = Path(self.local_media_root) / object_path
if full_path.exists():
full_path.unlink()
return True
return False
except Exception as e:
return False
def file_exists(self, object_path: str) -> bool:
"""Verifica si un archivo existe (MinIO o local)"""
if not object_path:
return False
# Si la ruta empieza con 'org_', es MinIO
if object_path.startswith('org_'):
if self.storage_backend == 'minio':
return self.client.file_exists(object_path)
else:
return (Path(self.local_media_root) / object_path).exists()
else:
# Ruta local antigua (ej: 'documents/archivo.xml')
# Siempre verificar en MEDIA_ROOT
return (Path(self.local_media_root) / object_path).exists()
def download_file(self, object_path: str, destination_path: str) -> bool:
"""
Descarga un archivo de MinIO al sistema de archivos local.
"""
if not object_path:
return False
if self.storage_backend == 'minio':
try:
self.client._client.fget_object(
bucket_name=self.client._bucket_name,
object_name=object_path,
file_path=destination_path
)
return True
except Exception as e:
return False
else:
import shutil
src = Path(self.local_media_root) / object_path
if src.exists():
shutil.copy(src, destination_path)
return True
return False
def is_minio_path(self, path):
if not path:
return False
return path.startswith('org_')
# =============================================================================================================
# POR AHORA NO FUERON SOLICITADOS PERO POR EL PROBLEMA DEL 15/04/2026, CONSIDERO PRUDENTE PODER TENER ESTOS
# DOS METODOS PARA NO COMPLICARNOS EN UN FUTURO, EN CASO DE SER NECESARIOS
# =============================================================================================================
# def delete_organization_folder(self, organizacion_id: Union[int, str]) -> bool:
# """
# Elimina TODOS los archivos de una organización.
# Útil cuando un cliente se va y necesitas borrar sus datos.
# Esta operación es IRREVERSIBLE.
# """
# prefix = f"org_{organizacion_id}/"
# if self.storage_backend == 'minio':
# try:
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# self.client.delete_file(obj.object_name)
# return True
# except Exception as e:
# return False
# else:
# try:
# import shutil
# full_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if full_path.exists():
# shutil.rmtree(full_path)
# return True
# except Exception as e:
# return False
# def export_organization_files(
# self,
# organizacion_id: Union[int, str],
# output_zip_path: str
# ) -> bool:
# """
# Exporta TODOS los archivos de una organización a un ZIP.
# Útil para entregar datos a un cliente que se va.
# Args:
# organizacion_id: ID de la organización
# output_zip_path: Ruta donde guardar el ZIP
# Returns: bool
# """
# import zipfile
# from io import BytesIO
# prefix = f"org_{organizacion_id}/"
# try:
# with zipfile.ZipFile(output_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# if self.storage_backend == 'minio':
# objects = self.client._client.list_objects(self.client._bucket_name,prefix=prefix,recursive=True)
# for obj in objects:
# response = self.client._client.get_object(self.client._bucket_name,obj.object_name)
# data = response.read()
# zip_path = obj.object_name.replace(prefix, '', 1)
# zipf.writestr(zip_path, data)
# response.close()
# else:
# local_path = Path(self.local_media_root) / f"org_{organizacion_id}"
# if local_path.exists():
# for file_path in local_path.rglob('*'):
# if file_path.is_file():
# zip_path = str(file_path.relative_to(local_path))
# zipf.write(file_path, zip_path)
# return True
# except Exception as e:
# return False
# Singleton para uso global
storage_service = StorageService()

View File

@@ -20,8 +20,10 @@ class Vucem(models.Model):
password = models.CharField(max_length=100, help_text="Contraseña de VUCEM")
patente = models.CharField(max_length=100, unique=True, help_text="Patente de VUCEM")
efirma = models.CharField(max_length=100, blank=True, null=True,help_text="E-Firma de VUCEM")
key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM")
cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM")
# key = models.FileField(upload_to='vucem_keys/', help_text="Llave privada de VUCEM")
# cer = models.FileField(upload_to='vucem_certs/', help_text="Certificado de VUCEM")
key = models.CharField(max_length=500, blank=True, null=True, help_text="Llave privada de VUCEM")
cer = models.CharField(max_length=500, blank=True, null=True, help_text="Certificado de VUCEM")
is_importador = models.BooleanField(default=False, help_text="Indica si es importador")
acusecove = models.BooleanField(default=False, help_text="Indica si generara acusecove")

View File

@@ -1,5 +1,6 @@
from api.utils.storage_service import storage_service
from rest_framework import serializers
from .models import Vucem, CredencialesImportador
@@ -9,11 +10,91 @@ from .models import Vucem, CredencialesImportador
class VucemSerializer(serializers.ModelSerializer):
importadores = serializers.SerializerMethodField()
key = serializers.FileField(write_only=True, required=False, allow_null=True)
cer = serializers.FileField(write_only=True, required=False, allow_null=True)
key_download_url = serializers.SerializerMethodField(read_only=True)
cer_download_url = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Vucem
fields = '__all__'
read_only_fields = ('created_at', 'updated_at', 'organizacion', 'created_by', 'updated_by')
def get_key_download_url(self, obj):
if obj.key:
return storage_service.get_file_url(obj.key)
return None
def get_cer_download_url(self, obj):
if obj.cer:
return storage_service.get_file_url(obj.cer)
return None
def create(self, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion')
vucem = super().create(validated_data)
if key_file:
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.key = ruta
else:
vucem.delete()
raise serializers.ValidationError({"key": "Error al guardar la llave"})
if cer_file:
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id,
metadata={'vucem_id': str(vucem.id)}
)
if ruta:
vucem.cer = ruta
else:
vucem.delete()
raise serializers.ValidationError({"cer_file": "Error al guardar el certificado"})
vucem.save()
return vucem
def update(self, instance, validated_data):
key_file = validated_data.pop('key', None)
cer_file = validated_data.pop('cer', None)
organizacion = validated_data.get('organizacion', instance.organizacion)
instance = super().update(instance, validated_data)
if key_file:
if instance.key:
storage_service.delete_file(str(instance.key))
ruta = storage_service.save_vucem_key(
file=key_file,
organizacion_id=organizacion.id
)
if ruta:
instance.key = ruta
if cer_file:
if instance.cer:
storage_service.delete_file(str(instance.cer))
ruta = storage_service.save_vucem_cert(
file=cer_file,
organizacion_id=organizacion.id
)
if ruta:
instance.cer = ruta
instance.save()
return instance
def get_importadores(self, obj):
# Importar aquí para evitar importación circular
from api.customs.serializers import ImportadorSerializer

View File

@@ -1,3 +1,7 @@
import atexit
import os
import tempfile
from django.shortcuts import render
from ..organization.models import Organizacion
from rest_framework import viewsets
@@ -8,6 +12,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from rest_framework.response import Response
from django.http import FileResponse, Http404
from api.utils.storage_service import storage_service
from .serializers import VucemSerializer, CredencialesImportadorSerializer, CredencialesImportadorSimpleSerializer
from rest_framework import serializers
@@ -20,15 +25,14 @@ class VucemUpdateSerializer(VucemSerializer):
class Meta(VucemSerializer.Meta):
fields = VucemSerializer.Meta.fields
from .models import Vucem, CredencialesImportador
from core.permissions import IsSameOrganizationDeveloper
from rest_framework import mixins
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser,
IsSameOrganizationAndInAllowedGroups
IsSameOrganizationAndInAllowedGroups,
get_org_context,
is_internal_service_request,
require_permission,
user_has_permission,
)
class CustomVucemPagination(PageNumberPagination):
@@ -48,8 +52,6 @@ class CustomVucemPagination(PageNumberPagination):
# Create your views here.
class VucemView(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated , (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
queryset = Vucem.objects.all()
pagination_class = CustomVucemPagination
filterset_fields = ['organizacion', 'patente', 'usuario', 'is_importador', 'acusecove', 'acuseedocument', 'is_active']
@@ -63,27 +65,45 @@ class VucemView(viewsets.ModelViewSet):
return VucemSerializer
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [IsAuthenticated(), IsSameOrganizationAndInAllowedGroups()]
return super().get_permissions()
perms = {
'list': 'vucem.view',
'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):
# Verificar que el usuario esté autenticado y tenga organización
if not self.request.user.is_authenticated:
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:
queryset = queryset.all()
elif not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
return queryset.none()
elif self.request.user.groups.filter(name='Importador').exists():
queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario=self.request.user.rfc)
if not user_has_permission(self.request.user, 'vucem.view'):
return self.queryset.none()
org = get_org_context(self.request.user)
if not org:
return self.queryset.none()
if self.request.user.is_importador:
queryset = self.queryset.filter(
organizacion=org,
usuario__in=self.request.user.rfc.all(),
)
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')
if importador_rfc:
queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct()
@@ -91,78 +111,87 @@ class VucemView(viewsets.ModelViewSet):
return queryset
def perform_create(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.")
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
)
if is_internal_service_request(self.request):
serializer.save(updated_by=self.request.user)
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(
organizacion=self.request.user.organizacion,
organizacion=org,
created_by=self.request.user,
updated_by=self.request.user
updated_by=self.request.user,
)
return
def perform_update(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.")
if is_internal_service_request(self.request):
instance = self.get_object()
if self.request.user.is_superuser:
serializer.save(
created_by=instance.created_by,
updated_by=self.request.user
updated_by=self.request.user,
)
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(
organizacion=self.request.user.organizacion,
organizacion=org,
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):
"""
Descarga directa del archivo cer.
"""
vucem = self.get_object()
if not vucem.cer:
return Response({"detail": "No hay archivo cer disponible."}, status=404)
response = FileResponse(vucem.cer.open('rb'), as_attachment=True, filename=vucem.cer.name.split('/')[-1])
ruta = str(vucem.cer)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'), as_attachment=True, filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
@action(detail=True, methods=["get"])
def download_key(self, request, pk=None):
"""
Descarga directa del archivo key.
"""
vucem = self.get_object()
if not vucem.key:
return Response({"detail": "No hay archivo key disponible."}, status=404)
response = FileResponse(vucem.key.open('rb'), as_attachment=True, filename=vucem.key.name.split('/')[-1])
ruta = str(vucem.key)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
success = storage_service.download_file(ruta, tmp_path)
if not success:
raise Http404("No se pudo descargar el archivo")
filename = os.path.basename(ruta)
response = FileResponse(open(tmp_path, 'rb'), as_attachment=True, filename=filename)
atexit.register(lambda: os.unlink(tmp_path) if os.path.exists(tmp_path) else None)
return response
def perform_destroy(self, instance):
if instance.key:
storage_service.delete_file(str(instance.key))
if instance.cer:
storage_service.delete_file(str(instance.cer))
instance.delete()
class CredencialesImportadorViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
queryset = CredencialesImportador.objects.all()
serializer_class = CredencialesImportadorSimpleSerializer
filterset_fields = ['organizacion', 'vucem', 'rfc']
@@ -173,27 +202,34 @@ class CredencialesImportadorViewSet(viewsets.ModelViewSet):
my_tags = ['Credenciales por Importador']
def get_permissions(self):
if self.action in ['create', 'update', 'partial_update', 'destroy']:
return [IsAuthenticated()]
return super().get_permissions()
perms = {
'list': 'vucem.view',
'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):
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'):
if not self.request.user.is_authenticated:
return self.queryset.none()
queryset = self.queryset.filter(organizacion=self.request.user.organizacion)
return queryset
if is_internal_service_request(self.request):
return self.queryset.all()
if not user_has_permission(self.request.user, 'vucem.view'):
return self.queryset.none()
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):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.")
serializer.save(organizacion=self.request.user.organizacion)
if is_internal_service_request(self.request):
serializer.save()
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

@@ -1,8 +1,11 @@
import os
from celery import Celery
from datetime import timedelta
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('config')
app.config_from_object('django.conf:settings', namespace='CELERY')
# corroborar que las tareas esten programadas, se cambio el horario a hora denver
# print("Beat schedule cargado:", app.conf.beat_schedule)
app.autodiscover_tasks()

View File

@@ -27,10 +27,17 @@ import re
# Celery Beat Schedule
from celery.schedules import crontab
from config.stg.storage import *
CELERY_BEAT_SCHEDULE = {
'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
},
# '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
# },
}
# Cargar variables de entorno desde un archivo .env
@@ -85,11 +92,13 @@ THIRD_APPS = [
]
OWN_APPS = [
'api',
'api.customs',
'api.record',
'api.organization',
'api.licence',
'api.cuser',
'api.rbac',
'api.datastage',
'api.vucem',
'api.logger',
@@ -280,6 +289,9 @@ else:
STATICFILES_DIRS = []
STATIC_ROOT = BASE_DIR / 'static'
if STORAGE_BACKEND == 'minio':
MEDIA_URL = f"http://{os.getenv('MINIO_ENDPOINT')}/{AWS_STORAGE_BUCKET_NAME}/"
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
@@ -300,7 +312,8 @@ DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# Configuración Celery
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0')
CELERY_RESULT_BACKEND = os.getenv('CELERY_RESULT_BACKEND', 'redis://redis:6379/0')
CELERY_TIMEZONE = 'America/Mexico_City'
# CELERY_TIMEZONE = 'America/Mexico_City'
CELERY_TIMEZONE = 'America/Denver'
# Configuración para procesamiento asíncrono nativo de Django
ASGI_APPLICATION = 'config.asgi.application'

29
config/stg/storage.py Normal file
View File

@@ -0,0 +1,29 @@
# backend/config/stg/storage.py
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent.parent
STORAGE_BACKEND = os.getenv('STORAGE_BACKEND', 'local')
if STORAGE_BACKEND == 'minio':
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
AWS_ACCESS_KEY_ID = os.getenv('MINIO_ACCESS_KEY')
AWS_SECRET_ACCESS_KEY = os.getenv('MINIO_SECRET_KEY')
AWS_STORAGE_BUCKET_NAME = os.getenv('MINIO_BUCKET_NAME')
AWS_S3_ENDPOINT_URL = f"http://{os.getenv('MINIO_ENDPOINT')}"
AWS_S3_REGION_NAME = os.getenv('MINIO_REGION', 'us-east-1')
AWS_S3_USE_SSL = os.getenv('MINIO_SECURE', 'false').lower() == 'true'
AWS_DEFAULT_ACL = 'private'
AWS_LOCATION = 'documents'
AWS_S3_FILE_OVERWRITE = False
AWS_QUERYSTRING_AUTH = True
AWS_QUERYSTRING_EXPIRE = 3600 # es 1 hora
# STATICFILES_STORAGE = 'storages.backends.s3boto3.S3StaticStorage'
else:
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

View File

@@ -51,6 +51,7 @@ urlpatterns = [
path('api/v1/cards/', include('api.cards.urls')), # Cards app
path('api/v1/reports/', include('api.reports.urls')), # Reports 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
if settings.DEBUG:

View File

@@ -1,100 +1,244 @@
# permissions.py
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:
# - El objeto pertenece a la misma organización (acceso por usuario relacionado)
return (getattr(obj, 'dirigido', None) and obj.dirigido.organizacion == request.user.organizacion)
# ---------------------------------------------------------------------------
# Helpers centrales — toda la lógica de RBAC pasa por aquí
# ---------------------------------------------------------------------------
class IsSameOrganizationAndAdmin(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 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:
def is_internal_service_request(request):
"""True si la petición proviene de un service account (Token auth + superuser).
Misma lógica que IsInternalService, útil en get_queryset() y perform_* methods."""
user = getattr(request, 'user', None)
if not user or not user.is_superuser:
return False
if hasattr(obj, 'organizacion'):
return obj.organizacion == request.user.organizacion
return isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication)
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
class IsSameOrganizationDeveloper(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
from api.rbac.models import UserPermission, UserRole
def has_object_permission(self, request, view, obj):
# Permite operaciones solo si el usuario es developer, Agente Aduanal o user y la organización coincide
allowed_groups = ['developer', 'Agente Aduanal', 'user']
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists()
if not user_in_group:
return False
if hasattr(obj, 'organizacion'):
return obj.organizacion == request.user.organizacion
try:
override = UserPermission.objects.get(user=user, permission__codename=codename)
return override.granted
except UserPermission.DoesNotExist:
pass
return UserRole.objects.filter(
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 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):
return request.user.is_superuser
# ---------------------------------------------------------------------------
# Base compartida — aplica el requisito de org activa a superusuarios
# ---------------------------------------------------------------------------
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):
# Permite el acceso si el usuario tiene el permiso 'can_access_storage'
return request.user.has_perm('api.cuser.can_access_storage')
if not request.user.is_authenticated:
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):
# Permite operaciones sobre un objeto específico si el usuario tiene el permiso
return request.user.has_perm('api.cuser.can_access_storage')
org = get_org_context(request.user)
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):
"""
Permite update/delete solo si el usuario está en TODOS los grupos permitidos
y pertenece a la misma organización que el registro, o es superuser.
"""
allowed_groups = ['admin', 'Agente Aduanal', 'user']
class IsSameOrganizationAndAdmin(OrgScopedPermission):
"""Usuario con rol admin, Agente Aduanal o user en su organización."""
def has_object_permission(self, request, view, obj):
user = request.user
if not user.is_authenticated:
return False
if user.is_superuser:
return True
if not hasattr(user, 'organizacion') or not user.organizacion:
org = get_org_context(user)
if not org:
return False
# Debe tener los tres grupos asignados
for group in self.allowed_groups:
if not user.groups.filter(name=group).exists():
tiene_rol = (
user_has_role(user, 'admin') or
user_has_role(user, 'Agente Aduanal') or
user_has_role(user, 'user')
)
if not tiene_rol:
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

View File

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

@@ -1,12 +1,17 @@
alembic==1.14.0
amqp==5.3.1
annotated-types==0.7.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
asgiref==3.9.1
async-timeout==5.0.1
attrs==25.3.0
billiard==4.2.1
boto3==1.42.91
botocore==1.42.91
celery==5.5.3
certifi==2025.6.15
cffi==2.0.0
channels==4.3.1
channels_redis==4.3.0
charset-normalizer==3.4.2
@@ -18,6 +23,7 @@ Django==5.2.3
django-cors-headers==4.7.0
django-filter==25.1
django-jet-reboot==1.3.10
django-storages==1.14.6
djangorestframework==3.16.0
djangorestframework_simplejwt==5.5.0
drf-yasg==1.21.10
@@ -30,12 +36,14 @@ humanize==4.12.3
idna==3.10
importlib_resources==6.5.2
inflection==0.5.1
jmespath==1.1.0
jsonschema==4.24.0
jsonschema-specifications==2025.4.1
kombu==5.5.4
Mako==1.3.10
Markdown==3.8
MarkupSafe==3.0.2
minio==7.2.20
msgpack==1.1.1
openpyxl==3.1.5
packaging==25.0
@@ -44,6 +52,8 @@ pillow==11.2.1
prometheus_client==0.22.1
prompt_toolkit==3.0.51
psycopg2-binary==2.9.10
pycparser==3.0
pycryptodome==3.23.0
PyJWT==2.9.0
python-dateutil==2.9.0.post0
python-dotenv==1.1.0
@@ -55,6 +65,7 @@ redis==6.2.0
referencing==0.36.2
requests==2.32.4
rpds-py==0.25.1
s3transfer==0.16.0
six==1.17.0
sniffio==1.3.1
SQLAlchemy==2.0.36