Compare commits

..

44 Commits

Author SHA1 Message Date
709a5dedab feature/pedimentos-correccion-partidas 2026-05-28 07:10:39 -06:00
94846fec8a fix/forzar-carga-acuses 2026-05-25 14:52:06 -06:00
e378f2d949 Merge pull request 'feature/rbac permisos y roles implementados' (#30) from feature/rbac-implementation into main
Reviewed-on: #30
2026-05-21 13:59:50 +00:00
a318b70324 feature/rbac permisos y roles implementados 2026-05-21 07:54:59 -06:00
9bbed42cf3 Merge pull request 'feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo' (#29) from feature/background-tasks into main
Reviewed-on: #29
2026-05-19 15:02:24 +00:00
1966218081 feature/agregar eventos en las tareas de fondo, se modificaron modelos para capturar cuales si deben accionar tareas de fondo y cuales no necesariamente tienen que accionar tareas de fondo 2026-05-19 08:59:56 -06:00
b57ce83dc5 Merge pull request 'feature/T2026-05-016-y-T2026-05-031' (#28) from feature/T2026-05-016-y-T2026-05-031 into main
Reviewed-on: #28
2026-05-18 18:05:26 +00:00
Dulce
c2ae752932 fix/T2025-09-007 corregir documentos duplicados 2026-05-18 11:55:46 -06:00
Dulce
8cc0b9f573 feature/T2026-05-016 implementar cargas de tareas en background e implementar y corregir auditoria para datastages 2026-05-18 11:54:46 -06:00
Dulce
3a636c14ae T2026-05-030 2026-05-18 11:51:30 -06:00
Dulce
63f051c566 feature/T2026-05-031 agregar multiples rfc's a un usuario 2026-05-18 11:47:41 -06:00
c890e79394 Merge pull request 'feature/implementacion de gestor de informacion y archivos minIO' (#27) from feature/minio-implementation into main
Reviewed-on: #27
2026-04-22 18:02:58 +00:00
Dulce
39504e196c feature/implementacion de gestor de informacion y archivos minIO 2026-04-22 11:10:05 -06:00
69d07f2713 Merge pull request 'filtros de pedimento completo' (#26) from filtros-doctype into main
Reviewed-on: #26
2026-03-27 14:30:11 +00:00
Dulce
27c8d24a56 filtros de pedimento completo 2026-03-27 08:17:29 -06:00
627d78f4b8 Merge pull request 'modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado' (#25) from formato-pedimento-completo into main
Reviewed-on: #25
2026-03-26 18:00:07 +00:00
Dulce
4c7eb22b28 modificar formato de pedimento completo, el formato que le da el datastage no coincide con el adecuado 2026-03-26 11:44:58 -06:00
30b6d73567 Merge pull request 'primeros 2 difitos' (#24) from pedimento-app-patente into main
Reviewed-on: #24
2026-03-23 22:29:34 +00:00
Dulce
460da47571 primeros 2 difitos 2026-03-23 15:39:37 -06:00
32aff7649e Merge pull request 'cambiar forma de agregar aduana al pedimento_app, ahora se incluyen siempre 3 digitos de aduana' (#23) from pedimento-app-patente into main
Reviewed-on: #23
2026-03-13 18:51:36 +00:00
Dulce
d115cdd072 cambiar forma de agregar aduana al pedimento_app, ahora se incluyen siempre 3 digitos de aduana 2026-03-13 11:57:38 -06:00
28d2eaedda Merge pull request 'nuevo enpoint en segundo plano' (#22) from efc-nuevos-cambios into main
Reviewed-on: #22
2026-03-09 21:35:45 +00:00
f2bf904c84 nuevo enpoint en segundo plano 2026-03-06 12:48:51 -07:00
271c562654 Merge pull request 'fix: se ajusta enpoint de bulk-create-pedimento_desk para scrapear el archivo de validacion.' (#21) from T2026-01-098 into main
Reviewed-on: #21
2026-02-09 19:49:02 +00:00
1c350cf2bf fix: se ajusta enpoint de bulk-create-pedimento_desk para scrapear el archivo de validacion. 2026-02-09 11:06:20 -07:00
e81a1aef4d Merge pull request 'Se ajusta validacion de existencia de pedimento asi como el registro correcto de la aduana, patente y pedimento' (#20) from T2025-12-100 into main
Reviewed-on: #20
2026-02-06 17:46:25 +00:00
eca519a789 Se ajusta validacion de existencia de pedimento asi como el registro correcto de la aduana, patente y pedimento 2026-02-06 10:26:11 -07:00
1dd05463c5 Merge pull request 'fix: Se ajusta codigo para generar el reporte de datastage condensado segun los campos seleccionados por el usuario/' (#19) from T2025-09-056 into main
Reviewed-on: #19
2026-02-05 16:09:03 +00:00
cbbcb3b323 Merge pull request 'T2026-01-032' (#18) from T2026-01-032 into main
Reviewed-on: #18
2026-02-05 16:08:21 +00:00
70999d413e fix: Se ajusta codigo para generar el reporte de datastage condensado segun los campos seleccionados por el usuario/ 2026-02-04 16:58:48 -07:00
fa518972ba fix: se agregan nuevos ajustes al filtro y ejecucion de procesos en base al filtro seleccionado. 2026-02-03 16:38:07 -07:00
6299c6f0fe fix: Filtrar procesos por organizacion dependiando del usuario, solo se debe mostrar todos cuando sea superusuario, en caso contrario solo lo que pertenezca al usuario. 2026-02-03 12:01:22 -07:00
67f339bd18 Merge pull request 'fix: se agrega nuevo endpoint para ejecutar el codigo de los comandos creados por kevin para procesdar las consultas a vucem.' (#17) from req--T2025-08-098 into main
Reviewed-on: #17
2026-02-03 17:54:28 +00:00
98331dae8f fix: se agrega nuevo endpoint para ejecutar el codigo de los comandos creados por kevin para procesdar las consultas a vucem. 2026-02-03 10:27:14 -07:00
6eaf6dc6d9 Merge pull request 'fix: se crea comando para ejecutar manualmente todos los pedimentos completos que aun no se hayan descargado por organizacion.' (#16) from fix-ejecucion-manual-proceso-pedimento-completo into main
Reviewed-on: #16
2026-01-30 14:00:57 +00:00
426c2f7065 fix: se crea comando para ejecutar manualmente todos los pedimentos completos que aun no se hayan descargado por organizacion. 2026-01-29 16:55:52 -07:00
86c0dd6d8b Merge pull request 'T2025-09-004' (#15) from T2025-09-004 into main
Reviewed-on: #15
2026-01-29 17:52:36 +00:00
7141e40dc1 fix: se agrega variable para mostrar mensaje correspondiente a las peticiones y respuestas solicitados por el auditor del frontend. 2026-01-29 10:13:53 -07:00
34eb8ed7d9 fix: se crea una nueva pestaña en detalle de expediente para visualizar los archivos de errores devueltos por ventanilla unica. 2026-01-29 07:53:10 -07:00
5e4d498a3c Merge pull request 'fix: Se agrega validacion para no intentar crear de nuevo el pedimento en caso de ya existir. tambien se agrega funcion de obtener del xml de pedimento completo el dato de la aduana completo.' (#14) from fix-T2025-09-007 into main
Reviewed-on: #14
2026-01-27 17:04:23 +00:00
04d19118be fix: Se agrega validacion para no intentar crear de nuevo el pedimento en caso de ya existir. tambien se agrega funcion de obtener del xml de pedimento completo el dato de la aduana completo. 2026-01-26 15:52:11 -07:00
4ccb5fd718 Merge pull request 'only-datastage' (#13) from only-datastage into main
Reviewed-on: #13
2026-01-26 16:20:27 +00:00
Dulce
8e42ae1a43 cambios solo del datastage 2026-01-26 09:07:48 -07:00
Dulce
f98ae6b207 procesar datastage completo 2026-01-26 09:05:22 -07:00
100 changed files with 13287 additions and 2580 deletions

1
.gitignore vendored
View File

@@ -179,3 +179,4 @@ cython_debug/
# End of https://www.toptal.com/developers/gitignore/api/django # End of https://www.toptal.com/developers/gitignore/api/django
*.bak *.bak
.vscode/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,4 +6,5 @@ class CustomsConfig(AppConfig):
name = 'api.customs' name = 'api.customs'
def ready(self): def ready(self):
import api.customs.signals # corregir el import aqui
import api.customs.signals.procesamiento

View File

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

View File

@@ -0,0 +1,382 @@
"""
Diagnóstico y corrección de partidas con descargado=True cuyos documentos
de respuesta VUCEM contienen <tieneError>true</tieneError>.
Convenciones de nomenclatura del microservicio:
- REQUEST (type 17): vu_PT_{pedimento_app}_{partida}_REQUEST.xml
- ERROR (type 18): vu_PT_{pedimento_app}_{partida}_ERROR.xml
- Éxito (type 1): vu_PT_{pedimento_app}_{partida}.xml
Acciones por cada documento con error VUCEM encontrado:
- document_type_id: actual → 18 (PT ERROR)
- archivo: renombrado a vu_PT_{pedimento_app}_{partida}_ERROR.xml
- Partida.descargado: True → False
Criterio de pedimento malformado (cualquiera de):
- aduana: nulo/vacío o len < 3
- numero_operacion: nulo o vacío
- patente: nulo/vacío o len < 4
- pedimento (campo): nulo/vacío o len < 7
Uso:
python manage.py fix_partidas_error --pedimento <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID> --dry-run
python manage.py fix_partidas_error --organizacion <UUID>
python manage.py fix_partidas_error --dry-run # todas las orgs
"""
import io
import posixpath
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Q
from django.db.models.functions import Length
from api.customs.models import Partida, Pedimento
from api.record.models import Document
from api.utils.minio_client import minio_client
_PT_REQUEST = 17
_PT_ERROR = 18
class Command(BaseCommand):
help = "Corrección de partidas descargado=True con respuestas de error VUCEM."
def add_arguments(self, parser):
parser.add_argument(
"--organizacion", metavar="UUID",
help="UUID de la organización. Sin este arg: todas las orgs.",
)
parser.add_argument(
"--pedimento", metavar="UUID",
help="UUID del pedimento a diagnosticar/corregir.",
)
# Filtros de fecha (aplican sobre fecha_pago del pedimento)
parser.add_argument(
"--fecha-desde", metavar="YYYY-MM-DD",
help="Procesar pedimentos con fecha_pago >= esta fecha.",
)
parser.add_argument(
"--fecha-hasta", metavar="YYYY-MM-DD",
help="Procesar pedimentos con fecha_pago <= esta fecha.",
)
# Control de lote
parser.add_argument(
"--offset", type=int, default=0,
help="Saltar los primeros N pedimentos malformados (default: 0).",
)
parser.add_argument(
"--limit", type=int, default=0,
help="Procesar máximo N pedimentos (default: 0 = todos).",
)
parser.add_argument(
"--dry-run", action="store_true",
help="Solo diagnóstico, sin aplicar cambios.",
)
# ------------------------------------------------------------------ #
# Entry point
# ------------------------------------------------------------------ #
def handle(self, *args, **options):
org_id = options.get("organizacion")
ped_id = options.get("pedimento")
fecha_desde = options.get("fecha_desde")
fecha_hasta = options.get("fecha_hasta")
offset = options["offset"]
limit = options["limit"]
dry_run = options["dry_run"]
if dry_run:
self.stdout.write(self.style.WARNING(
"=== MODO PRUEBA (--dry-run): Sin cambios en BD ni storage ===\n"
))
if ped_id:
self._handle_single(ped_id, dry_run)
return
ped_qs = self._malformed_qs()
if org_id:
ped_qs = ped_qs.filter(organizacion_id=org_id)
if fecha_desde:
ped_qs = ped_qs.filter(fecha_pago__gte=fecha_desde)
if fecha_hasta:
ped_qs = ped_qs.filter(fecha_pago__lte=fecha_hasta)
ped_qs = ped_qs.select_related("organizacion").order_by("fecha_pago", "pedimento_app")
total_sin_filtro = ped_qs.count()
if offset:
ped_qs = ped_qs[offset:]
if limit:
ped_qs = ped_qs[:limit]
total = ped_qs.count() if not (offset or limit) else min(
limit or total_sin_filtro, max(0, total_sin_filtro - offset)
)
self.stdout.write(
f"Pedimentos malformados (total): {total_sin_filtro}\n"
f"Procesando este lote : {total}"
+ (f" [offset={offset}]" if offset else "")
+ (f" [limit={limit}]" if limit else "")
+ "\n"
)
if total == 0:
self.stdout.write(self.style.SUCCESS("Nada que corregir en este lote."))
return
total_partidas = total_docs = 0
for ped in ped_qs:
p, d = self._process_pedimento(ped, dry_run)
total_partidas += p
total_docs += d
self._print_summary(total, total_partidas, total_docs, dry_run)
# ------------------------------------------------------------------ #
# Flujo --pedimento
# ------------------------------------------------------------------ #
def _handle_single(self, ped_id, dry_run):
try:
ped = Pedimento.objects.get(id=ped_id)
except Pedimento.DoesNotExist:
raise CommandError(f"Pedimento {ped_id!r} no encontrado.")
checks = self._field_checks(ped)
self._print_ped_diagnosis(ped, checks)
if not any(checks.values()):
return
self._process_pedimento(ped, dry_run)
# ------------------------------------------------------------------ #
# Queryset de pedimentos malformados
# ------------------------------------------------------------------ #
def _malformed_qs(self):
return Pedimento.objects.annotate(
aduana_len=Length("aduana"),
patente_len=Length("patente"),
pedimento_len=Length("pedimento"),
).filter(
Q(aduana__isnull=True) | Q(aduana="") | Q(aduana_len__lt=3)
| Q(numero_operacion__isnull=True) | Q(numero_operacion="")
| Q(patente__isnull=True) | Q(patente="") | Q(patente_len__lt=4)
| Q(pedimento__isnull=True) | Q(pedimento="") | Q(pedimento_len__lt=7)
)
# ------------------------------------------------------------------ #
# Diagnóstico de un pedimento
# ------------------------------------------------------------------ #
def _field_checks(self, ped):
return {
"aduana (debe tener 3 dígitos)": not ped.aduana or len(ped.aduana.strip()) < 3,
"numero_operacion (obligatorio)": not ped.numero_operacion or not ped.numero_operacion.strip(),
"patente (debe tener 4 dígitos)": not ped.patente or len(ped.patente.strip()) < 4,
"pedimento_fld (debe tener 7 dígitos)": not ped.pedimento or len(ped.pedimento.strip()) < 7,
}
def _print_ped_diagnosis(self, ped, checks):
es_malo = any(checks.values())
estado = self.style.ERROR("MALFORMADO") if es_malo else self.style.SUCCESS("VÁLIDO")
self.stdout.write(
f"Pedimento {ped.pedimento_app} (id={ped.id}) → {estado}\n"
f" aduana = {ped.aduana!r} (len={len(ped.aduana or '')})\n"
f" patente = {ped.patente!r} (len={len(ped.patente or '')})\n"
f" numero_op = {ped.numero_operacion!r}\n"
f" pedimento_fld = {ped.pedimento!r} (len={len(ped.pedimento or '')})\n"
)
for campo, malo in checks.items():
marca = self.style.ERROR("") if malo else self.style.SUCCESS("")
self.stdout.write(f" {marca} {campo}")
self.stdout.write("")
# ------------------------------------------------------------------ #
# Procesamiento de un pedimento malformado
# ------------------------------------------------------------------ #
def _process_pedimento(self, ped, dry_run):
self.stdout.write(
f"Pedimento: {ped.pedimento_app} | "
f"aduana={ped.aduana!r} patente={ped.patente!r} num_op={ped.numero_operacion!r}"
)
partidas = Partida.objects.filter(pedimento=ped, descargado=True)
n_partidas = partidas.count()
if n_partidas == 0:
self.stdout.write(" → Sin partidas con descargado=True\n")
return 0, 0
self.stdout.write(f" Partidas con descargado=True: {n_partidas}")
total_docs_error = 0
for partida in partidas:
# Documentos de respuesta: excluir REQUEST (17) y los ya marcados ERROR (18)
patron = f"vu_PT_{ped.pedimento_app}_{partida.numero_partida}_"
candidatos = list(
Document.objects.filter(
pedimento=ped,
archivo__icontains=patron,
).exclude(document_type_id__in=[_PT_REQUEST, _PT_ERROR])
)
self.stdout.write(
f"\n Partida {partida.numero_partida}: {len(candidatos)} doc(s) candidatos a revisar"
)
docs_con_error = []
for doc in candidatos:
# estado: "error" | "ok" | "no_verificable"
estado, motivo = self._check_vucem_error(doc)
if estado == "error":
icono = self.style.ERROR("✗ ERROR VUCEM")
elif estado == "ok":
icono = self.style.SUCCESS("✓ ok")
else:
icono = self.style.WARNING("⚠ sin archivo en storage")
self.stdout.write(f" [{icono}] type={doc.document_type_id} | {doc.archivo.name}")
if estado == "error":
self.stdout.write(f" motivo : {motivo}")
new_name = self._build_error_filename(
doc.archivo.name, ped.pedimento_app, partida.numero_partida, len(docs_con_error)
)
self.stdout.write(f"{new_name}")
docs_con_error.append(doc)
elif estado == "no_verificable":
self.stdout.write(f" {motivo} — ejecuta en producción para verificar")
total_docs_error += len(docs_con_error)
if not dry_run and docs_con_error:
self._apply_fix(partida, docs_con_error, ped.pedimento_app)
self.stdout.write("")
return n_partidas, total_docs_error
# ------------------------------------------------------------------ #
# Detección de error VUCEM en el XML
# ------------------------------------------------------------------ #
def _check_vucem_error(self, doc):
"""
Lee el XML desde MinIO y verifica si VUCEM devolvió un error.
Retorna ("error" | "ok" | "no_verificable", motivo: str | None).
"""
try:
name = doc.archivo.name
if not minio_client.file_exists(name):
return "no_verificable", "archivo no encontrado en storage"
response = minio_client._client.get_object(minio_client._bucket_name, name)
try:
content = response.read()
finally:
response.close()
response.release_conn()
text = content.decode("utf-8", errors="replace")
if "tieneError>true<" in text:
return "error", "tieneError=true detectado en XML"
return "ok", None
except Exception as e:
return "no_verificable", f"excepción al leer archivo: {e}"
# ------------------------------------------------------------------ #
# Construcción del nombre de archivo de error
# ------------------------------------------------------------------ #
def _build_error_filename(self, old_name, pedimento_app, numero_partida, index=0):
"""
Retorna la ruta con nomenclatura de error:
index=0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR.xml
index>0 → {dir}/vu_PT_{pedimento_app}_{numero_partida}_ERROR_{index}.xml
El índice evita colisión cuando una partida tiene más de un doc con error.
"""
dir_part = posixpath.dirname(old_name)
suffix = f"_{index}" if index > 0 else ""
new_filename = f"vu_PT_{pedimento_app}_{numero_partida}_ERROR{suffix}.xml"
return posixpath.join(dir_part, new_filename)
# ------------------------------------------------------------------ #
# Aplicación de correcciones
# ------------------------------------------------------------------ #
@transaction.atomic
def _apply_fix(self, partida, docs, pedimento_app):
"""
Renombra archivos en storage y actualiza BD dentro de una transacción.
Nota: si la transacción revierte, los cambios en storage NO se deshacen.
"""
for idx, doc in enumerate(docs):
new_name = self._build_error_filename(
doc.archivo.name, pedimento_app, partida.numero_partida, idx
)
final_name = self._rename_in_storage(doc.archivo.name, new_name)
doc.archivo = final_name
doc.document_type_id = _PT_ERROR
doc.vu = True
doc.save(update_fields=["archivo", "document_type_id", "vu"])
self.stdout.write(self.style.SUCCESS(
f" ✓ Doc {doc.id}: type=18 | {final_name}"
))
partida.descargado = False
partida.save(update_fields=["descargado"])
self.stdout.write(self.style.SUCCESS(
f" ✓ Partida {partida.numero_partida}: descargado=False"
))
def _rename_in_storage(self, old_name, new_name):
if old_name == new_name:
return old_name
if minio_client.file_exists(new_name):
# Rename ya ocurrió en ejecución previa parcial
self.stderr.write(self.style.WARNING(
f" ⚠ ERROR ya existe en storage, usando: {new_name}"
))
if minio_client.file_exists(old_name):
minio_client.delete_file(old_name)
return new_name
if not minio_client.file_exists(old_name):
self.stderr.write(self.style.WARNING(
f" ⚠ Archivo no encontrado en storage: {old_name}"
))
return old_name
response = minio_client._client.get_object(minio_client._bucket_name, old_name)
try:
content = response.read()
finally:
response.close()
response.release_conn()
minio_client.upload_file(new_name, file_data=io.BytesIO(content), content_type="application/xml")
minio_client.delete_file(old_name)
return new_name
# ------------------------------------------------------------------ #
# Resumen final
# ------------------------------------------------------------------ #
def _print_summary(self, total_peds, total_partidas, total_docs, dry_run):
self.stdout.write(
f"\n{'' * 60}\nRESUMEN\n"
f" Pedimentos malformados : {total_peds}\n"
f" Partidas con descargado=True : {total_partidas}\n"
f" Documentos con error VUCEM : {total_docs}\n"
)
if dry_run:
self.stdout.write(self.style.WARNING(
"\nMODO PRUEBA: ejecuta sin --dry-run para aplicar los cambios."
))
else:
self.stdout.write(self.style.SUCCESS("\nCorrección completada."))

View File

@@ -36,7 +36,8 @@ class Command(BaseCommand):
if organizacion_id: if organizacion_id:
if procesamiento: if procesamiento:
microservice_v2.ejecutar_procesamiento_por_organizacion(organizacion_id, procesamiento) # microservice_v2.ejecutar_procesamiento_por_organizacion(organizacion_id, procesamiento)
microservice_v2.ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento)
self.stdout.write(self.style.SUCCESS(f'Se ejecutó el procesamiento {procesamiento} para la organización {organizacion_id}.')) self.stdout.write(self.style.SUCCESS(f'Se ejecutó el procesamiento {procesamiento} para la organización {organizacion_id}.'))
else: else:
microservice_v2.ejecutar_todos_por_organizacion(organizacion_id) microservice_v2.ejecutar_todos_por_organizacion(organizacion_id)

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import uuid import uuid
from django.db import models from django.db import models
from django.contrib.auth import get_user_model
# Create your models here. # Create your models here.
@@ -35,6 +34,7 @@ class Pedimento(models.Model):
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True) fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada") alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente")
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True) contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal") agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
@@ -62,7 +62,7 @@ class Pedimento(models.Model):
db_table = 'pedimento' db_table = 'pedimento'
ordering = ['pedimento'] ordering = ['pedimento']
unique_together = [ unique_together = [
['organizacion', 'pedimento'], # ['organizacion', 'pedimento'],
['organizacion', 'pedimento_app'] ['organizacion', 'pedimento_app']
] ]
@@ -211,45 +211,4 @@ class Importador(models.Model):
ordering = ['rfc'] ordering = ['rfc']
def __str__(self): def __str__(self):
return f"{self.rfc} - {self.nombre}" return f"{self.rfc} - {self.nombre}"
# bulk de datos
class BulkUploadTask(models.Model):
STATUS_CHOICES = [
('pending', 'Pendiente'),
('processing', 'Procesando'),
('completed', 'Completado'),
('failed', 'Fallido'),
('partial', 'Parcialmente completado'),
]
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, related_name='bulk_upload_tasks')
organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE)
contribuyente = models.CharField(max_length=255, blank=True, null=True)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
task_type = models.CharField(max_length=50, default='bulk_create')
total_files = models.IntegerField(default=0)
processed_files = models.IntegerField(default=0)
created_pedimentos = models.IntegerField(default=0)
created_documents = models.IntegerField(default=0)
result = models.JSONField(default=dict, blank=True)
failed_files = models.JSONField(default=list, blank=True)
error_message = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
started_at = models.DateTimeField(null=True, blank=True)
finished_at = models.DateTimeField(null=True, blank=True)
fecha_pago = models.DateField(null=True, blank=True)
clave_pedimento = models.CharField(max_length=50, blank=True, null=True)
tipo_operacion_id = models.IntegerField(null=True, blank=True)
curp_apoderado = models.CharField(max_length=50, blank=True, null=True)
partidas = models.IntegerField(default=0)
celery_task_id = models.CharField(max_length=255, blank=True, null=True)
def __str__(self):
return f"BulkUpload {self.id} - {self.status}"
class Meta:
verbose_name = "Tarea de Carga Masiva"
verbose_name_plural = "Tareas de Carga Masiva"
db_table = 'bulk_upload_task'
ordering = ['-created_at']

View File

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

View File

@@ -3,7 +3,7 @@ from django.dispatch import receiver
from django.db import transaction from django.db import transaction
from time import sleep from time import sleep
from api.customs.models import Pedimento, ProcesamientoPedimento, Cove, EDocument from api.customs.models import EstadoDeProcesamiento, Pedimento, ProcesamientoPedimento, Cove, EDocument
from api.customs.tasks.internal_services import ( from api.customs.tasks.internal_services import (
crear_procesamiento_remesa, crear_procesamiento_remesa,
crear_procesamiento_partida, crear_procesamiento_partida,
@@ -20,8 +20,52 @@ from api.customs.tasks.microservice import (
@receiver(post_save, sender=Pedimento) @receiver(post_save, sender=Pedimento)
def trigger_celery_task_on_create(sender, instance, created, **kwargs): def trigger_celery_task_on_create(sender, instance, created, **kwargs):
if created:
procesar_pedimento_completo_individual.apply_async(args=[instance.id, instance.organizacion.id]) if not created:
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info("NO es creación de pedimento, no se crea procesamiento.")
return
if not instance.consultar_vucem:
return
def crear_procesamiento():
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Pedimento confirmado en BD: {instance.id}, creando procesamiento...")
try:
estado, _ = EstadoDeProcesamiento.objects.get_or_create(
estado='En Espera'
)
except Exception:
estado = EstadoDeProcesamiento.objects.first()
try:
ProcesamientoPedimento.objects.get_or_create(
pedimento=instance,
organizacion=instance.organizacion,
defaults={
'estado': estado,
'servicio_id': 3,
'tipo_procesamiento_id': 2,
}
)
except Exception as e:
logger.exception(
f"No se pudo crear ProcesamientoPedimento "
f"para pedimento {instance.id}: {e}"
)
# Disparar la tarea asíncrona existente
try:
procesar_pedimento_completo_individual.apply_async(args=[instance.id, instance.organizacion.id])
except Exception as e:
logger.exception(f"Error al encolar procesar_pedimento_completo_individual: {e}")
transaction.on_commit(crear_procesamiento)
@receiver(post_save, sender=Pedimento) @receiver(post_save, sender=Pedimento)
def trigger_celery_task_on_update(sender, instance, created,**kwargs): def trigger_celery_task_on_update(sender, instance, created,**kwargs):
@@ -46,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
import logging import logging
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Cove creado: {instance.id}, creando procesamiento...") logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)]) pedimento_id = str(instance.pedimento.id)
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)]) def enqueue_cove_tasks():
crear_procesamiento_cove.apply_async(args=[pedimento_id])
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_cove_tasks)
@receiver(post_save, sender=EDocument) @receiver(post_save, sender=EDocument)
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs): def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
@@ -55,5 +102,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
import logging import logging
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...") logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)]) pedimento_id = str(instance.pedimento.id)
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)]) def enqueue_edocument_tasks():
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
transaction.on_commit(enqueue_edocument_tasks)

View File

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

View File

@@ -6,6 +6,49 @@ from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocumen
from core.utils import xml_controller from core.utils import xml_controller
import requests import requests
from core.utils import xml_remesas_controller from core.utils import xml_remesas_controller
from core.redis_events import publish_task_event
import logging
logger = logging.getLogger(__name__)
def _crear_notificacion_auditoria(user_id: str, task_id: str, label: str, resultado: dict):
"""Crea una Notificacion persistente cuando una tarea de auditoría masiva completa."""
try:
from api.notificaciones.models import Notificacion, TipoNotificacion
from api.cuser.models import CustomUser
tipo, _ = TipoNotificacion.objects.get_or_create(
tipo="auditoria_completada",
defaults={"descripcion": "Auditoría masiva completada"},
)
usuario = CustomUser.objects.filter(id=user_id).first()
if not usuario:
return
total = resultado.get('total_pedimentos', 0)
completados = resultado.get('completados', resultado.get('procesados', 0))
pendientes = resultado.get('con_pendientes', 0)
errores = resultado.get('con_errores', 0)
partes = [f"Auditoría de {label} completada — {completados}/{total} pedimentos"]
if pendientes:
partes.append(f"{pendientes} con pendientes")
if errores:
partes.append(f"{errores} con errores")
Notificacion.objects.create(
tipo=tipo,
dirigido=usuario,
mensaje=", ".join(partes),
datos={
"task_id": task_id,
"label": label,
"resultado": resultado,
},
)
except Exception as exc:
logger.error(f"[auditoria] Error creando notificación para tarea {task_id}: {exc}")
def obtener_pedimentos(organizacion_id): def obtener_pedimentos(organizacion_id):
return Pedimento.objects.filter(organizacion_id=organizacion_id) return Pedimento.objects.filter(organizacion_id=organizacion_id)
@@ -35,23 +78,31 @@ def auditor_descargas(pedimento, servicio, related_name, variable, mensaje):
pedimento_id = pedimento.id pedimento_id = pedimento.id
docs = getattr(pedimento, related_name).all() docs = getattr(pedimento, related_name).all()
print(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
logger.info(f"pedimento: {pedimento}, servicio: {servicio}, related_name: {related_name}, variable: {variable}, mensaje: {mensaje}")
# Si no hay documentos, marcar como completado # Si no hay documentos, marcar como completado
if not docs.exists(): if not docs.exists():
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.") print(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
logger.info(f"✓ Pedimento {pedimento_id} no tiene {mensaje}s para procesar.")
else: else:
all_docs = all(getattr(doc, variable) for doc in docs) all_docs = all(getattr(doc, variable) for doc in docs)
if all_docs: if all_docs:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado" proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=3) # Estado "completado"
print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.") print(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
logger.info(f"✓ Pedimento {pedimento_id} tiene todos sus {mensaje} descargados.")
else: else:
proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso" proceso = modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=4) # Estado "en progreso"
print(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.") print(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
logger.info(f"✗ Pedimento {pedimento_id} NO tiene todos sus {mensaje} descargados.")
if proceso: if proceso:
print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.") print(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
logger.info(f"✓ Proceso de auditoría para pedimento {pedimento_id} completado.")
else: else:
print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.") print(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
logger.info(f"✗ No se encontró proceso de auditoría para pedimento {pedimento_id}.")
## Auditar pedimentos ## Auditar pedimentos
@@ -119,46 +170,82 @@ def auditar_procesamiento_remesa_por_pedimento(pedimento_id):
'pedimento_id': str(pedimento_id) 'pedimento_id': str(pedimento_id)
} }
@shared_task @shared_task(bind=True)
def crear_partidas(organizacion_id): def crear_partidas(self, organizacion_id, user_id=None):
from api.customs.models import Partida
task_id = self.request.id
pedimentos = obtener_pedimentos(organizacion_id) pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count() total_pedimentos = pedimentos.count()
pedimentos_procesados = 0
total_partidas_agregadas = 0
print(f"Iniciando procesamiento de {total_pedimentos} pedimentos para organización {organizacion_id}") publish_task_event(task_id, "processing", f"Creando partidas: {total_pedimentos} pedimentos", progress=0)
for pedimento in pedimentos: completados = []
pedimentos_procesados += 1 con_pendientes = []
partidas_agregadas_pedimento = 0 sin_datos = []
errores = []
# Validar que numero_partidas no sea None y sea mayor que 0
if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0: for idx, pedimento in enumerate(pedimentos):
partidas_existentes = pedimento.partidas.count() try:
if pedimento.numero_partidas > partidas_existentes: if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") sin_datos.append({
'pedimento_id': str(pedimento.id),
for i in range(1, pedimento.numero_partidas + 1): 'pedimento': pedimento.pedimento,
from api.customs.models import Partida 'razon': f'numero_partidas inválido ({pedimento.numero_partidas})',
partida, created = Partida.objects.get_or_create( })
pedimento=pedimento, continue
numero_partida=i,
organizacion_id=organizacion_id for i in range(1, pedimento.numero_partidas + 1):
) Partida.objects.get_or_create(
if created: pedimento=pedimento,
partidas_agregadas_pedimento += 1 numero_partida=i,
total_partidas_agregadas += 1 defaults={'organizacion_id': organizacion_id}
)
print(f" → Partidas agregadas para pedimento {pedimento.id}: {partidas_agregadas_pedimento}")
partidas = list(pedimento.partidas.order_by('numero_partida'))
no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
if not no_descargadas:
completados.append(str(pedimento.id))
else: else:
print(f"Pedimento {pedimento.id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") con_pendientes.append({
else: 'pedimento_id': str(pedimento.id),
print(f"Pedimento {pedimento.id} omitido - numero_partidas: {pedimento.numero_partidas} (inválido)") 'pedimento': pedimento.pedimento,
'total_partidas': len(partidas),
'descargadas': len(partidas) - len(no_descargadas),
'no_descargadas': no_descargadas,
})
print(f"\n=== RESUMEN ===") except Exception as e:
print(f"Pedimentos procesados: {pedimentos_procesados}") errores.append({
print(f"Total de partidas agregadas: {total_partidas_agregadas}") 'pedimento_id': str(pedimento.id),
print(f"Procesamiento completado para organización {organizacion_id}") 'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}")
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
pct = int(((idx + 1) / total_pedimentos) * 100)
publish_task_event(task_id, "processing", f"Creando partidas: {idx + 1}/{total_pedimentos}", progress=pct)
resultado = {
'organizacion_id': str(organizacion_id),
'auditoria': 'partidas',
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(con_pendientes),
'sin_datos': len(sin_datos),
'con_errores': len(errores),
'detalle_pendientes': con_pendientes,
'detalle_sin_datos': sin_datos,
'detalle_errores': errores,
}
publish_task_event(task_id, "completed", "Creación de partidas completada", resultado=resultado, progress=100)
if user_id:
_crear_notificacion_auditoria(user_id, task_id, "Partidas", resultado)
return resultado
@shared_task @shared_task
def crear_partidas_por_pedimento(pedimento_id): def crear_partidas_por_pedimento(pedimento_id):
@@ -169,6 +256,7 @@ def crear_partidas_por_pedimento(pedimento_id):
return return
print(f"Procesando pedimento individual {pedimento_id}...") print(f"Procesando pedimento individual {pedimento_id}...")
logger.info(f"Procesando pedimento individual {pedimento_id}...")
partidas_agregadas = 0 partidas_agregadas = 0
# Validar que numero_partidas no sea None y sea mayor que 0 # Validar que numero_partidas no sea None y sea mayor que 0
@@ -176,6 +264,7 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_existentes = pedimento.partidas.count() partidas_existentes = pedimento.partidas.count()
if pedimento.numero_partidas > partidas_existentes: if pedimento.numero_partidas > partidas_existentes:
print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}") print(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
logger.info(f"Pedimento {pedimento_id} - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
for i in range(1, pedimento.numero_partidas + 1): for i in range(1, pedimento.numero_partidas + 1):
from api.customs.models import Partida from api.customs.models import Partida
@@ -188,62 +277,201 @@ def crear_partidas_por_pedimento(pedimento_id):
partidas_agregadas += 1 partidas_agregadas += 1
print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}") print(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
logger.info(f"✓ Partidas agregadas para pedimento {pedimento_id}: {partidas_agregadas}")
else: else:
print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})") print(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
logger.info(f"Pedimento {pedimento_id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
else: else:
print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}") print(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
logger.info(f"Error: Pedimento {pedimento_id} tiene numero_partidas inválido: {pedimento.numero_partidas}")
# Auditar coves def _auditar_organizacion(organizacion_id, servicio, related_name, variable, label, task_id=None, user_id=None):
@shared_task """
def auditar_coves(organizacion_id): Itera todos los pedimentos de una organización auditando el campo `variable`
for pedimento in obtener_pedimentos(organizacion_id): en la relación `related_name`. Retorna un resumen estructurado por pedimento.
auditor_descargas( Publica eventos SSE en Redis si se proporciona task_id.
pedimento, """
servicio=8, pedimentos = obtener_pedimentos(organizacion_id)
related_name='coves', total_pedimentos = pedimentos.count()
variable='cove_descargado',
mensaje='COVE'
)
@shared_task if task_id:
def auditar_acuse_cove(organizacion_id): publish_task_event(task_id, "processing", f"Auditando {label}: {total_pedimentos} pedimentos", progress=0)
for pedimento in obtener_pedimentos(organizacion_id):
auditor_descargas(
pedimento,
servicio=9,
related_name='coves',
variable='acuse_cove_descargado',
mensaje='acuse de COVE'
)
# Revisa si el pedimento completo todos sus acuse coves completados = []
pendientes = []
errores = []
# Auditar edocuments for idx, pedimento in enumerate(pedimentos):
@shared_task try:
def auditar_edocuments(organizacion_id): docs = list(getattr(pedimento, related_name).all())
for pedimento in obtener_pedimentos(organizacion_id): total = len(docs)
auditor_descargas( faltantes = [
pedimento, getattr(doc, 'numero_cove', None) or getattr(doc, 'numero_edocument', None)
servicio=7, for doc in docs if not getattr(doc, variable)
related_name='documentos', ]
variable='edocument_descargado',
mensaje='EDocument' if total == 0 or len(faltantes) == 0:
) nuevo_estado = 3
completados.append(str(pedimento.id))
@shared_task else:
def auditar_acuse(organizacion_id): nuevo_estado = 4
for pedimento in obtener_pedimentos(organizacion_id): pendientes.append({
auditor_descargas( 'pedimento_id': str(pedimento.id),
pedimento, 'pedimento': pedimento.pedimento,
servicio=6, f'faltantes_{label}': faltantes,
related_name='documentos', 'total': total,
variable='acuse_descargado', 'descargados': total - len(faltantes),
mensaje='acuse' })
)
modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado)
# Publicar progreso cada 10 pedimentos para no saturar Redis
if task_id and total_pedimentos > 0 and (idx + 1) % 10 == 0:
pct = int(((idx + 1) / total_pedimentos) * 100)
publish_task_event(task_id, "processing", f"Auditando {label}: {idx + 1}/{total_pedimentos}", progress=pct)
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando pedimento {pedimento.id} [{label}]: {e}")
resultado = {
'organizacion_id': str(organizacion_id),
'auditoria': label,
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
if task_id:
publish_task_event(task_id, "completed", f"Auditoría de {label} completada", resultado=resultado, progress=100)
if user_id:
_crear_notificacion_auditoria(user_id, task_id, label, resultado)
return resultado
@shared_task(bind=True)
def auditar_coves(self, organizacion_id, user_id=None):
return _auditar_organizacion(
organizacion_id,
servicio=8,
related_name='coves',
variable='cove_descargado',
label='cove',
task_id=self.request.id,
user_id=user_id,
)
@shared_task(bind=True)
def auditar_acuse_cove(self, organizacion_id, user_id=None):
return _auditar_organizacion(
organizacion_id,
servicio=9,
related_name='coves',
variable='acuse_cove_descargado',
label='acuse_cove',
task_id=self.request.id,
user_id=user_id,
)
@shared_task(bind=True)
def auditar_edocuments(self, organizacion_id, user_id=None):
return _auditar_organizacion(
organizacion_id,
servicio=7,
related_name='documentos',
variable='edocument_descargado',
label='edocument',
task_id=self.request.id,
user_id=user_id,
)
@shared_task(bind=True)
def auditar_acuse(self, organizacion_id, user_id=None):
return _auditar_organizacion(
organizacion_id,
servicio=6,
related_name='documentos',
variable='acuse_descargado',
label='acuse',
task_id=self.request.id,
user_id=user_id,
)
@shared_task(bind=True)
def auditar_remesas(self, organizacion_id, user_id=None):
"""
Audita el estado de descarga de remesas para todos los pedimentos de una organización.
A diferencia de coves/edocuments, las remesas no tienen campo booleano propio —
se verifica la existencia de un documento de tipo 3 (Remesa) en el pedimento.
"""
task_id = self.request.id
pedimentos = obtener_pedimentos(organizacion_id)
total_pedimentos = pedimentos.count()
if task_id:
publish_task_event(task_id, "processing", f"Auditando remesas: {total_pedimentos} pedimentos", progress=0)
completados = []
pendientes = []
errores = []
for idx, pedimento in enumerate(pedimentos):
try:
if not pedimento.remesas:
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
elif pedimento.documents.filter(document_type=3).exists():
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=3)
completados.append(str(pedimento.id))
else:
modificar_estado_procesamiento(pedimento, servicio_id=5, nuevo_estado=4)
pendientes.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
})
except Exception as e:
errores.append({
'pedimento_id': str(pedimento.id),
'pedimento': pedimento.pedimento,
'error': str(e),
})
logger.error(f"Error auditando remesa de pedimento {pedimento.id}: {e}")
if task_id and total_pedimentos > 0 and (idx + 1) % 10 == 0:
pct = int(((idx + 1) / total_pedimentos) * 100)
publish_task_event(task_id, "processing", f"Auditando remesas: {idx + 1}/{total_pedimentos}", progress=pct)
resultado = {
'organizacion_id': str(organizacion_id),
'auditoria': 'remesa',
'total_pedimentos': total_pedimentos,
'completados': len(completados),
'con_pendientes': len(pendientes),
'con_errores': len(errores),
'detalle_pendientes': pendientes,
'detalle_errores': errores,
}
if task_id:
publish_task_event(task_id, "completed", "Auditoría de remesas completada", resultado=resultado, progress=100)
if user_id:
_crear_notificacion_auditoria(user_id, task_id, "Remesas", resultado)
return resultado
@shared_task @shared_task
def auditar_cove_por_pedimento(pedimento_id): def auditar_cove_por_pedimento(pedimento_id):
try: try:
print(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
logger.info(f"auditar_cove_por_pedimento >>>> {pedimento_id}")
from api.customs.models import Pedimento from api.customs.models import Pedimento
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
auditor_descargas( auditor_descargas(

View File

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

View File

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

View File

@@ -1,421 +0,0 @@
# tasks/bulk_pedimentos.py COMPLETO
import os
import tempfile
import zipfile
import shutil
import re
from datetime import datetime
from celery import shared_task
from django.core.files.base import ContentFile
from django.utils import timezone
from django.db import transaction
import traceback
from rarfile import RarFile, RarCannotExec, Error as RarError
import subprocess
from ..models import BulkUploadTask, Pedimento, Importador, TipoOperacion
from ...record.models import DocumentType, Document, Fuente
from django.contrib.auth import get_user_model
from django.core.files.storage import default_storage
User = get_user_model()
def extract_rar_to_dir(rar_path, dest_dir):
"""
Extrae archivos RAR con múltiples métodos de fallback
"""
try:
with RarFile(rar_path, 'r') as rar_ref:
rar_ref.extractall(dest_dir)
return True
except (RarCannotExec, RarError):
try:
subprocess.run(['7z', 'x', rar_path, f'-o{dest_dir}'],
check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
try:
subprocess.run(['unrar', 'x', rar_path, dest_dir],
check=True, capture_output=True)
return True
except (subprocess.CalledProcessError, FileNotFoundError):
return False
def is_same_document(doc, file_name):
"""
Determina si un documento es el mismo basado en el nombre
"""
if not doc.archivo:
return False
doc_name = os.path.basename(doc.archivo.name).lower()
new_name = file_name.lower()
doc_base = os.path.splitext(doc_name)[0]
new_base = os.path.splitext(new_name)[0]
return doc_base == new_base
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def process_bulk_upload(self, bulk_upload_id):
"""
Tarea principal para procesar bulk upload de forma asíncrona
SOLO recibe el ID, obtiene todo de la base de datos
"""
try:
bulk_upload = BulkUploadTask.objects.get(id=bulk_upload_id)
bulk_upload.celery_task_id = self.request.id
bulk_upload.status = 'processing'
bulk_upload.started_at = timezone.now()
bulk_upload.save(update_fields=['celery_task_id', 'status', 'started_at'])
if bulk_upload.task_type == 'bulk_create_pedimento_desk':
result = _process_bulk_pedimento_desk(bulk_upload)
else:
result = _process_bulk_create(bulk_upload)
bulk_upload.status = 'completed' if not result['failed_files'] else 'partial'
bulk_upload.finished_at = timezone.now()
bulk_upload.result = result
bulk_upload.created_pedimentos = result.get('created_count', 0)
bulk_upload.created_documents = result.get('documents_created', 0)
bulk_upload.failed_files = result.get('failed_files', [])
bulk_upload.processed_files = result.get('processed_files', 0)
bulk_upload.save()
temp_files_info = bulk_upload.result.get('temp_files', [])
for file_info in temp_files_info:
try:
default_storage.delete(file_info['saved_path'])
except Exception as e:
print(f"Error al limpiar archivo temporal: {str(e)}")
return result
except Exception as e:
if 'bulk_upload' in locals():
bulk_upload.status = 'failed'
bulk_upload.error_message = f"{str(e)}\n{traceback.format_exc()}"
bulk_upload.finished_at = timezone.now()
bulk_upload.save()
raise self.retry(exc=e, countdown=60)
def _process_bulk_create(bulk_upload):
"""
Procesa bulk_create normal - VERSIÓN CORREGIDA
"""
created_pedimentos = []
failed_files = []
documents_created = 0
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
try:
organizacion = bulk_upload.organizacion
contribuyente = bulk_upload.contribuyente
temp_files_info = bulk_upload.result.get('temp_files', [])
try:
document_type = DocumentType.objects.get(nombre="Pedimento")
except DocumentType.DoesNotExist:
document_type = DocumentType.objects.create(
nombre="Pedimento",
descripcion="Documento de pedimento"
)
for idx, file_info in enumerate(temp_files_info):
temp_dir = tempfile.mkdtemp()
try:
temp_file_path = os.path.join(temp_dir, file_info['original_name'])
with default_storage.open(file_info['saved_path'], 'rb') as src:
with open(temp_file_path, 'wb') as dst:
dst.write(src.read())
archivo_name_sin_extension = os.path.splitext(file_info['original_name'])[0]
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
os.makedirs(sub_dir, exist_ok=True)
archivo_name = file_info['original_name'].lower()
if archivo_name.endswith('.zip'):
with zipfile.ZipFile(temp_file_path, 'r') as zip_ref:
zip_ref.extractall(sub_dir)
elif archivo_name.endswith('.rar'):
if not extract_rar_to_dir(temp_file_path, sub_dir):
failed_files.append({
"file": file_info['original_name'],
"error": "No se pudo extraer archivo RAR"
})
continue
else:
shutil.move(temp_file_path, os.path.join(sub_dir, file_info['original_name']))
for root, dirs, files in os.walk(sub_dir):
for file_name in files:
file_path = os.path.join(root, file_name)
relative_path = os.path.relpath(file_path, sub_dir)
folder_name = archivo_name_sin_extension
match = nomenclatura_pattern.match(folder_name)
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
if not match and not match_sin_anio:
archivo_original = folder_name + os.path.splitext(file_info['original_name'])[1]
failed_files.append({
"file": file_name,
"archivo_original": archivo_original,
"error": f"Nomenclatura inválida en nombre del ZIP: {folder_name}. Esperado: anio-aduana-patente-pedimento"
})
continue
if match:
anio, aduana, patente, pedimento_num = match.groups()
try:
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
fecha_pago = datetime(anio_completo, 1, 1).date()
except ValueError:
failed_files.append({
"file": file_name,
"archivo_original": file_info['original_name'],
"error": f"Año inválido: {anio}"
})
continue
elif match_sin_anio:
aduana, patente, pedimento_num = match_sin_anio.groups()
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
año_actual = datetime.now().year
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
if año_con_digito <= año_actual:
año_final = año_con_digito
else:
año_final = año_con_digito - 10
anio = año_final % 100
fecha_pago = datetime(año_final, 1, 1).date()
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
existing_pedimento = Pedimento.objects.filter(
pedimento_app=pedimento_app,
organizacion=organizacion
).first()
if not existing_pedimento:
importador, created = Importador.objects.get_or_create(
rfc=contribuyente,
defaults={
'nombre': f"Importador {contribuyente}",
'organizacion': organizacion
}
)
try:
pedimento = Pedimento.objects.create(
organizacion=organizacion,
contribuyente=importador,
pedimento=int(pedimento_num),
aduana=int(aduana),
patente=int(patente),
fecha_pago=fecha_pago,
pedimento_app=pedimento_app,
agente_aduanal=f"Agente {patente}",
clave_pedimento="A1"
)
created_pedimentos.append({
"id": str(pedimento.id),
"pedimento_app": pedimento_app,
"contribuyente": importador.rfc,
"contribuyente_nombre": importador.nombre
})
except Exception as e:
failed_files.append({
"file": file_name,
"archivo_original": file_info['original_name'],
"error": f"Error al crear pedimento: {str(e)}"
})
continue
pedimento_obj = pedimento
else:
pedimento_obj = existing_pedimento
try:
with open(file_path, 'rb') as f:
file_content = f.read()
extension = os.path.splitext(file_name)[1].lower().lstrip('.')
existing_documents = Document.objects.filter(
pedimento_id=pedimento_obj.id,
organizacion=organizacion
)
existing_document = None
for doc in existing_documents:
if is_same_document(doc, file_name):
existing_document = doc
break
django_file = ContentFile(file_content, name=file_name)
if existing_document:
try:
if existing_document.archivo and os.path.exists(existing_document.archivo.path):
os.remove(existing_document.archivo.path)
except (ValueError, OSError) as e:
print(f"No se pudo eliminar archivo físico anterior: {str(e)}")
existing_document.archivo = django_file
existing_document.size = len(file_content)
existing_document.extension = extension
existing_document.updated_at = timezone.now()
existing_document.save()
else:
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=pedimento_obj.id,
document_type=document_type,
fuente_id=4,
archivo=django_file,
size=len(file_content),
extension=extension
)
documents_created += 1
except Exception as e:
failed_files.append({
"file": file_name,
"archivo_original": file_info['original_name'],
"error": f"Error al crear documento: {str(e)}"
})
continue
if os.path.exists(temp_file_path):
os.remove(temp_file_path)
try:
default_storage.delete(file_info['saved_path'])
except:
pass
bulk_upload.processed_files = idx + 1
bulk_upload.save(update_fields=['processed_files'])
except Exception as e:
failed_files.append({
"file": file_info['original_name'],
"error": str(e)
})
continue
finally:
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
print(f"Error al eliminar directorio temporal: {str(e)}")
result = {
"created_count": len(created_pedimentos),
"created_pedimentos": created_pedimentos,
"documents_created": documents_created,
"failed_files": failed_files,
"processed_files": len(temp_files_info),
"summary": f"Procesados {len(temp_files_info)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)"
}
return result
except Exception as e:
failed_files.append({
"file": "global",
"error": f"Error global: {str(e)}"
})
return {
"created_count": 0,
"created_pedimentos": [],
"documents_created": 0,
"failed_files": failed_files,
"processed_files": 0,
"summary": f"Error en procesamiento: {str(e)}"
}
def _process_bulk_pedimento_desk(bulk_upload):
"""
Procesa bulk_create_pedimento_desk - OBTIENE DATOS DEL MODELO
"""
created_pedimentos = []
failed_files = []
documents_created = 0
temp_dir = None
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
try:
organizacion = bulk_upload.organizacion
contribuyente = bulk_upload.contribuyente
temp_files_info = bulk_upload.result.get('temp_files', [])
fecha_pago_input = bulk_upload.fecha_pago
clave_pedimento_input = bulk_upload.clave_pedimento
tipo_operacion_id = bulk_upload.tipo_operacion_id
curp_apoderado_input = bulk_upload.curp_apoderado
partidas_input = bulk_upload.partidas
tipo_operacion_obj = None
if tipo_operacion_id:
try:
tipo_operacion_obj = TipoOperacion.objects.get(id=tipo_operacion_id)
except TipoOperacion.DoesNotExist:
print(f"TipoOperacion ID {tipo_operacion_id} no encontrado")
result = {
"created_count": len(created_pedimentos),
"created_pedimentos": created_pedimentos,
"documents_created": documents_created,
"failed_files": failed_files,
"processed_files": len(temp_files_info),
"summary": f"Procesados {len(temp_files_info)} archivo(s): {len(created_pedimentos)} pedimento(s) creado(s), {documents_created} documento(s) asociado(s)"
}
return result
except Exception as e:
failed_files.append({
"file": "global",
"error": f"Error global: {str(e)}"
})
return {
"created_count": 0,
"created_pedimentos": [],
"documents_created": 0,
"failed_files": failed_files,
"processed_files": 0,
"summary": f"Error en procesamiento: {str(e)}"
}
finally:
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e:
print(f"Error al eliminar directorio temporal: {str(e)}")

View File

@@ -0,0 +1,710 @@
from celery import shared_task
from django.utils import timezone
import os
import zipfile
import tempfile
import shutil
import logging
import re
import uuid
from datetime import datetime
logger = logging.getLogger(__name__)
def normalize_filename(filename):
"""
Normaliza el nombre del archivo removiendo caracteres especiales,
espacios y asegurando consistencia.
"""
from unicodedata import normalize
filename = normalize('NFKD', filename).encode('ASCII', 'ignore').decode('ASCII')
filename = re.sub(r'[^\w\s.-]', '_', filename)
filename = re.sub(r'[\s()]+', '_', filename)
filename = re.sub(r'_+', '_', filename)
filename = filename.strip('_')
return filename
def extract_django_suffix(filename):
"""
Extrae el sufijo UUID de 8 chars que storage_service añade a los archivos.
"""
name_without_ext = os.path.splitext(filename)[0]
match = re.search(r'_([a-zA-Z0-9]{8})$', name_without_ext)
if match:
return match.group(1)
return None
def get_clean_base_filename(filename):
"""
Obtiene el nombre base limpio sin el sufijo UUID de storage_service.
"""
normalized = normalize_filename(filename)
name_without_ext, ext = os.path.splitext(normalized)
django_suffix = extract_django_suffix(name_without_ext)
if django_suffix:
base_name = name_without_ext[:-9] # elimina _XXXXXXXX (underscore + 8 chars UUID)
else:
base_name = name_without_ext
base_name = re.sub(r'(_copy|_copia|_-_copia|_-_copy)(_\d+)?$', '', base_name)
return base_name.lower().strip('_')
def is_same_document(existing_doc, new_filename):
"""
Compara si un documento existente y un nuevo archivo son el mismo documento.
"""
existing_basename = os.path.basename(existing_doc.archivo.name)
existing_base = get_clean_base_filename(existing_basename)
new_base = get_clean_base_filename(new_filename)
existing_ext = existing_doc.extension.lower()
new_ext = os.path.splitext(new_filename)[1].lower().lstrip('.')
return existing_base == new_base and existing_ext == new_ext
def procesar_archivo_m_con_nomenclatura(content, pedimento_instance):
"""
Procesa archivos con nomenclatura M8988852.300 (7 dígitos, punto, 3 dígitos)
y extrae información de registros específicos para actualizar el pedimento.
Args:
content: bytes del contenido del archivo
pedimento_instance: instancia del modelo Pedimento
Returns:
dict: Diccionario con información extraída
"""
try:
content_text = content.decode('utf-8', errors='ignore')
registros = {}
for line in content_text.splitlines():
line = line.strip()
if not line:
continue
parts = line.split('|')
if len(parts) < 2:
continue
tipo_registro = parts[0]
if tipo_registro not in registros:
registros[tipo_registro] = []
registros[tipo_registro].append(parts)
info_extraida = {
'tiene_nomenclatura_especial': False,
'registros_encontrados': list(registros.keys()),
'detalles_registro_500': [],
'detalles_registro_506': [],
'detalles_registro_501': [],
'detalles_registro_551': [],
'detalles_registro_800': [],
'detalles_registro_801': [],
'actualizaciones_aplicadas': []
}
if '500' in registros:
info_extraida['tiene_nomenclatura_especial'] = True
for reg_500 in registros['500']:
if len(reg_500) >= 1:
info_extraida['detalles_registro_500'].append({
'tipo_movimiento': reg_500[1] if len(reg_500) > 1 else None,
'patente': reg_500[2] if len(reg_500) > 1 else None,
'numero_pedimento': reg_500[3] if len(reg_500) > 1 else None,
'aduana_seccion': reg_500[4] if len(reg_500) > 1 else None,
'acuse_electronico': reg_500[5] if len(reg_500) > 1 else None,
})
for reg_506 in registros.get('506', []):
if len(reg_506) >= 1:
info_extraida['detalles_registro_506'].append({
'numero_pedimento': reg_506[1] if len(reg_506) > 1 else None,
'tipo_fecha': reg_506[2] if len(reg_506) > 1 else None,
'fecha': reg_506[3] if len(reg_506) > 1 else None
})
for reg_501 in registros.get('501', []):
if len(reg_501) >= 1:
info_extraida['detalles_registro_501'].append({
'patente': reg_501[1] if len(reg_501) > 1 else None,
'numero_pedimento': reg_501[2] if len(reg_501) > 1 else None,
'aduana_seccion': reg_501[3] if len(reg_501) > 1 else None,
'rfc': reg_501[8] if len(reg_501) > 1 else None,
'curp': reg_501[9] if len(reg_501) > 1 else None
})
for reg_551 in registros.get('551', []):
if len(reg_551) >= 1:
info_extraida['detalles_registro_551'].append({
'numero_pedimento': reg_501[1] if len(reg_501) > 1 else None,
'fraccion_arancelaria': reg_551[2] if len(reg_551) > 1 else None,
'partida': reg_551[3] if len(reg_551) > 1 else None,
'subfraccion': reg_551[4] if len(reg_551) > 1 else None
})
for reg_801 in registros.get('800', []):
if len(reg_801) >= 1:
info_extraida['detalles_registro_800'].append({
'numero_pedimento': reg_801[1] if len(reg_801) > 1 else None
})
for reg_801 in registros.get('801', []):
if len(reg_801) >= 1:
info_extraida['detalles_registro_801'].append({
'total_partidas': reg_801[1] if len(reg_801) > 1 else None
})
actualizaciones = actualizar_pedimento_con_registros(pedimento_instance, registros)
info_extraida['actualizaciones_aplicadas'] = actualizaciones
return info_extraida
except Exception as e:
logger.error(f"Error al procesar archivo con nomenclatura especial: {str(e)}")
return {
'tiene_nomenclatura_especial': False,
'error': str(e),
'registros_encontrados': []
}
def actualizar_pedimento_con_registros(pedimento_instance, registros):
"""
Actualiza el pedimento con información extraída de los registros.
Args:
pedimento_instance: Instancia del pedimento a actualizar
registros: Diccionario con registros parseados
Returns:
list: Lista de actualizaciones aplicadas
"""
actualizaciones = []
try:
if '500' in registros and registros['500']:
for reg_500 in registros['500']:
if len(reg_500) >= 1:
if pedimento_instance.pedimento == reg_500[3]:
try:
pedimento_instance.aduana = reg_500[4]
actualizaciones.append(f"aduana actualizada a {reg_500[4]}")
except ValueError:
pass
if '501' in registros and registros['501']:
for reg_501 in registros['501']:
if len(reg_501) >= 1:
rfc = reg_501[8] if len(reg_501) > 1 else None
if rfc and not pedimento_instance.contribuyente and pedimento_instance.pedimento == reg_501[2]:
try:
from api.customs.models import Importador
importador, created = Importador.objects.get_or_create(
rfc=rfc,
defaults={
'nombre': f"Importador {rfc}",
'organizacion': pedimento_instance.organizacion
}
)
pedimento_instance.contribuyente = importador
if created:
actualizaciones.append(f"importador creado con RFC {rfc}")
else:
actualizaciones.append(f"importador asociado con RFC {rfc}")
except Exception as e:
logger.error(f"Error al crear/obtener importador: {str(e)}")
if '501' in registros and registros['501']:
for reg_501 in registros['501']:
if len(reg_501) >= 1:
curp = reg_501[9] if len(reg_501) > 1 else None
if curp and not pedimento_instance.curp_apoderado and pedimento_instance.pedimento == reg_501[2]:
pedimento_instance.curp_apoderado = curp
actualizaciones.append(f"curp_apoderado actualizado a {curp}")
if '501' in registros and registros['501']:
for reg_501 in registros['501']:
if len(reg_501) >= 1:
tipo_operacion = reg_501[4] if len(reg_501) > 1 else None
if tipo_operacion and pedimento_instance.pedimento == reg_501[2]:
if tipo_operacion=='1':
nombre_tipo_op = "Importacion"
elif tipo_operacion=='2':
nombre_tipo_op = "Exportacion"
else:
nombre_tipo_op = f"Tipo {tipo_operacion}"
try:
from api.customs.models import TipoOperacion
tipo_op_obj, created = TipoOperacion.objects.get_or_create(
id=tipo_operacion,
tipo=nombre_tipo_op,
defaults={'descripcion': f"Tipo de Operación {tipo_operacion}"}
)
pedimento_instance.tipo_operacion = tipo_op_obj
if created:
actualizaciones.append(f"tipo_operacion creado con tipo {tipo_operacion}")
else:
actualizaciones.append(f"tipo_operacion asociado con tipo {tipo_operacion}")
except Exception as e:
logger.error(f"Error al crear/obtener tipo de operación: {str(e)}")
if '501' in registros and registros['501']:
for reg_501 in registros['501']:
if len(reg_501) >= 1:
clave = reg_501[5] if len(reg_501) > 1 else None
if clave and pedimento_instance.pedimento == reg_501[2]:
pedimento_instance.clave_pedimento = clave
actualizaciones.append(f"clave pedimento actualizada a {clave}")
if '506' in registros and registros['506']:
for reg_506 in registros['506']:
if not pedimento_instance.pedimento == reg_506[1]:
continue
if len(reg_506) >= 1:
tipo_fecha = reg_506[2] if len(reg_506) > 1 else None
fecha_str = reg_506[3] if len(reg_506) > 1 else None
if not tipo_fecha == '2':
continue
if fecha_str:
try:
if len(fecha_str) == 8:
fecha = datetime.strptime(fecha_str, '%d%m%Y').date()
elif len(fecha_str) == 6:
fecha = datetime.strptime(fecha_str, '%d%m%y').date()
else:
continue
pedimento_instance.fecha_pago = fecha
actualizaciones.append(f"fecha_pago actualizada a {fecha}")
except (ValueError, TypeError):
pass
num_partidas = 0
if '551' in registros and registros['551']:
for reg_551 in registros['551']:
if not pedimento_instance.pedimento == reg_551[1]:
continue
num_partidas += 1
pedimento_instance.numero_partidas = num_partidas
actualizaciones.append(f"numero_partidas actualizado a {num_partidas}")
if actualizaciones:
pedimento_instance.save()
except Exception as e:
logger.error(f"Error al actualizar pedimento con registros: {str(e)}")
actualizaciones.append(f"error: {str(e)}")
return actualizaciones
@shared_task(bind=True, max_retries=3, time_limit=600)
def bulk_upload_record_task(self, organizacion_id, parametros, archivo_paths):
"""
Procesa archivos ZIP de pedimentos en segundo plano.
Args:
organizacion_id: UUID de la organización
parametros: dict con keys:
- contribuyente
- fecha_pago_input
- clave_pedimento_input
- patente_input
- tipo_operacion_input
- aduana_input
- curp_apoderado_input
- partidas_input
archivo_paths: lista de rutas temporales de archivos ZIP
"""
from api.organization.models import Organizacion
from api.customs.models import Pedimento, Importador, TipoOperacion
from api.record.models import Document, DocumentType, Fuente
created_pedimentos = []
updated_pedimentos = []
failed_records = []
documents_created = 0
temp_dir = None
try:
organizacion = Organizacion.objects.get(id=organizacion_id)
# Extraer parámetros
contribuyente = parametros.get('contribuyente', None)
fecha_pago_input = parametros.get('fecha_pago_input', None)
clave_pedimento_input = parametros.get('clave_pedimento_input', None)
patente_input = parametros.get('patente_input', None)
tipo_operacion_input = parametros.get('tipo_operacion_input', None)
aduana_input = parametros.get('aduana_input', None)
curp_apoderado_input = parametros.get('curp_apoderado_input', None)
partidas_input = parametros.get('partidas_input', None)
# Regex patterns
nomenclatura_pattern = re.compile(r'^(\d{2})-(\d{2,3})-(\d{4})-(\d{7})$')
nomenclatura_pattern_sin_anio = re.compile(r'^(\d{2,3})-(\d{4})-(\d{7})$')
# Obtener DocumentType
try:
document_type = DocumentType.objects.get(nombre="Pedimento")
except DocumentType.DoesNotExist:
document_type = DocumentType.objects.create(
nombre="Pedimento",
descripcion="Documento de pedimento"
)
# Fuente
fuente, _ = Fuente.objects.get_or_create(
nombre="APP-EFC",
descripcion='Transmitido por la app de escritorio'
)
# Usar el directorio donde están los archivos (ya guardado en MEDIA_ROOT)
# El directorio base es el padre del primer archivo
if archivo_paths:
temp_dir = os.path.dirname(archivo_paths[0])
else:
temp_dir = tempfile.mkdtemp()
# Patrón para nomenclatura especial M8988852.300
patron_nomenclatura = re.compile(r'^[m|M]\d{7}\.\d{3}$', re.IGNORECASE)
existing_pedimento = None
for archivo_path in archivo_paths:
archivo_name = os.path.basename(archivo_path).lower()
archivo_name_sin_extension = os.path.splitext(os.path.basename(archivo_path))[0]
sub_dir = os.path.join(temp_dir, archivo_name_sin_extension)
os.makedirs(sub_dir, exist_ok=True)
print(f"Procesando archivo: {archivo_name} en ruta temporal: {archivo_path}")
if archivo_name.endswith('.zip'):
try:
with zipfile.ZipFile(archivo_path, 'r') as zip_ref:
zip_ref.extractall(sub_dir)
os.remove(archivo_path) # Eliminar el archivo ZIP después de extraerlo
except zipfile.BadZipFile as e:
failed_records.append({
"file": archivo_path,
"archivo_original": archivo_name,
"error": f"Archivo ZIP corrupto o inválido: {str(e)}"
})
continue
except Exception as e:
failed_records.append({
"file": archivo_path,
"archivo_original": archivo_name,
"error": f"Error al extraer ZIP: {str(e)}"
})
continue
else:
failed_records.append({
"file": archivo_path,
"archivo_original": archivo_name,
"error": "Solo se admiten archivos ZIP"
})
continue
# Procesar archivos extraídos
for root, dirs, files in os.walk(temp_dir):
for file_name in files:
file_path = os.path.join(root, file_name)
relative_path = os.path.relpath(file_path, temp_dir)
# Determinar folder_name
folder_name = None
if os.path.dirname(relative_path):
folder_parts = relative_path.split(os.sep)
folder_name = folder_parts[0]
else:
folder_name = os.path.splitext(file_name)[0]
# Validar nomenclatura
match = nomenclatura_pattern.match(folder_name)
match_sin_anio = nomenclatura_pattern_sin_anio.match(folder_name)
if not match and not match_sin_anio:
archivo_original = folder_name + '.zip'
failed_records.append({
"file": relative_path,
"archivo_original": archivo_original,
"error": f"Nomenclatura inválida: {folder_name}. Esperado: anio-aduana-patente-pedimento"
})
continue
if match:
anio, aduana, patente, pedimento_num = match.groups()
try:
anio_completo = 2000 + int(anio) if int(anio) < 50 else 1900 + int(anio)
fecha_pago = datetime(anio_completo, 1, 1).date()
except ValueError:
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Año inválido: {anio}"
})
continue
elif match_sin_anio:
aduana, patente, pedimento_num = match_sin_anio.groups()
primer_digito_pedimento = int(pedimento_num[0]) if pedimento_num else 0
año_actual = datetime.now().year
año_con_digito = int(str(año_actual)[:-1] + str(primer_digito_pedimento))
if año_con_digito <= año_actual:
año_final = año_con_digito
else:
año_final = año_con_digito - 10
anio = año_final % 100
fecha_pago = datetime(año_final, 1, 1).date()
# Generar pedimento_app
pedimento_app = f"{anio}-{aduana.zfill(2)}-{patente}-{pedimento_num}"
# Verificar si el pedimento ya existe
existing_pedimento = Pedimento.objects.filter(
pedimento_app=pedimento_app,
organizacion=organizacion
).first()
if not existing_pedimento:
# Crear nuevo pedimento
try:
importador = None
if contribuyente:
importador, created = Importador.objects.get_or_create(
rfc=contribuyente,
defaults={
'nombre': f"Importador {contribuyente}",
'organizacion': organizacion
}
)
tipo_op = None
if tipo_operacion_input:
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
pedimento = Pedimento.objects.create(
organizacion=organizacion,
contribuyente=importador if importador else None,
pedimento=str(pedimento_num),
aduana=str(aduana),
patente=str(patente),
fecha_pago=fecha_pago_input if fecha_pago_input else fecha_pago,
curp_apoderado=curp_apoderado_input if curp_apoderado_input else "",
numero_partidas=partidas_input if partidas_input else 0,
tipo_operacion=tipo_op if tipo_op else None,
pedimento_app=pedimento_app,
agente_aduanal=f"Agente {patente}",
clave_pedimento=clave_pedimento_input if clave_pedimento_input else "A1"
)
existing_pedimento = pedimento
created_pedimentos.append({
"id": str(pedimento.id),
"pedimento_app": pedimento_app,
"contribuyente": getattr(importador, 'rfc', None),
"contribuyente_nombre": getattr(importador, 'nombre', None)
})
except Exception as e:
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Error al crear pedimento: {str(e)}"
})
continue
else:
# Actualizar pedimento existente
if contribuyente:
importador, created = Importador.objects.get_or_create(
rfc=contribuyente,
defaults={
'nombre': f"Importador {contribuyente}",
'organizacion': organizacion
}
)
importador_db = existing_pedimento.contribuyente
if importador_db:
if importador_db != importador:
existing_pedimento.contribuyente = importador
else:
existing_pedimento.contribuyente = importador
existing_pedimento.save()
# Actualizar Tipo Operacion
if tipo_operacion_input:
tipo_op = TipoOperacion.objects.get(id=tipo_operacion_input)
if tipo_op and not existing_pedimento.tipo_operacion:
existing_pedimento.tipo_operacion = tipo_op
existing_pedimento.save()
# Actualizar fecha de pago
if fecha_pago_input:
fecha_db = existing_pedimento.fecha_pago
if fecha_db:
if isinstance(fecha_db, datetime):
fecha_db = fecha_db.date()
if fecha_db.month == 1 and fecha_db.day == 1:
existing_pedimento.fecha_pago = fecha_pago_input
existing_pedimento.save()
else:
existing_pedimento.fecha_pago = fecha_pago_input
existing_pedimento.save()
# Actualizar clave_pedimento
if clave_pedimento_input:
clave_pedimento = existing_pedimento.clave_pedimento
if not clave_pedimento or clave_pedimento.strip() != clave_pedimento_input.strip():
existing_pedimento.clave_pedimento = clave_pedimento_input
existing_pedimento.save()
# Actualizar curp_apoderado
if curp_apoderado_input:
if not existing_pedimento.curp_apoderado:
existing_pedimento.curp_apoderado = curp_apoderado_input
existing_pedimento.save()
# Actualizar partidas
if partidas_input:
num_partidas = existing_pedimento.numero_partidas
if not num_partidas or num_partidas <= 0:
existing_pedimento.numero_partidas = partidas_input
existing_pedimento.save()
# Crear documento asociado al pedimento
try:
with open(file_path, 'rb') as f:
file_content = f.read()
file_name_lower = file_name.lower()
tiene_nomenclatura_especial = False
info_extraida = {}
nombre_base, extension = os.path.splitext(file_name)
if patron_nomenclatura.match(file_name_lower):
tiene_nomenclatura_especial = True
info_extraida = procesar_archivo_m_con_nomenclatura(file_content, existing_pedimento)
# Buscar documento existente
existing_documents = Document.objects.filter(
pedimento_id=existing_pedimento.id,
organizacion=organizacion
)
existing_document = None
for doc in existing_documents:
if is_same_document(doc, file_name):
existing_document = doc
break
if existing_document:
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento ya existente, omitido",
"documento": file_name
})
else:
# Crear registro sin archivo primero
document = Document.objects.create(
organizacion=organizacion,
pedimento_id=existing_pedimento.id,
document_type=document_type,
fuente_id=fuente.id,
size=len(file_content),
extension=os.path.splitext(file_name)[1].lower().lstrip('.')
)
from api.utils.storage_service import storage_service
ruta = storage_service.save_document_from_path(
file_path=file_path,
file_name=file_name,
organizacion_id=organizacion.id,
pedimento_app=existing_pedimento.pedimento_app,
metadata={
'pedimento_id': str(existing_pedimento.id),
'document_id': str(document.id),
'source': 'bulk_upload_async'
}
)
if ruta:
document.archivo = ruta
document.save()
documents_created += 1
updated_pedimentos.append({
"id": str(existing_pedimento.id),
"pedimento_app": existing_pedimento.pedimento_app,
"accion": "Documento creado",
"documento": file_name
})
else:
document.delete()
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Error al guardar {file_name} en almacenamiento"
})
except Exception as e:
failed_records.append({
"file": relative_path,
"archivo_original": folder_name + '.zip',
"error": f"Error al crear documento: {str(e)}"
})
continue
# Actualizar estado de expediente
if documents_created > 0 and existing_pedimento:
existing_pedimento.existe_expediente = True
existing_pedimento.save()
return {
'status': 'completed',
'created_pedimentos': created_pedimentos,
'updated_pedimentos': updated_pedimentos,
'failed_records': failed_records,
'documents_created': documents_created,
'tieneError': len(failed_records) > 0
}
except Exception as e:
logger.error(f"Error en bulk_upload_record_task: {str(e)}")
raise self.retry(exc=e, countdown=60)
finally:
# Limpiar directorio temporal
if temp_dir and os.path.exists(temp_dir):
try:
shutil.rmtree(temp_dir)
except Exception as e:
logger.warning(f"Error al limpiar directorio temporal: {e}")

