Compare commits

..

10 Commits

Author SHA1 Message Date
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
55 changed files with 4790 additions and 1776 deletions

View File

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

View File

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

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') organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users')
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True) profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
# Contexto de trabajo activo para superusuarios. Filtra datos igual que un usuario normal.
# Sin este campo activo, el superuser no puede consultar datos — debe hacer switch primero.
active_organization = models.ForeignKey(
'organization.Organizacion',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='superusers_activos',
help_text="Solo superusuarios: organización activa para contexto de trabajo",
)
is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer") is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer")
rfc = models.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): def __str__(self):
return self.username return self.username

View File

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

View File

@@ -34,6 +34,7 @@ class Pedimento(models.Model):
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True) 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") 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) 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") 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() documentos = serializers.SerializerMethodField()
def get_documentos(self, obj): 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): if not obj or not getattr(obj, 'pedimento', None):
return [] return []
if not obj or not getattr(obj, 'numero_partida', None): if not obj or not getattr(obj, 'numero_partida', None):
return [] return []
try: try:
pedimentoApp = str(obj.pedimento.pedimento_app).strip() pedimento_app = str(obj.pedimento.pedimento_app).strip()
numero = str(obj.numero_partida).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 # 17 = REQUEST partida, 18 = ERROR partida
patron_exacto = f'documents/vu_PT_{pedimentoApp}_{numero}.xml'
# Buscar documentos que empiecen EXACTAMENTE con ese patrón
qs = Document.objects.filter( 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: if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion) qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context) serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data return serializer.data
#return []
except Exception: except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return [] return []
class Meta: class Meta:
model = Partida model = Partida
@@ -208,10 +184,11 @@ class EDocumentSerializer(serializers.ModelSerializer):
numero = str(obj.numero_edocument).strip() numero = str(obj.numero_edocument).strip()
# id_pedimento = str(obj.pedimento_id).strip() # id_pedimento = str(obj.pedimento_id).strip()
# excluir e documents de tipo request y de tipo error
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
) ).exclude(document_type_id__in=[21, 25])
# Filtro por organización si aplica # Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion: if hasattr(obj, 'organizacion') and obj.organizacion:
@@ -263,10 +240,15 @@ class CoveSerializer(serializers.ModelSerializer):
try: try:
numero = str(obj.numero_cove).strip() numero = str(obj.numero_cove).strip()
# Excluir los tipo de documento 20, 24, 23 y 19
# 20 = error solicitud cove
# 24 = error solicitud acuse cove
# 23 = request acuse cove
# 19 = request cove
qs = Document.objects.filter( qs = Document.objects.filter(
pedimento=obj.pedimento, pedimento=obj.pedimento,
archivo__icontains=numero, archivo__icontains=numero,
) ).exclude(document_type_id__in=[20, 24, 23, 19])
# Filtro por organización si aplica # Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion: if hasattr(obj, 'organizacion') and obj.organizacion:

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.") logger.info("NO es creación de pedimento, no se crea procesamiento.")
return return
if not instance.consultar_vucem:
return
def crear_procesamiento(): def crear_procesamiento():
import logging import logging
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
@@ -87,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
import logging import logging
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Cove creado: {instance.id}, creando procesamiento...") logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)]) pedimento_id = str(instance.pedimento.id)
crear_procesamiento_acuse_cove.apply_async(args=[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) @receiver(post_save, sender=EDocument)
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs): 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 import logging
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...") logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)]) pedimento_id = str(instance.pedimento.id)
crear_procesamiento_acuse.apply_async(args=[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 .microservice import *
from .internal_services import * from .internal_services import *
from .bulk_upload 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 from core.utils import xml_controller
import requests import requests
from core.utils import xml_remesas_controller from core.utils import xml_remesas_controller
import logging
logger = logging.getLogger(__name__)
def obtener_pedimentos(organizacion_id): def obtener_pedimentos(organizacion_id):
return Pedimento.objects.filter(organizacion_id=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 pedimento_id = pedimento.id
docs = getattr(pedimento, related_name).all() 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 # Si no hay documentos, marcar como completado
if not docs.exists(): if not docs.exists():
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.") print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
logger.info(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
else: else:
all_docs = all(getattr(doc, variable) for doc in docs) all_docs = all(getattr(doc, variable) for doc in docs)
if all_docs: if all_docs:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.") print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
logger.info(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
else: else:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso" 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.") 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: if proceso:
print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.") print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
logger.info(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
else: else:
print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.") 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 ## Auditar pedimentos
@@ -121,44 +131,66 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
@shared_task @shared_task
def crear_partidas(organizacion_id): def crear_partidas(organizacion_id):
from api.customs.models import Partida
pedimentos = obtener_pedimentos(organizacion_id) pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count() 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: for pedimento in pedimentos:
pedimentos_procesados += 1 try:
partidas_agregadas_pedimento = 0 if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
sin_datos.append({
# Validar que numero_partidas no sea None y sea mayor que 0 'pedimento_id': str(pedimento.id),
if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0: 'pedimento': pedimento.pedimento,
partidas_existentes = pedimento.partidas.count() 'razon': f'numero_partidas inválido ({pedimento.numero_partidas})',
if pedimento.numero_partidas > partidas_existentes: })
print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") continue
for i in range(1, pedimento.numero_partidas + 1): for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida Partida.objects.get_or_create(
partida, created = Partida.objects.get_or_create(
pedimento=pedimento, pedimento=pedimento,
numero_partida=i, 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}") partidas = list(pedimento.partidas.order_by('numero_partida'))
else: no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
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)")
print(f"\n=== RESUMEN ===") if not no_descargadas:
print(f"Pedimentos procesados: {pedimentos_procesados}") completados.append(str(pedimento.id))
print(f"Total de partidas agregadas: {total_partidas_agregadas}") else:
print(f"Procesamiento completado para organización {organizacion_id}") 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 @shared_task
def crear_partidas_por_pedimento(pedimento_id): def crear_partidas_por_pedimento(pedimento_id):
@@ -169,6 +201,7 @@ def crear_partidas_por_pedimento(pedimento_id):
return return
print(f"Procesando pedimento individual {pedimento_id}...") print(f"Procesando pedimento individual {pedimento_id}...")
logger.info(f"Procesando pedimento individual {pedimento_id}...")
partidas_agregadas = 0 partidas_agregadas = 0
# Validar que numero_partidas no sea None y sea mayor que 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() partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes: if pedimento.numero_partidas > partidas_existentes:
print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") 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): for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida from api.customs.models import Partida
@@ -188,62 +222,165 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_agregadas += 1 partidas_agregadas += 1
print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}") print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
logger.info(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
else: else:
print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") 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: else:
print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}") 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 @shared_task
def auditar_coves(organizacion_id): def auditar_coves(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id): return _auditar_organizacion(
auditor_descargas( organizacion_id,
pedimento,
servicio=8, servicio=8,
related_name='coves', related_name='coves',
variable='cove_descargado', variable='cove_descargado',
mensaje='COVE' label='cove',
) )
@shared_task @shared_task
def auditar_acuse_cove(organizacion_id): def auditar_acuse_cove(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id): return _auditar_organizacion(
auditor_descargas( organizacion_id,
pedimento,
servicio=9, servicio=9,
related_name='coves', related_name='coves',
variable='acuse_cove_descargado', variable='acuse_cove_descargado',
mensaje='acuse de COVE' label='acuse_cove',
) )
# Revisa si el pedimento completo todos sus acuse coves
# Auditar edocuments
@shared_task @shared_task
def auditar_edocuments(organizacion_id): def auditar_edocuments(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id): return _auditar_organizacion(
auditor_descargas( organizacion_id,
pedimento,
servicio=7, servicio=7,
related_name='documentos', related_name='documentos',
variable='edocument_descargado', variable='edocument_descargado',
mensaje='EDocument' label='edocument',
) )
@shared_task @shared_task
def auditar_acuse(organizacion_id): def auditar_acuse(organizacion_id):
for pedimento in obtener_pedimentos(organizacion_id): return _auditar_organizacion(
auditor_descargas( organizacion_id,
pedimento,
servicio=6, servicio=6,
related_name='documentos', related_name='documentos',
variable='acuse_descargado', 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 @shared_task
def auditar_cove_por_pedimento(pedimento_id): def auditar_cove_por_pedimento(pedimento_id):
try: try:
print(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
logger.info(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
from api.customs.models import Pedimento from api.customs.models import Pedimento
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
auditor_descargas( auditor_descargas(

View File

@@ -1,6 +1,8 @@
# auditoria_xml.py # auditoria_xml.py
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from datetime import datetime from datetime import datetime
import logging
logger = logging.getLogger('api.customs.auditoria_xml')
def extraer_info_pedimento_xml(xml_content): def extraer_info_pedimento_xml(xml_content):
""" """
@@ -13,8 +15,10 @@ def extraer_info_pedimento_xml(xml_content):
# Buscar el namespace (puede variar) # Buscar el namespace (puede variar)
namespaces = { namespaces = {
'S': 'http://schemas.xmlsoap.org/soap/envelope/', 'S': 'http://schemas.xmlsoap.org/soap/envelope/',
's': 'http://schemas.xmlsoap.org/soap/envelope/',
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto', '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 = {} resultado = {}
@@ -181,10 +185,37 @@ def extraer_info_pedimento_xml(xml_content):
if edocs_encontrados: if edocs_encontrados:
resultado['edocuments_en_xml'] = 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) tiene_error = root.find('.//ns3:tieneError', namespaces)
if tiene_error is not None: if tiene_error is not None:
resultado['tiene_error'] = tiene_error.text.lower() == 'true' 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 return resultado

View File

@@ -1,5 +1,4 @@
from celery import shared_task from celery import shared_task
from django.core.files.base import ContentFile
from django.utils import timezone from django.utils import timezone
import os import os
import zipfile import zipfile
@@ -27,16 +26,27 @@ def normalize_filename(filename):
return 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): 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) normalized = normalize_filename(filename)
name_without_ext, ext = os.path.splitext(normalized) name_without_ext, ext = os.path.splitext(normalized)
django_suffix = extract_django_suffix(name_without_ext) django_suffix = extract_django_suffix(name_without_ext)
if django_suffix: if django_suffix:
base_name = name_without_ext[:-8] base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else: else:
base_name = name_without_ext base_name = name_without_ext
@@ -45,17 +55,6 @@ def get_clean_base_filename(filename):
return base_name.lower().strip('_') 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): def is_same_document(existing_doc, new_filename):
""" """
Compara si un documento existente y un nuevo archivo son el mismo documento. 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 tiene_nomenclatura_especial = True
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento) info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento)
django_file = ContentFile(file_content, name=file_name)
# Buscar documento existente # Buscar documento existente
existing_documents = Document.objects.filter( existing_documents = Document.objects.filter(
pedimento_id=existing_pedimento.id, pedimento_id=existing_pedimento.id,
@@ -630,51 +627,53 @@ def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
break break
if existing_document: 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({ updated_pedimentos.append({
"id": str(existing_pedimento.id), "id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app, "pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento actualizado", "accion": "Documento ya existente, omitido",
"documento": file_name "documento": file_name
}) })
documents_created += 1
else: else:
# Crear nuevo documento # Crear registro sin archivo primero
document = Document.objects.create( document = Document.objects.create(
organizacion=organizacion, organizacion=organizacion,
pedimento_id=existing_pedimento.id, pedimento_id=existing_pedimento.id,
document_type=document_type, document_type=document_type,
fuente_id=fuente.id, fuente_id=fuente.id,
archivo=django_file,
size=len(file_content), size=len(file_content),
extension=os.path.splitext(file_name)[1].lower().lstrip('.') 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({ updated_pedimentos.append({
"id": str(existing_pedimento.id), "id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app, "pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento creado", "accion": "Documento creado",
"documento": file_name "documento": file_name
}) })
else:
documents_created += 1 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: except Exception as e:
failed_records.append({ failed_records.append({

View File

@@ -1,6 +1,14 @@
from celery import shared_task, group from celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller from core.utils import xml_controller
from 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 @shared_task
def crear_procesamiento_remesa(pedimento_id): def crear_procesamiento_remesa(pedimento_id):
@@ -11,7 +19,7 @@ def crear_procesamiento_remesa(pedimento_id):
if pedimento.remesas: if pedimento.remesas:
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=5, # ID del servicio de remesas servicio_id=5,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -19,10 +27,11 @@ def crear_procesamiento_remesa(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=5, servicio_id=5,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_partida(pedimento_id): 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}") logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=4, # ID del servicio de partidas servicio_id=4,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -40,10 +49,11 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=4, servicio_id=4,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_cove(pedimento_id): def crear_procesamiento_cove(pedimento_id):
@@ -54,7 +64,7 @@ def crear_procesamiento_cove(pedimento_id):
if pedimento.coves.exists(): if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=8, # ID del servicio de Coves servicio_id=8,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -62,10 +72,11 @@ def crear_procesamiento_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=8, servicio_id=8,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_acuse(pedimento_id): def crear_procesamiento_acuse(pedimento_id):
@@ -73,10 +84,10 @@ def crear_procesamiento_acuse(pedimento_id):
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {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( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=6, # ID del servicio de Acuse Cove servicio_id=6,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -84,10 +95,11 @@ def crear_procesamiento_acuse(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=6, servicio_id=6,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_acuse_cove(pedimento_id): def crear_procesamiento_acuse_cove(pedimento_id):
@@ -98,7 +110,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
if pedimento.coves.exists(): if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=9, # ID del servicio de Acuse Cove servicio_id=9,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -106,10 +118,11 @@ def crear_procesamiento_acuse_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=9, servicio_id=9,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_edocument(pedimento_id): def crear_procesamiento_edocument(pedimento_id):
@@ -120,7 +133,7 @@ def crear_procesamiento_edocument(pedimento_id):
if pedimento.documentos.exists(): if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=7, # ID del servicio de EDocument servicio_id=7,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -128,10 +141,11 @@ def crear_procesamiento_edocument(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=7, servicio_id=7,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_pedimento_completo(organizacion_id): 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 group
from celery import shared_task, group from celery import shared_task, group
from api.customs.models import * from api.customs.models import *
@@ -8,6 +9,11 @@ import requests
from config.settings import SERVICE_API_URL_V2 from config.settings import SERVICE_API_URL_V2
from datetime import datetime from datetime import datetime
import json 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): def credenciales_to_dict(credenciales):
if not credenciales: if not credenciales:
@@ -132,7 +138,7 @@ def procesar_edocs_pedimento(pedimento_id):
} }
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/download/edoc/", f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
@@ -277,10 +283,23 @@ def procesar_remesas(organizacion_id):
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos: for pedimento in pedimentos:
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa logger.info(f"pedimento >>>> {pedimento}")
# Convertir el pedimento a JSON usando el serializer 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) 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) credenciales_dict = credenciales_to_dict(credenciales)
@@ -289,15 +308,15 @@ def procesar_remesas(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas", f"{SERVICE_API_URL_V2}/services/remesas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"}
) )
# Aquí puedes continuar con el resto de tu lógica logger.info(f"Servicio enviado 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 @shared_task
def procesar_coves(organizacion_id): def procesar_coves(organizacion_id):
@@ -522,6 +541,37 @@ def ejecutar_todos_por_organizacion(organizacion_id):
procesar_pedimentos_completos.delay(organizacion_id) procesar_pedimentos_completos.delay(organizacion_id)
procesar_remesas.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.test import APITestCase, APIClient
from rest_framework import status from rest_framework import status
from django.contrib.auth import get_user_model 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.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument from .models import Pedimento, TipoOperacion, ProcesamientoPedimento, EDocument
User = get_user_model() User = get_user_model()
@@ -75,3 +80,147 @@ class CustomsViewsTests(APITestCase):
self.client.force_authenticate(user=self.admin) self.client.force_authenticate(user=self.admin)
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) 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_acuse_cove_endpoint,
auditar_edocuments_endpoint, auditar_edocuments_endpoint,
auditar_acuse_endpoint, auditar_acuse_endpoint,
auditar_remesas_endpoint,
auditar_cove_pedimento_endpoint, auditar_cove_pedimento_endpoint,
auditar_acuse_cove_pedimento_endpoint, auditar_acuse_cove_pedimento_endpoint,
auditar_edocument_pedimento_endpoint, auditar_edocument_pedimento_endpoint,
@@ -72,6 +73,7 @@ urlpatterns = [
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'), 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-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'), 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-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-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-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),

View File

@@ -1,3 +1,4 @@
from api.utils.storage_service import storage_service
from config.settings import SERVICE_API_URL from config.settings import SERVICE_API_URL
from django.shortcuts import render from django.shortcuts import render
from rest_framework import viewsets from rest_framework import viewsets
@@ -9,12 +10,20 @@ from rest_framework.views import APIView
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework import status from rest_framework import status
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django.http import HttpResponse
import django_filters
import io
import openpyxl
from rest_framework.filters import SearchFilter, OrderingFilter from rest_framework.filters import SearchFilter, OrderingFilter
from core.permissions import ( from core.permissions import (
IsSameOrganization, IsSameOrganization,
IsSameOrganizationDeveloper, IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin, IsSameOrganizationAndAdmin,
IsSuperUser IsSuperUser,
get_org_context,
require_permission,
user_has_permission,
is_internal_service_request,
) )
from api.customs.models import ( from api.customs.models import (
Pedimento, Pedimento,
@@ -61,7 +70,6 @@ except ImportError:
# Importar tarea de procesamiento de pedimento (Celery) # Importar tarea de procesamiento de pedimento (Celery)
from api.customs.tasks.microservice import procesar_pedimento_completo_individual from api.customs.tasks.microservice import procesar_pedimento_completo_individual
from api.utils.storage_service import storage_service
def get_available_extractors(): def get_available_extractors():
""" """
@@ -244,6 +252,19 @@ class PedimentoPagination(PageNumberPagination):
return super().paginate_queryset(queryset, request, view) return super().paginate_queryset(queryset, request, view)
# Create your views here. # Create your views here.
class PedimentoFilter(django_filters.FilterSet):
# Rango de fecha de pago: ?fecha_pago_desde=YYYY-MM-DD&fecha_pago_hasta=YYYY-MM-DD
fecha_pago_desde = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='gte')
fecha_pago_hasta = django_filters.DateFilter(field_name='fecha_pago', lookup_expr='lte')
class Meta:
model = Pedimento
fields = [
'patente', 'aduana', 'tipo_operacion', 'clave_pedimento',
'pedimento', 'existe_expediente', 'contribuyente',
'curp_apoderado', 'fecha_pago', 'pedimento_app',
]
class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): # Pendiente de permisos de creacion
""" """
ViewSet for Pedimento model. ViewSet for Pedimento model.
@@ -257,7 +278,9 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
- existe_expediente: Filtro por expediente (True/False) - existe_expediente: Filtro por expediente (True/False)
- contribuyente: Filtro por contribuyente - contribuyente: Filtro por contribuyente
- curp_apoderado: Filtro por curp del apoderado - curp_apoderado: Filtro por curp del apoderado
- fecha_pago: Filtro por fecha de pago (YYYY-MM-DD) - fecha_pago: Filtro por fecha de pago exacta (YYYY-MM-DD)
- fecha_pago_desde: Rango inicio de fecha de pago (YYYY-MM-DD)
- fecha_pago_hasta: Rango fin de fecha de pago (YYYY-MM-DD)
- patente: Filtro por patente - patente: Filtro por patente
- aduana: Filtro por aduana - aduana: Filtro por aduana
- tipo_operacion: Filtro por tipo de operación - tipo_operacion: Filtro por tipo de operación
@@ -267,43 +290,112 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
Ejemplos: Ejemplos:
- /pedimentos/ → Devuelve TODOS los pedimentos - /pedimentos/ → Devuelve TODOS los pedimentos
- /pedimentos/?page_size=10 → Devuelve los primeros 10 - /pedimentos/?page_size=10 → Devuelve los primeros 10
- /pedimentos/?page_size=10&page=2 → Devuelve los pedimentos 11-20 - /pedimentos/?fecha_pago_desde=2025-01-01&fecha_pago_hasta=2025-12-31 → Rango de fechas
- /pedimentos/?pedimento=12345678 → Filtra por número de pedimento - /pedimentos/export-excel/?contribuyente=EMPRESA → Descarga Excel con filtros
- /pedimentos/?existe_expediente=true → Filtra por expediente existente
- /pedimentos/?contribuyente=EMPRESA → Filtra por contribuyente
- /pedimentos/?curp_apoderado=XXXX → Filtra por curp apoderado
- /pedimentos/?fecha_pago=2025-07-18 → Filtra por fecha de pago
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = PedimentoSerializer serializer_class = PedimentoSerializer
pagination_class = PedimentoPagination pagination_class = PedimentoPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
model = Pedimento model = Pedimento
filterset_fields = ['patente', 'aduana', 'tipo_operacion', 'clave_pedimento', 'pedimento', 'existe_expediente', 'contribuyente', 'curp_apoderado', 'fecha_pago', 'pedimento_app'] filterset_class = PedimentoFilter
search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento'] search_fields = ['pedimento', 'pedimento_app', 'agente_aduanal', 'clave_pedimento']
# AGREGAR ESTOS CAMPOS PARA ORDENACIÓN
ordering_fields = ['created_at', 'pedimento', 'fecha_pago', 'aduana', 'patente'] ordering_fields = ['created_at', 'pedimento', 'fecha_pago', 'aduana', 'patente']
ordering = ['-created_at'] # Orden descendente por fecha de creación por defecto ordering = ['-created_at']
def get_permissions(self):
perms = {
'list': 'pedimentos.view',
'retrieve': 'pedimentos.view',
'create': 'pedimentos.create',
'update': 'pedimentos.edit',
'partial_update': 'pedimentos.edit',
'destroy': 'pedimentos.delete',
'procesar_completo': 'pedimentos.process',
'procesar_partidas': 'pedimentos.process',
'procesar_coves': 'pedimentos.process',
'procesar_acuse_coves': 'pedimentos.process',
'procesar_edocs': 'pedimentos.process',
'procesar_acuses': 'pedimentos.process',
'procesar_remesas': 'pedimentos.process',
'bulk_delete': 'pedimentos.delete',
'bulk_create': 'pedimentos.create',
'bulk_create_pedimento_desk': 'pedimentos.create',
'bulk_upload_record': 'documentos.upload',
'bulk_upload_record_async': 'documentos.upload',
}
codename = perms.get(self.action, 'pedimentos.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
if not user_has_permission(self.request.user, 'pedimentos.view'):
return Pedimento.objects.none()
return self.get_queryset_filtrado_por_organizacion()
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador @action(detail=False, methods=['get'], url_path='export-excel')
def export_excel(self, request):
"""Exporta a Excel todos los pedimentos que coincidan con los filtros activos."""
queryset = self.filter_queryset(self.get_queryset())
# pedimento_app_filter = self.request.GET.get('pedimento_app', None) columnas = [
('pedimento_app', 'Pedimento'),
('fecha_pago', 'Fecha Pago'),
('aduana', 'Aduana'),
('patente', 'Patente'),
('contribuyente', 'Contribuyente'),
('curp_apoderado','CURP Apoderado'),
('numero_partidas','Partidas'),
('created_at', 'F. Carga'),
('tipo_operacion','Tipo Op.'),
('clave_pedimento','Clave Pedimento'),
('documentos_count', 'Archivos'),
('existe_expediente','Expediente'),
]
# if pedimento_app_filter: def safe_value(val):
# print(f"Filtro por pedimento_app: {pedimento_app_filter}") if val is None:
# queryset = queryset.filter(pedimento_app__icontains=pedimento_app_filter) return ''
if isinstance(val, bool):
return '' if val else 'No'
if isinstance(val, (int, float)):
return val
if isinstance(val, (datetime, date)):
return str(val)[:10]
# ForeignKey instances u otros objetos Django → su representación string
return str(val)
return queryset wb = openpyxl.Workbook()
ws = wb.active
ws.title = 'Pedimentos'
ws.append([label for _, label in columnas])
for ped in queryset.iterator():
fila = []
for campo, _ in columnas:
val = getattr(ped, campo, None)
fila.append(safe_value(val))
ws.append(fila)
# Autoajuste de ancho de columnas
for col in ws.columns:
max_len = max((len(str(cell.value or '')) for cell in col), default=10)
ws.column_dimensions[col[0].column_letter].width = min(max_len + 2, 50)
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f"pedimentos_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
response = HttpResponse(
output.read(),
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
response['Content-Disposition'] = f'attachment; filename="{filename}"'
return response
def perform_create(self, serializer): def perform_create(self, serializer):
""" org = get_org_context(self.request.user)
Asigna automáticamente la organización del usuario autenticado al crear un pedimento.
"""
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 data = serializer.validated_data
if not data.get('pedimento_app'): if not data.get('pedimento_app'):
fecha_pago = data.get('fecha_pago') fecha_pago = data.get('fecha_pago')
@@ -312,7 +404,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
pedimento = data.get('pedimento') pedimento = data.get('pedimento')
if fecha_pago and aduana and patente and pedimento: if fecha_pago and aduana and patente and pedimento:
pedimento_app = f"{str(fecha_pago.year)[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento).zfill(7)[-7:]}" pedimento_app = f"{str(fecha_pago.year)[-2:]}-{str(aduana).zfill(2)[-2:]}-{str(patente).zfill(4)[-4:]}-{str(pedimento).zfill(7)[-7:]}"
serializer.save(organizacion=self.request.user.organizacion, pedimento_app=pedimento_app) serializer.save(organizacion=org, pedimento_app=pedimento_app)
try: try:
# Usar el nombre del servicio de Docker Compose en lugar de localhost # Usar el nombre del servicio de Docker Compose en lugar de localhost
@@ -375,6 +467,9 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
] ]
} }
def perform_destroy(self, instance):
instance.delete()
@action(detail=True, methods=['post'], url_path='procesar-completo') @action(detail=True, methods=['post'], url_path='procesar-completo')
def procesar_completo(self, request, pk=None): def procesar_completo(self, request, pk=None):
""" """
@@ -394,6 +489,131 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
except Exception as e: except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-partidas')
def procesar_partidas(self, request, pk=None):
"""
Acción para disparar el procesamiento de un partidas de un pedimento existente.
Dispara la tarea `procesar_partidas_individual` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
from api.customs.tasks import microservice_v2
# Usar el nombre del servicio de Docker Compose en lugar de localhost
task = microservice_v2.procesar_partidas_pedimento.delay(pedimento.id)
# Verificar si la respuesta fue exitosa
if task.id:
return Response({"status": "Iniciando Procesamiento de Partidas", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "El Servicio respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-coves')
def procesar_coves(self, request, pk=None):
"""
Acción para disparar el procesamiento de un cove de un pedimento existente.
Dispara la tarea `procesar_coves_individual` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
from api.customs.tasks import microservice_v2
# Usar el nombre del servicio de Docker Compose en lugar de localhost
task = microservice_v2.procesar_coves_pedimento.delay(pedimento.id)
# Verificar si la respuesta fue exitosa
if task.id:
return Response({"status": "Iniciando Procesamiento de COVES", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-acuse-coves')
def procesar_acuse_coves(self, request, pk=None):
"""
Acción para disparar el procesamiento de un acuse cove de un pedimento existente.
Dispara la tarea `procesar_acuse_coves_individual` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
# Usar el nombre del servicio de Docker Compose en lugar de localhost
from api.customs.tasks import microservice_v2
# Usar el nombre del servicio de Docker Compose en lugar de localhost
task = microservice_v2.procesar_acuse_coves_pedimento.delay(pedimento.id)
# Verificar si la respuesta fue exitosa
if task.id:
return Response({"status": "Iniciando Procesamiento de Acuse COVES", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-edocuments')
def procesar_edocs(self, request, pk=None):
"""
Acción para disparar el procesamiento de un edocuments de un pedimento existente.
Dispara la tarea `procesar_edocuments_individual` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
# Usar el nombre del servicio de Docker Compose en lugar de localhost
from api.customs.tasks import microservice_v2
# Usar el nombre del servicio de Docker Compose en lugar de localhost
task = microservice_v2.procesar_edocs_pedimento.delay(pedimento.id)
# Verificar si la respuesta fue exitosa
if task.id:
return Response({"status": "Iniciando Procesamiento de EDOCS", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-acuses')
def procesar_acuses(self, request, pk=None):
"""
Acción para disparar el procesamiento de un acuses de un pedimento existente.
Dispara la tarea `procesar_acuses_individual` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
from api.customs.tasks import microservice_v2
# Usar el nombre del servicio de Docker Compose en lugar de localhost
task = microservice_v2.procesar_acuses_pedimento.delay(pedimento.id)
# Verificar si la respuesta fue exitosa
if task.id:
return Response({"status": "Iniciando Procesamiento de Acuses", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=True, methods=['post'], url_path='procesar-remesas')
def procesar_remesas(self, request, pk=None):
"""
Acción para disparar el procesamiento de remesas de un pedimento existente.
Dispara la tarea `procesar_remesas_pedimento` de forma asíncrona
y devuelve el `task_id`.
"""
pedimento = self.get_object()
try:
from api.customs.tasks import microservice_v2
task = microservice_v2.procesar_remesas_pedimento.delay(pedimento.id)
if task.id:
return Response({"status": "Iniciando Procesamiento de Remesas", "task_id": task.id}, status=status.HTTP_202_ACCEPTED)
else:
return Response({"status": "Servicio API respondió con error", "task_id": 0}, status=status.HTTP_202_ACCEPTED)
except Exception as e:
return Response({"error": f"Error inesperado al llamar al servicio API: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=['post'], url_path='bulk-delete') @action(detail=False, methods=['post'], url_path='bulk-delete')
def bulk_delete(self, request): def bulk_delete(self, request):
import traceback import traceback
@@ -657,11 +877,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
"contribuyente": existing_pedimento.contribuyente.rfc if existing_pedimento.contribuyente else None, "contribuyente": existing_pedimento.contribuyente.rfc if existing_pedimento.contribuyente else None,
"archivo_original": archivo.name "archivo_original": archivo.name
}) })
# NO procesamos este archivo, pasamos al siguiente # Continuar al procesamiento de documentos del pedimento existente
continue
# Si el pedimento no existe, continuar con el procesamiento normal
print("📝 Pedimento no existe, continuando con procesamiento...")
# Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión # Crear subdirectorio para cada archivo usando el nombre del archivo sin extensión
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension) sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
@@ -713,7 +929,10 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
f.write(chunk) f.write(chunk)
print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path) print(f"Archivo individual {archivo.name} guardado en sub_dir:", archivo_path)
# Ahora crear el pedimento (ya verificamos que no existe) if existing_pedimento:
pedimento = existing_pedimento
else:
# Crear el pedimento nuevo
try: try:
print("🔄 Iniciando creación de pedimento...") print("🔄 Iniciando creación de pedimento...")
@@ -2076,30 +2295,67 @@ class PartidaViewSet(viewsets.ModelViewSet):
Ejemplo: GET /api/partidas/?pedimento=6782d22e-5e97-4efc-87c9-bd8497c8ac7e Ejemplo: GET /api/partidas/?pedimento=6782d22e-5e97-4efc-87c9-bd8497c8ac7e
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
queryset = Partida.objects.all()
serializer_class = PartidaSerializer serializer_class = PartidaSerializer
pagination_class = CustomPagination pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
filterset_fields = { filterset_fields = {
'pedimento': ['exact'], # Filtro directo por UUID del pedimento 'pedimento': ['exact'],
'pedimento__id': ['exact'], # Filtro alternativo 'pedimento__id': ['exact'],
'numero_partida': ['exact', 'gte', 'lte'], # Filtros por número de partida 'numero_partida': ['exact', 'gte', 'lte'],
'descargado': ['exact'], # Filtro por estado de descarga 'descargado': ['exact'],
'created_at': ['exact', 'gte', 'lte'], # Filtros por fecha de creación 'created_at': ['exact', 'gte', 'lte'],
'updated_at': ['exact', 'gte', 'lte'] # Filtros por fecha de actualización 'updated_at': ['exact', 'gte', 'lte'],
} }
search_fields = ['pedimento__pedimento', 'pedimento__pedimento_app'] search_fields = ['pedimento__pedimento', 'pedimento__pedimento_app']
ordering_fields = ['numero_partida', 'pedimento__pedimento', 'id', 'created_at', 'updated_at'] ordering_fields = ['numero_partida', 'pedimento__pedimento', 'id', 'created_at', 'updated_at']
ordering = ['numero_partida'] # Ordenar por número de partida por defecto ordering = ['numero_partida']
my_tags = ['Partidas'] my_tags = ['Partidas']
def get_permissions(self):
perms = {
'list': 'partidas.view',
'retrieve': 'partidas.view',
'create': 'partidas.create',
'update': 'partidas.edit',
'partial_update': 'partidas.edit',
'destroy': 'partidas.delete',
'bulk_delete_partidas_vu': 'partidas.delete',
}
codename = perms.get(self.action, 'partidas.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self):
user = self.request.user
if is_internal_service_request(self.request):
return Partida.objects.all()
if not user_has_permission(user, 'partidas.view'):
return Partida.objects.none()
org = get_org_context(user)
if not org:
return Partida.objects.none()
qs = Partida.objects.filter(pedimento__organizacion=org)
if user.is_importador:
qs = qs.filter(pedimento__contribuyente__in=user.rfc.all())
return qs
def perform_create(self, serializer):
if is_internal_service_request(self.request):
serializer.save()
return
pedimento = serializer.validated_data.get('pedimento')
org = get_org_context(self.request.user)
if pedimento and pedimento.organizacion != org:
raise PermissionDenied("El pedimento no pertenece a tu organización.")
serializer.save()
def perform_destroy(self, instance):
instance.delete()
class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet): class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet):
""" """
ViewSet for TipoOperacion model. ViewSet for TipoOperacion model.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('pedimentos.view')]
queryset = TipoOperacion.objects.all() queryset = TipoOperacion.objects.all()
serializer_class = TipoOperacionSerializer serializer_class = TipoOperacionSerializer
@@ -2112,6 +2368,14 @@ class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet):
my_tags = ['Tipos_Operacion'] my_tags = ['Tipos_Operacion']
def get_queryset(self):
if is_internal_service_request(self.request):
return TipoOperacion.objects.all()
org = get_org_context(self.request.user)
if not org:
return TipoOperacion.objects.none()
return TipoOperacion.objects.filter(organizacion=org)
def perform_create(self, serializer): def perform_create(self, serializer):
""" """
Asigna automáticamente la organización del usuario autenticado al crear un tipo de operación. Asigna automáticamente la organización del usuario autenticado al crear un tipo de operación.
@@ -2152,7 +2416,6 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
- /procesamientopedimentos/ → Devuelve TODOS los procesamientos - /procesamientopedimentos/ → Devuelve TODOS los procesamientos
- /procesamientopedimentos/?page_size=5 → Devuelve los primeros 5 - /procesamientopedimentos/?page_size=5 → Devuelve los primeros 5
""" """
permission_classes = [IsAuthenticated, IsSuperUser | IsSameOrganizationDeveloper ]
serializer_class = ProcesamientoPedimentoSerializer serializer_class = ProcesamientoPedimentoSerializer
pagination_class = CustomPagination pagination_class = CustomPagination
model = ProcesamientoPedimento model = ProcesamientoPedimento
@@ -2168,51 +2431,53 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci
ordering_fields = ['created_at', 'updated_at'] ordering_fields = ['created_at', 'updated_at']
ordering = ['-created_at'] ordering = ['-created_at']
def get_permissions(self):
perms = {
'list': 'pedimentos.view',
'retrieve': 'pedimentos.view',
'create': 'pedimentos.process',
'update': 'pedimentos.process',
'partial_update': 'pedimentos.process',
'destroy': 'pedimentos.process',
}
codename = perms.get(self.action, 'pedimentos.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
return self.get_queryset_filtrado_por_organizacion() user = self.request.user
if is_internal_service_request(self.request):
return ProcesamientoPedimento.objects.all()
if not user_has_permission(user, 'pedimentos.view'):
return ProcesamientoPedimento.objects.none()
org = get_org_context(user)
if not org:
return ProcesamientoPedimento.objects.none()
if user.is_importador:
return ProcesamientoPedimento.objects.filter(
organizacion=org,
pedimento__contribuyente__in=user.rfc.all()
)
return ProcesamientoPedimento.objects.filter(organizacion=org)
def perform_create(self, serializer): def perform_create(self, serializer):
""" if is_internal_service_request(self.request):
Asigna siempre la organización al crear un procesamiento de pedimento.
- Para superusuarios: requiere que la organización venga explícitamente en los datos validados.
- Para usuarios normales: asigna la organización del usuario autenticado.
"""
user = self.request.user
if not user.is_authenticated:
raise ValueError("Usuario no autenticado")
# Si es superusuario, debe venir la organización en los datos validados
if user.is_superuser:
organizacion = serializer.validated_data.get('organizacion', None)
if not organizacion:
raise ValueError("El superusuario debe especificar una organización al crear el procesamiento de pedimento.")
serializer.save() serializer.save()
return return
org = get_org_context(self.request.user)
# Para usuarios normales, asignar siempre la organización del usuario if not org:
if not hasattr(user, 'organizacion') or not user.organizacion: raise PermissionDenied("Sin organización activa.")
raise ValueError("Usuario sin organización") serializer.save(organizacion=org)
serializer.save(organizacion=user.organizacion)
def perform_update(self, serializer): def perform_update(self, serializer):
""" if is_internal_service_request(self.request):
Permite actualizar un procesamiento de pedimento, pero solo si el usuario es superusuario o pertenece a la misma organización.
"""
if not self.request.user.is_authenticated:
raise ValueError("Usuario no autenticado")
if self.request.user.is_superuser:
serializer.save() serializer.save()
return return
if not user_has_permission(self.request.user, 'pedimentos.process'):
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(): raise PermissionDenied("Se requiere el permiso pedimentos.process.")
# Para usuarios normales, usar siempre su organización org = get_org_context(self.request.user)
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: if not org:
raise ValueError("Usuario sin organización") raise PermissionDenied("Sin organización activa.")
serializer.save(organizacion=self.request.user.organizacion) serializer.save(organizacion=org)
return
raise ValueError("Usuario no autenticado o sin permisos para actualizar ProcesamientoPedimento")
my_tags = ['Procesamientos_Pedimentos'] my_tags = ['Procesamientos_Pedimentos']
@@ -2220,7 +2485,6 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
""" """
ViewSet for EDocument model. ViewSet for EDocument model.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = EDocumentSerializer serializer_class = EDocumentSerializer
pagination_class = CustomPagination pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -2229,59 +2493,48 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
ordering_fields = ['created_at', 'updated_at', 'numero_edocument'] ordering_fields = ['created_at', 'updated_at', 'numero_edocument']
ordering = ['-created_at'] ordering = ['-created_at']
model = EDocument model = EDocument
campo_contribuyente = 'pedimento__contribuyente'
my_tags = ['EDocuments'] my_tags = ['EDocuments']
def get_permissions(self):
perms = {
'list': 'edocuments.view',
'retrieve': 'edocuments.view',
'create': 'edocuments.create',
'update': 'edocuments.edit',
'partial_update': 'edocuments.edit',
'destroy': 'edocuments.delete',
'bulk_delete_edocs_vu': 'edocuments.delete',
}
codename = perms.get(self.action, 'edocuments.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
if not user_has_permission(self.request.user, 'edocuments.view'):
return EDocument.objects.none()
return self.get_queryset_filtrado_por_organizacion() return self.get_queryset_filtrado_por_organizacion()
def perform_create(self, serializer): def perform_create(self, serializer):
""" if is_internal_service_request(self.request):
Asigna automáticamente la organización del usuario autenticado al crear un EDocument.
Para superusuarios, permite especificar una organización diferente.
"""
if not self.request.user.is_authenticated:
raise ValueError("Usuario no autenticado")
# Si es superusuario y se especifica organizacion en los datos validados
if self.request.user.is_superuser:
# Permitir que el superusuario especifique la organización
serializer.save() serializer.save()
return return
org = get_org_context(self.request.user)
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): serializer.save(organizacion=org)
# Para usuarios normales, usar siempre su organización
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
return
raise ValueError("Usuario no autenticado o sin permisos para crear EDocument")
def perform_update(self, serializer): def perform_update(self, serializer):
""" if is_internal_service_request(self.request):
Permite actualizar un EDocument, pero solo si el usuario es superusuario o pertenece a la misma organización.
"""
if not self.request.user.is_authenticated:
raise ValueError("Usuario no autenticado")
# Si es superusuario, permite actualizar sin restricciones
if self.request.user.is_superuser:
serializer.save() serializer.save()
return return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): def perform_destroy(self, instance):
# Para usuarios normales, usar siempre su organización instance.delete()
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
raise ValueError("Usuario no autenticado o sin permisos para actualizar EDocument")
class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
""" """
ViewSet for Cove model. ViewSet for Cove model.
""" """
permission_classes = [IsAuthenticated & (IsSuperUser |IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
serializer_class = CoveSerializer serializer_class = CoveSerializer
pagination_class = CustomPagination pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -2290,61 +2543,48 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
ordering_fields = ['created_at', 'updated_at', 'numero_cove'] ordering_fields = ['created_at', 'updated_at', 'numero_cove']
ordering = ['-created_at'] ordering = ['-created_at']
model = Cove model = Cove
campo_contribuyente = 'pedimento__contribuyente'
my_tags = ['Coves'] my_tags = ['Coves']
def get_permissions(self):
perms = {
'list': 'coves.view',
'retrieve': 'coves.view',
'create': 'coves.create',
'update': 'coves.edit',
'partial_update': 'coves.edit',
'destroy': 'coves.delete',
'bulk_delete_coves_vu': 'coves.delete',
}
codename = perms.get(self.action, 'coves.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self): def get_queryset(self):
if not user_has_permission(self.request.user, 'coves.view'):
return Cove.objects.none()
return self.get_queryset_filtrado_por_organizacion() return self.get_queryset_filtrado_por_organizacion()
def perform_create(self, serializer): def perform_create(self, serializer):
""" if is_internal_service_request(self.request):
Asigna automáticamente la organización del usuario autenticado al crear un Cove.
Para superusuarios, permite especificar una organización diferente.
"""
if not self.request.user.is_authenticated:
raise ValueError("Usuario no autenticado")
# Si es superusuario y se especifica organizacion en los datos validados
if self.request.user.is_superuser:
# Permitir que el superusuario especifique la organización
serializer.save() serializer.save()
return return
org = get_org_context(self.request.user)
if ( serializer.save(organizacion=org)
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():
# Para usuarios normales, usar siempre su organización
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
return
raise ValueError("Usuario no autenticado o sin permisos para crear Cove")
def perform_update(self, serializer): def perform_update(self, serializer):
""" if is_internal_service_request(self.request):
Permite actualizar un Cove, pero solo si el usuario es superusuario o pertenece a la misma organización.
"""
if not self.request.user.is_authenticated:
raise ValueError("Usuario no autenticado")
# Si es superusuario, permite actualizar sin restricciones
if self.request.user.is_superuser:
serializer.save() serializer.save()
return return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user .groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): def perform_destroy(self, instance):
# Para usuarios normales, usar siempre su organización instance.delete()
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): class ImportadorViewSet(viewsets.ModelViewSet):
""" """
ViewSet for Importador model. ViewSet for Importador model.
""" """
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
serializer_class = ImportadorSerializer serializer_class = ImportadorSerializer
pagination_class = CustomPagination pagination_class = CustomPagination
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
@@ -2352,60 +2592,69 @@ class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
search_fields = ['rfc', 'nombre'] search_fields = ['rfc', 'nombre']
ordering_fields = ['created_at', 'updated_at', 'rfc'] ordering_fields = ['created_at', 'updated_at', 'rfc']
ordering = ['-created_at'] ordering = ['-created_at']
model = Importador
def get_queryset(self):
return self.get_queryset_filtrado_por_organizacion()
def perform_create(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("Usuario no autenticado o sin organización")
serializer.save(organizacion=self.request.user.organizacion)
def perform_update(self, serializer):
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
raise ValueError("Usuario no autenticado o sin organización")
# Si es superusuario, permite actualizar sin restricciones
if self.request.user.is_superuser:
serializer.save()
return
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
# Para usuarios normales, usar siempre su organización
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion:
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
return
raise ValueError("Usuario no autenticado o sin permisos para actualizar Importador")
my_tags = ['Importadores'] my_tags = ['Importadores']
def get_permissions(self):
# list/retrieve: solo IsAuthenticated — el queryset filtra según permisos
if self.action in ('list', 'retrieve'):
return [IsAuthenticated()]
perms = {
'create': 'importadores.create',
'update': 'importadores.edit',
'partial_update': 'importadores.edit',
'destroy': 'importadores.delete',
}
codename = perms.get(self.action, 'importadores.view')
return [IsAuthenticated(), require_permission(codename)()]
def get_queryset(self):
user = self.request.user
if is_internal_service_request(self.request):
return Importador.objects.all()
org = get_org_context(user)
if not org:
return Importador.objects.none()
# Con permiso ve todos; sin permiso solo los asignados al usuario
if user_has_permission(user, 'importadores.view'):
return Importador.objects.filter(organizacion=org)
return Importador.objects.filter(organizacion=org, users=user)
def perform_create(self, serializer):
if is_internal_service_request(self.request):
serializer.save()
return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
def perform_update(self, serializer):
if is_internal_service_request(self.request):
serializer.save()
return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
def perform_destroy(self, instance):
instance.delete()
class EjecutarComandoView(APIView): class EjecutarComandoView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
""" """
View para ejecutar el comando de microservicios desde una petición HTTP. View para ejecutar el comando de microservicios desde una petición HTTP.
""" """
def post(self, request): permission_classes = [IsAuthenticated, require_permission('pedimentos.process')]
# Obtener organizacion_id del request (si se envía) def post(self, request):
organizacion_id_request = request.data.get('organizacionid', None)
procesamiento = request.data.get('procesamiento', None) procesamiento = request.data.get('procesamiento', None)
todos = request.data.get('todos', False) todos = request.data.get('todos', False)
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): org = get_org_context(request.user)
raise ValueError("Usuario no autenticado o sin organización") if not org:
if organizacion_id_request is None:
return Response( return Response(
{"error": 'No se proporcionó la organización a ejecutar el proceso.'}, {"error": "Sin organización activa."},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_403_FORBIDDEN
) )
# organizacion_id = self.request.user.organizacion.id organizacion_id = str(org.id)
organizacion_id = organizacion_id_request nombre_organizacion = org.nombre
nombre_organizacion = self.request.user.organizacion.nombre
if procesamiento is None and todos == False: if procesamiento is None and todos == False:
return Response( return Response(
@@ -2889,7 +3138,7 @@ def extract_django_suffix(filename):
""" """
name_without_ext = os.path.splitext(filename)[0] name_without_ext = os.path.splitext(filename)[0]
match = re.search(r'_([a-zA-Z0-9]{7})$', name_without_ext) match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
if match: if match:
return match.group(1) return match.group(1)
return None return None
@@ -2903,7 +3152,7 @@ def get_clean_base_filename(filename):
django_suffix = extract_django_suffix(name_without_ext) django_suffix = extract_django_suffix(name_without_ext)
if django_suffix: if django_suffix:
base_name = name_without_ext[:-8] base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else: else:
base_name = name_without_ext base_name = name_without_ext

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,8 @@ class Registro501(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro501' db_table = 'registro501'
@@ -104,6 +106,8 @@ class Registro502(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro502s', null=True, blank=True) 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) patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro502' db_table = 'registro502'
@@ -120,6 +124,8 @@ class Registro503(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro503' db_table = 'registro503'
@@ -136,6 +142,8 @@ class Registro504(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro504' db_table = 'registro504'
@@ -165,6 +173,8 @@ class Registro505(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro505s', null=True, blank=True) 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) patente = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro505' db_table = 'registro505'
@@ -181,6 +191,8 @@ class Registro506(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro506' db_table = 'registro506'
@@ -199,6 +211,8 @@ class Registro507(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro507' db_table = 'registro507'
@@ -223,6 +237,8 @@ class Registro508(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro508' db_table = 'registro508'
@@ -241,6 +257,8 @@ class Registro509(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro509' db_table = 'registro509'
@@ -261,6 +279,8 @@ class Registro510(models.Model):
forma_pago = models.CharField(max_length=3, null=True, blank=True) forma_pago = models.CharField(max_length=3, null=True, blank=True)
importe_pago = models.CharField(max_length=12, 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: class Meta:
db_table = 'registro510' db_table = 'registro510'
@@ -278,6 +298,8 @@ class Registro511(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro511' db_table = 'registro511'
@@ -301,6 +323,8 @@ class Registro512(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro512' db_table = 'registro512'
@@ -363,6 +387,8 @@ class Registro551(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro551s', null=True, blank=True) 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) entidad_fed_destino = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro551' db_table = 'registro551'
@@ -381,6 +407,8 @@ class Registro552(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro552' db_table = 'registro552'
@@ -402,6 +430,8 @@ class Registro553(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro553s', null=True, blank=True) 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) consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro553' db_table = 'registro553'
@@ -421,6 +451,8 @@ class Registro554(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro554' db_table = 'registro554'
@@ -446,6 +478,8 @@ class Registro555(models.Model):
created_by = models.IntegerField(null=True, blank=True) created_by = models.IntegerField(null=True, blank=True)
consulta = models.CharField(max_length=50, null=True, blank=True) consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro555' db_table = 'registro555'
@@ -465,6 +499,8 @@ class Registro556(models.Model):
fraccion = models.CharField(max_length=8, null=True, blank=True) fraccion = models.CharField(max_length=8, null=True, blank=True)
secuencia_fraccion = models.CharField(max_length=50, 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: class Meta:
db_table = 'registro556' db_table = 'registro556'
@@ -484,6 +520,8 @@ class Registro557(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro557s', null=True, blank=True) 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) consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro557' db_table = 'registro557'
@@ -502,6 +540,8 @@ class Registro558(models.Model):
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro558s', null=True, blank=True) 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) consulta = models.CharField(max_length=50, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
db_table = 'registro558' db_table = 'registro558'
@@ -522,6 +562,8 @@ class RegistroSel(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro_sel' db_table = 'registro_sel'
@@ -546,6 +588,8 @@ class Registro701(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro701' db_table = 'registro701'
@@ -564,6 +608,8 @@ class Registro702(models.Model):
consulta = models.CharField(max_length=50, null=True, blank=True) 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) 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: class Meta:
db_table = 'registro702' db_table = 'registro702'

View File

@@ -9,6 +9,8 @@ import zipfile
import re import re
from api.utils.storage_service import storage_service from api.utils.storage_service import storage_service
logger = logging.getLogger(__name__)
@shared_task @shared_task
def procesar_datastage_task(datastage_id, user_organizacion_id=None): def procesar_datastage_task(datastage_id, user_organizacion_id=None):
import traceback import traceback
@@ -167,7 +169,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
continue continue
if first: 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] field_names_snake = [to_snake_case(f) for f in field_names]
first = False first = False
continue continue
@@ -176,6 +181,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
while values and values[-1] == '': while values and values[-1] == '':
values.pop() values.pop()
if len(values) != len(field_names_snake): 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 continue
data = dict(zip(field_names_snake, values)) data = dict(zip(field_names_snake, values))
@@ -185,27 +194,35 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
if hasattr(Model, 'datastage_id'): if hasattr(Model, 'datastage_id'):
data['datastage_id'] = datastage.id data['datastage_id'] = datastage.id
# Limpiar fechas vacías # Parsear y normalizar todos los campos de fecha/datetime
for field in Model._meta.get_fields(): for field in Model._meta.get_fields():
if hasattr(field, 'get_internal_type') and field.get_internal_type() in ["DateField", "DateTimeField"]: if not hasattr(field, 'get_internal_type'):
if data.get(field.name) == "": continue
field_type = field.get_internal_type()
val = data.get(field.name)
if val == '' or val is None:
data[field.name] = None data[field.name] = None
continue
# Convertir fecha_pago_real if field_type == 'DateTimeField' and isinstance(val, str):
if 'fecha_pago_real' in data and data['fecha_pago_real']:
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:
dt = None 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): if dt and timezone.is_naive(dt):
dt = timezone.make_aware(dt) dt = timezone.make_aware(dt)
if dt: data[field.name] = dt
data['fecha_pago_real'] = 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: try:
obj = Model(**data) obj = Model(**data)
@@ -280,12 +297,14 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
"importe_pedimento": data.get('importe_pedimento', 0.0), "importe_pedimento": data.get('importe_pedimento', 0.0),
"existe_expediente": data.get('existe_expediente', False), "existe_expediente": data.get('existe_expediente', False),
"remesas": data.get('remesas', False), "remesas": data.get('remesas', False),
"consultar_vucem": True,
} }
try: try:
Pedimento.objects.create(**pedimento_data) Pedimento.objects.create(**pedimento_data)
except Exception as ped_exc: except Exception as ped_exc:
pass logger.warning("No se pudo crear Pedimento %s: %s", pedimento_app, ped_exc)
except Exception as e: except Exception as e:
logger.error("%s línea %d: error creando objeto %s: %s", asc_name, line_count, model_name, e)
continue continue
# Bulk create # Bulk create

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,22 @@
from django.contrib import admin from django.contrib import admin
from .models import Organizacion from .models import Organizacion
# Register your models here.
@admin.register(Organizacion)
class OrganizacionAdmin(admin.ModelAdmin): class OrganizacionAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento') list_display = ('nombre', 'rfc', 'email', 'telefono', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
search_fields = ('nombre', 'rfc', 'email') search_fields = ('nombre', 'rfc', 'email')
list_filter = ('is_active', 'is_verified') list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
ordering = ('nombre',) ordering = ('nombre',)
autocomplete_fields = ('owner',)
# class UsuarioOrganizacionAdmin(admin.ModelAdmin): readonly_fields = ('created_at', 'updated_at')
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified') fieldsets = (
# search_fields = ('email', 'telefono', 'puesto') (None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
# list_filter = ('is_active', 'is_verified') ('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}),
# ordering = ('email',) ('Administrador maestro', {'fields': ('owner',)}),
('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}),
admin.site.register(Organizacion) ('Vigencia', {'fields': ('inicio', 'vencimiento')}),
# admin.site.register(UsuarioOrganizacion) ('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) estado = models.CharField(max_length=50)
ciudad = models.CharField(max_length=50) ciudad = models.CharField(max_length=50)
# Administrador maestro: acceso total a su org, no puede ser removido de su rol por otros admins.
# on_delete=PROTECT: no se puede eliminar el usuario sin reasignar el ownership primero.
owner = models.ForeignKey(
'cuser.CustomUser',
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='organizaciones_que_administra',
)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
is_verified = models.BooleanField(default=False) is_verified = models.BooleanField(default=False)
apply_auto_download = models.BooleanField(default=False)
inicio = models.DateField(null=True, blank=True) inicio = models.DateField(null=True, blank=True)
vencimiento = 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.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from .models import Organizacion, UsoAlmacenamiento from .models import Organizacion, UsoAlmacenamiento
@receiver(post_save, sender=Organizacion) @receiver(post_save, sender=Organizacion)
def crear_uso_almacenamiento(sender, instance, created, **kwargs): def crear_uso_almacenamiento(sender, instance, created, **kwargs):
if created: if created:
UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0) UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0)
@receiver(post_save, sender=Organizacion)
def crear_roles_default(sender, instance, created, **kwargs):
"""Al crear una organización nueva, genera automáticamente los 5 roles por defecto
con sus permisos. Depende de que el catálogo RolePermission ya exista (post-migration)."""
if not created:
return
try:
from api.rbac.roles import crear_roles_para_organizacion
crear_roles_para_organizacion(instance)
except Exception:
# Si la app rbac aún no está migrada (ej. primer deploy), no bloquear la creación de org
import logging
logging.getLogger(__name__).warning(
'No se pudieron crear roles para org %s — verifica que rbac esté migrado.',
instance.id,
)

View File

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

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

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

@@ -0,0 +1,99 @@
from django.contrib import admin
from .models import OrganizationRole, RolePermission, UserPermission, UserRole
@admin.register(RolePermission)
class RolePermissionAdmin(admin.ModelAdmin):
list_display = ('codename', 'modulo', 'descripcion')
list_filter = ('modulo',)
search_fields = ('codename', 'descripcion')
ordering = ('modulo', 'codename')
def get_readonly_fields(self, request, obj=None):
# Al editar un permiso existente los campos son readonly para evitar inconsistencias
if obj:
return ('codename', 'modulo', 'descripcion')
return ()
def has_add_permission(self, request):
return request.user.is_superuser
def has_change_permission(self, request, obj=None):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
return request.user.is_superuser
class UserRoleInline(admin.TabularInline):
model = UserRole
extra = 0
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
@admin.register(OrganizationRole)
class OrganizationRoleAdmin(admin.ModelAdmin):
list_display = ('nombre', 'organizacion', 'is_admin_role', 'permisos_count', 'usuarios_count')
list_filter = ('organizacion', 'is_admin_role')
search_fields = ('nombre', 'organizacion__nombre')
filter_horizontal = ('permissions',)
inlines = (UserRoleInline,)
readonly_fields = ('created_at', 'updated_at')
def permisos_count(self, obj):
return obj.permissions.count()
permisos_count.short_description = 'Permisos'
def usuarios_count(self, obj):
return obj.user_roles.count()
usuarios_count.short_description = 'Usuarios'
def has_add_permission(self, request):
return request.user.is_superuser
def has_delete_permission(self, request, obj=None):
if obj and obj.is_admin_role:
return False
return request.user.is_superuser
@admin.register(UserRole)
class UserRoleAdmin(admin.ModelAdmin):
list_display = ('user', 'role', 'organizacion', 'created_at')
list_filter = ('role__organizacion', 'role__nombre')
search_fields = ('user__username', 'user__email', 'role__nombre')
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
def organizacion(self, obj):
return obj.role.organizacion
organizacion.short_description = 'Organización'
def save_model(self, request, obj, form, change):
# Bloquear remoción del rol admin_role al owner de la org
if change and obj.role.is_admin_role:
org = obj.role.organizacion
if hasattr(org, 'owner') and org.owner == obj.user:
from django.contrib import messages
self.message_user(
request,
'No se puede remover el rol de administrador maestro al owner de la organización.',
level=messages.ERROR,
)
return
super().save_model(request, obj, form, change)
@admin.register(UserPermission)
class UserPermissionAdmin(admin.ModelAdmin):
list_display = ('user', 'permission', 'granted', 'organizacion', 'created_at')
list_filter = ('granted', 'permission__modulo')
search_fields = ('user__username', 'user__email', 'permission__codename')
autocomplete_fields = ('user',)
readonly_fields = ('created_at',)
def organizacion(self, obj):
return getattr(obj.user, 'organizacion', '')
organizacion.short_description = 'Organización'

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

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

View File

View File

View File

@@ -0,0 +1,101 @@
"""
Sincroniza el catálogo de permisos de roles.py con la base de datos.
Uso básico (solo catálogo):
python manage.py sync_rbac
Con propagación a roles existentes (agrega permisos nuevos a roles que ya existen):
python manage.py sync_rbac --roles
Con listado de lo que hay actualmente:
python manage.py sync_rbac --list
"""
from django.core.management.base import BaseCommand
from api.rbac.roles import DEFAULT_ROLES, PERMISSIONS_CATALOG
class Command(BaseCommand):
help = 'Sincroniza el catálogo de permisos (roles.py → BD) sin necesidad de migración.'
def add_arguments(self, parser):
parser.add_argument(
'--roles',
action='store_true',
help='Propaga los permisos nuevos a los OrganizationRoles existentes que coincidan con DEFAULT_ROLES.',
)
parser.add_argument(
'--list',
action='store_true',
help='Lista los permisos actuales en la BD agrupados por módulo.',
)
def handle(self, *args, **options):
from api.rbac.models import OrganizationRole, RolePermission
if options['list']:
self._list_permisos(RolePermission)
return
self._sync_catalogo(RolePermission)
if options['roles']:
self._sync_roles(RolePermission, OrganizationRole)
# ------------------------------------------------------------------
def _sync_catalogo(self, RolePermission):
creados = 0
existentes = 0
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
_, created = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
if created:
self.stdout.write(self.style.SUCCESS(f' [+] {codename} ({modulo})'))
creados += 1
else:
existentes += 1
self.stdout.write(
self.style.SUCCESS(f'\nCatálogo: {creados} permisos creados, {existentes} ya existían.')
)
def _sync_roles(self, RolePermission, OrganizationRole):
perms_map = {p.codename: p for p in RolePermission.objects.all()}
roles_actualizados = 0
permisos_agregados = 0
for org_role in OrganizationRole.objects.select_related('organizacion').prefetch_related('permissions'):
config = DEFAULT_ROLES.get(org_role.nombre)
if not config:
continue
esperados = {c: perms_map[c] for c in config['permissions'] if c in perms_map}
actuales = {p.codename for p in org_role.permissions.all()}
nuevos = {c: p for c, p in esperados.items() if c not in actuales}
if nuevos:
org_role.permissions.add(*nuevos.values())
roles_actualizados += 1
permisos_agregados += len(nuevos)
self.stdout.write(
f' Rol "{org_role.nombre}" en {org_role.organizacion}: '
f'+{len(nuevos)}{", ".join(nuevos.keys())}'
)
self.stdout.write(
self.style.SUCCESS(
f'\nRoles: {roles_actualizados} roles actualizados, {permisos_agregados} asignaciones nuevas.'
)
)
def _list_permisos(self, RolePermission):
modulo_actual = None
for perm in RolePermission.objects.order_by('modulo', 'codename'):
if perm.modulo != modulo_actual:
modulo_actual = perm.modulo
self.stdout.write(self.style.HTTP_INFO(f'\n {modulo_actual}'))
self.stdout.write(f' {perm.codename:<40} {perm.descripcion}')

View File

@@ -0,0 +1,116 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('organization', '0003_organizacion_apply_auto_download'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RolePermission',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('codename', models.CharField(max_length=100, unique=True)),
('descripcion', models.CharField(max_length=255)),
('modulo', models.CharField(max_length=50)),
],
options={
'verbose_name': 'Permiso',
'verbose_name_plural': 'Permisos',
'db_table': 'rbac_role_permission',
'ordering': ['modulo', 'codename'],
},
),
migrations.CreateModel(
name='OrganizationRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('nombre', models.CharField(max_length=100)),
('descripcion', models.CharField(blank=True, max_length=255)),
('is_admin_role', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('organizacion', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='roles',
to='organization.organizacion',
)),
('permissions', models.ManyToManyField(
blank=True,
related_name='roles',
to='rbac.rolepermission',
)),
],
options={
'verbose_name': 'Rol de Organización',
'verbose_name_plural': 'Roles de Organización',
'db_table': 'rbac_organization_role',
'ordering': ['nombre'],
},
),
migrations.AddConstraint(
model_name='organizationrole',
constraint=models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
),
migrations.CreateModel(
name='UserRole',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_roles',
to=settings.AUTH_USER_MODEL,
)),
('role', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_roles',
to='rbac.organizationrole',
)),
],
options={
'verbose_name': 'Rol de Usuario',
'verbose_name_plural': 'Roles de Usuario',
'db_table': 'rbac_user_role',
},
),
migrations.AddConstraint(
model_name='userrole',
constraint=models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
),
migrations.CreateModel(
name='UserPermission',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('granted', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='rbac_permissions',
to=settings.AUTH_USER_MODEL,
)),
('permission', models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name='user_overrides',
to='rbac.rolepermission',
)),
],
options={
'verbose_name': 'Permiso Singular',
'verbose_name_plural': 'Permisos Singulares',
'db_table': 'rbac_user_permission',
},
),
migrations.AddConstraint(
model_name='userpermission',
constraint=models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
),
]

View File

@@ -0,0 +1,88 @@
"""
Data migration que:
1. Crea el catálogo global de permisos (RolePermission).
2. Para cada Organizacion existente, crea los 5 roles por defecto con sus permisos.
3. Para cada CustomUser existente, mapea sus auth.Group actuales al UserRole equivalente.
Usa get_or_create en todos los pasos — segura de ejecutar múltiples veces.
"""
from django.db import migrations
# Importamos solo constantes (no modelos ni funciones con imports de Django)
# para que la migration sea estable ante futuros refactors del código de la app.
from api.rbac.roles import PERMISSIONS_CATALOG, DEFAULT_ROLES
def _crear_permisos(RolePermission):
perms_map = {}
for codename, descripcion, modulo in PERMISSIONS_CATALOG:
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
perms_map[codename] = perm
return perms_map
def _crear_roles_org(OrganizationRole, org, perms_map):
for nombre, config in DEFAULT_ROLES.items():
role, created = OrganizationRole.objects.get_or_create(
organizacion=org,
nombre=nombre,
defaults={
'descripcion': config['descripcion'],
'is_admin_role': config.get('is_admin_role', False),
},
)
if created:
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
role.permissions.set(role_perms)
def seed_rbac_data(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
UserRole = apps.get_model('rbac', 'UserRole')
Organizacion = apps.get_model('organization', 'Organizacion')
CustomUser = apps.get_model('cuser', 'CustomUser')
# Paso 1 — Catálogo de permisos
perms_map = _crear_permisos(RolePermission)
# Paso 2 — Roles por defecto para cada organización existente
for org in Organizacion.objects.all():
_crear_roles_org(OrganizationRole, org, perms_map)
# Paso 3 — Mapeo de usuarios: auth.Group → UserRole
# Solo usuarios que tengan organización asignada y grupos asignados
for user in CustomUser.objects.filter(organizacion__isnull=False).prefetch_related('groups'):
for group in user.groups.all():
try:
role = OrganizationRole.objects.get(
organizacion=user.organizacion,
nombre=group.name,
)
UserRole.objects.get_or_create(user=user, role=role)
except OrganizationRole.DoesNotExist:
# El grupo no tiene equivalente en los roles por defecto — se ignora
pass
def reverse_seed(apps, schema_editor):
# Revertir borra todos los datos RBAC. Los auth.Group originales no se tocan.
apps.get_model('rbac', 'UserRole').objects.all().delete()
apps.get_model('rbac', 'OrganizationRole').objects.all().delete()
apps.get_model('rbac', 'RolePermission').objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0001_initial'),
('cuser', '0005_customuser_rfc_fk_to_m2m'),
('organization', '0003_organizacion_apply_auto_download'),
]
operations = [
migrations.RunPython(seed_rbac_data, reverse_code=reverse_seed),
]

View File

@@ -0,0 +1,56 @@
"""
Agrega el permiso notificaciones.receive al catálogo y lo asigna a todos los
OrganizationRole que correspondan a los 5 roles por defecto (en todas las orgs).
"""
from django.db import migrations
NUEVO_PERMISO = (
'notificaciones.receive',
'Recibir notificaciones automáticas de eventos',
'notificaciones',
)
# Todos los roles por defecto deben recibir notificaciones
ROLES_CON_PERMISO = ['admin', 'developer', 'Agente Aduanal', 'user', 'Importador']
def agregar_notificaciones_receive(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
codename, descripcion, modulo = NUEVO_PERMISO
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
roles = OrganizationRole.objects.filter(nombre__in=ROLES_CON_PERMISO)
for role in roles:
role.permissions.add(perm)
def revertir(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
try:
perm = RolePermission.objects.get(codename='notificaciones.receive')
except RolePermission.DoesNotExist:
return
for role in OrganizationRole.objects.all():
role.permissions.remove(perm)
perm.delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0002_data_permissions'),
]
operations = [
migrations.RunPython(agregar_notificaciones_receive, reverse_code=revertir),
]

View File

@@ -0,0 +1,57 @@
"""
Agrega los permisos auditoria.view y auditoria.process al catálogo y los asigna
a los roles admin, developer (ambos) y Agente Aduanal (solo view).
"""
from django.db import migrations
NUEVOS_PERMISOS = [
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
]
ROLES_AUDITORIA_FULL = ['admin', 'developer']
ROLES_AUDITORIA_VIEW = ['Agente Aduanal']
def agregar_auditoria(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
perms = {}
for codename, descripcion, modulo in NUEVOS_PERMISOS:
perm, _ = RolePermission.objects.get_or_create(
codename=codename,
defaults={'descripcion': descripcion, 'modulo': modulo},
)
perms[codename] = perm
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_FULL):
role.permissions.add(perms['auditoria.view'], perms['auditoria.process'])
for role in OrganizationRole.objects.filter(nombre__in=ROLES_AUDITORIA_VIEW):
role.permissions.add(perms['auditoria.view'])
def revertir(apps, schema_editor):
RolePermission = apps.get_model('rbac', 'RolePermission')
OrganizationRole = apps.get_model('rbac', 'OrganizationRole')
for codename, _, _ in NUEVOS_PERMISOS:
try:
perm = RolePermission.objects.get(codename=codename)
except RolePermission.DoesNotExist:
continue
for role in OrganizationRole.objects.all():
role.permissions.remove(perm)
perm.delete()
class Migration(migrations.Migration):
dependencies = [
('rbac', '0003_notificaciones_receive'),
]
operations = [
migrations.RunPython(agregar_auditoria, reverse_code=revertir),
]

View File

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

@@ -1,12 +1,16 @@
from django.urls import reverse from django.urls import reverse
from django.test import TestCase
from rest_framework.test import APITestCase, APIClient from rest_framework.test import APITestCase, APIClient
from rest_framework import status from rest_framework import status
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from unittest.mock import patch, MagicMock
from api.organization.models import Organizacion, UsoAlmacenamiento from api.organization.models import Organizacion, UsoAlmacenamiento
from api.cuser.models import CustomUser from api.cuser.models import CustomUser
from api.customs.models import Pedimento 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 import io
class DocumentViewSetTests(APITestCase): class DocumentViewSetTests(APITestCase):
@@ -95,3 +99,177 @@ class DocumentViewSetTests(APITestCase):
url = reverse('descargar-documento', args=[doc.id]) url = reverse('descargar-documento', args=[doc.id])
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) 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

@@ -26,11 +26,13 @@ from django.utils import timezone
from django.db.models import Q from django.db.models import Q
from api.utils.storage_service import storage_service from api.utils.storage_service import storage_service
from rest_framework.authentication import TokenAuthentication
from core.permissions import ( from core.permissions import (
IsSameOrganization, get_org_context,
IsSameOrganizationDeveloper, require_permission,
IsSameOrganizationAndAdmin, user_has_permission,
IsSuperUser IsInternalService,
) )
import logging import logging
@@ -142,20 +144,46 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
""" """
ViewSet for Document model. ViewSet for Document model.
""" """
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
model = Document model = Document
pagination_class = CustomPagination pagination_class = CustomPagination
serializer_class = DocumentSerializer 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', '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'] my_tags = ['Documents']
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): 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() queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo') modulo_efc = self.request.query_params.get('modulo')
if modulo_efc: if modulo_efc:
@@ -273,6 +301,9 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
if ruta: if ruta:
documento.archivo = ruta documento.archivo = ruta
documento.save() 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: else:
documento.delete() documento.delete()
raise ValidationError({"archivo": "Error al guardar el archivo"}) raise ValidationError({"archivo": "Error al guardar el archivo"})
@@ -1275,15 +1306,21 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
# Usar tipo de documento por defecto siempre # Usar tipo de documento indicado o "Documento General" por defecto
document_type, created = DocumentType.objects.get_or_create( 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", nombre="Documento General",
defaults={'descripcion': "Documento general sin tipo específico"} 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 = [] uploaded_documents = []
failed_files = [] failed_files = []
@@ -1321,6 +1358,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"codigo": "bulk_storage_limit_exceeded" "codigo": "bulk_storage_limit_exceeded"
}, status=status.HTTP_400_BAD_REQUEST) }, 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 # Procesar cada archivo
espacio_usado_temp = espacio_inicial espacio_usado_temp = espacio_inicial
@@ -1335,7 +1378,40 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Obtener extensión del archivo # Obtener extensión del archivo
extension = file.name.split('.')[-1].lower() if '.' in file.name else '' 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( document = Document.objects.create(
organizacion=organizacion, organizacion=organizacion,
pedimento_id=pedimento_id, pedimento_id=pedimento_id,
@@ -1343,14 +1419,12 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
size=file.size, size=file.size,
extension=extension extension=extension
) )
ruta = storage_service.save_document( ruta = storage_service.save_document(
file=file, file=file,
organizacion_id=organizacion.id, organizacion_id=organizacion.id,
pedimento_app=pedimento.pedimento_app, pedimento_app=pedimento.pedimento_app,
metadata={'source': 'bulk_upload'} metadata={'source': 'bulk_upload'}
) )
if ruta: if ruta:
document.archivo = ruta document.archivo = ruta
document.save() document.save()
@@ -1367,7 +1441,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
"filename": file.name, "filename": file.name,
"size": file.size, "size": file.size,
"extension": extension, "extension": extension,
"document_type": document_type.nombre "document_type": document.document_type.nombre if document.document_type else None
}) })
except Exception as e: except Exception as e:
@@ -1711,8 +1785,267 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
return Response(response_data, status=response_status) return Response(response_data, status=response_status)
@action(detail=False, methods=['post'], url_path='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): class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('documentos.download')]
serializer_class = DocumentSerializer serializer_class = DocumentSerializer
model = Document model = Document
my_tags = ['Documents'] my_tags = ['Documents']
@@ -1725,16 +2058,13 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
import os import os
from api.utils.storage_service import storage_service from api.utils.storage_service import storage_service
if not request.user.is_authenticated or not hasattr(request.user, 'organizacion'):
raise Http404("Usuario no autenticado")
try: try:
doc = Document.objects.get(pk=pk) doc = Document.objects.get(pk=pk)
except Document.DoesNotExist: except Document.DoesNotExist:
raise Http404("Documento no encontrado") raise Http404("Documento no encontrado")
if not request.user.is_superuser: org = get_org_context(request.user)
if doc.organizacion != request.user.organizacion: if doc.organizacion != org:
raise Http404("No autorizado") raise Http404("No autorizado")
if not doc.archivo: if not doc.archivo:
@@ -1759,7 +2089,7 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin):
return response return response
class BulkDownloadZipView(APIView): class BulkDownloadZipView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents'] my_tags = ['Documents']
def post(self, request): def post(self, request):
@@ -1867,7 +2197,7 @@ class BulkDownloadZipView(APIView):
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}") logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class GetFuenteView(APIView): class GetFuenteView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('documentos.view')]
serializer_class = FuenteSerializer serializer_class = FuenteSerializer
my_tags = ['Fuente Documentos'] my_tags = ['Fuente Documentos']
@@ -1882,7 +2212,7 @@ class GetFuenteView(APIView):
return Response(serializer.data, status=200) return Response(serializer.data, status=200)
class DocumentTypeView(APIView): class DocumentTypeView(APIView):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('documentos.view')]
serializer_class = DocumentTypeSerializer serializer_class = DocumentTypeSerializer
my_tags = ['Tipo de Documentos'] my_tags = ['Tipo de Documentos']
@@ -1899,7 +2229,7 @@ class DocumentTypeView(APIView):
return Response(serializer.data, status=200) return Response(serializer.data, status=200)
class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin): class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents'] my_tags = ['Documents']
def post(self, request): def post(self, request):
@@ -2001,7 +2331,7 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin):
logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}") logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}")
class MultiPedimentoZipDownloadView(APIView): class MultiPedimentoZipDownloadView(APIView):
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)] permission_classes = [IsAuthenticated, require_permission('documentos.download')]
my_tags = ['Documents'] my_tags = ['Documents']
def post(self, request): def post(self, request):
@@ -2070,7 +2400,7 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
""" """
ViewSet for Document model. ViewSet for Document model.
""" """
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] permission_classes = [IsAuthenticated, require_permission('documentos.view')]
model = Document model = Document
pagination_class = CustomPagination pagination_class = CustomPagination
@@ -2084,6 +2414,8 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
my_tags = ['Documents'] my_tags = ['Documents']
def get_queryset(self): 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() queryset = self.get_queryset_filtrado_por_organizacion()
pedimento_id = self.request.query_params.get('pedimento') pedimento_id = self.request.query_params.get('pedimento')
@@ -2130,8 +2462,7 @@ class TriggerPedimentoCompletoView(APIView):
en el microservicio FastAPI. Reenvía el payload tal cual y devuelve en el microservicio FastAPI. Reenvía el payload tal cual y devuelve
la respuesta del microservicio (normalmente un `task_id`). la respuesta del microservicio (normalmente un `task_id`).
""" """
# permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated, require_permission('pedimentos.process')]
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )]
my_tags = ['Microservice - Pedimento Completo'] my_tags = ['Microservice - Pedimento Completo']

View File

@@ -3,7 +3,6 @@ import tempfile
from api.utils.storage_service import storage_service from api.utils.storage_service import storage_service
from celery import shared_task from celery import shared_task
from api.organization.models import Organizacion from api.organization.models import Organizacion
from django.core.files.base import ContentFile
from django.utils import timezone from django.utils import timezone
from api.reports.models import ReportDocument from api.reports.models import ReportDocument
from api.customs.models import Pedimento, Cove, EDocument, Partida from api.customs.models import Pedimento, Cove, EDocument, Partida
@@ -127,8 +126,8 @@ def generate_report_document(report_id):
@shared_task @shared_task
def generate_report_control_pedimento(report_id): def generate_report_control_pedimento(report_id):
report = None
try: try:
report = ReportDocument.objects.get(id=report_id) report = ReportDocument.objects.get(id=report_id)
report.status = 'processing' report.status = 'processing'
report.save(update_fields=['status']) report.save(update_fields=['status'])
@@ -222,8 +221,9 @@ def generate_report_control_pedimento(report_id):
# 4. GENERAR CSV CON DETALLES # 4. GENERAR CSV CON DETALLES
filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv" filename = f"report_{report.id}_{timezone.now().strftime('%Y%m%d%H%M%S')}.csv"
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 = [] todas_las_filas = []
@@ -278,7 +278,7 @@ def generate_report_control_pedimento(report_id):
todas_las_filas.append(fila) todas_las_filas.append(fila)
# 5. ESCRIBIR ARCHIVO CSV # 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) writer = csv.writer(f)
# SECCIÓN DE TOTALES # SECCIÓN DE TOTALES
@@ -308,14 +308,39 @@ def generate_report_control_pedimento(report_id):
writer.writerow(fila) writer.writerow(fila)
with open(file_path, 'rb') as f: with open(tmp_path, 'rb') as f:
report.file.save(filename, ContentFile(f.read()), save=True) 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' report.status = 'ready'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
report.finished_at = timezone.now() 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: except Exception as e:
if report:
report.status = 'error' report.status = 'error'
report.error_message = str(e) report.error_message = str(e)
report.finished_at = timezone.now() 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 csv
import io 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 uuid
import datetime import datetime
import zipfile import zipfile
import openpyxl
from django.apps import apps
from django.db import models 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): def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
model = apps.get_model(module, model_name) 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): class ExportDataStageView(APIView):
my_tags = ['Reportes-DataStage'] 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 # Constantes para partición
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo # MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
@@ -135,6 +113,27 @@ class ExportDataStageView(APIView):
else: else:
return str(value) 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)'}) @swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
@@ -148,6 +147,23 @@ class ExportDataStageView(APIView):
else: else:
return self.handle_simple_export(request) 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): def handle_simple_export(self, request):
"""Maneja exportación simple de DataStage (un solo modelo)""" """Maneja exportación simple de DataStage (un solo modelo)"""
model_name = request.data.get('model') model_name = request.data.get('model')
@@ -159,6 +175,10 @@ class ExportDataStageView(APIView):
if not model_name or not fields: if not model_name or not fields:
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST) 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: try:
model = apps.get_model(module, model_name) model = apps.get_model(module, model_name)
filters = self.apply_global_filters_to_model(global_filters, model, request.user) filters = self.apply_global_filters_to_model(global_filters, model, request.user)
@@ -190,18 +210,16 @@ class ExportDataStageView(APIView):
if not models_data: if not models_data:
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST) 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) related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
if export_type == 'excel': 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) return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
else: else:
# Para CSV, podemos mantener la lógica actual o mejorarla return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
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)
def estimate_total_records(self, models_data, global_filters, related_keys, user): def estimate_total_records(self, models_data, global_filters, related_keys, user):
"""Estima el total de registros para todos los modelos""" """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): 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""" """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try: try:
zip_buffer = io.BytesIO()
# 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido
from api.organization.models import Organizacion from api.organization.models import Organizacion
organizaciones = Organizacion.objects.all() org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
org_mapping = {str(org.id): org.nombre for org in organizaciones}
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: # 1. Recopilar todos los datos FUERA del contexto ZIP
all_models_data = {}
# 1. Recopilar todos los datos de cada modelo
all_models_data = {} # Ahora será una lista por clave
model_field_mappings = {} model_field_mappings = {}
for model_data in models_data: for model_data in models_data:
@@ -302,8 +314,6 @@ class ExportDataStageView(APIView):
if not model_name or not fields: if not model_name or not fields:
continue continue
# Normalizar nombres de campo entrantes: si se pasó "Organizacion"
# (cualquier capitalización), usar el campo real de la BD `organizacion_id`.
normalized_fields = [] normalized_fields = []
for f in fields: for f in fields:
try: try:
@@ -320,13 +330,11 @@ class ExportDataStageView(APIView):
fields = normalized_fields fields = normalized_fields
# Asegurar que tenemos los campos de relación
required_fields = ['seccion_aduanera', 'patente', 'pedimento'] required_fields = ['seccion_aduanera', 'patente', 'pedimento']
for field in required_fields: for field in required_fields:
if field not in fields: if field not in fields:
fields.append(field) 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()]: 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') fields.append('organizacion_id')
@@ -339,233 +347,182 @@ class ExportDataStageView(APIView):
else: else:
queryset = model.objects.none() queryset = model.objects.none()
total_records = queryset.count() if queryset.count() == 0:
if total_records == 0:
continue continue
# Determinar campos de relación disponibles en este modelo relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
relation_fields = []
for field_name in ['seccion_aduanera', 'patente', 'pedimento']:
if field_name in fields:
relation_fields.append(field_name)
if not relation_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]] 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: if model_name not in model_field_mappings:
model_field_mappings[model_name] = fields model_field_mappings[model_name] = fields
# Procesar cada registro
for record in queryset: for record in queryset:
# Crear clave de relación key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
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]))
if not key_parts: if not key_parts:
# Si no hay campos de relación, usar un hash del registro
import hashlib import hashlib
record_str = str(sorted(record.items())) key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
key = hashlib.md5(record_str.encode()).hexdigest()[:10]
else: else:
key = "_".join(key_parts) key = "_".join(key_parts)
# 🔥 PROCESAR CAMPO organizacion_id para convertirlo a nombre
processed_record = {} processed_record = {}
for field_name, value in record.items(): for field_name, value in record.items():
# Convertir organizacion_id a nombre
if field_name == 'organizacion_id' and value: if field_name == 'organizacion_id' and value:
org_id_str = str(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: if org_id_str in org_mapping:
processed_value = org_mapping[org_id_str] processed_value = org_mapping[org_id_str]
else: else:
# Si no se encuentra, intentar obtener de la base de datos
try: try:
org = Organizacion.objects.filter(id=value).first() org = Organizacion.objects.filter(id=value).first()
processed_value = org.nombre if org else str(value) processed_value = org.nombre if org else org_id_str
# Actualizar mapeo para futuras referencias
org_mapping[org_id_str] = processed_value org_mapping[org_id_str] = processed_value
except: except Exception:
processed_value = str(value) processed_value = org_id_str
else: else:
processed_value = value processed_value = value
# Agregar prefijo del modelo a los campos para evitar colisiones
if field_name in relation_fields: if field_name in relation_fields:
prefixed_field_name = field_name prefixed_field_name = field_name
else: else:
prefixed_field_name = f"{model_name}_{field_name}" prefixed_field_name = f"{model_name}_{field_name}"
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
if field_name == 'organizacion_id': if field_name == 'organizacion_id':
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre') prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value) 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: if key not in all_models_data:
all_models_data[key] = { all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
'relation_fields': {}, # Campos de relación compartidos
'model_records': {} # Diccionario de listas por modelo
}
# Guardar campos de relación (solo una vez, ya que son los mismos)
for rel_field in relation_fields: for rel_field in relation_fields:
if rel_field in record: if rel_field in record:
all_models_data[key]['relation_fields'][rel_field] = record[rel_field] 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']: 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] = []
# Agregar este registro a la lista del modelo
all_models_data[key]['model_records'][model_name].append(processed_record) all_models_data[key]['model_records'][model_name].append(processed_record)
except LookupError: except LookupError:
continue 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: 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 # 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
# Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave
combined_rows = [] combined_rows = []
for key, data in all_models_data.items(): for key, data in all_models_data.items():
relation_fields = data['relation_fields'] relation_fields_data = data['relation_fields']
model_records = data['model_records'] model_records = data['model_records']
# 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
# 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)
# 🔗 CREAR UNA FILA POR CADA COMBINACIÓN
for i in range(max_records_per_key): for i in range(max_records_per_key):
row_data = {} row_data = {}
# Campos de relación (mismos para todas las filas con esta clave) for rel_field, rel_value in relation_fields_data.items():
for rel_field, rel_value in relation_fields.items():
row_data[rel_field] = self.safe_excel_value(rel_value) row_data[rel_field] = self.safe_excel_value(rel_value)
# Datos de cada modelo
for model_name, records in model_records.items(): for model_name, records in model_records.items():
# Si hay un registro en esta posición i # Usar posición i o el último registro disponible
if i < len(records): record = records[i] if i < len(records) else records[-1]
record = records[i]
for field_name, value in record.items(): for field_name, value in record.items():
row_data[field_name] = value 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) combined_rows.append(row_data)
# 3. Determinar todos los campos únicos para los encabezados # 4. Encabezados ordenados
all_fields_set = set() 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: for row in combined_rows:
all_fields_set.update(row.keys()) all_fields_set.update(row.keys())
# Ordenar campos: relación primero, luego alfabéticamente
all_fields = [] all_fields = []
for rel_field in common_relation_fields: for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
if rel_field in all_fields_set: if rel_field in all_fields_set:
all_fields.append(rel_field) 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 = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()] for org_field in org_fields:
for org_field in sorted(org_fields):
all_fields.append(org_field) 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)) 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 def _write_sheet(ws, sheet_name, page_rows):
from django.core.paginator import Paginator ws.title = sheet_name[:31]
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE) ws.append(title_row)
ws.append(date_row)
for page_num in paginator.page_range: ws.append([])
page = paginator.page(page_num) ws.append(all_fields)
for row_data in page_rows:
# Crear nuevo workbook para cada partición ws.append([row_data.get(field, '') for field in all_fields])
current_wb = openpyxl.Workbook() for column in ws.columns:
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:
max_length = 0 max_length = 0
column_letter = column[0].column_letter col_letter = column[0].column_letter
for cell in column: for cell in column:
try: try:
if len(str(cell.value)) > max_length: if len(str(cell.value)) > max_length:
max_length = len(str(cell.value)) max_length = len(str(cell.value))
except: except Exception:
pass pass
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
adjusted_width = min(max_length + 2, 50) # 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
current_ws.column_dimensions[column_letter].width = adjusted_width 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() part_buffer = io.BytesIO()
current_wb.save(part_buffer) current_wb.save(part_buffer)
part_buffer.seek(0) part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
response = HttpResponse(zip_buffer.read(), content_type='application/zip') resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"' return resp
return response
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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) part_buffer.seek(0)
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
@@ -795,12 +748,11 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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): 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""" """Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
try: try:
@@ -1009,8 +961,8 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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) part_buffer.seek(0)
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue()) 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) zip_buffer.seek(0)
@@ -1137,8 +1087,8 @@ class ExportDataStageView(APIView):
except Exception as e: except Exception as e:
import traceback import traceback
error_details = traceback.format_exc() import logging
print(f"Error en exportación: {error_details}") 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) 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): 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: except Exception as e:
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) 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): 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""" """Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
zip_buffer = io.BytesIO() zip_buffer = io.BytesIO()
@@ -1472,8 +1560,13 @@ class ExportDataStageView(APIView):
def get_related_keys_from_filters(self, global_filters, models_data, user): 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 Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model 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 = { related_keys = {
'patentes': set(), 'patentes': set(),
@@ -1481,40 +1574,34 @@ class ExportDataStageView(APIView):
'datastage_ids': set() '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, '']): if not any(v for v in global_filters.values() if v not in [None, '']):
return {} 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 = [] all_records_with_filters = []
for model_data in models_data: for model_data in models_data:
model_name = model_data.get('model') model_name = model_data.get('model')
try: try:
model = apps.get_model('datastage', model_name) 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) filters = self.apply_global_filters_to_model(global_filters, model, user)
if not filters:
continue
if filters: records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
# 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')
all_records_with_filters.extend(list(records)) all_records_with_filters.extend(list(records))
except LookupError: except LookupError:
@@ -1585,9 +1672,17 @@ class ExportDataStageView(APIView):
filters = {} filters = {}
model_fields = [f.name for f in model._meta.get_fields()] 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'): 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!) # 2. RFC (¡ESTO ES LO QUE FALTA!)
if 'rfc' in model_fields and global_filters.get('rfc'): if 'rfc' in model_fields and global_filters.get('rfc'):
@@ -1741,7 +1836,11 @@ class ExportDataStageView(APIView):
class ExportModelView(APIView): class ExportModelView(APIView):
my_tags = ['Reportes'] 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( @swagger_auto_schema(
manual_parameters=[ manual_parameters=[
@@ -1779,6 +1878,8 @@ class ExportModelView(APIView):
model_name = request.data.get('model') model_name = request.data.get('model')
fields = request.data.get('fields') fields = request.data.get('fields')
filters = request.data.get('filters', {}) 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') export_type = request.data.get('type', 'csv')
module = request.data.get('module', 'datastage') module = request.data.get('module', 'datastage')
@@ -1790,40 +1891,12 @@ class ExportModelView(APIView):
else: else:
return export_model_to_csv(request, model_name, fields, module, filters) 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 # Resumen general para dashboard
@api_view(['GET']) @api_view(['GET'])
@permission_classes([ @permission_classes([IsAuthenticated, require_permission('reportes.view')])
IsAuthenticated
])
def dashboard_summary(request): def dashboard_summary(request):
org_id = request.query_params.get('organizacion_id')
filters = {} filters = {}
user = request.user user = request.user
@@ -1837,18 +1910,16 @@ def dashboard_summary(request):
fecha_pago_lte = request.query_params.get('fecha_pago__lte') fecha_pago_lte = request.query_params.get('fecha_pago__lte')
contribuyente__rfc = request.query_params.get('contribuyente__rfc') contribuyente__rfc = request.query_params.get('contribuyente__rfc')
# Si no se especifica organización y el usuario tiene organización, usarla org = get_org_context(user)
if not org_id and hasattr(user, 'organizacion') and user.organizacion: if not org:
org_id = user.organizacion.id return Response({'error': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
# Si no es superusuario, filtrar por organización filters['organizacion_id'] = org.id
if org_id and not getattr(user, 'is_superuser', False):
filters['organizacion_id'] = org_id
# Si el usuario pertenece al grupo Importador, filtrar por RFC # Importador: filtrar solo por sus RFC asignados
if user.groups.filter(name='Importador').exists(): if user.is_importador:
rfc = getattr(user, 'rfc', None) rfcs = list(user.rfc.values_list('rfc', flat=True))
if rfc: if rfcs:
filters['contribuyente__rfc'] = rfc filters['contribuyente__rfc__in'] = rfcs
if pedimento_app: if pedimento_app:
filters['pedimento_app'] = pedimento_app filters['pedimento_app'] = pedimento_app

View File

@@ -1,53 +1,54 @@
from django.shortcuts import render
from rest_framework import viewsets, filters from rest_framework import viewsets, filters
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from api.logger.mixins import LoggingMixin from api.logger.mixins import LoggingMixin
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin from core.permissions import require_permission, user_has_permission, IsInternalService
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
from .models import Task from .models import Task
from .serializers import TaskSerializer from .serializers import TaskSerializer
from .filters import TaskFilter from .filters import TaskFilter
from rest_framework.permissions import IsAuthenticated
# Create your views here.
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
class TaskPagination(PageNumberPagination): class TaskPagination(PageNumberPagination):
page_size = 10 page_size = 10
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 100 max_page_size = 100
class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): 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() queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer serializer_class = TaskSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = TaskFilter filterset_class = TaskFilter
pagination_class = TaskPagination pagination_class = TaskPagination
ordering_fields = ['timestamp'] ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero ordering = ['-timestamp']
my_tags = ['tasks'] my_tags = ['tasks']
def get_queryset(self): def get_permissions(self):
# Escritura: exclusivo para microservicio interno (Token + superuser)
# Lectura: usuarios con pedimentos.view via JWT
if self.action in ('create', 'update', 'partial_update', 'destroy'):
return [IsAuthenticated(), IsInternalService()]
return [IsAuthenticated(), require_permission('pedimentos.view')()]
""" def get_queryset(self):
Filtra las tareas según la organización del usuario. user = self.request.user
Superusuarios pueden ver todas las tareas. # Service account (Token + superuser): sin filtro de org, accede a todas las tasks
""" if user.is_superuser and isinstance(
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador getattr(self.request, 'successful_authenticator', None), TokenAuthentication
# if user.is_superuser: ):
# return self.queryset return Task.objects.select_related('pedimento', 'servicio').all()
# # return self.queryset.filter(organizacion_id=user.organizacion.id) if not user_has_permission(user, 'pedimentos.view'):
# else: return Task.objects.none()
# return self.queryset.filter(organizacion_id=user.organizacion.id) return self.get_queryset_filtrado_por_organizacion()
return queryset
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -57,42 +58,114 @@ from celery.result import AsyncResult
class TaskStatusView(APIView): class TaskStatusView(APIView):
""" permission_classes = [IsAuthenticated, require_permission('pedimentos.view')]
Vista para consultar el estado de tareas de Celery.
""" # Mapeo de status del microservicio → estados estándar
permission_classes = [IsAuthenticated] _STATUS_MAP = {
'failed': 'FAILURE',
'completed': 'SUCCESS',
'processing': 'STARTED',
'submitted': 'PENDING',
'pending': 'PENDING',
}
def get(self, request, task_id): def get(self, request, task_id):
""" """
Consulta el estado de una tarea de Celery. Consulta el estado de una tarea.
Returns: Fuente de verdad: registro Django Task (actualizado por el microservicio vía PUT).
- PENDING: La tarea está esperando ser procesada Celery AsyncResult se usa como complemento para tareas de auditoría masiva (SUCCESS)
- STARTED: La tarea ha sido iniciada y como fallback cuando la tarea no está en la BD todavía.
- SUCCESS: La tarea se completó exitosamente
- FAILURE: La tarea falló Estados posibles:
- RETRY: La tarea está reintentando 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: 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 = { response_data = {
'task_id': task_id, '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(), 'ready': task_result.ready(),
'successful': task_result.successful() if task_result.ready() else None, 'successful': task_result.successful() if task_result.ready() else None,
} }
if task_result.ready() and task_result.successful(): if state == 'SUCCESS':
try: result = task_result.result
response_data['result'] = task_result.result response_data['result'] = result
except Exception: if isinstance(result, dict) and 'total_pedimentos' in result:
pass 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) 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 response_data['info'] = str(task_result.info) if task_result.info else None
return Response(response_data, status=status.HTTP_200_OK) return Response(response_data, status=status.HTTP_200_OK)

View File

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

View File

@@ -1,8 +1,11 @@
import os import os
from celery import Celery from celery import Celery
from datetime import timedelta
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
app = Celery('config') app = Celery('config')
app.config_from_object('django.conf:settings', namespace='CELERY') 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() app.autodiscover_tasks()

View File

@@ -30,8 +30,14 @@ from celery.schedules import crontab
from config.stg.storage import * from config.stg.storage import *
CELERY_BEAT_SCHEDULE = { 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 # Cargar variables de entorno desde un archivo .env
@@ -92,6 +98,7 @@ OWN_APPS = [
'api.organization', 'api.organization',
'api.licence', 'api.licence',
'api.cuser', 'api.cuser',
'api.rbac',
'api.datastage', 'api.datastage',
'api.vucem', 'api.vucem',
'api.logger', 'api.logger',
@@ -305,7 +312,8 @@ DEFAULT_FROM_EMAIL = EMAIL_HOST_USER
# Configuración Celery # Configuración Celery
CELERY_BROKER_URL = os.getenv('CELERY_BROKER_URL', 'redis://redis:6379/0') 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_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 # Configuración para procesamiento asíncrono nativo de Django
ASGI_APPLICATION = 'config.asgi.application' ASGI_APPLICATION = 'config.asgi.application'

View File

@@ -51,6 +51,7 @@ urlpatterns = [
path('api/v1/cards/', include('api.cards.urls')), # Cards app path('api/v1/cards/', include('api.cards.urls')), # Cards app
path('api/v1/reports/', include('api.reports.urls')), # Reports app path('api/v1/reports/', include('api.reports.urls')), # Reports app
path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app
path('api/v1/rbac/', include('api.rbac.urls')), # RBAC app
] ]
# En producción, los archivos media son servidos por Nginx # En producción, los archivos media son servidos por Nginx
if settings.DEBUG: if settings.DEBUG:

View File

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

View File

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