Compare commits

..

4 Commits

44 changed files with 2957 additions and 963 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__in=self.request.user.rfc.all())
# 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,7 +249,9 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
} }
) )
def get_queryset(self): def get_queryset(self):
return self.get_queryset_filtrado() if self.request.user.is_importador:
return self.get_queryset_importador()
return self.get_queryset_filtrado()
def get(self, request): def get(self, request):
queryset = self.get_queryset() queryset = self.get_queryset()
@@ -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

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

View File

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

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

@@ -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')

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
@@ -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('.')
) )
updated_pedimentos.append({ from api.utils.storage_service import storage_service
"id": str(existing_pedimento.id), ruta = storage_service.save_document_from_path(
"pedimento_app": existing_pedimento.pedimento_app, file_path=file_path,
"accion": "Documento creado", file_name=file_name,
"documento": 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'
}
)
documents_created += 1 if ruta:
document.archivo = ruta
document.save()
documents_created += 1
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento creado",
"documento": file_name
})
else:
document.delete()
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Error al guardar {file_name} en almacenamiento"
})
except Exception as e: except Exception as e:
failed_records.append({ failed_records.append({

View File

@@ -563,11 +563,14 @@ def process_all_organizations():
""" """
Envía una tarea por organización activa a la cola org_processing. Envía una tarea por organización activa a la cola org_processing.
""" """
active_orgs = Organizacion.objects.filter(is_active=True, is_verified=True) active_orgs = Organizacion.objects.filter(
is_active=True,
is_verified=True,
apply_auto_download=True,
)
for org in active_orgs: for org in active_orgs:
process_organization_batch.apply_async( process_organization_batch.apply_async(
args=[org.id], args=[str(org.id)],
queue='org_processing' queue='org_processing'
) )
return f"Dispatched {active_orgs.count()} organizations" return f"Dispatched {active_orgs.count()} organizations"

View File

@@ -10,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,
@@ -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):
""" """
@@ -2200,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
@@ -2236,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.
@@ -2276,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
@@ -2292,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']
@@ -2344,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]
@@ -2353,60 +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)
print(f"self.request.user.groups >>>> {self.request.user.groups}") 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():
# 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]
@@ -2415,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]
@@ -2477,69 +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 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): def get_queryset(self):
user = self.request.user user = self.request.user
grupos = user.groups.values_list('name', flat=True) if is_internal_service_request(self.request):
if user.is_superuser:
return Importador.objects.all() return Importador.objects.all()
org = get_org_context(user)
if 'Importador' in grupos: if not org:
return user.rfc.all() return Importador.objects.none()
# Con permiso ve todos; sin permiso solo los asignados al usuario
return self.get_queryset_filtrado_por_organizacion() 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): 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("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() 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_update(self, serializer):
# Para usuarios normales, usar siempre su organización if is_internal_service_request(self.request):
if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: serializer.save()
raise ValueError("Usuario sin organización")
serializer.save(organizacion=self.request.user.organizacion)
return return
org = get_org_context(self.request.user)
serializer.save(organizacion=org)
raise ValueError("Usuario no autenticado o sin permisos para actualizar Importador") def perform_destroy(self, instance):
instance.delete()
my_tags = ['Importadores']
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(

View File

@@ -5,7 +5,7 @@ from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi from drf_yasg import openapi
from core.permissions import IsSuperUser, IsSameOrganizationDeveloper from core.permissions import require_permission
from .tasks.auditoria import ( from .tasks.auditoria import (
crear_partidas, crear_partidas,
auditar_coves, auditar_coves,
@@ -84,7 +84,7 @@ def get_document_path(documento):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def crear_partidas_organizacion(request): def crear_partidas_organizacion(request):
organizacion_id = request.data.get('organizacion_id') organizacion_id = request.data.get('organizacion_id')
@@ -122,7 +122,7 @@ def crear_partidas_organizacion(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def crear_partidas_pedimento(request): def crear_partidas_pedimento(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
@@ -202,7 +202,7 @@ def crear_partidas_pedimento(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_pedimentos_endpoint(request): def auditar_pedimentos_endpoint(request):
""" """
Inicia una tarea de auditoría para todos los pedimentos de una organización. Inicia una tarea de auditoría para todos los pedimentos de una organización.
@@ -252,7 +252,7 @@ def auditar_pedimentos_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_procesamiento_remesa_pedimento_endpoint(request): def auditar_procesamiento_remesa_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
@@ -339,7 +339,7 @@ def _lanzar_auditoria_organizacion(request, task_fn, label):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_coves_endpoint(request): def auditar_coves_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_coves, 'COVEs') return _lanzar_auditoria_organizacion(request, auditar_coves, 'COVEs')
@@ -359,7 +359,7 @@ def auditar_coves_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_acuse_cove_endpoint(request): def auditar_acuse_cove_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_acuse_cove, 'acuses de COVE') return _lanzar_auditoria_organizacion(request, auditar_acuse_cove, 'acuses de COVE')
@@ -379,7 +379,7 @@ def auditar_acuse_cove_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_edocuments_endpoint(request): def auditar_edocuments_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_edocuments, 'EDocuments') return _lanzar_auditoria_organizacion(request, auditar_edocuments, 'EDocuments')
@@ -399,7 +399,7 @@ def auditar_edocuments_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_acuse_endpoint(request): def auditar_acuse_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_acuse, 'acuses de EDocument') return _lanzar_auditoria_organizacion(request, auditar_acuse, 'acuses de EDocument')
@@ -419,7 +419,7 @@ def auditar_acuse_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_remesas_endpoint(request): def auditar_remesas_endpoint(request):
return _lanzar_auditoria_organizacion(request, auditar_remesas, 'remesas') return _lanzar_auditoria_organizacion(request, auditar_remesas, 'remesas')
@@ -442,7 +442,7 @@ def auditar_remesas_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_cove_pedimento_endpoint(request): def auditar_cove_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
if not pedimento_id: if not pedimento_id:
@@ -504,7 +504,7 @@ def auditar_cove_pedimento_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_acuse_cove_pedimento_endpoint(request): def auditar_acuse_cove_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
if not pedimento_id: if not pedimento_id:
@@ -566,7 +566,7 @@ def auditar_acuse_cove_pedimento_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_edocument_pedimento_endpoint(request): def auditar_edocument_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
if not pedimento_id: if not pedimento_id:
@@ -628,7 +628,7 @@ def auditar_edocument_pedimento_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_acuse_pedimento_endpoint(request): def auditar_acuse_pedimento_endpoint(request):
pedimento_id = request.data.get('pedimento_id') pedimento_id = request.data.get('pedimento_id')
if not pedimento_id: if not pedimento_id:
@@ -687,7 +687,7 @@ def auditar_acuse_pedimento_endpoint(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditor_procesar_pedimentos_organizacion(request): def auditor_procesar_pedimentos_organizacion(request):
""" """
Inicia una tarea de procesamiento para todos los pedimentos de todas las organizaciones. Inicia una tarea de procesamiento para todos los pedimentos de todas las organizaciones.
@@ -739,7 +739,7 @@ def auditor_procesar_pedimentos_organizacion(request):
### Fin Procesamiento de pedimentos ### ### Fin Procesamiento de pedimentos ###
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditar_peticion_respuesta_pedimento_completo(request): def auditar_peticion_respuesta_pedimento_completo(request):
""" """
Backend endpoint para obtener las peticiones y respuestas asociadas a un pedimento. Backend endpoint para obtener las peticiones y respuestas asociadas a un pedimento.
@@ -884,7 +884,7 @@ def auditar_peticion_respuesta_pedimento_completo(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_pedimento_vu(request): def auditor_obtener_peticion_pedimento_vu(request):
""" """
Backend endpoint para obtener las peticiones y respuestas asociadas a un pedimento. Backend endpoint para obtener las peticiones y respuestas asociadas a un pedimento.
@@ -938,7 +938,7 @@ def auditor_obtener_peticion_pedimento_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_pedimento_vu(request): def auditor_obtener_respuesta_pedimento_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a un pedimento. Backend endpoint para obtener las respuestas asociadas a un pedimento.
@@ -991,7 +991,7 @@ def auditor_obtener_respuesta_pedimento_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_remesa_vu(request): def auditor_obtener_peticion_remesa_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1045,7 +1045,7 @@ def auditor_obtener_peticion_remesa_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_remesa_vu(request): def auditor_obtener_respuesta_remesa_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1098,7 +1098,7 @@ def auditor_obtener_respuesta_remesa_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_partidas_vu(request): def auditor_obtener_peticion_partidas_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1178,7 +1178,7 @@ def auditor_obtener_peticion_partidas_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_partidas_vu(request): def auditor_obtener_respuesta_partidas_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1231,7 +1231,7 @@ def auditor_obtener_respuesta_partidas_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_acuse_vu(request): def auditor_obtener_peticion_acuse_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1285,7 +1285,7 @@ def auditor_obtener_peticion_acuse_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_acuse_vu(request): def auditor_obtener_respuesta_acuse_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1338,7 +1338,7 @@ def auditor_obtener_respuesta_acuse_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_cove_vu(request): def auditor_obtener_peticion_cove_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1392,7 +1392,7 @@ def auditor_obtener_peticion_cove_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_cove_vu(request): def auditor_obtener_respuesta_cove_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1445,7 +1445,7 @@ def auditor_obtener_respuesta_cove_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_acuse_cove_vu(request): def auditor_obtener_peticion_acuse_cove_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1499,7 +1499,7 @@ def auditor_obtener_peticion_acuse_cove_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_acuse_cove_vu(request): def auditor_obtener_respuesta_acuse_cove_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1552,7 +1552,7 @@ def auditor_obtener_respuesta_acuse_cove_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_peticion_edocument_vu(request): def auditor_obtener_peticion_edocument_vu(request):
""" """
Backend endpoint para obtener las peticiones asociadas a una remesa. Backend endpoint para obtener las peticiones asociadas a una remesa.
@@ -1606,7 +1606,7 @@ def auditor_obtener_peticion_edocument_vu(request):
}, status=status.HTTP_200_OK) }, status=status.HTTP_200_OK)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.view')])
def auditor_obtener_respuesta_edocument_vu(request): def auditor_obtener_respuesta_edocument_vu(request):
""" """
Backend endpoint para obtener las respuestas asociadas a una remesa. Backend endpoint para obtener las respuestas asociadas a una remesa.
@@ -1677,7 +1677,7 @@ def auditor_obtener_respuesta_edocument_vu(request):
} }
) )
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) @permission_classes([IsAuthenticated, require_permission('auditoria.process')])
def auditar_pedimento_endpoint(request): def auditar_pedimento_endpoint(request):
""" """
Audita un pedimento específico verificando si existe su XML y extrayendo información. Audita un pedimento específico verificando si existe su XML y extrayendo información.