View File

@@ -1,6 +1,17 @@
import logging
from celery import shared_task, group from celery import shared_task, group
from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument from api.customs.models import ProcesamientoPedimento, Pedimento, Cove, EDocument
from core.utils import xml_controller from core.utils import xml_controller
from core.redis_events import publish_task_event
from api.customs.tasks.auditoria import _crear_notificacion_auditoria
from api.customs.tasks.microservice import (
procesar_cove_individual,
procesar_acuse_individual,
procesar_acuse_cove_individual,
procesar_edoc_individual,
procesar_partida_individual,
procesar_remesa_individual,
)
@shared_task @shared_task
def crear_procesamiento_remesa(pedimento_id): def crear_procesamiento_remesa(pedimento_id):
@@ -11,7 +22,7 @@ def crear_procesamiento_remesa(pedimento_id):
if pedimento.remesas: if pedimento.remesas:
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=5, # ID del servicio de remesas servicio_id=5,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -19,10 +30,11 @@ def crear_procesamiento_remesa(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento remesa creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=5, servicio_id=5,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_remesa_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_partida(pedimento_id): def crear_procesamiento_partida(pedimento_id):
@@ -32,7 +44,7 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}") logger.info(f"[TAREA] crear_procesamiento_partida para pedimento {pedimento_id}")
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=4, # ID del servicio de partidas servicio_id=4,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -40,10 +52,11 @@ def crear_procesamiento_partida(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento partida creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=4, servicio_id=4,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_partida_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_cove(pedimento_id): def crear_procesamiento_cove(pedimento_id):
@@ -54,7 +67,7 @@ def crear_procesamiento_cove(pedimento_id):
if pedimento.coves.exists(): if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=8, # ID del servicio de Coves servicio_id=8,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -62,10 +75,11 @@ def crear_procesamiento_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=8, servicio_id=8,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_acuse(pedimento_id): def crear_procesamiento_acuse(pedimento_id):
@@ -73,10 +87,10 @@ def crear_procesamiento_acuse(pedimento_id):
logger = logging.getLogger('api.customs.async_operations') logger = logging.getLogger('api.customs.async_operations')
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}") logger.info(f"[TAREA] crear_procesamiento_acuse para pedimento {pedimento_id}")
if pedimento.coves.exists(): if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=6, # ID del servicio de Acuse Cove servicio_id=6,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -84,10 +98,11 @@ def crear_procesamiento_acuse(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento acuse creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=6, servicio_id=6,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_acuse_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_acuse_cove(pedimento_id): def crear_procesamiento_acuse_cove(pedimento_id):
@@ -98,7 +113,7 @@ def crear_procesamiento_acuse_cove(pedimento_id):
if pedimento.coves.exists(): if pedimento.coves.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=9, # ID del servicio de Acuse Cove servicio_id=9,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -106,10 +121,11 @@ def crear_procesamiento_acuse_cove(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento acuse_cove creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=9, servicio_id=9,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_acuse_cove_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_edocument(pedimento_id): def crear_procesamiento_edocument(pedimento_id):
@@ -120,7 +136,7 @@ def crear_procesamiento_edocument(pedimento_id):
if pedimento.documentos.exists(): if pedimento.documentos.exists():
existe = ProcesamientoPedimento.objects.filter( existe = ProcesamientoPedimento.objects.filter(
pedimento=pedimento, pedimento=pedimento,
servicio_id=7, # ID del servicio de EDocument servicio_id=7,
organizacion=pedimento.organizacion, organizacion=pedimento.organizacion,
estado_id__in=[1, 2, 3, 4] estado_id__in=[1, 2, 3, 4]
).exists() ).exists()
@@ -128,10 +144,11 @@ def crear_procesamiento_edocument(pedimento_id):
logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}") logger.info(f"[TAREA] ProcesamientoPedimento edocument creado para pedimento {pedimento_id}")
ProcesamientoPedimento.objects.create( ProcesamientoPedimento.objects.create(
pedimento=pedimento, pedimento=pedimento,
estado_id=1, # Estado "pendiente" estado_id=1,
servicio_id=7, servicio_id=7,
organizacion=pedimento.organizacion organizacion=pedimento.organizacion
) )
procesar_edoc_individual.apply_async(args=[str(pedimento.id), str(pedimento.organizacion.id)])
@shared_task @shared_task
def crear_procesamiento_pedimento_completo(organizacion_id): def crear_procesamiento_pedimento_completo(organizacion_id):
@@ -166,51 +183,88 @@ def crear_servicios(organizacion_id):
crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)]) crear_procesamiento_acuse_cove.apply_async(args=[str(pedimento.id)])
crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)]) crear_procesamiento_edocument.apply_async(args=[str(pedimento.id)])
@shared_task @shared_task(bind=True)
def auditar_pedimentos(organizacion_id): def auditar_pedimentos(self, organizacion_id, user_id=None):
_logger = logging.getLogger('api.customs.async_operations')
task_id = self.request.id
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos: total_pedimentos = pedimentos.count()
publish_task_event(task_id, "processing", f"Auditando pedimentos: {total_pedimentos} pedimentos", progress=0)
procesados = 0
sin_xml = 0
errores = []
for idx, pedimento in enumerate(pedimentos):
pc = pedimento.documents.filter(document_type__id=2).first() pc = pedimento.documents.filter(document_type__id=2).first()
if pc: if pc:
with open(f'./media/{pc.archivo}', 'r') as f:
xml_content = f.read()
xml_data = xml_controller.extract_data(xml_content)
pedimento.numero_operacion = xml_data.get('numero_operacion')
pedimento.curp_apoderado = xml_data.get('curp_apoderado')
pedimento.agente_aduanal = xml_data.get('agente_aduanal')
pedimento.numero_partidas = xml_data.get('numero_partidas')
pedimento.remesas = xml_data.get('remesas')
pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
pedimento.fecha_pago = xml_data.get('fecha_pago')
pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
for edoc in xml_data.get('edocuments', []):
EDocument.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
clave=edoc.get('clave'),
descripcion=edoc.get('descripcion'),
numero_edocument=edoc.get('complemento1')
)
from django.db import IntegrityError
try: try:
for cove in xml_data.get('coves', []): with open(f'./media/{pc.archivo}', 'r') as f:
try: xml_content = f.read()
Cove.objects.get_or_create(
pedimento=pedimento, xml_data = xml_controller.extract_data(xml_content)
organizacion=pedimento.organizacion,
numero_cove=cove pedimento.numero_operacion = xml_data.get('numero_operacion')
) pedimento.curp_apoderado = xml_data.get('curp_apoderado')
except IntegrityError: pedimento.agente_aduanal = xml_data.get('agente_aduanal')
# Si ya existe por unique, recupera el objeto existente pedimento.numero_partidas = xml_data.get('numero_partidas')
Cove.objects.get(numero_cove=cove) pedimento.remesas = xml_data.get('remesas')
except: pedimento.tipo_operacion__id = xml_data.get('tipo_operacion')
# Si ya existe por unique, recupera el objeto existente pedimento.fecha_pago = xml_data.get('fecha_pago')
pass pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd
for edoc in xml_data.get('edocuments', []):
EDocument.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
clave=edoc.get('clave'),
descripcion=edoc.get('descripcion'),
numero_edocument=edoc.get('complemento1')
)
from django.db import IntegrityError
try:
for cove in xml_data.get('coves', []):
try:
Cove.objects.get_or_create(
pedimento=pedimento,
organizacion=pedimento.organizacion,
numero_cove=cove
)
except IntegrityError:
# Si ya existe por unique, recupera el objeto existente
Cove.objects.get(numero_cove=cove)
except:
pass
procesados += 1
except Exception as e:
errores.append({'pedimento_id': str(pedimento.id), 'error': str(e)})
_logger.error(f"Error auditando pedimento {pedimento.id}: {e}")
else:
sin_xml += 1
if total_pedimentos > 0 and (idx + 1) % 10 == 0:
pct = int(((idx + 1) / total_pedimentos) * 100)
publish_task_event(task_id, "processing", f"Auditando pedimentos: {idx + 1}/{total_pedimentos}", progress=pct)
resultado = {
'organizacion_id': str(organizacion_id),
'auditoria': 'pedimentos',
'total_pedimentos': total_pedimentos,
'procesados': procesados,
'sin_xml': sin_xml,
'con_errores': len(errores),
'detalle_errores': errores,
}
publish_task_event(task_id, "completed", "Auditoría de pedimentos completada", resultado=resultado, progress=100)
if user_id:
_crear_notificacion_auditoria(user_id, task_id, "Pedimentos", resultado)
return resultado
@shared_task @shared_task
def crear_todos_los_servicios(): def crear_todos_los_servicios():

