From a318b703241d47981fd1bbc27d5a29582218fbf7 Mon Sep 17 00:00:00 2001 From: marcos Date: Thu, 21 May 2026 07:54:59 -0600 Subject: [PATCH] feature/rbac permisos y roles implementados --- api/cards/views.py | 73 +-- api/cuser/admin.py | 2 +- api/cuser/models.py | 11 + api/cuser/views.py | 112 ++-- api/customs/views.py | 513 +++++++++++------- api/customs/views_auditor.py | 62 +-- api/datastage/views.py | 111 ++-- api/logger/views.py | 3 +- api/notificaciones/signals/notificaciones.py | 60 +- api/notificaciones/views.py | 31 +- api/organization/admin.py | 28 +- api/organization/models.py | 10 + api/organization/signals.py | 22 +- api/organization/views.py | 56 +- api/rbac/__init__.py | 0 api/rbac/admin.py | 99 ++++ api/rbac/apps.py | 8 + api/rbac/management/__init__.py | 0 api/rbac/management/commands/__init__.py | 0 api/rbac/management/commands/sync_rbac.py | 101 ++++ api/rbac/migrations/0001_initial.py | 116 ++++ api/rbac/migrations/0002_data_permissions.py | 88 +++ .../migrations/0003_notificaciones_receive.py | 56 ++ .../migrations/0004_auditoria_permissions.py | 57 ++ api/rbac/migrations/__init__.py | 0 api/rbac/models.py | 109 ++++ api/rbac/roles.py | 176 ++++++ api/rbac/serializers.py | 105 ++++ api/rbac/urls.py | 23 + api/rbac/views.py | 412 ++++++++++++++ api/record/views.py | 80 ++- api/reports/views.py | 172 ++---- api/tasks/views.py | 136 +++-- api/vucem/views.py | 176 +++--- config/settings.py | 3 +- config/urls.py | 1 + core/permissions.py | 308 ++++++++--- mixins/filtrado_organizacion.py | 177 +++--- 38 files changed, 2596 insertions(+), 901 deletions(-) create mode 100644 api/rbac/__init__.py create mode 100644 api/rbac/admin.py create mode 100644 api/rbac/apps.py create mode 100644 api/rbac/management/__init__.py create mode 100644 api/rbac/management/commands/__init__.py create mode 100644 api/rbac/management/commands/sync_rbac.py create mode 100644 api/rbac/migrations/0001_initial.py create mode 100644 api/rbac/migrations/0002_data_permissions.py create mode 100644 api/rbac/migrations/0003_notificaciones_receive.py create mode 100644 api/rbac/migrations/0004_auditoria_permissions.py create mode 100644 api/rbac/migrations/__init__.py create mode 100644 api/rbac/models.py create mode 100644 api/rbac/roles.py create mode 100644 api/rbac/serializers.py create mode 100644 api/rbac/urls.py create mode 100644 api/rbac/views.py diff --git a/api/cards/views.py b/api/cards/views.py index ba630e4..7f8ff74 100644 --- a/api/cards/views.py +++ b/api/cards/views.py @@ -8,10 +8,9 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser + get_org_context, + require_permission, + user_has_permission, ) from api.organization.models import UsoAlmacenamiento, Organizacion @@ -34,7 +33,7 @@ class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin) View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days. Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('cards.view')] model = Document my_tags = ['Cards'] @@ -100,7 +99,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan View para obtener información de uso de servicios relacionados con pedimentos. Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('cards.view')] model = Document my_tags = ['Cards'] @@ -140,29 +139,17 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): return None - # Si es super usuario, devuelve todos los procesos - if self.request.user.is_superuser: - return ProcesamientoPedimento.objects.all() + org = get_org_context(self.request.user) + if not org: + return ProcesamientoPedimento.objects.none() - # Si es Administrador de la organizacion devuelve todos los servicios de la organizacion - if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): - return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion) + if self.request.user.is_importador: + return ProcesamientoPedimento.objects.filter( + pedimento__organizacion=org, + pedimento__contribuyente__in=self.request.user.rfc.all(), + ) - # Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion - if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): - return self.request.user.organizacion.procesamiento_pedimentos.all() - - if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): - return self.request.user.organizacion.procesamiento_pedimentos.all() - - # Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos - if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists(): - return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente__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) + return ProcesamientoPedimento.objects.filter(pedimento__organizacion=org) def get(self, request): queryset = self.get_queryset() @@ -193,12 +180,21 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): Endpoint para análisis de actividades de usuario. Devuelve el conteo de acciones por tipo y los 5 usuarios más activos. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] - + permission_classes = [IsAuthenticated, require_permission('cards.view')] + model = UserActivity + campo_organizacion = 'user__organizacion' my_tags = ['Cards'] + def get_queryset_importador(self): + # Importadores solo ven sus propias actividades + user = self.request.user + org = get_org_context(user) + if not org: + return UserActivity.objects.none() + return UserActivity.objects.filter(user__organizacion=org, user=user) + @swagger_auto_schema( operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.", manual_parameters=[ @@ -253,7 +249,9 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): } ) 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): queryset = self.get_queryset() @@ -289,11 +287,20 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): Endpoint para análisis de logs de peticiones. Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('cards.view')] model = RequestLog + campo_organizacion = 'user__organizacion' my_tags = ['Cards'] + def get_queryset_importador(self): + # Importadores solo ven sus propios logs + user = self.request.user + org = get_org_context(user) + if not org: + return RequestLog.objects.none() + return RequestLog.objects.filter(user__organizacion=org, user=user) + @swagger_auto_schema( operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.", manual_parameters=[ @@ -345,6 +352,8 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin): } ) def get_queryset(self): + if self.request.user.is_importador: + return self.get_queryset_importador() return self.get_queryset_filtrado() def get(self, request): @@ -376,7 +385,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin): View que obtiene los ultimos 10 documentos agregados. Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('cards.view')] model = Document my_tags = ['Cards'] diff --git a/api/cuser/admin.py b/api/cuser/admin.py index bad0b2f..9e17c95 100644 --- a/api/cuser/admin.py +++ b/api/cuser/admin.py @@ -30,7 +30,7 @@ class CustomUserAdmin(UserAdmin): # Fieldsets para editar un usuario fieldsets = ( (None, {'fields': ('username', 'password')}), - ('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'profile_picture', 'is_importador', 'rfc')}), + ('Información personal', {'fields': ('first_name', 'last_name', 'email', 'organizacion', 'active_organization', 'profile_picture', 'is_importador', 'rfc')}), ('Permisos', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), ('Fechas importantes', {'fields': ('last_login', 'date_joined')}), ) diff --git a/api/cuser/models.py b/api/cuser/models.py index cdea1c9..1fe423d 100644 --- a/api/cuser/models.py +++ b/api/cuser/models.py @@ -11,6 +11,17 @@ class CustomUser(AbstractUser): organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, null=True, blank=True, related_name='users') profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True) + # Contexto de trabajo activo para superusuarios. Filtra datos igual que un usuario normal. + # Sin este campo activo, el superuser no puede consultar datos — debe hacer switch primero. + active_organization = models.ForeignKey( + 'organization.Organizacion', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='superusers_activos', + help_text="Solo superusuarios: organización activa para contexto de trabajo", + ) + is_importador = models.BooleanField(default=False, help_text="Indicates if the user is an importer") rfc = models.ManyToManyField('customs.Importador', blank=True, related_name='users', help_text="RFCs de importadores asociados al usuario") diff --git a/api/cuser/views.py b/api/cuser/views.py index 5df97a6..7e92c3a 100644 --- a/api/cuser/views.py +++ b/api/cuser/views.py @@ -17,10 +17,14 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.exceptions import PermissionDenied from core.permissions import ( - IsSameOrganization, + IsSameOrganization, IsSameOrganizationDeveloper, IsSameOrganizationAndAdmin, - IsSuperUser + IsSuperUser, + get_org_context, + is_internal_service_request, + user_has_permission, + require_permission, ) from .serializers import CustomUserSerializer @@ -74,78 +78,62 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): """ ViewSet for CustomUser model. """ - permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )] pagination_class = CustomPagination model = CustomUser serializer_class = CustomUserSerializer filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador'] my_tags = ['User Profile'] - def get_permissions(self): - # Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización - if self.action == 'destroy': - user = self.request.user - if not ( - user.is_superuser or - user.groups.filter(name='admin').exists() or - user.groups.filter(name='Agente Aduanal').exists() or - user.groups.filter(name='user').exists() - ): - from rest_framework.exceptions import PermissionDenied - raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.") - elif self.action in ['create', 'update', 'partial_update']: - if not (self.request.user.is_superuser or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Importador').exists()) : - from rest_framework.exceptions import PermissionDenied - raise PermissionDenied("Solo admin o superusuario pueden modificar usuarios.") - return super().get_permissions() + if self.action in ('me', 'change_password'): + return [IsAuthenticated()] + perms = { + 'list': 'usuarios.view', + 'retrieve': 'usuarios.view', + 'create': 'usuarios.create', + 'update': 'usuarios.edit', + 'partial_update': 'usuarios.edit', + 'destroy': 'usuarios.delete', + } + codename = perms.get(self.action, 'usuarios.view') + return [IsAuthenticated(), require_permission(codename)()] def perform_destroy(self, instance): - # Solo permitir eliminar usuarios de la misma organización - if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion: + user = self.request.user + org = get_org_context(user) + if user.is_superuser or instance.organizacion == org: instance.delete() else: - from rest_framework.exceptions import PermissionDenied raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.") def get_queryset(self): - # Si es importador, solo puede ver su propio usuario - if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists(): - return CustomUser.objects.filter(pk=self.request.user.pk) - - # Otros roles: filtrar por organización - return self.get_queryset_filtrado_por_organizacion() + user = self.request.user + if is_internal_service_request(self.request): + return CustomUser.objects.all() + if not user_has_permission(user, 'usuarios.view'): + return CustomUser.objects.none() + org = get_org_context(user) + if not org: + return CustomUser.objects.none() + return CustomUser.objects.filter(organizacion=org) def perform_create(self, serializer): - # Always assign the creator's organization - if self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists(): - if not self.request.user.organizacion: - raise PermissionDenied("Los administradores deben tener una organización asignada para crear usuarios.") - user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) - send_activation_email(user, self.request) # Usa template HTML - return + creator = self.request.user - if self.request.user.is_superuser: - # If superuser, allow creating users without organization + if creator.is_superuser: user = serializer.save(is_active=False) - send_activation_email(user, self.request) # Usa template HTML + send_activation_email(user, self.request) return - if self.request.user.groups.filter(name='developer').exists(): - # Developers can create users but must assign an organization - if not self.request.user.organizacion: - raise PermissionDenied("Los desarrolladores deben tener una organización asignada para crear usuarios.") - user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) - send_activation_email(user, self.request) # Usa template HTML - return - - if self.request.user.groups.filter(name='importador').exists(): - # No puedes crear un usuario si eres importador + if creator.is_importador: raise PermissionDenied("Los importadores no pueden crear usuarios.") - user = serializer.save(organizacion=self.request.user.organizacion, is_active=False) - send_activation_email(user, self.request) # Usa template HTML - return + org = get_org_context(creator) + if not org: + raise PermissionDenied("Debes tener una organización asignada para crear usuarios.") + + user = serializer.save(organizacion=org, is_active=False) + send_activation_email(user, self.request) @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) def me(self, request): @@ -167,8 +155,11 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): """ user = self.get_object() current_user = request.user - # Solo el propio usuario, admin o superuser pueden cambiar la contraseña - if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user): + puede_cambiar_ajena = ( + current_user.is_superuser or + user_has_permission(current_user, 'usuarios.change_password') + ) + if not (puede_cambiar_ajena or user == current_user): raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.") old_password = request.data.get('old_password') @@ -176,8 +167,7 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): if not new_password: return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) - # Si no es admin/superuser, debe validar old_password - if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists()): + if not puede_cambiar_ajena: if not old_password or not user.check_password(old_password): return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400) @@ -226,11 +216,11 @@ class ProfilePictureView(LoggingMixin, APIView): my_tags = ['User Profile'] def get(self, request, user_id): - # Obtiene el usuario (automáticamente 404 si no existe) user = get_object_or_404(CustomUser, pk=user_id) - - # El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin - # Así que no necesitas validar manualmente los permisos aquí. + + org = get_org_context(request.user) + if not request.user.is_superuser and user.organizacion != org: + raise Http404("No autorizado") if not user.profile_picture: raise Http404("El usuario no tiene imagen de perfil") @@ -267,6 +257,8 @@ class PasswordResetConfirmView(APIView): return Response({'detail': 'Enlace inválido.'}, status=400) if not default_token_generator.check_token(user, token): return Response({'detail': 'Token inválido o expirado.'}, status=400) + if not user.is_active: + return Response({'detail': 'La cuenta de usuario no está activa.'}, status=400) password = request.data.get('password') if not password: return Response({'detail': 'La nueva contraseña es requerida.'}, status=400) diff --git a/api/customs/views.py b/api/customs/views.py index 277201a..82a52dd 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -10,12 +10,20 @@ from rest_framework.views import APIView from rest_framework.exceptions import PermissionDenied from rest_framework import status 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 core.permissions import ( - IsSameOrganization, + IsSameOrganization, IsSameOrganizationDeveloper, IsSameOrganizationAndAdmin, - IsSuperUser + IsSuperUser, + get_org_context, + require_permission, + user_has_permission, + is_internal_service_request, ) from api.customs.models import ( Pedimento, @@ -244,6 +252,19 @@ class PedimentoPagination(PageNumberPagination): return super().paginate_queryset(queryset, request, view) # 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 """ ViewSet for Pedimento model. @@ -257,53 +278,124 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada - existe_expediente: Filtro por expediente (True/False) - contribuyente: Filtro por contribuyente - 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 - aduana: Filtro por aduana - tipo_operacion: Filtro por tipo de operación - clave_pedimento: Filtro por clave de pedimento - ordering: Ordenar por campo (ej: -created_at, pedimento) - + Ejemplos: - /pedimentos/ → Devuelve TODOS los pedimentos - /pedimentos/?page_size=10 → Devuelve los primeros 10 - - /pedimentos/?page_size=10&page=2 → Devuelve los pedimentos 11-20 - - /pedimentos/?pedimento=12345678 → Filtra por número de pedimento - - /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 + - /pedimentos/?fecha_pago_desde=2025-01-01&fecha_pago_hasta=2025-12-31 → Rango de fechas + - /pedimentos/export-excel/?contribuyente=EMPRESA → Descarga Excel con filtros """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] serializer_class = PedimentoSerializer pagination_class = PedimentoPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] 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'] - # AGREGAR ESTOS CAMPOS PARA ORDENACIÓN 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): - - queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador + if not user_has_permission(self.request.user, 'pedimentos.view'): + return Pedimento.objects.none() + return self.get_queryset_filtrado_por_organizacion() - # pedimento_app_filter = self.request.GET.get('pedimento_app', None) + @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()) - # if pedimento_app_filter: - # print(f"Filtro por pedimento_app: {pedimento_app_filter}") - # queryset = queryset.filter(pedimento_app__icontains=pedimento_app_filter) + 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'), + ] - return queryset + def safe_value(val): + if val is None: + return '' + if isinstance(val, bool): + return 'Sí' 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) + + 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): - """ - 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") + org = get_org_context(self.request.user) data = serializer.validated_data if not data.get('pedimento_app'): fecha_pago = data.get('fecha_pago') @@ -312,7 +404,7 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada pedimento = data.get('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:]}" - serializer.save(organizacion=self.request.user.organizacion, pedimento_app=pedimento_app) + serializer.save(organizacion=org, pedimento_app=pedimento_app) try: # 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') def procesar_completo(self, request, pk=None): """ @@ -2197,33 +2292,70 @@ class PartidaViewSet(viewsets.ModelViewSet): Permite filtrar por: - pedimento: UUID del pedimento (query parameter principal) - pedimento__id: UUID del pedimento (alternativo) - + Ejemplo: GET /api/partidas/?pedimento=6782d22e-5e97-4efc-87c9-bd8497c8ac7e """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] - queryset = Partida.objects.all() serializer_class = PartidaSerializer pagination_class = CustomPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = { - 'pedimento': ['exact'], # Filtro directo por UUID del pedimento - 'pedimento__id': ['exact'], # Filtro alternativo - 'numero_partida': ['exact', 'gte', 'lte'], # Filtros por número de partida - 'descargado': ['exact'], # Filtro por estado de descarga - 'created_at': ['exact', 'gte', 'lte'], # Filtros por fecha de creación - 'updated_at': ['exact', 'gte', 'lte'] # Filtros por fecha de actualización + 'pedimento': ['exact'], + 'pedimento__id': ['exact'], + 'numero_partida': ['exact', 'gte', 'lte'], + 'descargado': ['exact'], + 'created_at': ['exact', 'gte', 'lte'], + 'updated_at': ['exact', 'gte', 'lte'], } search_fields = ['pedimento__pedimento', 'pedimento__pedimento_app'] 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'] + 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): """ ViewSet for TipoOperacion model. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('pedimentos.view')] queryset = TipoOperacion.objects.all() serializer_class = TipoOperacionSerializer @@ -2233,9 +2365,17 @@ class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet): search_fields = ['tipo', 'descripcion'] ordering_fields = ['tipo', 'descripcion'] ordering = ['tipo'] - + 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): """ 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/?page_size=5 → Devuelve los primeros 5 """ - permission_classes = [IsAuthenticated, IsSuperUser | IsSameOrganizationDeveloper ] serializer_class = ProcesamientoPedimentoSerializer pagination_class = CustomPagination model = ProcesamientoPedimento @@ -2291,60 +2430,61 @@ class ViewSetProcesamientoPedimento(viewsets.ModelViewSet, ProcesosPorOrganizaci search_fields = ['pedimento__pedimento_app', 'pedimento__pedimento'] ordering_fields = ['created_at', 'updated_at'] ordering = ['-created_at'] - - def get_queryset(self): - return self.get_queryset_filtrado_por_organizacion() - - def perform_create(self, serializer): - """ - 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.") + 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): + 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): + if is_internal_service_request(self.request): serializer.save() return - - # Para usuarios normales, asignar siempre la organización del usuario - if not hasattr(user, 'organizacion') or not user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=user.organizacion) + org = get_org_context(self.request.user) + if not org: + raise PermissionDenied("Sin organización activa.") + serializer.save(organizacion=org) def perform_update(self, serializer): - """ - 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: + if is_internal_service_request(self.request): serializer.save() return - - if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Para usuarios normales, usar siempre su organización - if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=self.request.user.organizacion) - return + if not user_has_permission(self.request.user, 'pedimentos.process'): + raise PermissionDenied("Se requiere el permiso pedimentos.process.") + org = get_org_context(self.request.user) + if not org: + raise PermissionDenied("Sin organización activa.") + serializer.save(organizacion=org) - raise ValueError("Usuario no autenticado o sin permisos para actualizar ProcesamientoPedimento") - my_tags = ['Procesamientos_Pedimentos'] class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): """ ViewSet for EDocument model. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] serializer_class = EDocumentSerializer pagination_class = CustomPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -2353,60 +2493,48 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada ordering_fields = ['created_at', 'updated_at', 'numero_edocument'] ordering = ['-created_at'] model = EDocument + campo_contribuyente = 'pedimento__contribuyente' 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): + if not user_has_permission(self.request.user, 'edocuments.view'): + return EDocument.objects.none() return self.get_queryset_filtrado_por_organizacion() def perform_create(self, serializer): - """ - 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 + if is_internal_service_request(self.request): serializer.save() return - - print(f"self.request.user.groups >>>> {self.request.user.groups}") - 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") + org = get_org_context(self.request.user) + serializer.save(organizacion=org) def perform_update(self, serializer): - """ - 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: + if is_internal_service_request(self.request): serializer.save() return - - if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Para usuarios normales, usar siempre su organización - if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=self.request.user.organizacion) + org = get_org_context(self.request.user) + serializer.save(organizacion=org) - raise ValueError("Usuario no autenticado o sin permisos para actualizar EDocument") + def perform_destroy(self, instance): + instance.delete() class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): """ ViewSet for Cove model. """ - permission_classes = [IsAuthenticated & (IsSuperUser |IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] serializer_class = CoveSerializer pagination_class = CustomPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -2415,61 +2543,48 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin): ordering_fields = ['created_at', 'updated_at', 'numero_cove'] ordering = ['-created_at'] model = Cove + campo_contribuyente = 'pedimento__contribuyente' 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): + if not user_has_permission(self.request.user, 'coves.view'): + return Cove.objects.none() return self.get_queryset_filtrado_por_organizacion() def perform_create(self, serializer): - """ - 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 + if is_internal_service_request(self.request): serializer.save() return - - if ( - self.request.user.groups.filter(name='developer').exists() - or self.request.user.groups.filter(name='admin').exists() - or self.request.user.groups.filter(name='user').exists() - ) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Para usuarios normales, usar siempre su organización - if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=self.request.user.organizacion) - return - - raise ValueError("Usuario no autenticado o sin permisos para crear Cove") + org = get_org_context(self.request.user) + serializer.save(organizacion=org) def perform_update(self, serializer): - """ - 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: + if is_internal_service_request(self.request): serializer.save() return - - if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user .groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Para usuarios normales, usar siempre su organización - if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=self.request.user.organizacion) + org = get_org_context(self.request.user) + serializer.save(organizacion=org) -class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): + def perform_destroy(self, instance): + instance.delete() + +class ImportadorViewSet(viewsets.ModelViewSet): """ ViewSet for Importador model. """ - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] serializer_class = ImportadorSerializer pagination_class = CustomPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] @@ -2477,69 +2592,69 @@ class ImportadorViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin): search_fields = ['rfc', 'nombre'] ordering_fields = ['created_at', 'updated_at', 'rfc'] 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): user = self.request.user - grupos = user.groups.values_list('name', flat=True) - - if user.is_superuser: + if is_internal_service_request(self.request): return Importador.objects.all() - - if 'Importador' in grupos: - return user.rfc.all() - - return self.get_queryset_filtrado_por_organizacion() + org = get_org_context(user) + if not org: + return Importador.objects.none() + # Con permiso ve todos; sin permiso solo los asignados al usuario + if user_has_permission(user, 'importadores.view'): + return Importador.objects.filter(organizacion=org) + return Importador.objects.filter(organizacion=org, users=user) def perform_create(self, serializer): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("Usuario no autenticado o sin organización") - serializer.save(organizacion=self.request.user.organizacion) - - def perform_update(self, serializer): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("Usuario no autenticado o sin organización") - - # Si es superusuario, permite actualizar sin restricciones - if self.request.user.is_superuser: + if is_internal_service_request(self.request): serializer.save() return - - if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Para usuarios normales, usar siempre su organización - if not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - raise ValueError("Usuario sin organización") - serializer.save(organizacion=self.request.user.organizacion) - return - - raise ValueError("Usuario no autenticado o sin permisos para actualizar Importador") + org = get_org_context(self.request.user) + serializer.save(organizacion=org) - my_tags = ['Importadores'] + def perform_update(self, serializer): + if is_internal_service_request(self.request): + serializer.save() + return + org = get_org_context(self.request.user) + serializer.save(organizacion=org) + + def perform_destroy(self, instance): + instance.delete() class EjecutarComandoView(APIView): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] """ 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) - organizacion_id_request = request.data.get('organizacionid', None) + def post(self, request): procesamiento = request.data.get('procesamiento', None) todos = request.data.get('todos', False) - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("Usuario no autenticado o sin organización") - - if organizacion_id_request is None: + org = get_org_context(request.user) + if not org: return Response( - {"error": 'No se proporcionó la organización a ejecutar el proceso.'}, - status=status.HTTP_400_BAD_REQUEST - ) + {"error": "Sin organización activa."}, + status=status.HTTP_403_FORBIDDEN + ) - # organizacion_id = self.request.user.organizacion.id - organizacion_id = organizacion_id_request - nombre_organizacion = self.request.user.organizacion.nombre + organizacion_id = str(org.id) + nombre_organizacion = org.nombre if procesamiento is None and todos == False: return Response( diff --git a/api/customs/views_auditor.py b/api/customs/views_auditor.py index d28a194..193bb3a 100644 --- a/api/customs/views_auditor.py +++ b/api/customs/views_auditor.py @@ -5,7 +5,7 @@ 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 core.permissions import IsSuperUser, IsSameOrganizationDeveloper +from core.permissions import require_permission from .tasks.auditoria import ( crear_partidas, auditar_coves, @@ -84,7 +84,7 @@ def get_document_path(documento): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def crear_partidas_organizacion(request): organizacion_id = request.data.get('organizacion_id') @@ -122,7 +122,7 @@ def crear_partidas_organizacion(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def crear_partidas_pedimento(request): pedimento_id = request.data.get('pedimento_id') @@ -202,7 +202,7 @@ def crear_partidas_pedimento(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_pedimentos_endpoint(request): """ 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']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_procesamiento_remesa_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') @@ -339,7 +339,7 @@ def _lanzar_auditoria_organizacion(request, task_fn, label): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_coves_endpoint(request): return _lanzar_auditoria_organizacion(request, auditar_coves, 'COVEs') @@ -359,7 +359,7 @@ def auditar_coves_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_acuse_cove_endpoint(request): return _lanzar_auditoria_organizacion(request, auditar_acuse_cove, 'acuses de COVE') @@ -379,7 +379,7 @@ def auditar_acuse_cove_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_edocuments_endpoint(request): return _lanzar_auditoria_organizacion(request, auditar_edocuments, 'EDocuments') @@ -399,7 +399,7 @@ def auditar_edocuments_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_acuse_endpoint(request): return _lanzar_auditoria_organizacion(request, auditar_acuse, 'acuses de EDocument') @@ -419,7 +419,7 @@ def auditar_acuse_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_remesas_endpoint(request): return _lanzar_auditoria_organizacion(request, auditar_remesas, 'remesas') @@ -442,7 +442,7 @@ def auditar_remesas_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_cove_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: @@ -504,7 +504,7 @@ def auditar_cove_pedimento_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_acuse_cove_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: @@ -566,7 +566,7 @@ def auditar_acuse_cove_pedimento_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_edocument_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: @@ -628,7 +628,7 @@ def auditar_edocument_pedimento_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_acuse_pedimento_endpoint(request): pedimento_id = request.data.get('pedimento_id') if not pedimento_id: @@ -687,7 +687,7 @@ def auditar_acuse_pedimento_endpoint(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditor_procesar_pedimentos_organizacion(request): """ 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 ### @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditar_peticion_respuesta_pedimento_completo(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_pedimento_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_pedimento_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_remesa_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_remesa_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_partidas_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_partidas_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_acuse_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_acuse_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_cove_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_cove_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_acuse_cove_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_acuse_cove_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_peticion_edocument_vu(request): """ 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) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.view')]) def auditor_obtener_respuesta_edocument_vu(request): """ Backend endpoint para obtener las respuestas asociadas a una remesa. @@ -1677,7 +1677,7 @@ def auditor_obtener_respuesta_edocument_vu(request): } ) @api_view(['POST']) -@permission_classes([IsAuthenticated & (IsSuperUser | IsSameOrganizationDeveloper)]) +@permission_classes([IsAuthenticated, require_permission('auditoria.process')]) def auditar_pedimento_endpoint(request): """ Audita un pedimento específico verificando si existe su XML y extrayendo información. diff --git a/api/datastage/views.py b/api/datastage/views.py index b748a8f..00decf1 100644 --- a/api/datastage/views.py +++ b/api/datastage/views.py @@ -12,106 +12,73 @@ from rest_framework.decorators import action from rest_framework.response import Response from django.http import FileResponse, Http404 import os - from .models import DataStage from .serializer import DataStageSerializer from api.logger.mixins import LoggingMixin -from mixins.filtrado_organizacion import OrganizacionFiltradaMixin -from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser -) +from core.permissions import get_org_context, is_internal_service_request, require_permission # Create your views here. class DataStagePagination(PageNumberPagination): page_size = 20 # Valor por defecto page_size_query_param = 'page_size' max_page_size = 1000 -class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin): - - +class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet): """ ViewSet for managing DataStage instances. Provides CRUD operations for DataStage. """ - - serializer_class = DataStageSerializer - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] model = DataStage my_tags = ['DataStage'] pagination_class = DataStagePagination + def get_permissions(self): + perms = { + 'list': 'datastage.view', + 'retrieve': 'datastage.view', + 'create': 'datastage.create', + 'update': 'datastage.create', + 'partial_update': 'datastage.create', + 'destroy': 'datastage.delete', + 'procesar': 'datastage.process', + 'download_datastage': 'datastage.view', + 'task_status': 'datastage.view', + } + codename = perms.get(self.action, 'datastage.view') + return [IsAuthenticated(), require_permission(codename)()] + def get_queryset(self): - if self.request.user.is_superuser: + if is_internal_service_request(self.request): return DataStage.objects.all().order_by('-created_at') + org = get_org_context(self.request.user) + if not org: + return DataStage.objects.none() + return DataStage.objects.filter(organizacion=org).order_by('-created_at') - if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists(): - return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at') - - return self.get_queryset_filtrado_por_organizacion().order_by('-created_at') - def perform_create(self, serializer): - """ - Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado. - """ - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("Usuario no autenticado o sin organización") + org = get_org_context(self.request.user) + datastage = serializer.save(organizacion=org) + self._trigger_processing(datastage) - 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): - """ - Método helper para disparar el procesamiento. - """ from api.datastage.tasks import procesar_datastage_task - user_organizacion = getattr(self.request.user, 'organizacion', None) - user_organizacion_id = user_organizacion.id if user_organizacion else None - + org = get_org_context(self.request.user) datastage.procesado = True datastage.save() - - task = procesar_datastage_task.delay(datastage.id, user_organizacion_id) - - def perform_update(self, serializer): - """ - Override to ensure organization is set on update. - """ - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("Usuario no autenticado o sin organización") + procesar_datastage_task.delay(datastage.id, org.id if org else None) - if self.request.user.is_superuser: - # Allow superuser to update without organization + def perform_update(self, serializer): + if is_internal_service_request(self.request): serializer.save() return - - if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - serializer.save(organizacion=self.request.user.organizacion) - return - - raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage") + org = get_org_context(self.request.user) + serializer.save(organizacion=org) + + def perform_destroy(self, instance): + if instance.archivo: + storage_service.delete_file(instance.archivo) + instance.delete() @action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage') 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. """ - # ojo aqui from api.datastage.tasks import procesar_datastage_task datastage = self.get_object() - user_organizacion = getattr(self.request.user, 'organizacion', None) - user_organizacion_id = user_organizacion.id if user_organizacion else None - task = procesar_datastage_task.delay(datastage.id, user_organizacion_id) + org = get_org_context(self.request.user) + task = procesar_datastage_task.delay(datastage.id, org.id if org else None) return Response({ 'task_id': task.id, 'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.' diff --git a/api/logger/views.py b/api/logger/views.py index 2e62c9d..0079ac9 100644 --- a/api/logger/views.py +++ b/api/logger/views.py @@ -58,8 +58,7 @@ class UserActivityViewSet(viewsets.ReadOnlyModelViewSet): if not self.request.user.is_authenticated: return UserActivity.objects.none() - # Los usuarios normales solo ven su propia actividad - if self.request.user.is_staff: + if self.request.user.is_superuser: return UserActivity.objects.all() return UserActivity.objects.filter(user=self.request.user) diff --git a/api/notificaciones/signals/notificaciones.py b/api/notificaciones/signals/notificaciones.py index 506f58c..6af914e 100644 --- a/api/notificaciones/signals/notificaciones.py +++ b/api/notificaciones/signals/notificaciones.py @@ -4,31 +4,43 @@ from django.dispatch import receiver from api.notificaciones.models import Notificacion from api.record.models import Document + @receiver(post_save, sender=Document) def trigger_notificacion(sender, instance, created, **kwargs): - if created: - from api.cuser.models import CustomUser - from api.customs.models import Pedimento - from api.notificaciones.models import TipoNotificacion + if not created: + return - # Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos) - tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"}) + from api.cuser.models import CustomUser + from api.notificaciones.models import TipoNotificacion + from core.permissions import user_has_permission - # Notificar a todos los usuarios de la organización - usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion) - for usuario in usuarios_org: - # Notificar solo a importadores cuyo RFC coincide - if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()): - if instance.pedimento.contribuyente in usuario.rfc.all(): - Notificacion.objects.create( - tipo=tipo_info, - dirigido=usuario, - mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}", - ) - # Notificar a otros roles (no importadores) - elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()): - Notificacion.objects.create( - tipo=tipo_info, - dirigido=usuario, - mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}", - ) \ No newline at end of file + tipo_info, _ = TipoNotificacion.objects.get_or_create( + tipo='info', + defaults={'descripcion': 'Notificación informativa'}, + ) + + mensaje = ( + f"Se agregó el documento {instance.archivo} " + f"al pedimento {instance.pedimento.pedimento}\n" + f"{instance.document_type.nombre}" + ) + + usuarios_org = CustomUser.objects.filter( + organizacion=instance.organizacion, + is_active=True, + ).prefetch_related('rfc') + + for usuario in usuarios_org: + if not user_has_permission(usuario, 'notificaciones.receive'): + continue + + # Importadores: solo si el pedimento corresponde a uno de sus RFC + if usuario.is_importador: + if instance.pedimento.contribuyente not in usuario.rfc.all(): + continue + + Notificacion.objects.create( + tipo=tipo_info, + dirigido=usuario, + mensaje=mensaje, + ) diff --git a/api/notificaciones/views.py b/api/notificaciones/views.py index 92f86da..0a0c11c 100644 --- a/api/notificaciones/views.py +++ b/api/notificaciones/views.py @@ -1,39 +1,36 @@ -from django.shortcuts import render from rest_framework import viewsets from rest_framework.permissions import IsAuthenticated +from rest_framework.exceptions import PermissionDenied + from .models import Notificacion, TipoNotificacion from .serializers import NotificacionSerializer, TipoNotificacionSerializer -from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser -) -# Create your views here. +from core.permissions import require_permission + class TipoNotificacionViewSet(viewsets.ModelViewSet): queryset = TipoNotificacion.objects.all() serializer_class = TipoNotificacionSerializer http_method_names = ['get'] - - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] - + permission_classes = [IsAuthenticated] my_tags = ['Notificaciones'] - + def get_queryset(self): return self.queryset.order_by('tipo') + class NotificacionViewSet(viewsets.ModelViewSet): queryset = Notificacion.objects.all() serializer_class = NotificacionSerializer http_method_names = ['get', 'post', 'put', 'patch', 'delete'] filterset_fields = ['visto'] - - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] my_tags = ['Notificaciones'] + def get_permissions(self): + if self.action in ('list', 'retrieve'): + return [IsAuthenticated(), require_permission('notificaciones.view')()] + return [IsAuthenticated()] + def get_queryset(self): - # Evita error en generación de esquema Swagger if getattr(self, 'swagger_fake_view', False): return Notificacion.objects.none() user = self.request.user @@ -45,6 +42,6 @@ class NotificacionViewSet(viewsets.ModelViewSet): if not self.request.user.is_authenticated: raise PermissionDenied("Usuario no autenticado") if self.request.user.is_superuser: - # Allow superusers and admins to create notifications for any user serializer.save() - raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios") \ No newline at end of file + return + raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios") diff --git a/api/organization/admin.py b/api/organization/admin.py index 0be0310..b29af1a 100644 --- a/api/organization/admin.py +++ b/api/organization/admin.py @@ -1,18 +1,22 @@ from django.contrib import admin + from .models import Organizacion -# Register your models here. + +@admin.register(Organizacion) class OrganizacionAdmin(admin.ModelAdmin): - list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento') + list_display = ('nombre', 'rfc', 'email', 'telefono', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento') search_fields = ('nombre', 'rfc', 'email') - list_filter = ('is_active', 'is_verified') + list_filter = ('is_active', 'is_verified', 'is_agente_aduanal') ordering = ('nombre',) - -# class UsuarioOrganizacionAdmin(admin.ModelAdmin): -# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified') -# search_fields = ('email', 'telefono', 'puesto') -# list_filter = ('is_active', 'is_verified') -# ordering = ('email',) - -admin.site.register(Organizacion) -# admin.site.register(UsuarioOrganizacion) \ No newline at end of file + autocomplete_fields = ('owner',) + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + (None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}), + ('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}), + ('Administrador maestro', {'fields': ('owner',)}), + ('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}), + ('Vigencia', {'fields': ('inicio', 'vencimiento')}), + ('Observaciones', {'fields': ('observaciones',)}), + ('Auditoría', {'fields': ('created_at', 'updated_at')}), + ) diff --git a/api/organization/models.py b/api/organization/models.py index 9f9b099..0785f59 100644 --- a/api/organization/models.py +++ b/api/organization/models.py @@ -40,6 +40,16 @@ class Organizacion(models.Model): estado = models.CharField(max_length=50) ciudad = models.CharField(max_length=50) + # Administrador maestro: acceso total a su org, no puede ser removido de su rol por otros admins. + # on_delete=PROTECT: no se puede eliminar el usuario sin reasignar el ownership primero. + owner = models.ForeignKey( + 'cuser.CustomUser', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='organizaciones_que_administra', + ) + is_active = models.BooleanField(default=True) is_verified = models.BooleanField(default=False) apply_auto_download = models.BooleanField(default=False) diff --git a/api/organization/signals.py b/api/organization/signals.py index d226496..55071db 100644 --- a/api/organization/signals.py +++ b/api/organization/signals.py @@ -1,8 +1,28 @@ from django.db.models.signals import post_save from django.dispatch import receiver + from .models import Organizacion, UsoAlmacenamiento + @receiver(post_save, sender=Organizacion) def crear_uso_almacenamiento(sender, instance, created, **kwargs): if created: - UsoAlmacenamiento.objects.create(organizacion=instance, espacio_utilizado=0) \ No newline at end of file + 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, + ) diff --git a/api/organization/views.py b/api/organization/views.py index cde5d84..fff2db0 100644 --- a/api/organization/views.py +++ b/api/organization/views.py @@ -6,10 +6,13 @@ from rest_framework.response import Response from api.record.models import Document from core.permissions import ( - IsSameOrganization, + IsSameOrganization, IsSameOrganizationDeveloper, IsSameOrganizationAndAdmin, - IsSuperUser + IsSuperUser, + get_org_context, + is_internal_service_request, + user_has_permission, ) from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer from .models import Organizacion, UsoAlmacenamiento @@ -32,21 +35,19 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr my_tags = ['Organizaciones'] def get_queryset(self): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + user = self.request.user + if not user.is_authenticated: return Organizacion.objects.none() - - if self.request.user.is_superuser: - # Superuser can see all organizations + + if is_internal_service_request(self.request): return Organizacion.objects.all() - - if (self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter('developer').exists() or self.request.user.groups.filter('user')) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Importers can only see their own organization - return Organizacion.objects.filter(users=self.request.user) - - if self.request.user.groups.filter(name='importador').exists(): - return Organizacion.objects.filter(users=self.request.user) - - return Organizacion.objects.none() + + org = get_org_context(user) + if not org: + return Organizacion.objects.none() + + # Superuser ve solo su org activa, no todas + return Organizacion.objects.filter(id=org.id) class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet): """ @@ -60,31 +61,26 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet): my_tags = ['Uso de Almacenamiento'] def get_queryset(self): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + if not self.request.user.is_authenticated: return UsoAlmacenamiento.objects.none() - - if self.request.user.is_superuser: - # Superuser can see all storage usage + if is_internal_service_request(self.request): return UsoAlmacenamiento.objects.all() - - if (self.request.user.groups.filter(name='developer').exists() or - self.request.user.groups.filter(name='admin').exists() or - self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists(): - # Developers, Admins, and Users can see their organization's storage usage - return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion) - - if self.request.user.groups.filter(name='importador').exists(): - # Importers can only see their own organization's storage usage + + org = get_org_context(self.request.user) + if not org: + return UsoAlmacenamiento.objects.none() + + if self.request.user.is_importador: raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.") - return UsoAlmacenamiento.objects.none() + return UsoAlmacenamiento.objects.filter(organizacion=org) @action(detail=False, methods=['get']) def mi_organizacion(self, request): """Obtiene el uso de almacenamiento de la organización del usuario actual""" - organizacion = request.user.organizacion + organizacion = get_org_context(request.user) # Obtener o crear el registro de uso uso, created = UsoAlmacenamiento.objects.get_or_create( diff --git a/api/rbac/__init__.py b/api/rbac/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rbac/admin.py b/api/rbac/admin.py new file mode 100644 index 0000000..0da1220 --- /dev/null +++ b/api/rbac/admin.py @@ -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' diff --git a/api/rbac/apps.py b/api/rbac/apps.py new file mode 100644 index 0000000..842e5c7 --- /dev/null +++ b/api/rbac/apps.py @@ -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' diff --git a/api/rbac/management/__init__.py b/api/rbac/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rbac/management/commands/__init__.py b/api/rbac/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rbac/management/commands/sync_rbac.py b/api/rbac/management/commands/sync_rbac.py new file mode 100644 index 0000000..35e5831 --- /dev/null +++ b/api/rbac/management/commands/sync_rbac.py @@ -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}') diff --git a/api/rbac/migrations/0001_initial.py b/api/rbac/migrations/0001_initial.py new file mode 100644 index 0000000..a764c92 --- /dev/null +++ b/api/rbac/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/api/rbac/migrations/0002_data_permissions.py b/api/rbac/migrations/0002_data_permissions.py new file mode 100644 index 0000000..00c4dd6 --- /dev/null +++ b/api/rbac/migrations/0002_data_permissions.py @@ -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), + ] diff --git a/api/rbac/migrations/0003_notificaciones_receive.py b/api/rbac/migrations/0003_notificaciones_receive.py new file mode 100644 index 0000000..8ad136e --- /dev/null +++ b/api/rbac/migrations/0003_notificaciones_receive.py @@ -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), + ] diff --git a/api/rbac/migrations/0004_auditoria_permissions.py b/api/rbac/migrations/0004_auditoria_permissions.py new file mode 100644 index 0000000..d4db294 --- /dev/null +++ b/api/rbac/migrations/0004_auditoria_permissions.py @@ -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), + ] diff --git a/api/rbac/migrations/__init__.py b/api/rbac/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/rbac/models.py b/api/rbac/models.py new file mode 100644 index 0000000..9b03f54 --- /dev/null +++ b/api/rbac/models.py @@ -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'), + ] diff --git a/api/rbac/roles.py b/api/rbac/roles.py new file mode 100644 index 0000000..f4544be --- /dev/null +++ b/api/rbac/roles.py @@ -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) diff --git a/api/rbac/serializers.py b/api/rbac/serializers.py new file mode 100644 index 0000000..e267965 --- /dev/null +++ b/api/rbac/serializers.py @@ -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()) diff --git a/api/rbac/urls.py b/api/rbac/urls.py new file mode 100644 index 0000000..e18d2d5 --- /dev/null +++ b/api/rbac/urls.py @@ -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'), +] diff --git a/api/rbac/views.py b/api/rbac/views.py new file mode 100644 index 0000000..be8c720 --- /dev/null +++ b/api/rbac/views.py @@ -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": "" } → 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.'}) diff --git a/api/record/views.py b/api/record/views.py index 02bfedb..9e8a0cc 100644 --- a/api/record/views.py +++ b/api/record/views.py @@ -26,11 +26,13 @@ from django.utils import timezone from django.db.models import Q from api.utils.storage_service import storage_service +from rest_framework.authentication import TokenAuthentication + from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser + get_org_context, + require_permission, + user_has_permission, + IsInternalService, ) import logging @@ -142,21 +144,47 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): """ ViewSet for Document model. """ - permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] model = Document - + pagination_class = CustomPagination serializer_class = DocumentSerializer - # Habilitar filtro por pedimento (UUID) y pedimento_numero (campo pedimento del modelo relacionado) filterset_fields = ['extension', 'size', 'document_type', 'pedimento', 'pedimento__pedimento', 'created_at'] - # filterset_fields = ['extension', 'size', 'pedimento', 'pedimento__pedimento'] - - # Puedes filtrar por pedimento usando: /api/record/documents/?pedimento= o /api/record/documents/?pedimento__pedimento= - # Ejemplo: /api/record/documents/?pedimento_numero=12345678 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): - 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') if modulo_efc: if modulo_efc == 'expedientes-detalle-pedimentos': @@ -2017,7 +2045,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('documentos.download')] serializer_class = DocumentSerializer model = Document my_tags = ['Documents'] @@ -2030,17 +2058,14 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): import os 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: doc = Document.objects.get(pk=pk) except Document.DoesNotExist: raise Http404("Documento no encontrado") - if not request.user.is_superuser: - if doc.organizacion != request.user.organizacion: - raise Http404("No autorizado") + org = get_org_context(request.user) + if doc.organizacion != org: + raise Http404("No autorizado") if not doc.archivo: raise Http404("Documento sin archivo asociado") @@ -2064,7 +2089,7 @@ class ProtectedDocumentDownloadView(APIView, DocumentosFiltradosMixin): return response class BulkDownloadZipView(APIView): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('documentos.download')] my_tags = ['Documents'] def post(self, request): @@ -2172,7 +2197,7 @@ class BulkDownloadZipView(APIView): logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}") class GetFuenteView(APIView): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('documentos.view')] serializer_class = FuenteSerializer my_tags = ['Fuente Documentos'] @@ -2187,7 +2212,7 @@ class GetFuenteView(APIView): return Response(serializer.data, status=200) class DocumentTypeView(APIView): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('documentos.view')] serializer_class = DocumentTypeSerializer my_tags = ['Tipo de Documentos'] @@ -2204,7 +2229,7 @@ class DocumentTypeView(APIView): return Response(serializer.data, status=200) class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + permission_classes = [IsAuthenticated, require_permission('documentos.download')] my_tags = ['Documents'] def post(self, request): @@ -2306,7 +2331,7 @@ class ExpedienteZipDownloadView(APIView, DocumentosFiltradosMixin): logger.warning(f"No se pudo eliminar archivo temporal {tmp_path}: {e}") class MultiPedimentoZipDownloadView(APIView): - permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper)] + permission_classes = [IsAuthenticated, require_permission('documentos.download')] my_tags = ['Documents'] def post(self, request): @@ -2375,7 +2400,7 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): """ ViewSet for Document model. """ - permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] + permission_classes = [IsAuthenticated, require_permission('documentos.view')] model = Document pagination_class = CustomPagination @@ -2389,6 +2414,8 @@ class PedimentoDocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin): my_tags = ['Documents'] def get_queryset(self): + if not user_has_permission(self.request.user, 'documentos.view'): + return Document.objects.none() queryset = self.get_queryset_filtrado_por_organizacion() pedimento_id = self.request.query_params.get('pedimento') @@ -2435,8 +2462,7 @@ class TriggerPedimentoCompletoView(APIView): en el microservicio FastAPI. Reenvía el payload tal cual y devuelve la respuesta del microservicio (normalmente un `task_id`). """ - # permission_classes = [IsAuthenticated] - permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] + permission_classes = [IsAuthenticated, require_permission('pedimentos.process')] my_tags = ['Microservice - Pedimento Completo'] diff --git a/api/reports/views.py b/api/reports/views.py index 4e5787f..0d6e009 100644 --- a/api/reports/views.py +++ b/api/reports/views.py @@ -1,57 +1,31 @@ -from warnings import filters -from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated -from api.customs.models import Pedimento, Cove, EDocument, Partida -from api.record.models import Document -from api.organization.models import Organizacion -from django.db.models import Count, Q - -# Registrar endpoint en urls.py: -# path('dashboard/summary/', dashboard_summary) import csv import io -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from .serializers import ExportModelSerializer -from rest_framework.response import Response -from django.http import HttpResponse -import openpyxl -from django.apps import apps -from rest_framework import status -from django.shortcuts import render -from rest_framework import viewsets - -from .serializers import ExportModelSerializer -from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser -) -from rest_framework.permissions import IsAuthenticated - -import csv -import io -import openpyxl -from django.http import HttpResponse -from django.apps import apps -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from drf_yasg.utils import swagger_auto_schema -from drf_yasg import openapi -from rest_framework.permissions import IsAuthenticated -from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser -) -from .serializers import ExportModelSerializer import uuid import datetime import zipfile + +import openpyxl +from django.apps import apps from django.db import models +from django.db.models import Count, Q +from django.http import HttpResponse +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import status, viewsets +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from api.customs.models import Cove, EDocument, Partida, Pedimento +from api.organization.models import Organizacion +from api.record.models import Document +from core.permissions import ( + get_org_context, + require_permission, + user_has_permission, +) +from .serializers import ExportModelSerializer def export_model_to_csv(request, model_name, fields, module='datastage', filters=None): model = apps.get_model(module, model_name) @@ -110,7 +84,11 @@ def export_model_to_excel(request, model_name, fields, module='datastage', filte class ExportDataStageView(APIView): my_tags = ['Reportes-DataStage'] - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + def get_permissions(self): + if self.request.method == 'GET': + return [IsAuthenticated(), require_permission('reportes.view')()] + return [IsAuthenticated(), require_permission('reportes.export')()] # Constantes para partición # MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo @@ -136,20 +114,14 @@ class ExportDataStageView(APIView): return str(value) 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: Registro501 = apps.get_model('datastage', 'Registro501') - if not request.user.is_superuser: - qs = Registro501.objects.filter(organizacion=request.user.organizacion) - else: - org_id = request.query_params.get('organizacion') - 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) + org = get_org_context(request.user) + if not org: + return Response({'error': 'Sin organización activa'}, status=status.HTTP_403_FORBIDDEN) + qs = Registro501.objects.filter(organizacion=org) rfcs = ( qs.exclude(rfc__isnull=True) @@ -178,23 +150,19 @@ class ExportDataStageView(APIView): def _resolve_org_filter(self, global_filters, user): """ Devuelve los global_filters asegurando que siempre haya una organización. - - Superuser sin org → error (no mezclar tenants). - - No-superuser sin org → se inyecta la org del usuario. + La org se obtiene de active_organization (superuser) o del campo organizacion (usuario normal). Retorna (filters_dict, error_response_or_None). """ - org_value = (global_filters or {}).get('organizacion', '') - if not org_value: - if user.is_superuser: + filters = dict(global_filters or {}) + if not filters.get('organizacion'): + org = get_org_context(user) + if not org: return None, Response( - {'error': 'El parámetro organizacion es obligatorio'}, - status=status.HTTP_400_BAD_REQUEST + {'error': 'Sin organización activa'}, + status=status.HTTP_403_FORBIDDEN, ) - # No-superuser: inyectar su propia org - if hasattr(user, 'organizacion') and user.organizacion: - filters = dict(global_filters or {}) - filters['organizacion'] = str(user.organizacion.id) - return filters, None - return dict(global_filters or {}), None + filters['organizacion'] = str(org.id) + return filters, None def handle_simple_export(self, request): """Maneja exportación simple de DataStage (un solo modelo)""" @@ -1868,7 +1836,11 @@ class ExportDataStageView(APIView): class ExportModelView(APIView): my_tags = ['Reportes'] - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + + def get_permissions(self): + if self.request.method == 'GET': + return [IsAuthenticated(), require_permission('reportes.view')()] + return [IsAuthenticated(), require_permission('reportes.export')()] @swagger_auto_schema( manual_parameters=[ @@ -1906,6 +1878,8 @@ class ExportModelView(APIView): model_name = request.data.get('model') fields = request.data.get('fields') filters = request.data.get('filters', {}) + org = get_org_context(request.user) + filters['organizacion__id'] = org.id if org else None export_type = request.data.get('type', 'csv') module = request.data.get('module', 'datastage') @@ -1917,40 +1891,12 @@ class ExportModelView(APIView): else: return export_model_to_csv(request, model_name, fields, module, filters) - -# Create your views here. - - -class ExportModelView(APIView): - my_tags = ['Reportes'] - permission_classes = [IsAuthenticated & ( - IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] - - @swagger_auto_schema(request_body=ExportModelSerializer, esponses={200: 'Archivo generado (Excel o CSV)'}) - def post(self, request, *args, **kwargs): - model_name = request.data.get('model') - fields = request.data.get('fields') - filters = request.data.get('filters', {}) - filters['organizacion__id'] = self.request.user.organizacion.id if hasattr(request.user, 'organizacion') and request.user.organizacion else None - export_type = request.data.get('type', 'csv') - if not model_name or not fields: - return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST) - - module = request.data.get('module', 'datastage') - if export_type == 'excel': - return export_model_to_excel(request, model_name, fields, module, filters) - else: - return export_model_to_csv(request, model_name, fields, module, filters) - # Resumen general para dashboard @api_view(['GET']) -@permission_classes([ - IsAuthenticated -]) +@permission_classes([IsAuthenticated, require_permission('reportes.view')]) def dashboard_summary(request): - org_id = request.query_params.get('organizacion_id') filters = {} user = request.user @@ -1964,18 +1910,16 @@ def dashboard_summary(request): fecha_pago_lte = request.query_params.get('fecha_pago__lte') contribuyente__rfc = request.query_params.get('contribuyente__rfc') - # Si no se especifica organización y el usuario tiene organización, usarla - if not org_id and hasattr(user, 'organizacion') and user.organizacion: - org_id = user.organizacion.id - # Si no es superusuario, filtrar por organización - if org_id and not getattr(user, 'is_superuser', False): - filters['organizacion_id'] = org_id + org = get_org_context(user) + if not org: + return Response({'error': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN) + filters['organizacion_id'] = org.id - # Si el usuario pertenece al grupo Importador, filtrar por RFC - if user.groups.filter(name='Importador').exists(): - rfc = getattr(user, 'rfc', None) - if rfc: - filters['contribuyente__rfc'] = rfc + # Importador: filtrar solo por sus RFC asignados + if user.is_importador: + rfcs = list(user.rfc.values_list('rfc', flat=True)) + if rfcs: + filters['contribuyente__rfc__in'] = rfcs if pedimento_app: filters['pedimento_app'] = pedimento_app diff --git a/api/tasks/views.py b/api/tasks/views.py index fefb1d1..e5f8228 100644 --- a/api/tasks/views.py +++ b/api/tasks/views.py @@ -1,53 +1,54 @@ -from django.shortcuts import render from rest_framework import viewsets, filters +from rest_framework.authentication import TokenAuthentication from django_filters.rest_framework import DjangoFilterBackend from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import IsAuthenticated from api.logger.mixins import LoggingMixin -from mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin +from core.permissions import require_permission, user_has_permission, IsInternalService +from mixins.filtrado_organizacion import OrganizacionFiltradaMixin from .models import Task from .serializers import TaskSerializer from .filters import TaskFilter -from rest_framework.permissions import IsAuthenticated -# Create your views here. -from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser -) class TaskPagination(PageNumberPagination): page_size = 10 page_size_query_param = 'page_size' max_page_size = 100 -class TaskViewSet(LoggingMixin,viewsets.ModelViewSet,OrganizacionFiltradaMixin): - permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + +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() serializer_class = TaskSerializer filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filterset_class = TaskFilter pagination_class = TaskPagination ordering_fields = ['timestamp'] - ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero - + ordering = ['-timestamp'] + my_tags = ['tasks'] - def get_queryset(self): + def get_permissions(self): + # Escritura: exclusivo para microservicio interno (Token + superuser) + # Lectura: usuarios con pedimentos.view via JWT + if self.action in ('create', 'update', 'partial_update', 'destroy'): + return [IsAuthenticated(), IsInternalService()] + return [IsAuthenticated(), require_permission('pedimentos.view')()] - """ - Filtra las tareas según la organización del usuario. - Superusuarios pueden ver todas las tareas. - """ - queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador - # if user.is_superuser: - # return self.queryset - # # return self.queryset.filter(organizacion_id=user.organizacion.id) - # else: - # return self.queryset.filter(organizacion_id=user.organizacion.id) - return queryset + def get_queryset(self): + user = self.request.user + # Service account (Token + superuser): sin filtro de org, accede a todas las tasks + if user.is_superuser and isinstance( + getattr(self.request, 'successful_authenticator', None), TokenAuthentication + ): + return Task.objects.select_related('pedimento', 'servicio').all() + if not user_has_permission(user, 'pedimentos.view'): + return Task.objects.none() + return self.get_queryset_filtrado_por_organizacion() from rest_framework.views import APIView @@ -57,20 +58,82 @@ from celery.result import AsyncResult 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): """ - 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: - PENDING — en cola, aún no inició - STARTED — worker la tomó y está ejecutando - SUCCESS — terminó correctamente, `result` contiene el resumen - FAILURE — lanzó una excepción no capturada, `error` describe el problema + PENDING — en cola o aún no registrada + STARTED — worker ejecutando + SUCCESS — completada sin errores + FAILURE — terminó con error RETRY — el worker la está reintentando """ try: + # 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) state = task_result.state @@ -84,25 +147,20 @@ class TaskStatusView(APIView): if state == 'SUCCESS': result = task_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: 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: - mensaje = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes' + 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') - mensaje = f'{completados}/{total} pedimentos completos — {", ".join(partes)}' - - response_data['mensaje'] = mensaje + response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}' elif state == 'FAILURE': response_data['error'] = str(task_result.info) diff --git a/api/vucem/views.py b/api/vucem/views.py index e37a1e7..7a345a9 100644 --- a/api/vucem/views.py +++ b/api/vucem/views.py @@ -25,15 +25,14 @@ class VucemUpdateSerializer(VucemSerializer): class Meta(VucemSerializer.Meta): fields = VucemSerializer.Meta.fields from .models import Vucem, CredencialesImportador -from core.permissions import IsSameOrganizationDeveloper from rest_framework import mixins from core.permissions import ( - IsSameOrganization, - IsSameOrganizationDeveloper, - IsSameOrganizationAndAdmin, - IsSuperUser, - IsSameOrganizationAndInAllowedGroups + IsSameOrganizationAndInAllowedGroups, + get_org_context, + is_internal_service_request, + require_permission, + user_has_permission, ) class CustomVucemPagination(PageNumberPagination): @@ -53,8 +52,6 @@ class CustomVucemPagination(PageNumberPagination): # Create your views here. class VucemView(viewsets.ModelViewSet): - permission_classes = [IsAuthenticated , (IsSuperUser | IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper )] - queryset = Vucem.objects.all() pagination_class = CustomVucemPagination filterset_fields = ['organizacion', 'patente', 'usuario', 'is_importador', 'acusecove', 'acuseedocument', 'is_active'] @@ -68,27 +65,45 @@ class VucemView(viewsets.ModelViewSet): return VucemSerializer def get_permissions(self): - if self.action in ['create', 'update', 'partial_update', 'destroy']: - return [IsAuthenticated(), IsSameOrganizationAndInAllowedGroups()] - return super().get_permissions() + perms = { + 'list': 'vucem.view', + 'retrieve': 'vucem.view', + 'create': 'vucem.manage', + 'update': 'vucem.manage', + 'partial_update': 'vucem.manage', + 'destroy': 'vucem.manage', + 'download_cer': 'vucem.view', + 'download_key': 'vucem.view', + } + codename = perms.get(self.action, 'vucem.view') + return [IsAuthenticated(), require_permission(codename)()] def get_queryset(self): - # Verificar que el usuario esté autenticado y tenga organización if not self.request.user.is_authenticated: return self.queryset.none() - queryset = self.queryset + if is_internal_service_request(self.request): + queryset = self.queryset.all() + importador_rfc = self.request.query_params.get('importador') + if importador_rfc: + queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct() + return queryset - if self.request.user.is_superuser: - queryset = queryset.all() - elif not hasattr(self.request.user, 'organizacion') or not self.request.user.organizacion: - return queryset.none() - elif self.request.user.groups.filter(name='Importador').exists(): - queryset = queryset.filter(organizacion=self.request.user.organizacion, usuario__in=self.request.user.rfc.all()) + if not user_has_permission(self.request.user, 'vucem.view'): + return self.queryset.none() + + org = get_org_context(self.request.user) + if not org: + return self.queryset.none() + + if self.request.user.is_importador: + queryset = self.queryset.filter( + organizacion=org, + usuario__in=self.request.user.rfc.all(), + ) else: - queryset = queryset.filter(organizacion=self.request.user.organizacion) + queryset = self.queryset.filter(organizacion=org) - # Filtro por importador (RFC) importador_rfc = self.request.query_params.get('importador') if importador_rfc: queryset = queryset.filter(usuarios_importadores__rfc__rfc=importador_rfc).distinct() @@ -96,54 +111,37 @@ class VucemView(viewsets.ModelViewSet): return queryset def perform_create(self, serializer): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") - if self.request.user.is_superuser: - organizacion_id = self.request.data.get('organizacion_id') - - if not organizacion_id: - raise ValueError("Los superusuarios deben especificar una organización") - - try: - # Importa el modelo Organizacion - # from ..organization.models import Organizacion - organizacion = Organizacion.objects.get(id=organizacion_id) - except Organizacion.DoesNotExist: - raise ValueError({"organizacion": "Organización no encontrada"}) - - serializer.save( - organizacion=organizacion, - created_by=self.request.user, - updated_by=self.request.user - ) - return - else: - serializer.save( - organizacion=self.request.user.organizacion, - created_by=self.request.user, - updated_by=self.request.user - ) - return + if is_internal_service_request(self.request): + serializer.save(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 crear credenciales VUCEM.") + serializer.save( + organizacion=org, + created_by=self.request.user, + updated_by=self.request.user, + ) def perform_update(self, serializer): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") + if is_internal_service_request(self.request): + instance = self.get_object() + 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() - if self.request.user.is_superuser: - serializer.save( - created_by=instance.created_by, - 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 + serializer.save( + organizacion=org, + created_by=instance.created_by, + updated_by=self.request.user, + ) - @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + @action(detail=True, methods=["get"]) def download_cer(self, request, pk=None): vucem = self.get_object() if not vucem.cer: @@ -164,7 +162,7 @@ class VucemView(viewsets.ModelViewSet): return response - @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + @action(detail=True, methods=["get"]) def download_key(self, request, pk=None): vucem = self.get_object() if not vucem.key: @@ -194,7 +192,6 @@ class VucemView(viewsets.ModelViewSet): class CredencialesImportadorViewSet(viewsets.ModelViewSet): - permission_classes = [IsAuthenticated] queryset = CredencialesImportador.objects.all() serializer_class = CredencialesImportadorSimpleSerializer filterset_fields = ['organizacion', 'vucem', 'rfc'] @@ -205,27 +202,34 @@ class CredencialesImportadorViewSet(viewsets.ModelViewSet): my_tags = ['Credenciales por Importador'] def get_permissions(self): - if self.action in ['create', 'update', 'partial_update', 'destroy']: - return [IsAuthenticated()] - return super().get_permissions() + perms = { + 'list': 'vucem.view', + 'retrieve': 'vucem.view', + 'create': 'vucem.manage', + 'update': 'vucem.manage', + 'partial_update': 'vucem.manage', + 'destroy': 'vucem.manage', + } + codename = perms.get(self.action, 'vucem.view') + return [IsAuthenticated(), require_permission(codename)()] def get_queryset(self): - - if self.request.user.is_superuser: - # Si es superusuario, devolver todos los registros - return self.queryset.all() - - # Verificar que el usuario esté autenticado y tenga organización - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + if not self.request.user.is_authenticated: return self.queryset.none() - - queryset = self.queryset.filter(organizacion=self.request.user.organizacion) - - - return queryset + if is_internal_service_request(self.request): + return self.queryset.all() + if not user_has_permission(self.request.user, 'vucem.view'): + return self.queryset.none() + org = get_org_context(self.request.user) + if not org: + return self.queryset.none() + return self.queryset.filter(organizacion=org) def perform_create(self, serializer): - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): - raise ValueError("El usuario debe estar autenticado y tener una organización asignada.") - serializer.save(organizacion=self.request.user.organizacion) - return \ No newline at end of file + if is_internal_service_request(self.request): + serializer.save() + return + org = get_org_context(self.request.user) + if not org: + raise ValueError("El usuario debe tener una organización activa.") + serializer.save(organizacion=org) \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 16296b0..2f21fd3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -97,7 +97,8 @@ OWN_APPS = [ 'api.record', 'api.organization', 'api.licence', - 'api.cuser', + 'api.cuser', + 'api.rbac', 'api.datastage', 'api.vucem', 'api.logger', diff --git a/config/urls.py b/config/urls.py index 0b3dd16..352bc89 100644 --- a/config/urls.py +++ b/config/urls.py @@ -51,6 +51,7 @@ urlpatterns = [ path('api/v1/cards/', include('api.cards.urls')), # Cards app path('api/v1/reports/', include('api.reports.urls')), # Reports app path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app + path('api/v1/rbac/', include('api.rbac.urls')), # RBAC app ] # En producción, los archivos media son servidos por Nginx if settings.DEBUG: diff --git a/core/permissions.py b/core/permissions.py index 9d4e798..92acb0c 100644 --- a/core/permissions.py +++ b/core/permissions.py @@ -1,100 +1,244 @@ -# permissions.py from rest_framework import permissions -from api.cuser.models import CustomUser +from rest_framework.exceptions import PermissionDenied +from rest_framework.authentication import TokenAuthentication -class IsSameOrganization(permissions.BasePermission): - """ - Permiso personalizado que solo permite acceder a usuarios de la misma organización - o a administradores/staff. - """ - def has_permission(self, request, view): - # Permite listar/crear solo si el usuario está autenticado - return request.user.is_authenticated - def has_object_permission(self, request, view, obj): - # Permite operaciones sobre un objeto específico solo si: - # - El objeto pertenece a la misma organización (acceso por usuario relacionado) - return (getattr(obj, 'dirigido', None) and obj.dirigido.organizacion == request.user.organizacion) - -class IsSameOrganizationAndAdmin(permissions.BasePermission): - """ - Permiso personalizado que solo permite acceder a usuarios de la misma organización - o a administradores/staff. - """ - def has_permission(self, request, view): - # Permite listar/crear solo si el usuario está autenticado - return request.user.is_authenticated +# --------------------------------------------------------------------------- +# Helpers centrales — toda la lógica de RBAC pasa por aquí +# --------------------------------------------------------------------------- - def has_object_permission(self, request, view, obj): - # Permite operaciones solo si el usuario es admin, Agente Aduanal o user y la organización coincide - allowed_groups = ['admin', 'Agente Aduanal', 'user'] - user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() - if not user_in_group: - return False - if hasattr(obj, 'organizacion'): - return obj.organizacion == request.user.organizacion +def is_internal_service_request(request): + """True si la petición proviene de un service account (Token auth + superuser). + Misma lógica que IsInternalService, útil en get_queryset() y perform_* methods.""" + user = getattr(request, 'user', None) + if not user or not user.is_superuser: return False - -class IsSameOrganizationDeveloper(permissions.BasePermission): - """ - Permiso personalizado que solo permite acceder a usuarios de la misma organización - o a administradores/staff. - """ - def has_permission(self, request, view): - # Permite listar/crear solo si el usuario está autenticado - return request.user.is_authenticated + return isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication) - def has_object_permission(self, request, view, obj): - # Permite operaciones solo si el usuario es developer, Agente Aduanal o user y la organización coincide - allowed_groups = ['developer', 'Agente Aduanal', 'user'] - user_in_group = request.user.groups.filter(name__in=allowed_groups).exists() - if not user_in_group: - return False - if hasattr(obj, 'organizacion'): - return obj.organizacion == request.user.organizacion + +def get_org_context(user): + """Retorna la organización activa para filtrado de datos. + Superusuarios usan active_organization; usuarios normales usan organizacion.""" + if user.is_superuser: + return getattr(user, 'active_organization', None) + return getattr(user, 'organizacion', None) + + +def user_has_permission(user, codename): + """Verifica si un usuario tiene un permiso RBAC por su codename. + + Orden de evaluación: + 1. is_superuser → True siempre + 2. UserPermission deny explícito → False + 3. UserPermission grant explícito → True + 4. Algún UserRole en su org tiene el permiso → True + 5. Denegar + """ + if user.is_superuser: + return True + + org = getattr(user, 'organizacion', None) + if not org: return False - -class IsOwnerOrOrgAdmin(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return ( - obj == request.user or - request.user.is_staff or - request.user.groups.filter(name='admin').exists() - ) - -class IsSuperUser(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return request.user.is_superuser - -class HasStoragePermission(permissions.BasePermission): - """ - Permiso personalizado que permite el acceso a los usuarios que tienen permisos de almacenamiento. - """ + + from api.rbac.models import UserPermission, UserRole + + try: + override = UserPermission.objects.get(user=user, permission__codename=codename) + return override.granted + except UserPermission.DoesNotExist: + pass + + return UserRole.objects.filter( + user=user, + role__organizacion=org, + role__permissions__codename=codename, + ).exists() + + +def user_has_role(user, role_name): + """Verifica si un usuario tiene un rol por nombre dentro de su organización. + Función puente durante la transición — lee desde UserRole en lugar de auth.Group.""" + from api.rbac.models import UserRole + + org = getattr(user, 'organizacion', None) + if not org: + return False + return UserRole.objects.filter( + user=user, + role__nombre=role_name, + role__organizacion=org, + ).exists() + + +# --------------------------------------------------------------------------- +# Base compartida — aplica el requisito de org activa a superusuarios +# --------------------------------------------------------------------------- + +class OrgScopedPermission(permissions.BasePermission): + """Base para todas las clases de permiso con scope de organización. + Superusuario sin active_organization recibe 403, EXCEPTO service accounts + (Token auth + superuser) que pasan sin restricción de org.""" + + message = 'No tienes permiso para realizar esta acción.' + def has_permission(self, request, view): - # Permite el acceso si el usuario tiene el permiso 'can_access_storage' - return request.user.has_perm('api.cuser.can_access_storage') + if not request.user.is_authenticated: + return False + if request.user.is_superuser: + from rest_framework.authentication import TokenAuthentication + # Service account interno: Token auth + superuser → siempre permitido + if isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication): + return True + # Superuser JWT: requiere active_organization + if not getattr(request.user, 'active_organization', None): + return False + return True + + +# --------------------------------------------------------------------------- +# Clases de permiso +# --------------------------------------------------------------------------- + +class IsSameOrganization(OrgScopedPermission): + """Usuario autenticado con org activa. Cualquier rol pasa (incluyendo Importador).""" def has_object_permission(self, request, view, obj): - # Permite operaciones sobre un objeto específico si el usuario tiene el permiso - return request.user.has_perm('api.cuser.can_access_storage') + org = get_org_context(request.user) + if not org: + return False + dirigido = getattr(obj, 'dirigido', None) + if dirigido: + return getattr(dirigido, 'organizacion', None) == org + return getattr(obj, 'organizacion', None) == org -class IsSameOrganizationAndInAllowedGroups(permissions.BasePermission): - """ - Permite update/delete solo si el usuario está en TODOS los grupos permitidos - y pertenece a la misma organización que el registro, o es superuser. - """ - allowed_groups = ['admin', 'Agente Aduanal', 'user'] + +class IsSameOrganizationAndAdmin(OrgScopedPermission): + """Usuario con rol admin, Agente Aduanal o user en su organización.""" def has_object_permission(self, request, view, obj): user = request.user - if not user.is_authenticated: - return False if user.is_superuser: return True - if not hasattr(user, 'organizacion') or not user.organizacion: + org = get_org_context(user) + if not org: return False - # Debe tener los tres grupos asignados - for group in self.allowed_groups: - if not user.groups.filter(name=group).exists(): + tiene_rol = ( + user_has_role(user, 'admin') or + user_has_role(user, 'Agente Aduanal') or + user_has_role(user, 'user') + ) + if not tiene_rol: + return False + return 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 obj.organizacion == user.organizacion \ No newline at end of file + 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 diff --git a/mixins/filtrado_organizacion.py b/mixins/filtrado_organizacion.py index c708e2a..130163e 100644 --- a/mixins/filtrado_organizacion.py +++ b/mixins/filtrado_organizacion.py @@ -1,142 +1,179 @@ import logging + +from core.permissions import get_org_context, user_has_role, is_internal_service_request + logger = logging.getLogger(__name__) + +def _is_internal_service(request): + return is_internal_service_request(request) + + class FiltroPorOrganizacionMixin: model = None campo_usuario = 'user' campo_organizacion = 'organizacion' - campo_rfc = 'rfc__id' - campo_contribuyente = 'pedimento__contribuyente' # solo si aplica + campo_contribuyente = 'pedimento__contribuyente' def get_queryset_filtrado(self): user = self.request.user - if not user.is_authenticated or not hasattr(user, self.campo_organizacion): + if not user.is_authenticated: return self.model.objects.none() - if user.is_superuser: + if _is_internal_service(self.request): return self.model.objects.all() - if (user.groups.filter(name='admin').exists() or user.groups.filter(name='developer').exists()) and user.is_authenticated and user.groups.filter(name='Agente Aduanal').exists(): - model_fields = [f.name for f in self.model._meta.get_fields()] - if self.campo_organizacion in model_fields: - filtro = {f"{self.campo_organizacion}": getattr(user, self.campo_organizacion)} - else: - return self.model.objects.none() + org = get_org_context(user) + if not org: + return self.model.objects.none() + + filtro = {self.campo_organizacion: org} + + # Superuser y usuarios con rol operativo ven todo lo de su org activa + if user.is_superuser: return self.model.objects.filter(**filtro) - if user.groups.filter(name='Importador').exists() and getattr(user, 'is_importador', False): - filtro = { - f"{self.campo_contribuyente}__{self.campo_rfc}": getattr(user, self.campo_rfc), - } + if ( + user_has_role(user, 'admin') or + user_has_role(user, 'developer') or + user_has_role(user, 'Agente Aduanal') or + user_has_role(user, 'user') + ): + return self.model.objects.filter(**filtro) + + # Importador: acceso filtrado por org + RFC como contribuyente + if user.is_importador: + filtro[f"{self.campo_contribuyente}__in"] = user.rfc.all() return self.model.objects.filter(**filtro) return self.model.objects.none() - -# en core/mixins/organizacion.py o similar + + class OrganizacionFiltradaMixin: - model = None # Puedes sobreescribir esto en la vista + model = None campo_organizacion = 'organizacion' - campo_contribuyente = 'contribuyente' # solo si aplica + campo_contribuyente = 'contribuyente' def get_queryset_filtrado_por_organizacion(self): model = self.model or self.queryset.model + user = self.request.user - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + if not user.is_authenticated: return model.objects.none() - if self.request.user.is_superuser: + if _is_internal_service(self.request): return model.objects.all() - org = self.request.user.organizacion + org = get_org_context(user) + if not org: + return model.objects.none() + filtros_base = { - f"{self.campo_organizacion}": org, - f"{self.campo_organizacion}__is_active": True, - f"{self.campo_organizacion}__is_verified": True, + self.campo_organizacion: org, + f'{self.campo_organizacion}__is_active': True, + f'{self.campo_organizacion}__is_verified': True, } - grupos = self.request.user.groups.values_list('name', flat=True) - - if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and (('admin' in grupos or 'developer' in grupos) and 'user' in grupos) : - if 'Agente Aduanal' in grupos: - return model.objects.filter(**filtros_base) - - # if hasattr(model, self.campo_contribuyente): - if self.request.user.is_authenticated and 'Importador' in grupos: - filtros_base[f"{self.campo_contribuyente}__in"] = self.request.user.rfc.all() + if user.is_superuser: + return model.objects.filter(**filtros_base) + + if ( + user_has_role(user, 'admin') or + user_has_role(user, 'developer') or + user_has_role(user, 'Agente Aduanal') or + user_has_role(user, 'user') + ): + return model.objects.filter(**filtros_base) + + if user.is_importador: + filtros_base[f'{self.campo_contribuyente}__in'] = user.rfc.all() return model.objects.filter(**filtros_base) - # Si no entra en los roles válidos return model.objects.none() + class DocumentosFiltradosMixin: model = None campo_organizacion = 'organizacion' - campo_contribuyente = 'pedimento' # solo si aplica + campo_contribuyente = 'pedimento' def get_queryset_filtrado_por_organizacion(self): model = self.model or self.queryset.model + user = self.request.user - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + if not user.is_authenticated: return model.objects.none() - if self.request.user.is_superuser: + if _is_internal_service(self.request): return model.objects.all() - org = self.request.user.organizacion + org = get_org_context(user) + if not org: + return model.objects.none() + filtros_base = { - f"{self.campo_organizacion}": org.id, - f"{self.campo_organizacion}__is_active": True, - f"{self.campo_organizacion}__is_verified": True, + f'{self.campo_organizacion}': org.id, + f'{self.campo_organizacion}__is_active': True, + f'{self.campo_organizacion}__is_verified': True, } - grupos = self.request.user.groups.values_list('name', flat=True) + if 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 'Agente Aduanal' in grupos: - return model.objects.filter(**filtros_base) - - if hasattr(model, self.campo_contribuyente): - if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False): - filtros_base[f"{self.campo_contribuyente}__contribuyente__in"] = self.request.user.rfc.all() - return model.objects.filter(**filtros_base) + if ( + user_has_role(user, 'admin') or + user_has_role(user, 'developer') or + user_has_role(user, 'Agente Aduanal') or + user_has_role(user, 'user') + ): + return model.objects.filter(**filtros_base) + + if user.is_importador: + filtros_base[f'{self.campo_contribuyente}__contribuyente__in'] = user.rfc.all() + return model.objects.filter(**filtros_base) - # Si no entra en los roles válidos return model.objects.none() + class ProcesosPorOrganizacionMixin: - model = None # Puedes sobreescribir esto en la vista + model = None campo_organizacion = 'organizacion' - campo_pedimento = 'pedimento' # solo si aplica + campo_pedimento = 'pedimento' def get_queryset_filtrado_por_organizacion(self): model = self.model or self.queryset.model + user = self.request.user - if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'): + if not user.is_authenticated: return model.objects.none() - if self.request.user.is_superuser: + if _is_internal_service(self.request): return model.objects.all() - org = self.request.user.organizacion + org = get_org_context(user) + if not org: + return model.objects.none() + filtros_base = { - f"{self.campo_organizacion}": org, - f"{self.campo_organizacion}__is_active": True, - f"{self.campo_organizacion}__is_verified": True, + self.campo_organizacion: org, + f'{self.campo_organizacion}__is_active': True, + f'{self.campo_organizacion}__is_verified': True, } - grupos = self.request.user.groups.values_list('name', flat=True) + if 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 'Agente Aduanal' in grupos: - return model.objects.filter(**filtros_base) - - if hasattr(model, self.campo_pedimento): - if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False): - filtros_base[f"{self.campo_pedimento}__contribuyente__in"] = self.request.user.rfc.all() - return model.objects.filter(**filtros_base) + if user.is_importador: + filtros_base[f'{self.campo_pedimento}__contribuyente__in'] = user.rfc.all() + return model.objects.filter(**filtros_base) - # Si no entra en los roles válidos return model.objects.none() -