View File

@@ -297,6 +297,7 @@ 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)

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

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,21 +35,19 @@ 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) return Organizacion.objects.none()
if self.request.user.groups.filter(name='importador').exists(): # Superuser ve solo su org activa, no todas
return Organizacion.objects.filter(users=self.request.user) return Organizacion.objects.filter(id=org.id)
return Organizacion.objects.none()
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet): class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
""" """
@@ -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

@@ -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,21 +144,47 @@ 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):
queryset = self.get_queryset_filtrado_por_organizacion() user = self.request.user
if user.is_superuser and isinstance(
getattr(self.request, 'successful_authenticator', None), TokenAuthentication
):
queryset = Document.objects.all()
else:
if not user_has_permission(user, 'documentos.view'):
return Document.objects.none()
queryset = self.get_queryset_filtrado_por_organizacion()
modulo_efc = self.request.query_params.get('modulo') modulo_efc = self.request.query_params.get('modulo')
if modulo_efc: if modulo_efc:
if modulo_efc == 'expedientes-detalle-pedimentos': if modulo_efc == 'expedientes-detalle-pedimentos':
@@ -1278,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')
nombre="Documento General", if document_type_id_param:
defaults={'descripcion': "Documento general sin tipo específico"} try:
) document_type = DocumentType.objects.get(id=int(document_type_id_param))
if created: except (DocumentType.DoesNotExist, ValueError):
print(f"✅ DocumentType creado: {document_type.nombre} (ID: {document_type.id})") return Response(
{"error": f"Tipo de documento con ID '{document_type_id_param}' no encontrado"},
status=status.HTTP_400_BAD_REQUEST
)
else: else:
print(f"♻️ DocumentType existente: {document_type.nombre} (ID: {document_type.id})") document_type, _ = DocumentType.objects.get_or_create(
nombre="Documento General",
defaults={'descripcion': "Documento general sin tipo específico"}
)
uploaded_documents = [] uploaded_documents = []
failed_files = [] failed_files = []
@@ -1371,6 +1405,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
existing_doc.archivo = ruta existing_doc.archivo = ruta
existing_doc.size = file.size existing_doc.size = file.size
existing_doc.extension = extension existing_doc.extension = extension
existing_doc.document_type = document_type
existing_doc.save() existing_doc.save()
else: else:
raise Exception(f"Error al guardar archivo: {file.name}") raise Exception(f"Error al guardar archivo: {file.name}")
@@ -1406,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:
@@ -1750,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']
@@ -1764,17 +2058,14 @@ 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:
raise Http404("Documento sin archivo asociado") raise Http404("Documento sin archivo asociado")
@@ -1798,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):
@@ -1906,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']
@@ -1921,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']
@@ -1938,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):
@@ -2040,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):
@@ -2109,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
@@ -2123,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')
@@ -2169,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,15 +308,40 @@ 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'
else:
report.status = 'error'
report.error_message = 'Error al guardar el archivo en storage'
report.status = 'ready'
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:
report.status = 'error' if report:
report.error_message = str(e) report.status = 'error'
report.finished_at = timezone.now() report.error_message = str(e)
report.save(update_fields=['status', 'error_message', 'finished_at']) report.finished_at = timezone.now()
report.save(update_fields=['status', 'error_message', 'finished_at'])

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
@@ -136,20 +114,14 @@ class ExportDataStageView(APIView):
return str(value) return str(value)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Retorna RFCs distintos de Registro501 para la organización indicada. El parámetro organizacion es obligatorio.""" """Retorna RFCs distintos de Registro501 para la organización activa del usuario."""
try: try:
Registro501 = apps.get_model('datastage', 'Registro501') Registro501 = apps.get_model('datastage', 'Registro501')
if not request.user.is_superuser: org = get_org_context(request.user)
qs = Registro501.objects.filter(organizacion=request.user.organizacion) if not org:
else: return Response({'error': 'Sin organización activa'}, status=status.HTTP_403_FORBIDDEN)
org_id = request.query_params.get('organizacion') qs = Registro501.objects.filter(organizacion=org)
if not org_id:
return Response({'error': 'El parámetro organizacion es obligatorio'}, status=status.HTTP_400_BAD_REQUEST)
try:
qs = Registro501.objects.filter(organizacion_id=uuid.UUID(org_id))
except (ValueError, AttributeError):
return Response({'error': 'UUID de organización inválido'}, status=status.HTTP_400_BAD_REQUEST)
rfcs = ( rfcs = (
qs.exclude(rfc__isnull=True) qs.exclude(rfc__isnull=True)
@@ -178,23 +150,19 @@ class ExportDataStageView(APIView):
def _resolve_org_filter(self, global_filters, user): def _resolve_org_filter(self, global_filters, user):
""" """
Devuelve los global_filters asegurando que siempre haya una organización. Devuelve los global_filters asegurando que siempre haya una organización.
- Superuser sin org → error (no mezclar tenants). La org se obtiene de active_organization (superuser) o del campo organizacion (usuario normal).
- No-superuser sin org → se inyecta la org del usuario.
Retorna (filters_dict, error_response_or_None). Retorna (filters_dict, error_response_or_None).
""" """
org_value = (global_filters or {}).get('organizacion', '') filters = dict(global_filters or {})
if not org_value: if not filters.get('organizacion'):
if user.is_superuser: org = get_org_context(user)
if not org:
return None, Response( return None, Response(
{'error': 'El parámetro organizacion es obligatorio'}, {'error': 'Sin organización activa'},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_403_FORBIDDEN,
) )
# No-superuser: inyectar su propia org filters['organizacion'] = str(org.id)
if hasattr(user, 'organizacion') and user.organizacion: return filters, None
filters = dict(global_filters or {})
filters['organizacion'] = str(user.organizacion.id)
return filters, None
return dict(global_filters or {}), 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)"""
@@ -1868,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=[
@@ -1906,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')
@@ -1917,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
@@ -1964,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):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
# Task se relaciona con pedimento, que tiene contribuyente
campo_contribuyente = 'pedimento__contribuyente'
queryset = Task.objects.select_related('pedimento', 'servicio').all() queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer serializer_class = TaskSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = TaskFilter filterset_class = TaskFilter
pagination_class = TaskPagination pagination_class = TaskPagination
ordering_fields = ['timestamp'] ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero ordering = ['-timestamp']
my_tags = ['tasks'] my_tags = ['tasks']
def get_queryset(self): def get_permissions(self):
# Escritura: exclusivo para microservicio interno (Token + superuser)
# Lectura: usuarios con pedimentos.view via JWT
if self.action in ('create', 'update', 'partial_update', 'destroy'):
return [IsAuthenticated(), IsInternalService()]
return [IsAuthenticated(), require_permission('pedimentos.view')()]
""" def get_queryset(self):
Filtra las tareas según la organización del usuario. user = self.request.user
Superusuarios pueden ver todas las tareas. # Service account (Token + superuser): sin filtro de org, accede a todas las tasks
""" if user.is_superuser and isinstance(
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador getattr(self.request, 'successful_authenticator', None), TokenAuthentication
# if user.is_superuser: ):
# return self.queryset return Task.objects.select_related('pedimento', 'servicio').all()
# # return self.queryset.filter(organizacion_id=user.organizacion.id) if not user_has_permission(user, 'pedimentos.view'):
# else: return Task.objects.none()
# return self.queryset.filter(organizacion_id=user.organizacion.id) return self.get_queryset_filtrado_por_organizacion()
return queryset
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -57,20 +58,82 @@ from celery.result import AsyncResult
class TaskStatusView(APIView): class TaskStatusView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated, require_permission('pedimentos.view')]
# Mapeo de status del microservicio → estados estándar
_STATUS_MAP = {
'failed': 'FAILURE',
'completed': 'SUCCESS',
'processing': 'STARTED',
'submitted': 'PENDING',
'pending': 'PENDING',
}
def get(self, request, task_id): def get(self, request, task_id):
""" """
Consulta el estado de una tarea Celery. Consulta el estado de una tarea.
Fuente de verdad: registro Django Task (actualizado por el microservicio vía PUT).
Celery AsyncResult se usa como complemento para tareas de auditoría masiva (SUCCESS)
y como fallback cuando la tarea no está en la BD todavía.
Estados posibles: Estados posibles:
PENDING — en cola, aún no inició PENDING — en cola o aún no registrada
STARTED — worker la tomó y está ejecutando STARTED — worker ejecutando
SUCCESS — terminó correctamente, `result` contiene el resumen SUCCESS — completada sin errores
FAILURE — lanzó una excepción no capturada, `error` describe el problema FAILURE — terminó con error
RETRY — el worker la está reintentando RETRY — el worker la está reintentando
""" """
try: try:
# Prioridad 1: Django Task record (fuente de verdad del microservicio)
try:
django_task = Task.objects.get(task_id=task_id)
effective_state = self._STATUS_MAP.get(
django_task.status.lower(), django_task.status.upper()
)
is_terminal = effective_state in ('SUCCESS', 'FAILURE')
response_data = {
'task_id': task_id,
'status': effective_state,
'ready': is_terminal,
'successful': (effective_state == 'SUCCESS') if is_terminal else None,
'message': django_task.message,
}
if effective_state == 'FAILURE':
response_data['error'] = django_task.message
elif effective_state == 'SUCCESS':
# Para auditoría masiva, intentar enriquecer con resultado de Celery
try:
celery_result = AsyncResult(task_id)
if celery_result.ready() and celery_result.successful():
result = celery_result.result
response_data['result'] = result
if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0:
response_data['mensaje'] = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else:
partes = []
if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores:
partes.append(f'{con_errores} con error')
response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
except Exception:
pass
return Response(response_data, status=status.HTTP_200_OK)
except Task.DoesNotExist:
pass
# Prioridad 2: Celery AsyncResult (tarea aún no registrada en BD)
task_result = AsyncResult(task_id) task_result = AsyncResult(task_id)
state = task_result.state state = task_result.state
@@ -84,25 +147,20 @@ class TaskStatusView(APIView):
if state == 'SUCCESS': if state == 'SUCCESS':
result = task_result.result result = task_result.result
response_data['result'] = result response_data['result'] = result
# Resumen legible cuando es auditoría masiva de organización
if isinstance(result, dict) and 'total_pedimentos' in result: if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0) total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0) completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0) con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0) con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0: if con_pendientes == 0 and con_errores == 0:
mensaje = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes' response_data['mensaje'] = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else: else:
partes = [] partes = []
if con_pendientes: if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes') partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores: if con_errores:
partes.append(f'{con_errores} con error') partes.append(f'{con_errores} con error')
mensaje = f'{completados}/{total} pedimentos completos — {", ".join(partes)}' response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
response_data['mensaje'] = mensaje
elif state == 'FAILURE': elif state == 'FAILURE':
response_data['error'] = str(task_result.info) response_data['error'] = str(task_result.info)