View File

@@ -11,6 +11,9 @@ from datetime import datetime
# =================== # ===================
@shared_task @shared_task
def procesar_pedimento_completo_individual(pedimento_id, organizacion_id): def procesar_pedimento_completo_individual(pedimento_id, organizacion_id):
import logging
logger = logging.getLogger('api.customs.async_operations')
logger.info(f"Pedimento a monitorear: {pedimento_id}, org:: {organizacion_id}, verificando servicios a crear...")
response = requests.post( response = requests.post(
f"{SERVICE_API_URL}/async/services/pedimento_completo", f"{SERVICE_API_URL}/async/services/pedimento_completo",
json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)} json={"pedimento": str(pedimento_id), "organizacion": str(organizacion_id)}

View File

@@ -1,3 +1,4 @@
from api.organization.models import Organizacion
from celery import group from celery import group
from celery import shared_task, group from celery import shared_task, group
from api.customs.models import * from api.customs.models import *
@@ -8,17 +9,37 @@ import requests
from config.settings import SERVICE_API_URL_V2 from config.settings import SERVICE_API_URL_V2
from datetime import datetime from datetime import datetime
import json import json
import logging
import uuid
# este solo fue para pruebas personales, lo dejo por si en un futuro lo requiero
TEST_ORG_ID = uuid.UUID('defc7848-4f39-4d67-9dba-5bb445248d23')
logger = logging.getLogger('api.customs.microservice_v2')
def credenciales_to_dict(credenciales): def credenciales_to_dict(credenciales):
if not credenciales: if not credenciales:
return {} return {}
key_value = None
if credenciales.key:
if hasattr(credenciales.key, 'url'):
key_value = credenciales.key.url
else:
key_value = str(credenciales.key)
cer_value = None
if credenciales.cer:
if hasattr(credenciales.cer, 'url'):
cer_value = credenciales.cer.url
else:
cer_value = str(credenciales.cer)
return { return {
"id": str(credenciales.id), "id": str(credenciales.id),
"user": credenciales.usuario, "user": credenciales.usuario,
"password": credenciales.password, "password": credenciales.password,
"efirma": credenciales.efirma, "efirma": credenciales.efirma,
"key": credenciales.key.url if credenciales.key else None, "key": key_value,
"cer": credenciales.cer.url if credenciales.cer else None, "cer": cer_value,
"is_active": credenciales.is_active, "is_active": credenciales.is_active,
"organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None, "organizacion": str(credenciales.organizacion.id) if credenciales.organizacion else None,
} }
@@ -70,12 +91,18 @@ def procesar_coves_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/coves", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/coves",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuse_coves_pedimento(pedimento_id): def procesar_acuse_coves_pedimento(pedimento_id):
@@ -86,19 +113,25 @@ def procesar_acuse_coves_pedimento(pedimento_id):
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)], "coves": [cove_to_dict(cove) for cove in pedimento.coves.filter(acuse_cove_descargado=False)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_edocs_pedimento(pedimento_id): def procesar_edocs_pedimento(pedimento_id):
@@ -109,19 +142,25 @@ def procesar_edocs_pedimento(pedimento_id):
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(edocument_descargado=False)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/download/edoc/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_acuses_pedimento(pedimento_id): def procesar_acuses_pedimento(pedimento_id):
@@ -132,19 +171,25 @@ def procesar_acuses_pedimento(pedimento_id):
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)], "edocs": [edoc_to_dict(edoc) for edoc in pedimento.documentos.filter(acuse_descargado=False)],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_partidas_pedimento(pedimento_id): def procesar_partidas_pedimento(pedimento_id):
@@ -155,19 +200,32 @@ def procesar_partidas_pedimento(pedimento_id):
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
).first() ).first()
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
payload = { payload = {
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], "partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/partidas/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/partidas/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
result = response.json()
logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
raise
@shared_task @shared_task
def procesar_remesas_pedimento(pedimento_id): def procesar_remesas_pedimento(pedimento_id):
@@ -184,17 +242,23 @@ def procesar_remesas_pedimento(pedimento_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/remesas", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/remesas",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}") timeout=60
)
response.raise_for_status()
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_pedimento_completo_individual(pedimento_id): def procesar_pedimento_completo_individual(pedimento_id, force=False):
pedimento = Pedimento.objects.get(id=pedimento_id) pedimento = Pedimento.objects.get(id=pedimento_id)
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo if force or not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter( credenciales = Vucem.objects.filter(
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
@@ -204,66 +268,111 @@ def procesar_pedimento_completo_individual(pedimento_id):
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/pedimento_completo", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/pedimento_completo",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
print(f"Servicio enviado para pedimento {pedimento.pedimento}") timeout=60
return response )
response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
return response
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
raise
@shared_task @shared_task
def procesar_pedimentos_completos(organizacion_id): def procesar_pedimentos_completos(organizacion_id):
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
respuestas = [] respuestas = []
for pedimento in pedimentos: for pedimento in pedimentos:
if not pedimento.contribuyente:
print(f"Pedimento {pedimento.pedimento} no tiene contribuyente")
continue
credencial_importador = CredencialesImportador.objects.filter(
rfc=pedimento.contribuyente
).first()
if not credencial_importador:
print(f"No credencial para RFC {pedimento.contribuyente.rfc}")
continue
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
# Convertir el pedimento a JSON usando el serializer # Convertir el pedimento a JSON usando el serializer
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first() # credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
if not credenciales:
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
continue
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
response = requests.post( dataJson = json.dumps(payload)
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
data=json.dumps(payload), try:
headers={"Content-Type": "application/json"} response = requests.post(
) url,
# Aquí puedes continuar con el resto de tu lógica data=dataJson,
print(f"Servicio enviado para pedimento {pedimento.pedimento}") headers={"Content-Type": "application/json"},
timeout=60
)
response.raise_for_status()
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando pedimento completo {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_remesas(organizacion_id): def procesar_remesas(organizacion_id):
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id) pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
for pedimento in pedimentos: for pedimento in pedimentos:
if not pedimento.documents.filter(document_type=3).exists(): # Tipo 3: Remesa logger.info(f"pedimento >>>> {pedimento}")
# Convertir el pedimento a JSON usando el serializer try:
# if pedimento.documents.filter(document_type=3).exists(): # Remesa ya descargada
# logger.info(f"Pedimento {pedimento.pedimento} ya tiene remesa descargada, omitiendo.")
# continue
pedimento_dict = pedimento_to_dict(pedimento) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credencial_importador = CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first()
if not credencial_importador:
logger.warning(f"Sin credenciales para RFC {pedimento.contribuyente} (pedimento {pedimento.pedimento}), omitiendo.")
continue
credenciales = Vucem.objects.filter(id=credencial_importador.vucem.id).first()
if not credenciales:
logger.warning(f"Credencial Vucem no encontrada para pedimento {pedimento.pedimento}, omitiendo.")
continue
credenciales_dict = credenciales_to_dict(credenciales) credenciales_dict = credenciales_to_dict(credenciales)
payload = { payload = {
"pedimento": pedimento_dict, "pedimento": pedimento_dict,
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas",
data=json.dumps(payload),
headers={"Content-Type": "application/json"}
)
# Aquí puedes continuar con el resto de tu lógica
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response = requests.post(
f"{SERVICE_API_URL_V2}/services/remesas/",
data=json.dumps(payload),
headers={"Content-Type": "application/json"},
timeout=60
)
response.raise_for_status()
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
except Exception as e:
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
@shared_task @shared_task
def procesar_coves(organizacion_id): def procesar_coves(organizacion_id):
@@ -286,14 +395,18 @@ def procesar_coves(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/coves", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/coves",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuse_coves(organizacion_id): def procesar_acuse_coves(organizacion_id):
@@ -317,14 +430,18 @@ def procesar_acuse_coves(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_acuses(organizacion_id): def procesar_acuses(organizacion_id):
@@ -348,14 +465,18 @@ def procesar_acuses(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_edocs(organizacion_id): def procesar_edocs(organizacion_id):
@@ -379,14 +500,18 @@ def procesar_edocs(organizacion_id):
"credencial": credenciales_dict "credencial": credenciales_dict
} }
response = requests.post( try:
f"{SERVICE_API_URL_V2}/services/download/all/edocs/", response = requests.post(
data=json.dumps(payload), f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
headers={"Content-Type": "application/json"} data=json.dumps(payload),
) headers={"Content-Type": "application/json"},
# Aquí puedes continuar con el resto de tu lógica timeout=60
)
print(f"Servicio enviado para pedimento {pedimento.pedimento}") response.raise_for_status()
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
except requests.exceptions.RequestException as e:
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
continue
@shared_task @shared_task
def procesar_partidas(organizacion_id): def procesar_partidas(organizacion_id):
@@ -394,29 +519,42 @@ def procesar_partidas(organizacion_id):
organizacion_id=organizacion_id, organizacion_id=organizacion_id,
partidas__isnull=False partidas__isnull=False
).distinct() ).distinct()
for pedimento in pedimentos: for pedimento in pedimentos:
if pedimento.partidas.filter(descargado=False).exists(): # Tipo 4: Partidas partidas_pendientes = list(pedimento.partidas.filter(descargado=False))
# Convertir el pedimento a JSON usando el serializer if not partidas_pendientes:
pedimento_dict = pedimento_to_dict(pedimento) continue
credenciales = Vucem.objects.filter(id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id).first()
credenciales_dict = credenciales_to_dict(credenciales) pedimento_dict = pedimento_to_dict(pedimento)
credenciales = Vucem.objects.filter(
payload = { id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
"partidas": [partida_to_dict(partida) for partida in pedimento.partidas.filter(descargado=False)], ).first()
"pedimento": pedimento_dict, credenciales_dict = credenciales_to_dict(credenciales)
"credencial": credenciales_dict
}
payload = {
"partidas": [partida_to_dict(p) for p in partidas_pendientes],
"pedimento": pedimento_dict,
"credencial": credenciales_dict
}
try:
response = requests.post( response = requests.post(
f"{SERVICE_API_URL_V2}/services/all/partidas/", f"{SERVICE_API_URL_V2}/services/all/partidas/",
data=json.dumps(payload), data=json.dumps(payload),
headers={"Content-Type": "application/json"} headers={"Content-Type": "application/json"},
timeout=60
) )
# Aquí puedes continuar con el resto de tu lógica response.raise_for_status()
result = response.json()
print(f"Servicio enviado para pedimento {pedimento.pedimento}") logging.info(
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
)
except requests.exceptions.RequestException as e:
logging.error(
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
)
continue
@shared_task @shared_task
def documentos_con_errores(organizacion_id): def documentos_con_errores(organizacion_id):
@@ -428,7 +566,35 @@ def documentos_con_errores(organizacion_id):
# Aquí puedes agregar lógica adicional para manejar documentos con errores # Aquí puedes agregar lógica adicional para manejar documentos con errores
# como enviar notificaciones, registrar en un log, etc. # como enviar notificaciones, registrar en un log, etc.
@shared_task
def procesar_procesamiento_pedimento(organizacion_id):
# print("Creando procesamientos de pedimentos para organización:", organizacion_id)
pedimentos = Pedimento.objects.filter(organizacion_id=organizacion_id)
# pedimentos = Pedimento.objects.filter(id='1c061182-ac68-45b0-b3d7-35bf2264982b')
if not pedimentos.exists():
print("No se encontraron pedimentos para la organización:", organizacion_id)
return
for pedimento in pedimentos:
if not pedimento.documents.filter(document_type=2).exists(): # Tipo 2: Pedimento Completo
procesamiento_pedimento = ProcesamientoPedimento.objects.filter(
pedimento_id=pedimento.id,
servicio_id=3, # servicio 3: Pedimento Completo
)
if not procesamiento_pedimento.exists():
ProcesamientoPedimento.objects.create(
pedimento_id=pedimento.id
, organizacion_id=pedimento.organizacion_id
, estado_id =1
, servicio_id=3
, tipo_procesamiento_id=2) # servicio 3: Pedimento Completo
# print("Procesamiento creado para pedimento:", pedimento.pedimento_app)
procesar_pedimentos_completos.delay(organizacion_id)
def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento): def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento):
if procesamiento == 'coves': if procesamiento == 'coves':
procesar_coves.delay(organizacion_id) procesar_coves.delay(organizacion_id)
@@ -444,9 +610,11 @@ def ejecutar_por_organizacion_y_procesamiento(organizacion_id, procesamiento):
procesar_pedimentos_completos.delay(organizacion_id) procesar_pedimentos_completos.delay(organizacion_id)
elif procesamiento == 'remesas': elif procesamiento == 'remesas':
procesar_remesas.delay(organizacion_id) procesar_remesas.delay(organizacion_id)
elif procesamiento == 'procesamiento_pedimento':
procesar_procesamiento_pedimento.delay(organizacion_id)
else: else:
# Procesamiento no reconocido # Procesamiento no reconocido
# print(f"Procesamiento no reconocido: {procesamiento}")
pass pass
def ejecutar_todos_por_organizacion(organizacion_id): def ejecutar_todos_por_organizacion(organizacion_id):
@@ -458,4 +626,37 @@ def ejecutar_todos_por_organizacion(organizacion_id):
procesar_pedimentos_completos.delay(organizacion_id) procesar_pedimentos_completos.delay(organizacion_id)
procesar_remesas.delay(organizacion_id) procesar_remesas.delay(organizacion_id)
def ejecutar_basicos_organizacion(organizacion_id):
# solo coves y e documents, si es necesario ya en un futuro se agregan los de partidas, pedimento completo y esas madres
procesar_coves.delay(organizacion_id)
procesar_acuse_coves.delay(organizacion_id)
procesar_edocs.delay(organizacion_id)
procesar_acuses.delay(organizacion_id)
# procesar_partidas.delay(organizacion_id)
# procesar_pedimentos_completos.delay(organizacion_id)
# procesar_remesas.delay(organizacion_id)
@shared_task
def process_organization_batch(org_id):
"""
Procesa todos los tipos de documentos pendientes para una organización.
"""
ejecutar_basicos_organizacion(org_id)
@shared_task
def process_all_organizations():
"""
Envía una tarea por organización activa a la cola org_processing.
"""
active_orgs = Organizacion.objects.filter(
is_active=True,
is_verified=True,
apply_auto_download=True,
)
for org in active_orgs:
process_organization_batch.apply_async(
args=[str(org.id)],
queue='org_processing'
)
return f"Dispatched {active_orgs.count()} organizations"

View File

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

View File

@@ -10,7 +10,8 @@ from .views import (
ViewSetEDocument, ViewSetEDocument,
ViewSetCove, ViewSetCove,
ImportadorViewSet, ImportadorViewSet,
PartidaViewSet PartidaViewSet,
EjecutarComandoView
) )
# from .views import YourViewSet # Import your viewsets here # from .views import YourViewSet # Import your viewsets here
@@ -38,6 +39,7 @@ from .views_auditor import (
auditar_acuse_cove_endpoint, auditar_acuse_cove_endpoint,
auditar_edocuments_endpoint, auditar_edocuments_endpoint,
auditar_acuse_endpoint, auditar_acuse_endpoint,
auditar_remesas_endpoint,
auditar_cove_pedimento_endpoint, auditar_cove_pedimento_endpoint,
auditar_acuse_cove_pedimento_endpoint, auditar_acuse_cove_pedimento_endpoint,
auditar_edocument_pedimento_endpoint, auditar_edocument_pedimento_endpoint,
@@ -60,6 +62,11 @@ from .views_auditor import (
auditor_obtener_peticion_edocument_vu, auditor_obtener_peticion_edocument_vu,
auditor_obtener_respuesta_edocument_vu, auditor_obtener_respuesta_edocument_vu,
auditar_pedimento_endpoint, auditar_pedimento_endpoint,
procesar_pedimento_completo_endpoint,
auto_corregir_pedamentos_endpoint,
auditar_pedamentos_incompletos_endpoint,
auditar_pedamento_incompleto_endpoint,
auto_corregir_pedamento_endpoint,
) )
urlpatterns = [ urlpatterns = [
@@ -71,12 +78,18 @@ urlpatterns = [
path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'), path('auditor/auditar-acuse-cove/', auditar_acuse_cove_endpoint, name='auditar-acuse-cove'),
path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'), path('auditor/auditar-edocuments/', auditar_edocuments_endpoint, name='auditar-edocuments'),
path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'), path('auditor/auditar-acuse/', auditar_acuse_endpoint, name='auditar-acuse'),
path('auditor/auditar-remesas/', auditar_remesas_endpoint, name='auditar-remesas'),
path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'), path('auditor/auditar-cove/pedimento/', auditar_cove_pedimento_endpoint, name='auditar-cove-pedimento'),
path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'), path('auditor/auditar-acuse-cove/pedimento/', auditar_acuse_cove_pedimento_endpoint, name='auditar-acuse-cove-pedimento'),
path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'), path('auditor/auditar-edocument/pedimento/', auditar_edocument_pedimento_endpoint, name='auditar-edocument-pedimento'),
path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'), path('auditor/auditar-acuse/pedimento/', auditar_acuse_pedimento_endpoint, name='auditar-acuse-pedimento'),
path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'), path('auditor/auditar-remesa/pedimento/', auditar_procesamiento_remesa_pedimento_endpoint, name='auditar-remesa-pedimento'),
path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'), path('auditor/auditar-pedimento/', auditar_pedimento_endpoint, name='auditar-pedimento'),
path('auditor/procesar-pedimento-completo/pedimento/', procesar_pedimento_completo_endpoint, name='procesar-pedimento-completo-pedimento'),
path('auditor/auto-corregir-pedamentos/', auto_corregir_pedamentos_endpoint, name='auto-corregir-pedamentos'),
path('auditor/auditar-pedamentos-incompletos/', auditar_pedamentos_incompletos_endpoint, name='auditar-pedamentos-incompletos'),
path('auditor/auto-corregir-pedamento/', auto_corregir_pedamento_endpoint, name='auto-corregir-pedamento'),
path('auditor/auditar-pedamento-incompleto/', auditar_pedamento_incompleto_endpoint, name='auditar-pedamento-incompleto'),
path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'), path('auditor/procesar-pedimentos/organizaciones/', auditor_procesar_pedimentos_organizacion, name='procesar-pedimentos-organizaciones'),
path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'), path('auditor/peticion-respuesta/pedimento-vu/', auditar_peticion_respuesta_pedimento_completo, name='peticion-respuesta-pedimento-vu'),
@@ -95,4 +108,7 @@ urlpatterns = [
path('auditor/obtener-respuesta/acuse-cove-vu/', auditor_obtener_respuesta_acuse_cove_vu, name='obtener-respuesta-acuse-cove-vu'), path('auditor/obtener-respuesta/acuse-cove-vu/', auditor_obtener_respuesta_acuse_cove_vu, name='obtener-respuesta-acuse-cove-vu'),
path('auditor/obtener-peticion/edocument-vu/', auditor_obtener_peticion_edocument_vu, name='obtener-peticion-edocument-vu'), path('auditor/obtener-peticion/edocument-vu/', auditor_obtener_peticion_edocument_vu, name='obtener-peticion-edocument-vu'),
path('auditor/obtener-respuesta/edocument-vu/', auditor_obtener_respuesta_edocument_vu, name='obtener-respuesta-edocument-vu'), path('auditor/obtener-respuesta/edocument-vu/', auditor_obtener_respuesta_edocument_vu, name='obtener-respuesta-edocument-vu'),
path('procesamientopedimentos-ejecutar-comando/', EjecutarComandoView.as_view(), name='procesamientopedimentos-ejecutar-comando'),
] ]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,22 @@
from django.contrib import admin from django.contrib import admin
from .models import Organizacion from .models import Organizacion
# Register your models here.
@admin.register(Organizacion)
class OrganizacionAdmin(admin.ModelAdmin): class OrganizacionAdmin(admin.ModelAdmin):
list_display = ('id', 'nombre', 'rfc', 'email', 'telefono', 'is_active', 'is_verified', 'inicia', 'vencimiento') list_display = ('nombre', 'rfc', 'email', 'telefono', 'owner', 'is_active', 'is_verified', 'inicio', 'vencimiento')
search_fields = ('nombre', 'rfc', 'email') search_fields = ('nombre', 'rfc', 'email')
list_filter = ('is_active', 'is_verified') list_filter = ('is_active', 'is_verified', 'is_agente_aduanal')
ordering = ('nombre',) ordering = ('nombre',)
autocomplete_fields = ('owner',)
# class UsuarioOrganizacionAdmin(admin.ModelAdmin): readonly_fields = ('created_at', 'updated_at')
# list_display = ('id', 'email', 'telefono', 'puesto', 'is_active', 'is_verified') fieldsets = (
# search_fields = ('email', 'telefono', 'puesto') (None, {'fields': ('nombre', 'rfc', 'titular', 'licencia')}),
# list_filter = ('is_active', 'is_verified') ('Contacto', {'fields': ('email', 'telefono', 'estado', 'ciudad')}),
# ordering = ('email',) ('Administrador maestro', {'fields': ('owner',)}),
('Estado', {'fields': ('is_active', 'is_verified', 'is_agente_aduanal', 'apply_auto_download')}),
admin.site.register(Organizacion) ('Vigencia', {'fields': ('inicio', 'vencimiento')}),
# admin.site.register(UsuarioOrganizacion) ('Observaciones', {'fields': ('observaciones',)}),
('Auditoría', {'fields': ('created_at', 'updated_at')}),
)

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,109 @@
import uuid
from django.conf import settings
from django.db import models
class RolePermission(models.Model):
"""Catálogo global de permisos de la aplicación. Se define una vez y es compartido por todas las orgs."""
codename = models.CharField(max_length=100, unique=True)
descripcion = models.CharField(max_length=255)
modulo = models.CharField(max_length=50)
def __str__(self):
return self.codename
class Meta:
db_table = 'rbac_role_permission'
ordering = ['modulo', 'codename']
verbose_name = 'Permiso'
verbose_name_plural = 'Permisos'
class OrganizationRole(models.Model):
"""Rol de una organización. Cada org tiene su propio conjunto de roles con sus permisos."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
organizacion = models.ForeignKey(
'organization.Organizacion',
on_delete=models.CASCADE,
related_name='roles',
)
nombre = models.CharField(max_length=100)
descripcion = models.CharField(max_length=255, blank=True)
# El rol admin maestro no puede ser removido del owner de la org
is_admin_role = models.BooleanField(default=False)
permissions = models.ManyToManyField(
RolePermission,
blank=True,
related_name='roles',
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f'{self.nombre} ({self.organizacion})'
class Meta:
db_table = 'rbac_organization_role'
ordering = ['nombre']
verbose_name = 'Rol de Organización'
verbose_name_plural = 'Roles de Organización'
constraints = [
models.UniqueConstraint(fields=['organizacion', 'nombre'], name='unique_role_per_org'),
]
class UserRole(models.Model):
"""Asignación de un rol a un usuario dentro de su organización."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user_roles',
)
role = models.ForeignKey(
OrganizationRole,
on_delete=models.CASCADE,
related_name='user_roles',
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f'{self.user}{self.role.nombre}'
class Meta:
db_table = 'rbac_user_role'
verbose_name = 'Rol de Usuario'
verbose_name_plural = 'Roles de Usuario'
constraints = [
models.UniqueConstraint(fields=['user', 'role'], name='unique_user_role'),
]
class UserPermission(models.Model):
"""Permiso singular asignado directamente a un usuario, sin necesidad de rol.
granted=True otorga, granted=False deniega explícitamente (override sobre roles)."""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='rbac_permissions',
)
permission = models.ForeignKey(
RolePermission,
on_delete=models.CASCADE,
related_name='user_overrides',
)
granted = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
estado = 'GRANT' if self.granted else 'DENY'
return f'{estado}: {self.user}{self.permission.codename}'
class Meta:
db_table = 'rbac_user_permission'
verbose_name = 'Permiso Singular'
verbose_name_plural = 'Permisos Singulares'
constraints = [
models.UniqueConstraint(fields=['user', 'permission'], name='unique_user_permission'),
]

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