View File

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

@@ -98,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',

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): return False
# Permite listar/crear solo si el usuario está autenticado return isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication)
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 def get_org_context(user):
allowed_groups = ['admin', 'Agente Aduanal', 'user'] """Retorna la organización activa para filtrado de datos.
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() Superusuarios usan active_organization; usuarios normales usan organizacion."""
if not user_in_group: if user.is_superuser:
return False return getattr(user, 'active_organization', None)
if hasattr(obj, 'organizacion'): return getattr(user, 'organizacion', None)
return obj.organizacion == request.user.organizacion
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 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 False
return obj.organizacion == user.organizacion 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: return self.model.objects.none()
filtro = {f"{self.campo_organizacion}": getattr(user, self.campo_organizacion)}
else: filtro = {self.campo_organizacion: org}
return self.model.objects.none()
# 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:
return model.objects.filter(**filtros_base)
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and (('admin' in grupos or 'developer' in grupos) and 'user' in grupos) :
if 'Agente Aduanal' in grupos: if (
return model.objects.filter(**filtros_base) user_has_role(user, 'admin') or
user_has_role(user, 'developer') or
# if hasattr(model, self.campo_contribuyente): user_has_role(user, 'Agente Aduanal') or
if self.request.user.is_authenticated and 'Importador' in grupos: user_has_role(user, 'user')
filtros_base[f"{self.campo_contribuyente}__in"] = self.request.user.rfc.all() ):
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:
return model.objects.filter(**filtros_base)
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos): if (
if 'Agente Aduanal' in grupos: user_has_role(user, 'admin') or
return model.objects.filter(**filtros_base) 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 hasattr(model, self.campo_contribuyente): if user.is_importador:
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False): filtros_base[f'{self.campo_contribuyente}__contribuyente__in'] = user.rfc.all()
filtros_base[f"{self.campo_contribuyente}__contribuyente__in"] = self.request.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)
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:
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 self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos) : if user.is_importador:
if 'Agente Aduanal' in grupos: filtros_base[f'{self.campo_pedimento}__contribuyente__in'] = user.rfc.all()
return model.objects.filter(**filtros_base) return model.objects.filter(**filtros_base)
if hasattr(model, self.campo_pedimento):
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
filtros_base[f"{self.campo_pedimento}__contribuyente__in"] = self.request.user.rfc.all()
return model.objects.filter(**filtros_base)
# Si no entra en los roles válidos
return model.objects.none() return model.objects.none()