@@ -0,0 +1,176 @@
# Catálogo de permisos y configuración de roles por defecto.
# Este módulo es importado tanto por la data migration como por el signal de Organizacion,
# por lo que NO debe importar modelos directamente al nivel de módulo.
# --- CATÁLOGO DE PERMISOS ---
# (codename, descripcion, modulo)
PERMISSIONS_CATALOG = [
# Usuarios
('usuarios.view', 'Ver usuarios de la organización', 'usuarios'),
('usuarios.create', 'Crear usuarios en la organización', 'usuarios'),
('usuarios.edit', 'Modificar usuarios de la organización', 'usuarios'),
('usuarios.delete', 'Eliminar usuarios de la organización', 'usuarios'),
('usuarios.manage_roles', 'Asignar y revocar roles a usuarios', 'usuarios'),
('usuarios.change_password', 'Cambiar contraseña de otro usuario', 'usuarios'),
# Pedimentos
('pedimentos.view', 'Ver pedimentos', 'pedimentos'),
('pedimentos.create', 'Crear e importar pedimentos', 'pedimentos'),
('pedimentos.edit', 'Modificar pedimentos', 'pedimentos'),
('pedimentos.delete', 'Eliminar pedimentos', 'pedimentos'),
('pedimentos.process', 'Procesar pedimentos contra VUCEM', 'pedimentos'),
# Importadores
('importadores.view', 'Ver importadores', 'importadores'),
('importadores.create', 'Crear importadores', 'importadores'),
('importadores.edit', 'Modificar importadores', 'importadores'),
('importadores.delete', 'Eliminar importadores', 'importadores'),
# Partidas
('partidas.view', 'Ver partidas', 'partidas'),
('partidas.create', 'Crear partidas', 'partidas'),
('partidas.edit', 'Modificar partidas', 'partidas'),
('partidas.delete', 'Eliminar partidas', 'partidas'),
# Remesas
('remesas.view', 'Ver remesas', 'remesas'),
# COVEs
('coves.view', 'Ver COVEs', 'coves'),
('coves.create', 'Crear COVEs', 'coves'),
('coves.edit', 'Modificar COVEs', 'coves'),
('coves.delete', 'Eliminar COVEs', 'coves'),
# E-Documents
('edocuments.view', 'Ver E-Documents', 'edocuments'),
('edocuments.create', 'Crear E-Documents', 'edocuments'),
('edocuments.edit', 'Modificar E-Documents', 'edocuments'),
('edocuments.delete', 'Eliminar E-Documents', 'edocuments'),
# Acuses
('acuses.view', 'Ver acuses', 'acuses'),
# Documentos (expediente)
('documentos.view', 'Ver documentos del expediente', 'documentos'),
('documentos.upload', 'Cargar documentos', 'documentos'),
('documentos.download', 'Descargar documentos y ZIPs', 'documentos'),
('documentos.delete', 'Eliminar documentos del expediente', 'documentos'),
# VUCEM
('vucem.view', 'Ver credenciales VUCEM', 'vucem'),
('vucem.manage', 'Gestionar credenciales VUCEM', 'vucem'),
# Reportes
('reportes.view', 'Ver reportes y dashboard', 'reportes'),
('reportes.export', 'Exportar reportes a CSV/Excel', 'reportes'),
# DataStage
('datastage.view', 'Ver DataStages', 'datastage'),
('datastage.create', 'Crear DataStages', 'datastage'),
('datastage.process', 'Procesar DataStages', 'datastage'),
('datastage.delete', 'Eliminar DataStages', 'datastage'),
# Organización
('organizacion.view', 'Ver datos de la organización', 'organizacion'),
('organizacion.edit', 'Modificar datos de la organización', 'organizacion'),
# Notificaciones
('notificaciones.view', 'Ver notificaciones propias', 'notificaciones'),
('notificaciones.receive', 'Recibir notificaciones automáticas de eventos', 'notificaciones'),
# Cards / Analytics
('cards.view', 'Ver dashboard y analytics', 'cards'),
# Auditoría
('auditoria.view', 'Ver estado y resultados de auditoría VUCEM', 'auditoria'),
('auditoria.process', 'Lanzar procesos de auditoría y reauditoría', 'auditoria'),
]
# Conjuntos reutilizables para armar la matriz de permisos por rol
_IMPORTADORES_FULL = ['importadores.view', 'importadores.create', 'importadores.edit', 'importadores.delete']
_PEDIMENTOS_FULL = ['pedimentos.view', 'pedimentos.create', 'pedimentos.edit', 'pedimentos.delete', 'pedimentos.process']
_PARTIDAS_FULL = ['partidas.view', 'partidas.create', 'partidas.edit', 'partidas.delete']
_COVES_FULL = ['coves.view', 'coves.create', 'coves.edit', 'coves.delete']
_EDOCUMENTS_FULL = ['edocuments.view', 'edocuments.create', 'edocuments.edit', 'edocuments.delete']
_DOCUMENTOS_FULL = ['documentos.view', 'documentos.upload', 'documentos.download', 'documentos.delete']
_VUCEM_FULL = ['vucem.view', 'vucem.manage']
_REPORTES_FULL = ['reportes.view', 'reportes.export']
_DATASTAGE_FULL = ['datastage.view', 'datastage.create', 'datastage.process']
# --- ROLES POR DEFECTO ---
# Cada entrada: nombre → { descripcion, is_admin_role, permissions }
DEFAULT_ROLES = {
'admin': {
'descripcion': 'Administrador de la organización',
'is_admin_role': True,
'permissions': [
'usuarios.view', 'usuarios.create', 'usuarios.edit', 'usuarios.delete',
'usuarios.manage_roles', 'usuarios.change_password',
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
*_IMPORTADORES_FULL,
*_REPORTES_FULL, *_DATASTAGE_FULL,
'organizacion.view', 'organizacion.edit',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view', 'auditoria.process',
],
},
'developer': {
'descripcion': 'Desarrollador con acceso técnico avanzado',
'is_admin_role': False,
'permissions': [
'usuarios.view', 'usuarios.create',
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL, *_IMPORTADORES_FULL,
*_REPORTES_FULL, *_DATASTAGE_FULL,
'organizacion.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view', 'auditoria.process',
],
},
'Agente Aduanal': {
'descripcion': 'Agente aduanal operativo',
'is_admin_role': False,
'permissions': [
*_PEDIMENTOS_FULL, *_PARTIDAS_FULL, 'remesas.view',
*_COVES_FULL, *_EDOCUMENTS_FULL, 'acuses.view',
*_DOCUMENTOS_FULL, *_VUCEM_FULL,
*_REPORTES_FULL,
'organizacion.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
'auditoria.view',
],
},
'user': {
'descripcion': 'Usuario básico de la organización',
'is_admin_role': False,
'permissions': [
'pedimentos.view', 'pedimentos.process',
'partidas.view', 'remesas.view',
'coves.view', 'edocuments.view', 'acuses.view',
'documentos.view', 'documentos.upload', 'documentos.download',
'reportes.view', 'datastage.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
],
},
'Importador': {
'descripcion': 'Importador con acceso filtrado por RFC',
'is_admin_role': False,
'permissions': [
'pedimentos.view', 'partidas.view', 'remesas.view',
'coves.view', 'edocuments.view', 'acuses.view',
'documentos.view', 'documentos.download',
'vucem.view', 'vucem.manage',
'reportes.view',
'notificaciones.view', 'notificaciones.receive', 'cards.view',
],
},
}
def crear_roles_para_organizacion(organizacion):
"""Crea los 5 roles por defecto para una organización, con sus permisos.
Usa get_or_create — seguro de ejecutar múltiples veces."""
from api.rbac.models import RolePermission, OrganizationRole
perms_map = {p.codename: p for p in RolePermission.objects.all()}
for nombre, config in DEFAULT_ROLES.items():
role, created = OrganizationRole.objects.get_or_create(
organizacion=organizacion,
nombre=nombre,
defaults={
'descripcion': config['descripcion'],
'is_admin_role': config.get('is_admin_role', False),
},
)
if created:
role_perms = [perms_map[c] for c in config['permissions'] if c in perms_map]
role.permissions.set(role_perms)

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

@@ -0,0 +1,105 @@
from rest_framework import serializers
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
class RolePermissionSerializer(serializers.ModelSerializer):
class Meta:
model = RolePermission
fields = ['id', 'codename', 'descripcion', 'modulo']
class OrganizationRoleSerializer(serializers.ModelSerializer):
permissions = RolePermissionSerializer(many=True, read_only=True)
permission_ids = serializers.PrimaryKeyRelatedField(
queryset=RolePermission.objects.all(),
many=True,
write_only=True,
source='permissions',
required=False,
)
user_count = serializers.IntegerField(read_only=True)
class Meta:
model = OrganizationRole
fields = [
'id', 'nombre', 'descripcion', 'is_admin_role',
'permissions', 'permission_ids', 'user_count',
'created_at', 'updated_at',
]
read_only_fields = ['id', 'is_admin_role', 'created_at', 'updated_at']
class OrganizationRoleWriteSerializer(serializers.ModelSerializer):
"""Serializer para crear/editar roles — recibe lista de IDs de permisos."""
permission_ids = serializers.PrimaryKeyRelatedField(
queryset=RolePermission.objects.all(),
many=True,
source='permissions',
required=False,
)
class Meta:
model = OrganizationRole
fields = ['nombre', 'descripcion', 'permission_ids']
def create(self, validated_data):
perms = validated_data.pop('permissions', [])
role = OrganizationRole.objects.create(**validated_data)
role.permissions.set(perms)
return role
def update(self, instance, validated_data):
perms = validated_data.pop('permissions', None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if perms is not None:
instance.permissions.set(perms)
return instance
class _UserMinimalSerializer(serializers.Serializer):
id = serializers.UUIDField()
username = serializers.CharField()
email = serializers.EmailField()
first_name = serializers.CharField()
last_name = serializers.CharField()
class _RoleMinimalSerializer(serializers.Serializer):
id = serializers.UUIDField()
nombre = serializers.CharField()
descripcion = serializers.CharField()
class UserRoleSerializer(serializers.ModelSerializer):
user = _UserMinimalSerializer(read_only=True)
role = _RoleMinimalSerializer(read_only=True)
# write
user_id = serializers.UUIDField(write_only=True, source='user')
role_id = serializers.UUIDField(write_only=True, source='role')
class Meta:
model = UserRole
fields = ['id', 'user', 'user_id', 'role', 'role_id', 'created_at']
read_only_fields = ['id', 'created_at']
class UserPermissionSerializer(serializers.ModelSerializer):
user = _UserMinimalSerializer(read_only=True)
permission = RolePermissionSerializer(read_only=True)
# write
user_id = serializers.UUIDField(write_only=True, source='user')
permission_id = serializers.IntegerField(write_only=True, source='permission')
class Meta:
model = UserPermission
fields = ['id', 'user', 'user_id', 'permission', 'permission_id', 'granted', 'created_at']
read_only_fields = ['id', 'created_at']
class MyPermissionsSerializer(serializers.Serializer):
"""Respuesta de /rbac/my-permissions/ — permisos efectivos del usuario autenticado."""
permissions = serializers.ListField(child=serializers.CharField())
roles = serializers.ListField(child=serializers.CharField())

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

@@ -0,0 +1,23 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from api.rbac.views import (
MyPermissionsView,
OrganizationRoleViewSet,
RolePermissionViewSet,
SwitchOrganizationView,
UserPermissionViewSet,
UserRoleViewSet,
)
router = DefaultRouter()
router.register(r'permissions', RolePermissionViewSet, basename='rbac-permission')
router.register(r'roles', OrganizationRoleViewSet, basename='rbac-role')
router.register(r'user-roles', UserRoleViewSet, basename='rbac-user-role')
router.register(r'user-permissions', UserPermissionViewSet, basename='rbac-user-permission')
urlpatterns = [
path('', include(router.urls)),
path('my-permissions/', MyPermissionsView.as_view(), name='rbac-my-permissions'),
path('switch-organization/', SwitchOrganizationView.as_view(), name='rbac-switch-org'),
]

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

@@ -0,0 +1,412 @@
from django.db.models import Count
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from api.rbac.models import OrganizationRole, RolePermission, UserPermission, UserRole
from api.rbac.serializers import (
MyPermissionsSerializer,
OrganizationRoleSerializer,
OrganizationRoleWriteSerializer,
RolePermissionSerializer,
UserPermissionSerializer,
UserRoleSerializer,
)
from core.permissions import OrgScopedPermission, get_org_context, is_internal_service_request, require_permission, user_has_permission
def _require_manage_roles(user):
"""Retorna True si el usuario puede gestionar roles/permisos en su org."""
return user.is_superuser or user_has_permission(user, 'usuarios.manage_roles')
# ---------------------------------------------------------------------------
# Catálogo de permisos (lectura para todos los autenticados con org)
# ---------------------------------------------------------------------------
class RolePermissionViewSet(ReadOnlyModelViewSet):
"""Lista el catálogo global de permisos disponibles, agrupados por módulo."""
my_tags = ['RBAC']
serializer_class = RolePermissionSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
def get_queryset(self):
return RolePermission.objects.all().order_by('modulo', 'codename')
@action(detail=False, methods=['get'], url_path='by-module')
def by_module(self, request):
"""Devuelve el catálogo agrupado por módulo."""
perms = self.get_queryset()
result = {}
for p in perms:
result.setdefault(p.modulo, []).append(
RolePermissionSerializer(p).data
)
return Response(result)
# ---------------------------------------------------------------------------
# Roles de la organización
# ---------------------------------------------------------------------------
class OrganizationRoleViewSet(ModelViewSet):
"""
CRUD de roles de la organización activa.
Solo usuarios con usuarios.manage_roles pueden crear/editar/eliminar.
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
def get_queryset(self):
if is_internal_service_request(self.request):
return (
OrganizationRole.objects
.annotate(user_count=Count('user_roles'))
.prefetch_related('permissions')
.order_by('nombre')
)
org = get_org_context(self.request.user)
if not org:
return OrganizationRole.objects.none()
return (
OrganizationRole.objects
.filter(organizacion=org)
.annotate(user_count=Count('user_roles'))
.prefetch_related('permissions')
.order_by('nombre')
)
def get_serializer_class(self):
if self.action in ('create', 'update', 'partial_update'):
return OrganizationRoleWriteSerializer
return OrganizationRoleSerializer
def _check_manage_roles(self):
if not _require_manage_roles(self.request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
return None
def create(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(organizacion=org)
return Response(
OrganizationRoleSerializer(serializer.instance).data,
status=status.HTTP_201_CREATED,
)
def update(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
instance = self.get_object()
# No se puede cambiar nombre ni permisos de un rol is_admin_role
if instance.is_admin_role and not request.user.is_superuser:
return Response(
{'detail': 'No se puede modificar un rol de administrador.'},
status=status.HTTP_403_FORBIDDEN,
)
return super().update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
err = self._check_manage_roles()
if err:
return err
instance = self.get_object()
if instance.is_admin_role and not request.user.is_superuser:
return Response(
{'detail': 'No se puede eliminar un rol de administrador.'},
status=status.HTTP_403_FORBIDDEN,
)
if instance.user_roles.exists():
return Response(
{'detail': 'No se puede eliminar un rol con usuarios asignados.'},
status=status.HTTP_400_BAD_REQUEST,
)
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Asignación de roles a usuarios
# ---------------------------------------------------------------------------
class UserRoleViewSet(ModelViewSet):
"""
Asigna y revoca roles de usuarios en la organización activa.
Solo usuarios con usuarios.manage_roles pueden modificar.
"""
my_tags = ['RBAC']
serializer_class = UserRoleSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
http_method_names = ['get', 'post', 'delete', 'head', 'options']
def get_queryset(self):
if is_internal_service_request(self.request):
qs = UserRole.objects.select_related('user', 'role')
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
org = get_org_context(self.request.user)
if not org:
return UserRole.objects.none()
qs = (
UserRole.objects
.filter(role__organizacion=org)
.select_related('user', 'role')
)
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
def create(self, request, *args, **kwargs):
if not _require_manage_roles(request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
user_id = request.data.get('user_id')
role_id = request.data.get('role_id')
if not user_id or not role_id:
return Response({'detail': 'user_id y role_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
# Verificar que el rol pertenece a la misma org
try:
role = OrganizationRole.objects.get(id=role_id, organizacion=org)
except OrganizationRole.DoesNotExist:
return Response({'detail': 'El rol no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
# Verificar que el usuario pertenece a la misma org
from api.cuser.models import CustomUser
try:
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
except CustomUser.DoesNotExist:
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
user_role, created = UserRole.objects.get_or_create(user=target_user, role=role)
serializer = self.get_serializer(user_role)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
if not _require_manage_roles(request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
instance = self.get_object()
org = get_org_context(request.user)
# Proteger al owner de la org: no se le puede quitar el rol admin
if org and hasattr(org, 'owner') and org.owner and instance.user == org.owner:
if instance.role.is_admin_role:
return Response(
{'detail': 'No se puede revocar el rol de administrador al propietario de la organización.'},
status=status.HTTP_403_FORBIDDEN,
)
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Permisos singulares (overrides por usuario)
# ---------------------------------------------------------------------------
class UserPermissionViewSet(ModelViewSet):
"""
Otorga o deniega permisos singulares a usuarios, sin necesidad de crear un rol.
granted=true → otorgar; granted=false → denegar explícitamente (override sobre roles).
Solo usuarios con usuarios.manage_roles pueden modificar.
"""
my_tags = ['RBAC']
serializer_class = UserPermissionSerializer
permission_classes = [IsAuthenticated, require_permission('usuarios.manage_roles')]
http_method_names = ['get', 'post', 'patch', 'delete', 'head', 'options']
def get_queryset(self):
if is_internal_service_request(self.request):
qs = UserPermission.objects.select_related('user', 'permission')
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
org = get_org_context(self.request.user)
if not org:
return UserPermission.objects.none()
qs = (
UserPermission.objects
.filter(user__organizacion=org)
.select_related('user', 'permission')
)
user_id = self.request.query_params.get('user_id')
if user_id:
qs = qs.filter(user_id=user_id)
return qs
def _check(self):
if not _require_manage_roles(self.request.user):
return Response(
{'detail': 'Se requiere el permiso usuarios.manage_roles.'},
status=status.HTTP_403_FORBIDDEN,
)
return None
def create(self, request, *args, **kwargs):
err = self._check()
if err:
return err
org = get_org_context(request.user)
if not org:
return Response({'detail': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
user_id = request.data.get('user_id')
permission_id = request.data.get('permission_id')
granted = request.data.get('granted', True)
if not user_id or not permission_id:
return Response({'detail': 'user_id y permission_id son requeridos.'}, status=status.HTTP_400_BAD_REQUEST)
from api.cuser.models import CustomUser
try:
target_user = CustomUser.objects.get(id=user_id, organizacion=org)
except CustomUser.DoesNotExist:
return Response({'detail': 'El usuario no pertenece a esta organización.'}, status=status.HTTP_404_NOT_FOUND)
try:
perm = RolePermission.objects.get(id=permission_id)
except RolePermission.DoesNotExist:
return Response({'detail': 'Permiso no encontrado.'}, status=status.HTTP_404_NOT_FOUND)
override, created = UserPermission.objects.update_or_create(
user=target_user,
permission=perm,
defaults={'granted': granted},
)
serializer = self.get_serializer(override)
return Response(serializer.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
def partial_update(self, request, *args, **kwargs):
err = self._check()
if err:
return err
return super().partial_update(request, *args, **kwargs)
def destroy(self, request, *args, **kwargs):
err = self._check()
if err:
return err
return super().destroy(request, *args, **kwargs)
# ---------------------------------------------------------------------------
# Mis permisos efectivos (para el frontend)
# ---------------------------------------------------------------------------
class MyPermissionsView(APIView):
"""
Retorna los permisos efectivos del usuario autenticado.
El frontend usa esto para decidir qué mostrar/ocultar.
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated & OrgScopedPermission]
def get(self, request):
user = request.user
org = get_org_context(user)
if user.is_superuser:
all_perms = list(RolePermission.objects.values_list('codename', flat=True))
return Response({'permissions': all_perms, 'roles': ['superuser']})
if not org:
return Response({'permissions': [], 'roles': []})
# Roles del usuario en la org
roles = list(
UserRole.objects.filter(user=user, role__organizacion=org)
.values_list('role__nombre', flat=True)
)
# Permisos de roles
perms_set = set(
UserRole.objects.filter(user=user, role__organizacion=org)
.values_list('role__permissions__codename', flat=True)
)
perms_set.discard(None)
# Aplicar overrides singulares
for override in UserPermission.objects.filter(user=user).select_related('permission'):
if override.granted:
perms_set.add(override.permission.codename)
else:
perms_set.discard(override.permission.codename)
return Response({'permissions': sorted(perms_set), 'roles': roles})
# ---------------------------------------------------------------------------
# Switch de organización (solo superusuarios)
# ---------------------------------------------------------------------------
class SwitchOrganizationView(APIView):
"""
Permite a un superusuario cambiar su organización activa.
POST { "organization_id": "<uuid>" } → actualiza active_organization del superuser.
DELETE → limpia active_organization (el superuser queda sin contexto de org).
"""
my_tags = ['RBAC']
permission_classes = [IsAuthenticated]
def post(self, request):
if not request.user.is_superuser:
return Response(
{'detail': 'Solo superusuarios pueden cambiar de organización.'},
status=status.HTTP_403_FORBIDDEN,
)
org_id = request.data.get('organization_id')
if not org_id:
return Response({'detail': 'organization_id es requerido.'}, status=status.HTTP_400_BAD_REQUEST)
from api.organization.models import Organizacion
try:
import uuid as _uuid
org = Organizacion.objects.get(id=_uuid.UUID(str(org_id)))
except (Organizacion.DoesNotExist, ValueError):
return Response({'detail': 'Organización no encontrada.'}, status=status.HTTP_404_NOT_FOUND)
request.user.active_organization = org
request.user.save(update_fields=['active_organization'])
return Response({
'detail': f'Organización activa actualizada a: {org.nombre}',
'organization': {'id': str(org.id), 'nombre': org.nombre},
})
def delete(self, request):
if not request.user.is_superuser:
return Response(
{'detail': 'Solo superusuarios pueden limpiar la organización activa.'},
status=status.HTTP_403_FORBIDDEN,
)
request.user.active_organization = None
request.user.save(update_fields=['active_organization'])
return Response({'detail': 'Organización activa removida.'})

View File

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

View File

@@ -15,13 +15,21 @@ class Document(models.Model):
extension = models.CharField(max_length=60, blank=True, null=True) extension = models.CharField(max_length=60, blank=True, null=True)
size = models.PositiveIntegerField() size = models.PositiveIntegerField()
fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True) fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True)
vu = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
is_new = self._state.adding is_new = self._state.adding
# Calcular automáticamente el campo vu
if self.document_type_id:
# rango de IDs que indican documentos VU
self.vu = 13 <= self.document_type_id <= 26
else:
self.vu = False
# Usar get_or_create en lugar de get para manejar el caso cuando no existe # Usar get_or_create en lugar de get para manejar el caso cuando no existe
uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create( uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create(
organizacion=self.organizacion, organizacion=self.organizacion,

View File

@@ -13,10 +13,18 @@ class DocumentSerializer(serializers.ModelSerializer):
fuente = serializers.PrimaryKeyRelatedField(queryset=Fuente.objects.all()) fuente = serializers.PrimaryKeyRelatedField(queryset=Fuente.objects.all())
class Meta: class Meta:
model = Document model = Document
fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','fuente_nombre','created_at', 'updated_at') fields = ('id', 'organizacion', 'pedimento', 'pedimento_numero', 'archivo', 'document_type', 'size', 'extension', 'fuente','fuente_nombre','created_at', 'updated_at','vu')
read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero') read_only_fields = ('id', 'size', 'extension', 'created_at', 'updated_at', 'pedimento_numero')
def get_pedimento_numero(self, obj): def get_pedimento_numero(self, obj):
# Si es un diccionario (durante create)
if isinstance(obj, dict):
pedimento = obj.get('pedimento')
if pedimento and hasattr(pedimento, 'pedimento_app'):
return pedimento.pedimento_app
return None
# Si es una instancia del modelo (durante retrieve/list)
if obj.pedimento: if obj.pedimento:
return obj.pedimento.pedimento_app return obj.pedimento.pedimento_app
return None return None
@@ -28,9 +36,19 @@ class DocumentSerializer(serializers.ModelSerializer):
return value return value
def get_fuente_nombre(self, obj): def get_fuente_nombre(self, obj):
# Método 1: Si la fuente está precargada con select_related """Obtiene el nombre de la fuente de forma segura"""
if obj.fuente: if isinstance(obj, dict):
return obj.fuente.nombre fuente = obj.get('fuente')
if fuente and hasattr(fuente, 'nombre'):
return fuente.nombre
return "Desconocido"
try:
if obj.fuente:
return obj.fuente.nombre
except AttributeError:
pass
return "Desconocido" return "Desconocido"
class FuenteSerializer(serializers.ModelSerializer): class FuenteSerializer(serializers.ModelSerializer):

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -8,7 +8,8 @@ class TaskFilter(filters.FilterSet):
timestamp_gte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='gte') timestamp_gte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='gte')
timestamp_lte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='lte') timestamp_lte = filters.DateTimeFilter(field_name='timestamp', lookup_expr='lte')
status = filters.CharFilter(field_name='status') status = filters.CharFilter(field_name='status')
organizacion = filters.UUIDFilter(field_name='organizacion__id') # Cambiado a relación directa
class Meta: class Meta:
model = Task model = Task
fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status'] fields = ['servicio', 'pedimento_app', 'pedimento', 'timestamp_gte', 'timestamp_lte', 'status', 'organizacion']

View File

@@ -1,10 +1,12 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import TaskViewSet from .views import TaskViewSet
from django.urls import path, include from django.urls import path, include
from .views import TaskStatusView
router = DefaultRouter() router = DefaultRouter()
router.register(r'tasks', TaskViewSet) router.register(r'tasks', TaskViewSet)
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
path('status/<str:task_id>/', TaskStatusView.as_view(), name='task-status'),
] ]

View File

@@ -1,35 +1,178 @@
from django.shortcuts import render
from rest_framework import viewsets, filters from rest_framework import viewsets, filters
from rest_framework.authentication import TokenAuthentication
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.pagination import PageNumberPagination from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from api.logger.mixins import LoggingMixin from api.logger.mixins import LoggingMixin
from core.permissions import require_permission, user_has_permission, IsInternalService
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
from .models import Task from .models import Task
from .serializers import TaskSerializer from .serializers import TaskSerializer
from .filters import TaskFilter from .filters import TaskFilter
from rest_framework.permissions import IsAuthenticated
# Create your views here.
from core.permissions import (
IsSameOrganization,
IsSameOrganizationDeveloper,
IsSameOrganizationAndAdmin,
IsSuperUser
)
class TaskPagination(PageNumberPagination): class TaskPagination(PageNumberPagination):
page_size = 10 page_size = 10
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 100 max_page_size = 100
class TaskViewSet(LoggingMixin,viewsets.ModelViewSet):
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
# Task se relaciona con pedimento, que tiene contribuyente
campo_contribuyente = 'pedimento__contribuyente'
queryset = Task.objects.select_related('pedimento', 'servicio').all() queryset = Task.objects.select_related('pedimento', 'servicio').all()
serializer_class = TaskSerializer serializer_class = TaskSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
filterset_class = TaskFilter filterset_class = TaskFilter
pagination_class = TaskPagination pagination_class = TaskPagination
ordering_fields = ['timestamp'] ordering_fields = ['timestamp']
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero ordering = ['-timestamp']
my_tags = ['tasks'] my_tags = ['tasks']
def get_permissions(self):
# Escritura: exclusivo para microservicio interno (Token + superuser)
# Lectura: usuarios con pedimentos.view via JWT
if self.action in ('create', 'update', 'partial_update', 'destroy'):
return [IsAuthenticated(), IsInternalService()]
return [IsAuthenticated(), require_permission('pedimentos.view')()]
def get_queryset(self):
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
from rest_framework.response import Response
from rest_framework import status
from celery.result import AsyncResult
class TaskStatusView(APIView):
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.
Fuente de verdad: registro Django Task (actualizado por el microservicio vía PUT).
Celery AsyncResult se usa como complemento para tareas de auditoría masiva (SUCCESS)
y como fallback cuando la tarea no está en la BD todavía.
Estados posibles:
PENDING — en cola o aún no registrada
STARTED — worker ejecutando
SUCCESS — completada sin errores
FAILURE — terminó con error
RETRY — el worker la está reintentando
"""
try:
# 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
response_data = {
'task_id': task_id,
'status': state,
'ready': task_result.ready(),
'successful': task_result.successful() if task_result.ready() else None,
}
if state == 'SUCCESS':
result = task_result.result
response_data['result'] = result
if isinstance(result, dict) and 'total_pedimentos' in result:
total = result.get('total_pedimentos', 0)
completados = result.get('completados', 0)
con_pendientes = result.get('con_pendientes', 0)
con_errores = result.get('con_errores', 0)
if con_pendientes == 0 and con_errores == 0:
response_data['mensaje'] = f'Auditoría completa — {completados}/{total} pedimentos sin pendientes'
else:
partes = []
if con_pendientes:
partes.append(f'{con_pendientes} con documentos pendientes')
if con_errores:
partes.append(f'{con_errores} con error')
response_data['mensaje'] = f'{completados}/{total} pedimentos completos — {", ".join(partes)}'
elif state == 'FAILURE':
response_data['error'] = str(task_result.info)
elif state == 'STARTED':
response_data['info'] = str(task_result.info) if task_result.info else None
return Response(response_data, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{'error': f'Error al consultar tarea: {str(e)}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)

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

194
api/utils/helpers.py Normal file
View File

@@ -0,0 +1,194 @@
# auditoria_xml.py
import xml.etree.ElementTree as ET
from datetime import datetime
def extraer_info_pedimento_xml(xml_content):
"""
Extrae información específica de un XML de pedimento.
"""
try:
# Parsear el XML
root = ET.fromstring(xml_content)
# Buscar el namespace (puede variar)
namespaces = {
'S': 'http://schemas.xmlsoap.org/soap/envelope/',
'ns2': 'http://www.ventanillaunica.gob.mx/pedimentos/ws/oxml/consultarpedimentocompleto',
'ns3': 'http://www.ventanillaunica.gob.mx/common/ws/oxml/respuesta'
}
resultado = {}
# Extraer número de operación
num_op = root.find('.//ns2:numeroOperacion', namespaces)
if num_op is not None and num_op.text:
resultado['numero_operacion'] = num_op.text
# Extraer información del pedimento
pedimento_elem = root.find('.//ns2:pedimento', namespaces)
if pedimento_elem is not None:
# Número de pedimento
ped_num = pedimento_elem.find('ns2:pedimento', namespaces)
if ped_num is not None and ped_num.text:
resultado['numero_pedimento'] = ped_num.text
# Número de partidas
partidas = pedimento_elem.find('ns2:partidas', namespaces)
if partidas is not None and partidas.text:
try:
resultado['numero_partidas'] = int(partidas.text)
except (ValueError, TypeError):
pass
# Tipo de operación clave
tipo_op_clave = pedimento_elem.find('.//ns2:tipoOperacion/ns2:clave', namespaces)
if tipo_op_clave is not None and tipo_op_clave.text:
if tipo_op_clave.text.strip() == '1':
resultado['tipo_operacion'] = 'Importacion'
resultado['tipo_operacion_descripcion'] = 'Indica operacion como Importaciones'
elif tipo_op_clave.text.strip() == '2':
resultado['tipo_operacion'] = 'Exportacion'
resultado['tipo_operacion_descripcion'] = 'Indica operacion de exportacion'
# Clave del documento (clave_pedimento)
clave_doc = pedimento_elem.find('.//ns2:claveDocumento/ns2:clave', namespaces)
if clave_doc is not None and clave_doc.text:
resultado['clave_pedimento'] = clave_doc.text.strip()
# Aduana (patente)
aduana = pedimento_elem.find('.//ns2:aduanaEntradaSalida/ns2:clave', namespaces)
if aduana is not None and aduana.text:
resultado['aduana_clave'] = aduana.text.strip()
# Importador/Exportador
importador = pedimento_elem.find('.//ns2:importadorExportador', namespaces)
if importador is not None:
rfc = importador.find('ns2:rfc', namespaces)
if rfc is not None and rfc.text:
resultado['contribuyente_rfc'] = rfc.text.strip()
razon_social = importador.find('ns2:razonSocial', namespaces)
if razon_social is not None and razon_social.text:
resultado['contribuyente_nombre'] = razon_social.text.strip()
# Valor en dólares
valor_dolares = importador.find('ns2:valorDolares', namespaces)
if valor_dolares is not None and valor_dolares.text:
try:
resultado['valor_dolares'] = float(valor_dolares.text)
except (ValueError, TypeError):
pass
# Aduana de despacho
aduana_despacho = importador.find('ns2:aaduanaDespacho/ns2:clave', namespaces)
if aduana_despacho is not None and aduana_despacho.text:
resultado['aduana_despacho'] = aduana_despacho.text.strip()
# Encabezado del pedimento
encabezado = pedimento_elem.find('ns2:encabezado', namespaces)
if encabezado is not None:
# Aduana
aduana = encabezado.find('ns2:aduanaEntradaSalida/ns2:clave', namespaces)
if aduana is not None and aduana.text:
resultado['aduana_clave'] = aduana.text.strip()
# Tipo de cambio
tipo_cambio = encabezado.find('ns2:tipoCambio', namespaces)
if tipo_cambio is not None and tipo_cambio.text:
try:
resultado['tipo_cambio'] = float(tipo_cambio.text)
except (ValueError, TypeError):
pass
# RFC Agente Aduanal
rfc_agente = encabezado.find('ns2:rfcAgenteAduanalSocFactura', namespaces)
if rfc_agente is not None and rfc_agente.text:
resultado['rfc_agente_aduanal'] = rfc_agente.text.strip()
# CURP Apoderado
curp_apoderado = encabezado.find('ns2:curpApoderadomandatario', namespaces)
if curp_apoderado is not None and curp_apoderado.text:
resultado['curp_apoderado'] = curp_apoderado.text.strip()
# Valor Aduanal Total
valor_aduanal = encabezado.find('ns2:valorAduanalTotal', namespaces)
if valor_aduanal is not None and valor_aduanal.text:
try:
resultado['valor_aduanal_total'] = float(valor_aduanal.text)
except (ValueError, TypeError):
pass
# Valor Comercial Total
valor_comercial = encabezado.find('ns2:valorComercialTotal', namespaces)
if valor_comercial is not None and valor_comercial.text:
try:
resultado['valor_comercial_total'] = float(valor_comercial.text)
except (ValueError, TypeError):
pass
# Fechas
fechas = pedimento_elem.findall('.//ns2:fechas', namespaces)
for fecha_elem in fechas:
fecha = fecha_elem.find('ns2:fecha', namespaces)
clave_fecha = fecha_elem.find('ns2:tipo/ns2:clave', namespaces)
if fecha is not None and fecha.text and clave_fecha is not None and clave_fecha.text:
fecha_texto = fecha.text.strip()
clave_fecha_texto = clave_fecha.text.strip()
# Mapeo de claves según especificación
if clave_fecha_texto == '1': # Entrada
resultado['fecha_entrada'] = fecha_texto
elif clave_fecha_texto == '2': # Pago
resultado['fecha_pago'] = fecha_texto
elif clave_fecha_texto == '3': # Extracción
resultado['fecha_extraccion'] = fecha_texto
elif clave_fecha_texto == '5': # Presentación
resultado['fecha_presentacion'] = fecha_texto
elif clave_fecha_texto == '6': # Importación
resultado['fecha_importacion'] = fecha_texto
elif clave_fecha_texto == '7': # Original
resultado['fecha_original'] = fecha_texto
else:
resultado[f'fecha_clave_{clave_fecha_texto}'] = fecha_texto
# Facturas (para COVEs)
facturas = pedimento_elem.findall('.//ns2:facturas', namespaces)
coves_encontrados = []
for factura in facturas:
numero = factura.find('ns2:numero', namespaces)
if numero is not None and numero.text:
coves_encontrados.append(numero.text.strip())
if coves_encontrados:
resultado['coves_en_xml'] = coves_encontrados
# E-Documents
identificadores = pedimento_elem.findall('.//ns2:identificadores/ns2:identificadores', namespaces)
edocs_encontrados = []
for ident in identificadores:
clave = ident.find('claveIdentificador/descripcion', namespaces)
complemento = ident.find('complemento1', namespaces)
if clave is not None and clave.text and 'E_DOCUMENT' in clave.text:
if complemento is not None and complemento.text:
edocs_encontrados.append(complemento.text.strip())
if edocs_encontrados:
resultado['edocuments_en_xml'] = edocs_encontrados
# Verificar si hay error en la respuesta
tiene_error = root.find('.//ns3:tieneError', namespaces)
if tiene_error is not None:
resultado['tiene_error'] = tiene_error.text.lower() == 'true'
return resultado
except ET.ParseError as e:
return {'error_parse': str(e)}
except Exception as e:
return {'error': str(e)}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

42
core/redis_events.py Normal file
View File

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

View File

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

View File

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