Compare commits
24 Commits
T2026-01-0
...
feature/pe
| Author | SHA1 | Date | |
|---|---|---|---|
| 709a5dedab | |||
| 94846fec8a | |||
| e378f2d949 | |||
| a318b70324 | |||
| 9bbed42cf3 | |||
| 1966218081 | |||
| b57ce83dc5 | |||
|
|
c2ae752932 | ||
|
|
8cc0b9f573 | ||
|
|
3a636c14ae | ||
|
|
63f051c566 | ||
| c890e79394 | |||
|
|
39504e196c | ||
| 69d07f2713 | |||
|
|
27c8d24a56 | ||
| 627d78f4b8 | |||
|
|
4c7eb22b28 | ||
| 30b6d73567 | |||
|
|
460da47571 | ||
| 32aff7649e | |||
|
|
d115cdd072 | ||
| 28d2eaedda | |||
| f2bf904c84 | |||
| 271c562654 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||||
@@ -8,10 +8,9 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
|
||||||
from core.permissions import (
|
from core.permissions import (
|
||||||
IsSameOrganization,
|
get_org_context,
|
||||||
IsSameOrganizationDeveloper,
|
require_permission,
|
||||||
IsSameOrganizationAndAdmin,
|
user_has_permission,
|
||||||
IsSuperUser
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from api.organization.models import UsoAlmacenamiento, Organizacion
|
from api.organization.models import UsoAlmacenamiento, Organizacion
|
||||||
@@ -34,7 +33,7 @@ class DocumentUtilInformation(LoggingMixin, APIView, FiltroPorOrganizacionMixin)
|
|||||||
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
|
View to get the total storage used by the organization and stats of documents added in last 1, 7, and 30 days.
|
||||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
@@ -100,7 +99,7 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
|||||||
View para obtener información de uso de servicios relacionados con pedimentos.
|
View para obtener información de uso de servicios relacionados con pedimentos.
|
||||||
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
|
Devuelve la cantidad de procesos por estado (1: espera, 2: proceso, 3: finalizado, 4: error) para la organización.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
@@ -140,29 +139,17 @@ class ViewPedimentoServicesUtilInformation(LoggingMixin, APIView, FiltroPorOrgan
|
|||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Si es super usuario, devuelve todos los procesos
|
org = get_org_context(self.request.user)
|
||||||
if self.request.user.is_superuser:
|
if not org:
|
||||||
return ProcesamientoPedimento.objects.all()
|
return ProcesamientoPedimento.objects.none()
|
||||||
|
|
||||||
# Si es Administrador de la organizacion devuelve todos los servicios de la organizacion
|
if self.request.user.is_importador:
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
return ProcesamientoPedimento.objects.filter(
|
||||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
pedimento__organizacion=org,
|
||||||
|
pedimento__contribuyente__in=self.request.user.rfc.all(),
|
||||||
|
)
|
||||||
|
|
||||||
# Si es Desarrollador de la organizacion devuelve todos los servicios de la organizacion
|
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=org)
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='developer').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
|
||||||
|
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='user').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.all()
|
|
||||||
|
|
||||||
# Si es importador de la organizacion, devuelve los servicios relacionados con sus pedimentos
|
|
||||||
if self.request.user.is_authenticated and self.request.user.groups.filter(name='importador').exists() and self.request.user.is_importador and self.request.user.groups.filter(name='user').exists():
|
|
||||||
return self.request.user.organizacion.procesamiento_pedimentos.filter(pedimento__contribuyente=self.request.user.rfc)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Si es parte de una organización, filtrar por esa organización
|
|
||||||
return ProcesamientoPedimento.objects.filter(pedimento__organizacion=self.request.user.organizacion)
|
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
queryset = self.get_queryset()
|
queryset = self.get_queryset()
|
||||||
@@ -193,12 +180,21 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
Endpoint para análisis de actividades de usuario.
|
Endpoint para análisis de actividades de usuario.
|
||||||
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
|
Devuelve el conteo de acciones por tipo y los 5 usuarios más activos.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
|
|
||||||
model = UserActivity
|
model = UserActivity
|
||||||
|
campo_organizacion = 'user__organizacion'
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
|
def get_queryset_importador(self):
|
||||||
|
# Importadores solo ven sus propias actividades
|
||||||
|
user = self.request.user
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return UserActivity.objects.none()
|
||||||
|
return UserActivity.objects.filter(user__organizacion=org, user=user)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
|
operation_description="Get analysis of user activities. Permite filtrar por fecha de actividades.",
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
@@ -253,6 +249,8 @@ class UserActivityAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_importador:
|
||||||
|
return self.get_queryset_importador()
|
||||||
return self.get_queryset_filtrado()
|
return self.get_queryset_filtrado()
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -289,11 +287,20 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
Endpoint para análisis de logs de peticiones.
|
Endpoint para análisis de logs de peticiones.
|
||||||
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
|
Devuelve el conteo por método, los paths más solicitados y el promedio de tiempo de respuesta.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = RequestLog
|
model = RequestLog
|
||||||
|
campo_organizacion = 'user__organizacion'
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|
||||||
|
def get_queryset_importador(self):
|
||||||
|
# Importadores solo ven sus propios logs
|
||||||
|
user = self.request.user
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return RequestLog.objects.none()
|
||||||
|
return RequestLog.objects.filter(user__organizacion=org, user=user)
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
|
operation_description="Get analysis of request logs. Permite filtrar por fecha de logs.",
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
@@ -345,6 +352,8 @@ class RequestLogAnalysis(LoggingMixin, APIView, FiltroPorOrganizacionMixin):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
|
if self.request.user.is_importador:
|
||||||
|
return self.get_queryset_importador()
|
||||||
return self.get_queryset_filtrado()
|
return self.get_queryset_filtrado()
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
@@ -376,7 +385,7 @@ class LastDocumentView(LoggingMixin, APIView, DocumentosFiltradosMixin):
|
|||||||
View que obtiene los ultimos 10 documentos agregados.
|
View que obtiene los ultimos 10 documentos agregados.
|
||||||
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
Permite filtrar por fecha usando los parámetros ?fecha_inicio=YYYY-MM-DD&fecha_fin=YYYY-MM-DD
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
permission_classes = [IsAuthenticated, require_permission('cards.view')]
|
||||||
model = Document
|
model = Document
|
||||||
|
|
||||||
my_tags = ['Cards']
|
my_tags = ['Cards']
|
||||||
|
|||||||
@@ -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')}),
|
||||||
)
|
)
|
||||||
|
|||||||
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal file
57
api/cuser/migrations/0005_customuser_rfc_fk_to_m2m.py
Normal 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',
|
||||||
|
),
|
||||||
|
]
|
||||||
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal file
25
api/cuser/migrations/0006_customuser_active_organization.py
Normal 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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ from core.permissions import (
|
|||||||
IsSameOrganization,
|
IsSameOrganization,
|
||||||
IsSameOrganizationDeveloper,
|
IsSameOrganizationDeveloper,
|
||||||
IsSameOrganizationAndAdmin,
|
IsSameOrganizationAndAdmin,
|
||||||
IsSuperUser
|
IsSuperUser,
|
||||||
|
get_org_context,
|
||||||
|
is_internal_service_request,
|
||||||
|
user_has_permission,
|
||||||
|
require_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .serializers import CustomUserSerializer
|
from .serializers import CustomUserSerializer
|
||||||
@@ -74,78 +78,62 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
"""
|
"""
|
||||||
ViewSet for CustomUser model.
|
ViewSet for CustomUser model.
|
||||||
"""
|
"""
|
||||||
permission_classes = [IsAuthenticated & (IsSuperUser | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSameOrganization )]
|
|
||||||
pagination_class = CustomPagination
|
pagination_class = CustomPagination
|
||||||
model = CustomUser
|
model = CustomUser
|
||||||
serializer_class = CustomUserSerializer
|
serializer_class = CustomUserSerializer
|
||||||
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
|
filterset_fields = ['username', 'email', 'first_name', 'last_name', 'organizacion', 'is_importador']
|
||||||
my_tags = ['User Profile']
|
my_tags = ['User Profile']
|
||||||
|
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
# Permitir eliminar usuarios solo a admin, Agente Aduanal y user de la misma organización
|
if self.action in ('me', 'change_password'):
|
||||||
if self.action == 'destroy':
|
return [IsAuthenticated()]
|
||||||
user = self.request.user
|
perms = {
|
||||||
if not (
|
'list': 'usuarios.view',
|
||||||
user.is_superuser or
|
'retrieve': 'usuarios.view',
|
||||||
user.groups.filter(name='admin').exists() or
|
'create': 'usuarios.create',
|
||||||
user.groups.filter(name='Agente Aduanal').exists() or
|
'update': 'usuarios.edit',
|
||||||
user.groups.filter(name='user').exists()
|
'partial_update': 'usuarios.edit',
|
||||||
):
|
'destroy': 'usuarios.delete',
|
||||||
from rest_framework.exceptions import PermissionDenied
|
}
|
||||||
raise PermissionDenied("Solo admin, Agente Aduanal o user pueden eliminar usuarios.")
|
codename = perms.get(self.action, 'usuarios.view')
|
||||||
elif self.action in ['create', 'update', 'partial_update']:
|
return [IsAuthenticated(), require_permission(codename)()]
|
||||||
if not (self.request.user.is_superuser or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Importador').exists()) :
|
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
raise PermissionDenied("Solo admin o superusuario pueden modificar usuarios.")
|
|
||||||
return super().get_permissions()
|
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
# Solo permitir eliminar usuarios de la misma organización
|
user = self.request.user
|
||||||
if self.request.user.is_superuser or instance.organizacion == self.request.user.organizacion:
|
org = get_org_context(user)
|
||||||
|
if user.is_superuser or instance.organizacion == org:
|
||||||
instance.delete()
|
instance.delete()
|
||||||
else:
|
else:
|
||||||
from rest_framework.exceptions import PermissionDenied
|
|
||||||
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
|
raise PermissionDenied("Solo puedes eliminar usuarios de tu organización.")
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
# Si es importador, solo puede ver su propio usuario
|
user = self.request.user
|
||||||
if self.request.user.groups.filter(name='importador').exists() or self.request.user.groups.filter(name='Importador').exists():
|
if is_internal_service_request(self.request):
|
||||||
return CustomUser.objects.filter(pk=self.request.user.pk)
|
return CustomUser.objects.all()
|
||||||
|
if not user_has_permission(user, 'usuarios.view'):
|
||||||
# Otros roles: filtrar por organización
|
return CustomUser.objects.none()
|
||||||
return self.get_queryset_filtrado_por_organizacion()
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return CustomUser.objects.none()
|
||||||
|
return CustomUser.objects.filter(organizacion=org)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
# Always assign the creator's organization
|
creator = self.request.user
|
||||||
if self.request.user.groups.filter(name='admin').exists() and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
if not self.request.user.organizacion:
|
|
||||||
raise PermissionDenied("Los administradores deben tener una organización asignada para crear usuarios.")
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if creator.is_superuser:
|
||||||
# If superuser, allow creating users without organization
|
|
||||||
user = serializer.save(is_active=False)
|
user = serializer.save(is_active=False)
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
send_activation_email(user, self.request)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='developer').exists():
|
if creator.is_importador:
|
||||||
# Developers can create users but must assign an organization
|
|
||||||
if not self.request.user.organizacion:
|
|
||||||
raise PermissionDenied("Los desarrolladores deben tener una organización asignada para crear usuarios.")
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
|
||||||
# No puedes crear un usuario si eres importador
|
|
||||||
raise PermissionDenied("Los importadores no pueden crear usuarios.")
|
raise PermissionDenied("Los importadores no pueden crear usuarios.")
|
||||||
|
|
||||||
user = serializer.save(organizacion=self.request.user.organizacion, is_active=False)
|
org = get_org_context(creator)
|
||||||
send_activation_email(user, self.request) # Usa template HTML
|
if not org:
|
||||||
return
|
raise PermissionDenied("Debes tener una organización asignada para crear usuarios.")
|
||||||
|
|
||||||
|
user = serializer.save(organizacion=org, is_active=False)
|
||||||
|
send_activation_email(user, self.request)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
@action(detail=False, methods=['get'], permission_classes=[IsAuthenticated])
|
||||||
def me(self, request):
|
def me(self, request):
|
||||||
@@ -167,8 +155,11 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
"""
|
"""
|
||||||
user = self.get_object()
|
user = self.get_object()
|
||||||
current_user = request.user
|
current_user = request.user
|
||||||
# Solo el propio usuario, admin o superuser pueden cambiar la contraseña
|
puede_cambiar_ajena = (
|
||||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists() or user == current_user):
|
current_user.is_superuser or
|
||||||
|
user_has_permission(current_user, 'usuarios.change_password')
|
||||||
|
)
|
||||||
|
if not (puede_cambiar_ajena or user == current_user):
|
||||||
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
|
raise PermissionDenied("No tienes permiso para cambiar la contraseña de este usuario.")
|
||||||
|
|
||||||
old_password = request.data.get('old_password')
|
old_password = request.data.get('old_password')
|
||||||
@@ -176,8 +167,7 @@ class CustomUserViewSet(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
|||||||
if not new_password:
|
if not new_password:
|
||||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||||
|
|
||||||
# Si no es admin/superuser, debe validar old_password
|
if not puede_cambiar_ajena:
|
||||||
if not (current_user.is_superuser or current_user.groups.filter(name='admin').exists()):
|
|
||||||
if not old_password or not user.check_password(old_password):
|
if not old_password or not user.check_password(old_password):
|
||||||
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
|
return Response({'detail': 'La contraseña actual es incorrecta.'}, status=400)
|
||||||
|
|
||||||
@@ -226,11 +216,11 @@ class ProfilePictureView(LoggingMixin, APIView):
|
|||||||
my_tags = ['User Profile']
|
my_tags = ['User Profile']
|
||||||
|
|
||||||
def get(self, request, user_id):
|
def get(self, request, user_id):
|
||||||
# Obtiene el usuario (automáticamente 404 si no existe)
|
|
||||||
user = get_object_or_404(CustomUser, pk=user_id)
|
user = get_object_or_404(CustomUser, pk=user_id)
|
||||||
|
|
||||||
# El permiso IsOwnerOrAdmin ya verificó que request.user == user o es admin
|
org = get_org_context(request.user)
|
||||||
# Así que no necesitas validar manualmente los permisos aquí.
|
if not request.user.is_superuser and user.organizacion != org:
|
||||||
|
raise Http404("No autorizado")
|
||||||
|
|
||||||
if not user.profile_picture:
|
if not user.profile_picture:
|
||||||
raise Http404("El usuario no tiene imagen de perfil")
|
raise Http404("El usuario no tiene imagen de perfil")
|
||||||
@@ -267,6 +257,8 @@ class PasswordResetConfirmView(APIView):
|
|||||||
return Response({'detail': 'Enlace inválido.'}, status=400)
|
return Response({'detail': 'Enlace inválido.'}, status=400)
|
||||||
if not default_token_generator.check_token(user, token):
|
if not default_token_generator.check_token(user, token):
|
||||||
return Response({'detail': 'Token inválido o expirado.'}, status=400)
|
return Response({'detail': 'Token inválido o expirado.'}, status=400)
|
||||||
|
if not user.is_active:
|
||||||
|
return Response({'detail': 'La cuenta de usuario no está activa.'}, status=400)
|
||||||
password = request.data.get('password')
|
password = request.data.get('password')
|
||||||
if not password:
|
if not password:
|
||||||
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
return Response({'detail': 'La nueva contraseña es requerida.'}, status=400)
|
||||||
|
|||||||
117
api/customs/management/commands/fix_archivo_case.py
Normal file
117
api/customs/management/commands/fix_archivo_case.py
Normal 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())
|
||||||
382
api/customs/management/commands/fix_partidas_error.py
Normal file
382
api/customs/management/commands/fix_partidas_error.py
Normal 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."))
|
||||||
50
api/customs/migrations/0017_bulkuploadtask.py
Normal file
50
api/customs/migrations/0017_bulkuploadtask.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal file
18
api/customs/migrations/0019_pedimento_consultar_vucem.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -34,6 +34,7 @@ class Pedimento(models.Model):
|
|||||||
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
|
fecha_pago = models.DateField(help_text="Fecha de pago del pedimento", blank=True, null=True)
|
||||||
|
|
||||||
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
|
alerta = models.BooleanField(default=False, help_text="Indica si el pedimento tiene una alerta asociada")
|
||||||
|
consultar_vucem = models.BooleanField(default=False, help_text="Solo pedimentos originados desde datastage deben consultar VUCEM automáticamente")
|
||||||
|
|
||||||
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
|
contribuyente = models.ForeignKey('Importador', on_delete=models.CASCADE, related_name='pedimentos', help_text="Contribuyente asociado al pedimento", blank=True, null=True)
|
||||||
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
|
agente_aduanal = models.CharField(max_length=100, blank=True, null=True, help_text="RFC del agente aduanal")
|
||||||
|
|||||||
@@ -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,10 +240,11 @@ 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:
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ def trigger_celery_task_on_create(sender, instance, created, **kwargs):
|
|||||||
logger.info("NO es creación de pedimento, no se crea procesamiento.")
|
logger.info("NO es creación de pedimento, no se crea procesamiento.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not instance.consultar_vucem:
|
||||||
|
return
|
||||||
|
|
||||||
def crear_procesamiento():
|
def crear_procesamiento():
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
@@ -87,8 +90,11 @@ def trigger_celery_task_on_cove_create(sender, instance, created, **kwargs):
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
|
logger.info(f"Cove creado: {instance.id}, creando procesamiento...")
|
||||||
crear_procesamiento_cove.apply_async(args=[str(instance.pedimento.id)])
|
pedimento_id = str(instance.pedimento.id)
|
||||||
crear_procesamiento_acuse_cove.apply_async(args=[str(instance.pedimento.id)])
|
def enqueue_cove_tasks():
|
||||||
|
crear_procesamiento_cove.apply_async(args=[pedimento_id])
|
||||||
|
crear_procesamiento_acuse_cove.apply_async(args=[pedimento_id])
|
||||||
|
transaction.on_commit(enqueue_cove_tasks)
|
||||||
|
|
||||||
@receiver(post_save, sender=EDocument)
|
@receiver(post_save, sender=EDocument)
|
||||||
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
|
def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs):
|
||||||
@@ -96,5 +102,8 @@ def trigger_celery_task_on_edocument_create(sender, instance, created, **kwargs)
|
|||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger('api.customs.async_operations')
|
logger = logging.getLogger('api.customs.async_operations')
|
||||||
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
|
logger.info(f"EDocument creado: {instance.id}, creando procesamiento...")
|
||||||
crear_procesamiento_edocument.apply_async(args=[str(instance.pedimento.id)])
|
pedimento_id = str(instance.pedimento.id)
|
||||||
crear_procesamiento_acuse.apply_async(args=[str(instance.pedimento.id)])
|
def enqueue_edocument_tasks():
|
||||||
|
crear_procesamiento_edocument.apply_async(args=[pedimento_id])
|
||||||
|
crear_procesamiento_acuse.apply_async(args=[pedimento_id])
|
||||||
|
transaction.on_commit(enqueue_edocument_tasks)
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
from .microservice import *
|
from .microservice import *
|
||||||
from .internal_services import *
|
from .internal_services import *
|
||||||
|
from .bulk_upload import *
|
||||||
|
from .microservice_v2 import *
|
||||||
|
|||||||
@@ -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
|
for idx, pedimento in enumerate(pedimentos):
|
||||||
if pedimento.numero_partidas is not None and pedimento.numero_partidas > 0:
|
try:
|
||||||
partidas_existentes = pedimento.partidas.count()
|
if not pedimento.numero_partidas or pedimento.numero_partidas <= 0:
|
||||||
if pedimento.numero_partidas > partidas_existentes:
|
sin_datos.append({
|
||||||
print(f"Procesando pedimento {pedimento.id} ({pedimentos_procesados}/{total_pedimentos}) - Partidas existentes: {partidas_existentes}, Requeridas: {pedimento.numero_partidas}")
|
'pedimento_id': str(pedimento.id),
|
||||||
|
'pedimento': pedimento.pedimento,
|
||||||
|
'razon': f'numero_partidas inválido ({pedimento.numero_partidas})',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
for i in range(1, pedimento.numero_partidas + 1):
|
for i in range(1, pedimento.numero_partidas + 1):
|
||||||
from api.customs.models import Partida
|
Partida.objects.get_or_create(
|
||||||
partida, created = Partida.objects.get_or_create(
|
|
||||||
pedimento=pedimento,
|
pedimento=pedimento,
|
||||||
numero_partida=i,
|
numero_partida=i,
|
||||||
organizacion_id=organizacion_id
|
defaults={'organizacion_id': organizacion_id}
|
||||||
)
|
)
|
||||||
if created:
|
|
||||||
partidas_agregadas_pedimento += 1
|
|
||||||
total_partidas_agregadas += 1
|
|
||||||
|
|
||||||
print(f" → Partidas agregadas para pedimento {pedimento.id}: {partidas_agregadas_pedimento}")
|
partidas = list(pedimento.partidas.order_by('numero_partida'))
|
||||||
else:
|
no_descargadas = [p.numero_partida for p in partidas if not p.descargado]
|
||||||
print(f"Pedimento {pedimento.id} ya tiene todas sus partidas ({partidas_existentes}/{pedimento.numero_partidas})")
|
|
||||||
else:
|
|
||||||
print(f"Pedimento {pedimento.id} omitido - numero_partidas: {pedimento.numero_partidas} (inválido)")
|
|
||||||
|
|
||||||
print(f"\n=== RESUMEN ===")
|
if not no_descargadas:
|
||||||
print(f"Pedimentos procesados: {pedimentos_procesados}")
|
completados.append(str(pedimento.id))
|
||||||
print(f"Total de partidas agregadas: {total_partidas_agregadas}")
|
else:
|
||||||
print(f"Procesamiento completado para organización {organizacion_id}")
|
con_pendientes.append({
|
||||||
|
'pedimento_id': str(pedimento.id),
|
||||||
|
'pedimento': pedimento.pedimento,
|
||||||
|
'total_partidas': len(partidas),
|
||||||
|
'descargadas': len(partidas) - len(no_descargadas),
|
||||||
|
'no_descargadas': no_descargadas,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
errores.append({
|
||||||
|
'pedimento_id': str(pedimento.id),
|
||||||
|
'pedimento': pedimento.pedimento,
|
||||||
|
'error': str(e),
|
||||||
|
})
|
||||||
|
logger.error(f"Error creando partidas para pedimento {pedimento.id}: {e}")
|
||||||
|
|
||||||
|
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,
|
"""
|
||||||
|
pedimentos = obtener_pedimentos(organizacion_id)
|
||||||
|
total_pedimentos = pedimentos.count()
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
publish_task_event(task_id, "processing", f"Auditando {label}: {total_pedimentos} pedimentos", progress=0)
|
||||||
|
|
||||||
|
completados = []
|
||||||
|
pendientes = []
|
||||||
|
errores = []
|
||||||
|
|
||||||
|
for idx, pedimento in enumerate(pedimentos):
|
||||||
|
try:
|
||||||
|
docs = list(getattr(pedimento, related_name).all())
|
||||||
|
total = len(docs)
|
||||||
|
faltantes = [
|
||||||
|
getattr(doc, 'numero_cove', None) or getattr(doc, 'numero_edocument', None)
|
||||||
|
for doc in docs if not getattr(doc, variable)
|
||||||
|
]
|
||||||
|
|
||||||
|
if total == 0 or len(faltantes) == 0:
|
||||||
|
nuevo_estado = 3
|
||||||
|
completados.append(str(pedimento.id))
|
||||||
|
else:
|
||||||
|
nuevo_estado = 4
|
||||||
|
pendientes.append({
|
||||||
|
'pedimento_id': str(pedimento.id),
|
||||||
|
'pedimento': pedimento.pedimento,
|
||||||
|
f'faltantes_{label}': faltantes,
|
||||||
|
'total': total,
|
||||||
|
'descargados': total - len(faltantes),
|
||||||
|
})
|
||||||
|
|
||||||
|
modificar_estado_procesamiento(pedimento, servicio_id=servicio, nuevo_estado=nuevo_estado)
|
||||||
|
|
||||||
|
# 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,
|
servicio=8,
|
||||||
related_name='coves',
|
related_name='coves',
|
||||||
variable='cove_descargado',
|
variable='cove_descargado',
|
||||||
mensaje='COVE'
|
label='cove',
|
||||||
|
task_id=self.request.id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True)
|
||||||
def auditar_acuse_cove(organizacion_id):
|
def auditar_acuse_cove(self, organizacion_id, user_id=None):
|
||||||
for pedimento in obtener_pedimentos(organizacion_id):
|
return _auditar_organizacion(
|
||||||
auditor_descargas(
|
organizacion_id,
|
||||||
pedimento,
|
|
||||||
servicio=9,
|
servicio=9,
|
||||||
related_name='coves',
|
related_name='coves',
|
||||||
variable='acuse_cove_descargado',
|
variable='acuse_cove_descargado',
|
||||||
mensaje='acuse de COVE'
|
label='acuse_cove',
|
||||||
|
task_id=self.request.id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Revisa si el pedimento completo todos sus acuse coves
|
@shared_task(bind=True)
|
||||||
|
def auditar_edocuments(self, organizacion_id, user_id=None):
|
||||||
# Auditar edocuments
|
return _auditar_organizacion(
|
||||||
@shared_task
|
organizacion_id,
|
||||||
def auditar_edocuments(organizacion_id):
|
|
||||||
for pedimento in obtener_pedimentos(organizacion_id):
|
|
||||||
auditor_descargas(
|
|
||||||
pedimento,
|
|
||||||
servicio=7,
|
servicio=7,
|
||||||
related_name='documentos',
|
related_name='documentos',
|
||||||
variable='edocument_descargado',
|
variable='edocument_descargado',
|
||||||
mensaje='EDocument'
|
label='edocument',
|
||||||
|
task_id=self.request.id,
|
||||||
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@shared_task
|
@shared_task(bind=True)
|
||||||
def auditar_acuse(organizacion_id):
|
def auditar_acuse(self, organizacion_id, user_id=None):
|
||||||
for pedimento in obtener_pedimentos(organizacion_id):
|
return _auditar_organizacion(
|
||||||
auditor_descargas(
|
organizacion_id,
|
||||||
pedimento,
|
|
||||||
servicio=6,
|
servicio=6,
|
||||||
related_name='documentos',
|
related_name='documentos',
|
||||||
variable='acuse_descargado',
|
variable='acuse_descargado',
|
||||||
mensaje='acuse'
|
label='acuse',
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
477
api/customs/tasks/auto_corregir.py
Normal file
477
api/customs/tasks/auto_corregir.py
Normal 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)
|
||||||
|
# 13–26 = 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: 3–12 (documentos de contenido: pedimento, remesas, acuse,
|
||||||
|
edocument, estado, cove, digitalizacion, error, general).
|
||||||
|
Tipos excluidos: 1 (partida), 2 (ya procesado), 13–26 (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
|
||||||
710
api/customs/tasks/bulk_upload.py
Normal file
710
api/customs/tasks/bulk_upload.py
Normal 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}")
|
||||||
@@ -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,13 +183,24 @@ 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:
|
||||||
|
try:
|
||||||
with open(f'./media/{pc.archivo}', 'r') as f:
|
with open(f'./media/{pc.archivo}', 'r') as f:
|
||||||
xml_content = f.read()
|
xml_content = f.read()
|
||||||
|
|
||||||
@@ -209,9 +237,35 @@ def auditar_pedimentos(organizacion_id):
|
|||||||
# Si ya existe por unique, recupera el objeto existente
|
# Si ya existe por unique, recupera el objeto existente
|
||||||
Cove.objects.get(numero_cove=cove)
|
Cove.objects.get(numero_cove=cove)
|
||||||
except:
|
except:
|
||||||
# Si ya existe por unique, recupera el objeto existente
|
|
||||||
pass
|
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():
|
||||||
from organization.models import Organizacion
|
from organization.models import Organizacion
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio de COVEs enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuse_coves_pedimento(pedimento_id):
|
def procesar_acuse_coves_pedimento(pedimento_id):
|
||||||
@@ -93,12 +120,18 @@ def procesar_acuse_coves_pedimento(pedimento_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio de acuses de COVEs enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses de COVEs para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_edocs_pedimento(pedimento_id):
|
def procesar_edocs_pedimento(pedimento_id):
|
||||||
@@ -116,12 +149,18 @@ def procesar_edocs_pedimento(pedimento_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/download/edoc/",
|
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio de E-documents enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando E-documents para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_acuses_pedimento(pedimento_id):
|
def procesar_acuses_pedimento(pedimento_id):
|
||||||
@@ -139,12 +178,18 @@ def procesar_acuses_pedimento(pedimento_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio de acuses enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando acuses para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_partidas_pedimento(pedimento_id):
|
def procesar_partidas_pedimento(pedimento_id):
|
||||||
@@ -156,18 +201,31 @@ def procesar_partidas_pedimento(pedimento_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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
print(f"Servicio de partidas enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logging.info(
|
||||||
|
f"Partidas encoladas para pedimento {pedimento.pedimento}: "
|
||||||
|
f"{result.get('total', 0)} de {len(partidas_pendientes)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(
|
||||||
|
f"Error encolando partidas para pedimento {pedimento.pedimento}: {e}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
f"{SERVICE_API_URL_V2}/services/remesas",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio de remesas enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"Remesa encolada para pedimento {pedimento.pedimento}")
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logging.error(f"Error encolando remesa para pedimento {pedimento.pedimento}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
@shared_task
|
@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,23 +268,43 @@ def procesar_pedimento_completo_individual(pedimento_id):
|
|||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
response.raise_for_status()
|
||||||
|
logging.info(f"Pedimento completo encolado: {pedimento.pedimento}")
|
||||||
return response
|
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:
|
if not credenciales:
|
||||||
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
|
print(f"No se encontraron credenciales para el pedimento {pedimento.pedimento_app}")
|
||||||
@@ -231,23 +315,45 @@ def procesar_pedimentos_completos(organizacion_id):
|
|||||||
"pedimento": pedimento_dict,
|
"pedimento": pedimento_dict,
|
||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
url = f"{SERVICE_API_URL_V2}/services/pedimento_completo"
|
||||||
|
dataJson = json.dumps(payload)
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/pedimento_completo",
|
url,
|
||||||
data=json.dumps(payload),
|
data=dataJson,
|
||||||
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()
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
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)
|
||||||
|
|
||||||
@@ -256,15 +362,17 @@ def procesar_remesas(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/remesas",
|
f"{SERVICE_API_URL_V2}/services/remesas/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
response.raise_for_status()
|
||||||
|
logger.info(f"Remesa encolada para pedimento {pedimento.pedimento} — status {response.status_code}")
|
||||||
|
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
except Exception as e:
|
||||||
|
logger.error(f"Error procesando remesa para pedimento {pedimento.pedimento}: {e}", exc_info=True)
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def procesar_coves(organizacion_id):
|
def procesar_coves(organizacion_id):
|
||||||
@@ -287,14 +395,18 @@ def procesar_coves(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/coves",
|
f"{SERVICE_API_URL_V2}/services/all/coves",
|
||||||
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()
|
||||||
|
logging.info(f"COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
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):
|
||||||
@@ -318,14 +430,18 @@ def procesar_acuse_coves(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
f"{SERVICE_API_URL_V2}/services/all/acuse/cove/",
|
||||||
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()
|
||||||
|
logging.info(f"Acuses de COVEs encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
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):
|
||||||
@@ -349,14 +465,18 @@ def procesar_acuses(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
f"{SERVICE_API_URL_V2}/services/all/acuse/pedimento/",
|
||||||
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()
|
||||||
|
logging.info(f"Acuses encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
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):
|
||||||
@@ -380,14 +500,18 @@ def procesar_edocs(organizacion_id):
|
|||||||
"credencial": credenciales_dict
|
"credencial": credenciales_dict
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
f"{SERVICE_API_URL_V2}/services/download/all/edocs/",
|
||||||
data=json.dumps(payload),
|
data=json.dumps(payload),
|
||||||
headers={"Content-Type": "application/json"}
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=60
|
||||||
)
|
)
|
||||||
# Aquí puedes continuar con el resto de tu lógica
|
response.raise_for_status()
|
||||||
|
logging.info(f"E-documents encolados para pedimento {pedimento.pedimento}: {response.json().get('total', '?')}")
|
||||||
print(f"Servicio enviado para pedimento {pedimento.pedimento}")
|
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):
|
||||||
@@ -397,27 +521,40 @@ def procesar_partidas(organizacion_id):
|
|||||||
).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()
|
|
||||||
|
|
||||||
|
pedimento_dict = pedimento_to_dict(pedimento)
|
||||||
|
credenciales = Vucem.objects.filter(
|
||||||
|
id=CredencialesImportador.objects.filter(rfc=pedimento.contribuyente).first().vucem.id
|
||||||
|
).first()
|
||||||
credenciales_dict = credenciales_to_dict(credenciales)
|
credenciales_dict = credenciales_to_dict(credenciales)
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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):
|
||||||
@@ -489,6 +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"
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ from .views_auditor import (
|
|||||||
auditar_acuse_cove_endpoint,
|
auditar_acuse_cove_endpoint,
|
||||||
auditar_edocuments_endpoint,
|
auditar_edocuments_endpoint,
|
||||||
auditar_acuse_endpoint,
|
auditar_acuse_endpoint,
|
||||||
|
auditar_remesas_endpoint,
|
||||||
auditar_cove_pedimento_endpoint,
|
auditar_cove_pedimento_endpoint,
|
||||||
auditar_acuse_cove_pedimento_endpoint,
|
auditar_acuse_cove_pedimento_endpoint,
|
||||||
auditar_edocument_pedimento_endpoint,
|
auditar_edocument_pedimento_endpoint,
|
||||||
@@ -61,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 = [
|
||||||
@@ -72,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'),
|
||||||
|
|||||||
1861
api/customs/views.py
1861
api/customs/views.py
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal file
18
api/datastage/migrations/0012_alter_datastage_archivo.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal file
26
api/datastage/migrations/0013_registro501_add_timestamps.py
Normal 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=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -84,6 +85,8 @@ class Registro501(models.Model):
|
|||||||
consulta = models.CharField(max_length=50, null=True, blank=True)
|
consulta = models.CharField(max_length=50, null=True, blank=True)
|
||||||
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
|
datastage = models.ForeignKey(DataStage, on_delete=models.CASCADE, related_name='registro501s', null=True, blank=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'registro501'
|
db_table = 'registro501'
|
||||||
|
|
||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
# Obtener datastage
|
||||||
|
try:
|
||||||
datastage = DataStage.objects.get(id=datastage_id)
|
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:
|
||||||
|
try:
|
||||||
user_organizacion = Organizacion.objects.get(id=user_organizacion_id)
|
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
|
|
||||||
except Exception as e:
|
|
||||||
continue
|
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
|
||||||
|
field_type = field.get_internal_type()
|
||||||
|
val = data.get(field.name)
|
||||||
|
if val == '' or val is None:
|
||||||
data[field.name] = None
|
data[field.name] = None
|
||||||
# Convertir fecha_pago_real a timezone-aware si existe
|
continue
|
||||||
if 'fecha_pago_real' in data and data['fecha_pago_real']:
|
if field_type == 'DateTimeField' and isinstance(val, str):
|
||||||
from django.utils import timezone
|
|
||||||
import datetime
|
|
||||||
fecha_val = data['fecha_pago_real']
|
|
||||||
if isinstance(fecha_val, str):
|
|
||||||
try:
|
|
||||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d %H:%M:%S')
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
dt = datetime.datetime.strptime(fecha_val, '%Y-%m-%d')
|
|
||||||
except Exception:
|
|
||||||
dt = None
|
dt = None
|
||||||
|
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d'):
|
||||||
|
try:
|
||||||
|
dt = datetime.datetime.strptime(val, fmt)
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
if dt and timezone.is_naive(dt):
|
if dt and timezone.is_naive(dt):
|
||||||
dt = timezone.make_aware(dt)
|
dt = timezone.make_aware(dt)
|
||||||
if dt:
|
data[field.name] = dt
|
||||||
data['fecha_pago_real'] = dt
|
|
||||||
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
|
||||||
|
|
||||||
|
# Bulk create
|
||||||
if objects_to_create:
|
if objects_to_create:
|
||||||
try:
|
try:
|
||||||
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
Model.objects.bulk_create(objects_to_create, batch_size=1000)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {'archivo': asc_name, 'error': str(e), 'traceback': traceback.format_exc()}
|
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)
|
||||||
@@ -245,32 +322,10 @@ def procesar_archivo_asc_task(datastage_id, user_organizacion_id, asc_name):
|
|||||||
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}
|
|
||||||
@@ -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,135 +12,147 @@ from rest_framework.decorators import action
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from django.http import FileResponse, Http404
|
from django.http import FileResponse, Http404
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from .models import DataStage
|
from .models import DataStage
|
||||||
from .serializer import DataStageSerializer
|
from .serializer import DataStageSerializer
|
||||||
|
|
||||||
from api.logger.mixins import LoggingMixin
|
from api.logger.mixins import LoggingMixin
|
||||||
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
|
from core.permissions import get_org_context, is_internal_service_request, require_permission
|
||||||
from core.permissions import (
|
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
class DataStagePagination(PageNumberPagination):
|
class DataStagePagination(PageNumberPagination):
|
||||||
page_size = 20 # Valor por defecto
|
page_size = 20 # Valor por defecto
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = 'page_size'
|
||||||
max_page_size = 1000
|
max_page_size = 1000
|
||||||
|
|
||||||
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
class DataStageViewSet(LoggingMixin, viewsets.ModelViewSet):
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ViewSet for managing DataStage instances.
|
ViewSet for managing DataStage instances.
|
||||||
Provides CRUD operations for DataStage.
|
Provides CRUD operations for DataStage.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
serializer_class = DataStageSerializer
|
serializer_class = DataStageSerializer
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
model = DataStage
|
model = DataStage
|
||||||
my_tags = ['DataStage']
|
my_tags = ['DataStage']
|
||||||
pagination_class = DataStagePagination
|
pagination_class = DataStagePagination
|
||||||
|
|
||||||
|
def get_permissions(self):
|
||||||
|
perms = {
|
||||||
|
'list': 'datastage.view',
|
||||||
|
'retrieve': 'datastage.view',
|
||||||
|
'create': 'datastage.create',
|
||||||
|
'update': 'datastage.create',
|
||||||
|
'partial_update': 'datastage.create',
|
||||||
|
'destroy': 'datastage.delete',
|
||||||
|
'procesar': 'datastage.process',
|
||||||
|
'download_datastage': 'datastage.view',
|
||||||
|
'task_status': 'datastage.view',
|
||||||
|
}
|
||||||
|
codename = perms.get(self.action, 'datastage.view')
|
||||||
|
return [IsAuthenticated(), require_permission(codename)()]
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if self.request.user.is_superuser:
|
if is_internal_service_request(self.request):
|
||||||
return DataStage.objects.all().order_by('-created_at')
|
return DataStage.objects.all().order_by('-created_at')
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
if self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='Agente Aduanal').exists():
|
if not org:
|
||||||
return DataStage.objects.filter(organizacion=self.request.user.organizacion).order_by('-created_at')
|
return DataStage.objects.none()
|
||||||
|
return DataStage.objects.filter(organizacion=org).order_by('-created_at')
|
||||||
return self.get_queryset_filtrado_por_organizacion().order_by('-created_at')
|
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""
|
org = get_org_context(self.request.user)
|
||||||
Permite que la organización sea opcional en el request, pero si no se envía, se asigna la del usuario autenticado.
|
datastage = serializer.save(organizacion=org)
|
||||||
"""
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
||||||
raise ValueError("Usuario no autenticado o sin organización")
|
|
||||||
|
|
||||||
data = serializer.validated_data
|
|
||||||
organizacion = data.get('organizacion')
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Permitir que el superusuario cree sin organización o la especifique
|
|
||||||
datastage = serializer.save()
|
|
||||||
self._trigger_processing(datastage)
|
self._trigger_processing(datastage)
|
||||||
return
|
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
|
||||||
if not organizacion:
|
|
||||||
datastage = serializer.save(organizacion=self.request.user.organizacion)
|
|
||||||
else:
|
|
||||||
datastage = serializer.save()
|
|
||||||
|
|
||||||
self._trigger_processing(datastage)
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
raise ValueError("No cuentas con los permisos necesarios para crear un DataStage")
|
|
||||||
|
|
||||||
def _trigger_processing(self, datastage):
|
def _trigger_processing(self, datastage):
|
||||||
"""
|
|
||||||
Método helper para disparar el procesamiento.
|
|
||||||
"""
|
|
||||||
from api.datastage.tasks import procesar_datastage_task
|
from api.datastage.tasks import procesar_datastage_task
|
||||||
user_organizacion = getattr(self.request.user, 'organizacion', None)
|
org = get_org_context(self.request.user)
|
||||||
user_organizacion_id = user_organizacion.id if user_organizacion else None
|
|
||||||
|
|
||||||
datastage.procesado = True
|
datastage.procesado = True
|
||||||
datastage.save()
|
datastage.save()
|
||||||
|
procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||||
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
"""
|
if is_internal_service_request(self.request):
|
||||||
Override to ensure organization is set on update.
|
|
||||||
"""
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
|
||||||
raise ValueError("Usuario no autenticado o sin organización")
|
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Allow superuser to update without organization
|
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return
|
return
|
||||||
|
org = get_org_context(self.request.user)
|
||||||
|
serializer.save(organizacion=org)
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
def perform_destroy(self, instance):
|
||||||
serializer.save(organizacion=self.request.user.organizacion)
|
if instance.archivo:
|
||||||
return
|
storage_service.delete_file(instance.archivo)
|
||||||
|
instance.delete()
|
||||||
raise ValueError("No cuentas con los permisos necesarios para actualizar un DataStage")
|
|
||||||
|
|
||||||
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
|
@action(detail=True, methods=['get'], url_path='download-datastage', url_name='download-datastage')
|
||||||
def download_datastage(self, request, pk=None):
|
def download_datastage(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
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))
|
|
||||||
|
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
|
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):
|
||||||
"""
|
"""
|
||||||
Endpoint para procesar el DataStage de forma asíncrona usando Celery.
|
Endpoint para procesar el DataStage de forma asíncrona usando Celery.
|
||||||
"""
|
"""
|
||||||
# ojo aqui
|
|
||||||
from api.datastage.tasks import procesar_datastage_task
|
from api.datastage.tasks import procesar_datastage_task
|
||||||
datastage = self.get_object()
|
datastage = self.get_object()
|
||||||
user_organizacion = getattr(self.request.user, 'organizacion', None)
|
org = get_org_context(self.request.user)
|
||||||
user_organizacion_id = user_organizacion.id if user_organizacion else None
|
task = procesar_datastage_task.delay(datastage.id, org.id if org else None)
|
||||||
task = procesar_datastage_task.delay(datastage.id, user_organizacion_id)
|
|
||||||
return Response({
|
return Response({
|
||||||
'task_id': task.id,
|
'task_id': task.id,
|
||||||
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'
|
'detail': 'Procesamiento iniciado. Puede consultar el estado con el task_id.'
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
0
api/management/__init__.py
Normal file
0
api/management/__init__.py
Normal file
0
api/management/commands/__init__.py
Normal file
0
api/management/commands/__init__.py
Normal file
472
api/management/commands/migrate_to_minio.py
Normal file
472
api/management/commands/migrate_to_minio.py
Normal 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']}"
|
||||||
|
)
|
||||||
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal file
18
api/notificaciones/migrations/0002_notificacion_datos.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|
||||||
|
|
||||||
@@ -4,31 +4,43 @@ from django.dispatch import receiver
|
|||||||
from api.notificaciones.models import Notificacion
|
from api.notificaciones.models import Notificacion
|
||||||
from api.record.models import Document
|
from api.record.models import Document
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Document)
|
@receiver(post_save, sender=Document)
|
||||||
def trigger_notificacion(sender, instance, created, **kwargs):
|
def trigger_notificacion(sender, instance, created, **kwargs):
|
||||||
if created:
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
from api.cuser.models import CustomUser
|
from api.cuser.models import CustomUser
|
||||||
from api.customs.models import Pedimento
|
|
||||||
from api.notificaciones.models import TipoNotificacion
|
from api.notificaciones.models import TipoNotificacion
|
||||||
|
from core.permissions import user_has_permission
|
||||||
|
|
||||||
# Obtener el tipo de notificación (puedes ajustar el nombre si tienes tipos definidos)
|
tipo_info, _ = TipoNotificacion.objects.get_or_create(
|
||||||
tipo_info, _ = TipoNotificacion.objects.get_or_create(tipo="info", defaults={"descripcion": "Notificación informativa"})
|
tipo='info',
|
||||||
|
defaults={'descripcion': 'Notificación informativa'},
|
||||||
|
)
|
||||||
|
|
||||||
|
mensaje = (
|
||||||
|
f"Se agregó el documento {instance.archivo} "
|
||||||
|
f"al pedimento {instance.pedimento.pedimento}\n"
|
||||||
|
f"{instance.document_type.nombre}"
|
||||||
|
)
|
||||||
|
|
||||||
|
usuarios_org = CustomUser.objects.filter(
|
||||||
|
organizacion=instance.organizacion,
|
||||||
|
is_active=True,
|
||||||
|
).prefetch_related('rfc')
|
||||||
|
|
||||||
# Notificar a todos los usuarios de la organización
|
|
||||||
usuarios_org = CustomUser.objects.filter(organizacion=instance.organizacion)
|
|
||||||
for usuario in usuarios_org:
|
for usuario in usuarios_org:
|
||||||
# Notificar solo a importadores cuyo RFC coincide
|
if not user_has_permission(usuario, 'notificaciones.receive'):
|
||||||
if (usuario.is_importador or usuario.groups.filter(name='Importador').exists()):
|
continue
|
||||||
if usuario.rfc == instance.pedimento.contribuyente:
|
|
||||||
|
# Importadores: solo si el pedimento corresponde a uno de sus RFC
|
||||||
|
if usuario.is_importador:
|
||||||
|
if instance.pedimento.contribuyente not in usuario.rfc.all():
|
||||||
|
continue
|
||||||
|
|
||||||
Notificacion.objects.create(
|
Notificacion.objects.create(
|
||||||
tipo=tipo_info,
|
tipo=tipo_info,
|
||||||
dirigido=usuario,
|
dirigido=usuario,
|
||||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
mensaje=mensaje,
|
||||||
)
|
|
||||||
# Notificar a otros roles (no importadores)
|
|
||||||
elif (usuario.is_superuser or usuario.groups.filter(name='Agente Aduanal').exists() or usuario.groups.filter(name='admin').exists()):
|
|
||||||
Notificacion.objects.create(
|
|
||||||
tipo=tipo_info,
|
|
||||||
dirigido=usuario,
|
|
||||||
mensaje=f"Se agregó el documento {instance.archivo} al pedimento {instance.pedimento.pedimento} \n {instance.document_type.nombre}",
|
|
||||||
)
|
)
|
||||||
@@ -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()
|
||||||
|
return
|
||||||
raise PermissionDenied("No tienes permiso para crear notificaciones para otros usuarios")
|
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)
|
||||||
|
|||||||
@@ -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')}),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
25
api/organization/migrations/0004_organizacion_owner.py
Normal file
25
api/organization/migrations/0004_organizacion_owner.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ from core.permissions import (
|
|||||||
IsSameOrganization,
|
IsSameOrganization,
|
||||||
IsSameOrganizationDeveloper,
|
IsSameOrganizationDeveloper,
|
||||||
IsSameOrganizationAndAdmin,
|
IsSameOrganizationAndAdmin,
|
||||||
IsSuperUser
|
IsSuperUser,
|
||||||
|
get_org_context,
|
||||||
|
is_internal_service_request,
|
||||||
|
user_has_permission,
|
||||||
)
|
)
|
||||||
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
|
from .serializers import OrganizacionSerializer, UsoAlmacenamientoSerializer
|
||||||
from .models import Organizacion, UsoAlmacenamiento
|
from .models import Organizacion, UsoAlmacenamiento
|
||||||
@@ -32,22 +35,20 @@ class ViewSetOrganizacion(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltr
|
|||||||
my_tags = ['Organizaciones']
|
my_tags = ['Organizaciones']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
user = self.request.user
|
||||||
|
if not user.is_authenticated:
|
||||||
return Organizacion.objects.none()
|
return Organizacion.objects.none()
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if is_internal_service_request(self.request):
|
||||||
# Superuser can see all organizations
|
|
||||||
return Organizacion.objects.all()
|
return Organizacion.objects.all()
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='admin').exists() or self.request.user.groups.filter('developer').exists() or self.request.user.groups.filter('user')) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
org = get_org_context(user)
|
||||||
# Importers can only see their own organization
|
if not org:
|
||||||
return Organizacion.objects.filter(users=self.request.user)
|
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
|
||||||
return Organizacion.objects.filter(users=self.request.user)
|
|
||||||
|
|
||||||
return Organizacion.objects.none()
|
return Organizacion.objects.none()
|
||||||
|
|
||||||
|
# Superuser ve solo su org activa, no todas
|
||||||
|
return Organizacion.objects.filter(id=org.id)
|
||||||
|
|
||||||
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
Vista para consultar el uso de almacenamiento
|
Vista para consultar el uso de almacenamiento
|
||||||
@@ -60,31 +61,26 @@ class UsoAlmacenamientoViewSet(LoggingMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
my_tags = ['Uso de Almacenamiento']
|
my_tags = ['Uso de Almacenamiento']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not self.request.user.is_authenticated:
|
||||||
return UsoAlmacenamiento.objects.none()
|
return UsoAlmacenamiento.objects.none()
|
||||||
|
|
||||||
|
if is_internal_service_request(self.request):
|
||||||
if self.request.user.is_superuser:
|
|
||||||
# Superuser can see all storage usage
|
|
||||||
return UsoAlmacenamiento.objects.all()
|
return UsoAlmacenamiento.objects.all()
|
||||||
|
|
||||||
if (self.request.user.groups.filter(name='developer').exists() or
|
org = get_org_context(self.request.user)
|
||||||
self.request.user.groups.filter(name='admin').exists() or
|
if not org:
|
||||||
self.request.user.groups.filter(name='user').exists()) and self.request.user.groups.filter(name='Agente Aduanal').exists():
|
return UsoAlmacenamiento.objects.none()
|
||||||
# Developers, Admins, and Users can see their organization's storage usage
|
|
||||||
return UsoAlmacenamiento.objects.filter(organizacion=self.request.user.organizacion)
|
|
||||||
|
|
||||||
if self.request.user.groups.filter(name='importador').exists():
|
if self.request.user.is_importador:
|
||||||
# Importers can only see their own organization's storage usage
|
|
||||||
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
|
raise PermissionDenied("Los importadores no tienen acceso al uso de almacenamiento.")
|
||||||
|
|
||||||
return UsoAlmacenamiento.objects.none()
|
return UsoAlmacenamiento.objects.filter(organizacion=org)
|
||||||
|
|
||||||
@action(detail=False, methods=['get'])
|
@action(detail=False, methods=['get'])
|
||||||
def mi_organizacion(self, request):
|
def mi_organizacion(self, request):
|
||||||
|
|
||||||
"""Obtiene el uso de almacenamiento de la organización del usuario actual"""
|
"""Obtiene el uso de almacenamiento de la organización del usuario actual"""
|
||||||
organizacion = request.user.organizacion
|
organizacion = get_org_context(request.user)
|
||||||
|
|
||||||
# Obtener o crear el registro de uso
|
# Obtener o crear el registro de uso
|
||||||
uso, created = UsoAlmacenamiento.objects.get_or_create(
|
uso, created = UsoAlmacenamiento.objects.get_or_create(
|
||||||
|
|||||||
0
api/rbac/__init__.py
Normal file
0
api/rbac/__init__.py
Normal file
99
api/rbac/admin.py
Normal file
99
api/rbac/admin.py
Normal 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
8
api/rbac/apps.py
Normal 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'
|
||||||
0
api/rbac/management/__init__.py
Normal file
0
api/rbac/management/__init__.py
Normal file
0
api/rbac/management/commands/__init__.py
Normal file
0
api/rbac/management/commands/__init__.py
Normal file
101
api/rbac/management/commands/sync_rbac.py
Normal file
101
api/rbac/management/commands/sync_rbac.py
Normal 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}')
|
||||||
116
api/rbac/migrations/0001_initial.py
Normal file
116
api/rbac/migrations/0001_initial.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
88
api/rbac/migrations/0002_data_permissions.py
Normal file
88
api/rbac/migrations/0002_data_permissions.py
Normal 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),
|
||||||
|
]
|
||||||
56
api/rbac/migrations/0003_notificaciones_receive.py
Normal file
56
api/rbac/migrations/0003_notificaciones_receive.py
Normal 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),
|
||||||
|
]
|
||||||
57
api/rbac/migrations/0004_auditoria_permissions.py
Normal file
57
api/rbac/migrations/0004_auditoria_permissions.py
Normal 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),
|
||||||
|
]
|
||||||
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal file
18
api/rbac/migrations/0005_alter_rolepermission_id.py
Normal 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
api/rbac/migrations/__init__.py
Normal file
0
api/rbac/migrations/__init__.py
Normal file
109
api/rbac/models.py
Normal file
109
api/rbac/models.py
Normal 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
176
api/rbac/roles.py
Normal 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
105
api/rbac/serializers.py
Normal 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
23
api/rbac/urls.py
Normal 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
412
api/rbac/views.py
Normal 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.'})
|
||||||
18
api/record/migrations/0003_document_vu.py
Normal file
18
api/record/migrations/0003_document_vu.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -17,6 +17,14 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||||||
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 isinstance(obj, dict):
|
||||||
|
fuente = obj.get('fuente')
|
||||||
|
if fuente and hasattr(fuente, 'nombre'):
|
||||||
|
return fuente.nombre
|
||||||
|
return "Desconocido"
|
||||||
|
|
||||||
|
try:
|
||||||
if obj.fuente:
|
if obj.fuente:
|
||||||
return obj.fuente.nombre
|
return obj.fuente.nombre
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return "Desconocido"
|
return "Desconocido"
|
||||||
|
|
||||||
class FuenteSerializer(serializers.ModelSerializer):
|
class FuenteSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
@@ -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
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal file
18
api/reports/migrations/0002_reportdocument_report_type.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal file
18
api/reports/migrations/0003_alter_reportdocument_file.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
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'
|
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,14 +308,39 @@ def generate_report_control_pedimento(report_id):
|
|||||||
writer.writerow(fila)
|
writer.writerow(fila)
|
||||||
|
|
||||||
|
|
||||||
with open(file_path, 'rb') as f:
|
with open(tmp_path, 'rb') as f:
|
||||||
report.file.save(filename, ContentFile(f.read()), save=True)
|
file_content = f.read()
|
||||||
|
|
||||||
|
uploaded_file = SimpleUploadedFile(
|
||||||
|
name=filename,
|
||||||
|
content=file_content,
|
||||||
|
content_type='text/csv'
|
||||||
|
)
|
||||||
|
|
||||||
|
ruta = storage_service.save_report(
|
||||||
|
file=uploaded_file,
|
||||||
|
organizacion_id=filters.get('organizacion_id'),
|
||||||
|
metadata={
|
||||||
|
'report_id': str(report.id),
|
||||||
|
'report_type': 'control_pedimento',
|
||||||
|
'user_id': str(report.user.id) if report.user else None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
if ruta:
|
||||||
|
report.file = ruta
|
||||||
report.status = 'ready'
|
report.status = 'ready'
|
||||||
|
else:
|
||||||
|
report.status = 'error'
|
||||||
|
report.error_message = 'Error al guardar el archivo en storage'
|
||||||
|
|
||||||
report.finished_at = timezone.now()
|
report.finished_at = timezone.now()
|
||||||
report.save(update_fields=['status', 'file', 'finished_at'])
|
report.save(update_fields=['status', 'file', 'finished_at', 'error_message'])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if report:
|
||||||
report.status = 'error'
|
report.status = 'error'
|
||||||
report.error_message = str(e)
|
report.error_message = str(e)
|
||||||
report.finished_at = timezone.now()
|
report.finished_at = timezone.now()
|
||||||
|
|||||||
@@ -1,57 +1,31 @@
|
|||||||
from warnings import filters
|
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from api.customs.models import Pedimento, Cove, EDocument, Partida
|
|
||||||
from api.record.models import Document
|
|
||||||
from api.organization.models import Organizacion
|
|
||||||
from django.db.models import Count, Q
|
|
||||||
|
|
||||||
# Registrar endpoint en urls.py:
|
|
||||||
# path('dashboard/summary/', dashboard_summary)
|
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
|
||||||
from drf_yasg import openapi
|
|
||||||
from .serializers import ExportModelSerializer
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from django.http import HttpResponse
|
|
||||||
import openpyxl
|
|
||||||
from django.apps import apps
|
|
||||||
from rest_framework import status
|
|
||||||
from django.shortcuts import render
|
|
||||||
from rest_framework import viewsets
|
|
||||||
|
|
||||||
from .serializers import ExportModelSerializer
|
|
||||||
from core.permissions import (
|
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
|
|
||||||
import csv
|
|
||||||
import io
|
|
||||||
import openpyxl
|
|
||||||
from django.http import HttpResponse
|
|
||||||
from django.apps import apps
|
|
||||||
from rest_framework.views import APIView
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
|
||||||
from drf_yasg import openapi
|
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
from core.permissions import (
|
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
from .serializers import ExportModelSerializer
|
|
||||||
import uuid
|
import uuid
|
||||||
import datetime
|
import datetime
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
from django.apps import apps
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import Count, Q
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from drf_yasg import openapi
|
||||||
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
|
from rest_framework import status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
from api.customs.models import Cove, EDocument, Partida, Pedimento
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
from api.record.models import Document
|
||||||
|
from core.permissions import (
|
||||||
|
get_org_context,
|
||||||
|
require_permission,
|
||||||
|
user_has_permission,
|
||||||
|
)
|
||||||
|
from .serializers import ExportModelSerializer
|
||||||
|
|
||||||
def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
|
def export_model_to_csv(request, model_name, fields, module='datastage', filters=None):
|
||||||
model = apps.get_model(module, model_name)
|
model = apps.get_model(module, model_name)
|
||||||
@@ -110,7 +84,11 @@ def export_model_to_excel(request, model_name, fields, module='datastage', filte
|
|||||||
|
|
||||||
class ExportDataStageView(APIView):
|
class ExportDataStageView(APIView):
|
||||||
my_tags = ['Reportes-DataStage']
|
my_tags = ['Reportes-DataStage']
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.request.method == 'GET':
|
||||||
|
return [IsAuthenticated(), require_permission('reportes.view')()]
|
||||||
|
return [IsAuthenticated(), require_permission('reportes.export')()]
|
||||||
|
|
||||||
# Constantes para partición
|
# Constantes para partición
|
||||||
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
|
# MAX_RECORDS_PER_FILE = 100 # Límite seguro por archivo
|
||||||
@@ -135,6 +113,27 @@ class ExportDataStageView(APIView):
|
|||||||
else:
|
else:
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""Retorna RFCs distintos de Registro501 para la organización activa del usuario."""
|
||||||
|
try:
|
||||||
|
Registro501 = apps.get_model('datastage', 'Registro501')
|
||||||
|
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
if not org:
|
||||||
|
return Response({'error': 'Sin organización activa'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
qs = Registro501.objects.filter(organizacion=org)
|
||||||
|
|
||||||
|
rfcs = (
|
||||||
|
qs.exclude(rfc__isnull=True)
|
||||||
|
.exclude(rfc='')
|
||||||
|
.values_list('rfc', flat=True)
|
||||||
|
.distinct()
|
||||||
|
.order_by('rfc')
|
||||||
|
)
|
||||||
|
return Response({'rfcs': list(rfcs)})
|
||||||
|
except LookupError:
|
||||||
|
return Response({'rfcs': []})
|
||||||
|
|
||||||
@swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
|
@swagger_auto_schema(request_body=ExportModelSerializer, responses={200: 'Archivo generado (Excel o CSV)'})
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
@@ -148,6 +147,23 @@ class ExportDataStageView(APIView):
|
|||||||
else:
|
else:
|
||||||
return self.handle_simple_export(request)
|
return self.handle_simple_export(request)
|
||||||
|
|
||||||
|
def _resolve_org_filter(self, global_filters, user):
|
||||||
|
"""
|
||||||
|
Devuelve los global_filters asegurando que siempre haya una organización.
|
||||||
|
La org se obtiene de active_organization (superuser) o del campo organizacion (usuario normal).
|
||||||
|
Retorna (filters_dict, error_response_or_None).
|
||||||
|
"""
|
||||||
|
filters = dict(global_filters or {})
|
||||||
|
if not filters.get('organizacion'):
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return None, Response(
|
||||||
|
{'error': 'Sin organización activa'},
|
||||||
|
status=status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
filters['organizacion'] = str(org.id)
|
||||||
|
return filters, None
|
||||||
|
|
||||||
def handle_simple_export(self, request):
|
def handle_simple_export(self, request):
|
||||||
"""Maneja exportación simple de DataStage (un solo modelo)"""
|
"""Maneja exportación simple de DataStage (un solo modelo)"""
|
||||||
model_name = request.data.get('model')
|
model_name = request.data.get('model')
|
||||||
@@ -159,6 +175,10 @@ class ExportDataStageView(APIView):
|
|||||||
if not model_name or not fields:
|
if not model_name or not fields:
|
||||||
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
global_filters, err = self._resolve_org_filter(global_filters, request.user)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model = apps.get_model(module, model_name)
|
model = apps.get_model(module, model_name)
|
||||||
filters = self.apply_global_filters_to_model(global_filters, model, request.user)
|
filters = self.apply_global_filters_to_model(global_filters, model, request.user)
|
||||||
@@ -190,18 +210,16 @@ class ExportDataStageView(APIView):
|
|||||||
if not models_data:
|
if not models_data:
|
||||||
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': 'models are required for multiple export'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
global_filters, err = self._resolve_org_filter(global_filters, request.user)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
|
||||||
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
|
related_keys = self.get_related_keys_from_filters(global_filters, models_data, request.user)
|
||||||
|
|
||||||
if export_type == 'excel':
|
if export_type == 'excel':
|
||||||
# Siempre usar el método particionado inteligente para Excel
|
|
||||||
return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
|
return self.export_datastage_multiple_partitioned_excel_agrupados(request, models_data, global_filters, related_keys)
|
||||||
else:
|
else:
|
||||||
# Para CSV, podemos mantener la lógica actual o mejorarla
|
return self.export_datastage_multiple_to_csv_combined(request, models_data, global_filters, related_keys)
|
||||||
total_estimated_records = self.estimate_total_records(models_data, global_filters, related_keys, request.user)
|
|
||||||
if total_estimated_records > self.MAX_RECORDS_PER_FILE:
|
|
||||||
return self.export_datastage_multiple_partitioned_csv(request, models_data, global_filters, related_keys)
|
|
||||||
else:
|
|
||||||
return self.export_datastage_multiple_to_csv(request, models_data, global_filters, related_keys)
|
|
||||||
|
|
||||||
def estimate_total_records(self, models_data, global_filters, related_keys, user):
|
def estimate_total_records(self, models_data, global_filters, related_keys, user):
|
||||||
"""Estima el total de registros para todos los modelos"""
|
"""Estima el total de registros para todos los modelos"""
|
||||||
@@ -282,17 +300,11 @@ class ExportDataStageView(APIView):
|
|||||||
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
|
def export_datastage_multiple_partitioned_excel_agrupados(self, request, models_data, global_filters, related_keys):
|
||||||
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
||||||
try:
|
try:
|
||||||
zip_buffer = io.BytesIO()
|
|
||||||
|
|
||||||
# 🔥 PRECARGAR ORGANIZACIONES para mapeo rápido
|
|
||||||
from api.organization.models import Organizacion
|
from api.organization.models import Organizacion
|
||||||
organizaciones = Organizacion.objects.all()
|
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||||
org_mapping = {str(org.id): org.nombre for org in organizaciones}
|
|
||||||
|
|
||||||
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
# 1. Recopilar todos los datos FUERA del contexto ZIP
|
||||||
|
all_models_data = {}
|
||||||
# 1. Recopilar todos los datos de cada modelo
|
|
||||||
all_models_data = {} # Ahora será una lista por clave
|
|
||||||
model_field_mappings = {}
|
model_field_mappings = {}
|
||||||
|
|
||||||
for model_data in models_data:
|
for model_data in models_data:
|
||||||
@@ -302,8 +314,6 @@ class ExportDataStageView(APIView):
|
|||||||
if not model_name or not fields:
|
if not model_name or not fields:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Normalizar nombres de campo entrantes: si se pasó "Organizacion"
|
|
||||||
# (cualquier capitalización), usar el campo real de la BD `organizacion_id`.
|
|
||||||
normalized_fields = []
|
normalized_fields = []
|
||||||
for f in fields:
|
for f in fields:
|
||||||
try:
|
try:
|
||||||
@@ -320,13 +330,11 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
fields = normalized_fields
|
fields = normalized_fields
|
||||||
|
|
||||||
# Asegurar que tenemos los campos de relación
|
|
||||||
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
|
required_fields = ['seccion_aduanera', 'patente', 'pedimento']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if field not in fields:
|
if field not in fields:
|
||||||
fields.append(field)
|
fields.append(field)
|
||||||
|
|
||||||
# 🔥 Añadir organizacion_id a los campos si no está y existe en el modelo
|
|
||||||
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
|
if 'organizacion_id' not in fields and 'organizacion_id' in [f.name for f in apps.get_model('datastage', model_name)._meta.get_fields()]:
|
||||||
fields.append('organizacion_id')
|
fields.append('organizacion_id')
|
||||||
|
|
||||||
@@ -339,233 +347,182 @@ class ExportDataStageView(APIView):
|
|||||||
else:
|
else:
|
||||||
queryset = model.objects.none()
|
queryset = model.objects.none()
|
||||||
|
|
||||||
total_records = queryset.count()
|
if queryset.count() == 0:
|
||||||
|
|
||||||
if total_records == 0:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Determinar campos de relación disponibles en este modelo
|
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
|
||||||
relation_fields = []
|
|
||||||
for field_name in ['seccion_aduanera', 'patente', 'pedimento']:
|
|
||||||
if field_name in fields:
|
|
||||||
relation_fields.append(field_name)
|
|
||||||
|
|
||||||
if not relation_fields:
|
if not relation_fields:
|
||||||
# Si no hay campos de relación, usar un identificador único
|
|
||||||
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||||
|
|
||||||
# Guardar mapeo de campos para este modelo
|
|
||||||
if model_name not in model_field_mappings:
|
if model_name not in model_field_mappings:
|
||||||
model_field_mappings[model_name] = fields
|
model_field_mappings[model_name] = fields
|
||||||
|
|
||||||
# Procesar cada registro
|
|
||||||
for record in queryset:
|
for record in queryset:
|
||||||
# Crear clave de relación
|
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||||
key_parts = []
|
|
||||||
for rel_field in relation_fields:
|
|
||||||
if rel_field in record and record[rel_field] is not None:
|
|
||||||
key_parts.append(str(record[rel_field]))
|
|
||||||
|
|
||||||
if not key_parts:
|
if not key_parts:
|
||||||
# Si no hay campos de relación, usar un hash del registro
|
|
||||||
import hashlib
|
import hashlib
|
||||||
record_str = str(sorted(record.items()))
|
key = hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||||
key = hashlib.md5(record_str.encode()).hexdigest()[:10]
|
|
||||||
else:
|
else:
|
||||||
key = "_".join(key_parts)
|
key = "_".join(key_parts)
|
||||||
|
|
||||||
# 🔥 PROCESAR CAMPO organizacion_id para convertirlo a nombre
|
|
||||||
processed_record = {}
|
processed_record = {}
|
||||||
for field_name, value in record.items():
|
for field_name, value in record.items():
|
||||||
# Convertir organizacion_id a nombre
|
|
||||||
if field_name == 'organizacion_id' and value:
|
if field_name == 'organizacion_id' and value:
|
||||||
org_id_str = str(value)
|
org_id_str = str(value)
|
||||||
# Usar el nombre de la organización si está en el mapeo
|
|
||||||
if org_id_str in org_mapping:
|
if org_id_str in org_mapping:
|
||||||
processed_value = org_mapping[org_id_str]
|
processed_value = org_mapping[org_id_str]
|
||||||
else:
|
else:
|
||||||
# Si no se encuentra, intentar obtener de la base de datos
|
|
||||||
try:
|
try:
|
||||||
org = Organizacion.objects.filter(id=value).first()
|
org = Organizacion.objects.filter(id=value).first()
|
||||||
processed_value = org.nombre if org else str(value)
|
processed_value = org.nombre if org else org_id_str
|
||||||
# Actualizar mapeo para futuras referencias
|
|
||||||
org_mapping[org_id_str] = processed_value
|
org_mapping[org_id_str] = processed_value
|
||||||
except:
|
except Exception:
|
||||||
processed_value = str(value)
|
processed_value = org_id_str
|
||||||
else:
|
else:
|
||||||
processed_value = value
|
processed_value = value
|
||||||
|
|
||||||
# Agregar prefijo del modelo a los campos para evitar colisiones
|
|
||||||
if field_name in relation_fields:
|
if field_name in relation_fields:
|
||||||
prefixed_field_name = field_name
|
prefixed_field_name = field_name
|
||||||
else:
|
else:
|
||||||
prefixed_field_name = f"{model_name}_{field_name}"
|
prefixed_field_name = f"{model_name}_{field_name}"
|
||||||
|
|
||||||
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
|
|
||||||
if field_name == 'organizacion_id':
|
if field_name == 'organizacion_id':
|
||||||
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
||||||
|
|
||||||
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
|
processed_record[prefixed_field_name] = self.safe_excel_value(processed_value)
|
||||||
|
|
||||||
# 🔥 CORRECIÓN: Ahora almacenamos una LISTA de registros por clave
|
|
||||||
if key not in all_models_data:
|
if key not in all_models_data:
|
||||||
all_models_data[key] = {
|
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||||
'relation_fields': {}, # Campos de relación compartidos
|
|
||||||
'model_records': {} # Diccionario de listas por modelo
|
|
||||||
}
|
|
||||||
|
|
||||||
# Guardar campos de relación (solo una vez, ya que son los mismos)
|
|
||||||
for rel_field in relation_fields:
|
for rel_field in relation_fields:
|
||||||
if rel_field in record:
|
if rel_field in record:
|
||||||
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||||
|
|
||||||
# 🔥 GUARDAR COMO LISTA: Crear lista si no existe
|
|
||||||
if model_name not in all_models_data[key]['model_records']:
|
if model_name not in all_models_data[key]['model_records']:
|
||||||
all_models_data[key]['model_records'][model_name] = []
|
all_models_data[key]['model_records'][model_name] = []
|
||||||
|
|
||||||
# Agregar este registro a la lista del modelo
|
|
||||||
all_models_data[key]['model_records'][model_name].append(processed_record)
|
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||||
|
|
||||||
except LookupError:
|
except LookupError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Si no hay datos, retornar error
|
# 2. Sin datos → Excel vacío (no JSON 404 que rompe la descarga en el frontend)
|
||||||
if not all_models_data:
|
if not all_models_data:
|
||||||
return Response({'error': 'No se encontraron datos para exportar'}, status=status.HTTP_404_NOT_FOUND)
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Sin datos"
|
||||||
|
ws.append(["No se encontraron datos para los filtros especificados"])
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
resp = HttpResponse(
|
||||||
|
output.read(),
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.xlsx"'
|
||||||
|
return resp
|
||||||
|
|
||||||
# 2. Crear estructura de filas combinadas
|
# 3. Construir filas combinadas — repetir el último registro en lugar de dejar vacíos
|
||||||
# Ahora necesitamos expandir las filas cuando hay múltiples registros con la misma clave
|
|
||||||
combined_rows = []
|
combined_rows = []
|
||||||
|
|
||||||
for key, data in all_models_data.items():
|
for key, data in all_models_data.items():
|
||||||
relation_fields = data['relation_fields']
|
relation_fields_data = data['relation_fields']
|
||||||
model_records = data['model_records']
|
model_records = data['model_records']
|
||||||
|
|
||||||
# 🔥 NUEVO: Calcular cuántas filas necesitamos para esta clave
|
max_records_per_key = max((len(recs) for recs in model_records.values()), default=1)
|
||||||
# Encontrar el modelo con más registros para esta clave
|
|
||||||
max_records_per_key = 1
|
|
||||||
for model_name, records in model_records.items():
|
|
||||||
if len(records) > max_records_per_key:
|
|
||||||
max_records_per_key = len(records)
|
|
||||||
|
|
||||||
# 🔗 CREAR UNA FILA POR CADA COMBINACIÓN
|
|
||||||
for i in range(max_records_per_key):
|
for i in range(max_records_per_key):
|
||||||
row_data = {}
|
row_data = {}
|
||||||
|
|
||||||
# Campos de relación (mismos para todas las filas con esta clave)
|
for rel_field, rel_value in relation_fields_data.items():
|
||||||
for rel_field, rel_value in relation_fields.items():
|
|
||||||
row_data[rel_field] = self.safe_excel_value(rel_value)
|
row_data[rel_field] = self.safe_excel_value(rel_value)
|
||||||
|
|
||||||
# Datos de cada modelo
|
|
||||||
for model_name, records in model_records.items():
|
for model_name, records in model_records.items():
|
||||||
# Si hay un registro en esta posición i
|
# Usar posición i o el último registro disponible
|
||||||
if i < len(records):
|
record = records[i] if i < len(records) else records[-1]
|
||||||
record = records[i]
|
|
||||||
for field_name, value in record.items():
|
for field_name, value in record.items():
|
||||||
row_data[field_name] = value
|
row_data[field_name] = value
|
||||||
else:
|
|
||||||
# Si no hay más registros para este modelo, poner campos vacíos
|
|
||||||
for field_name in model_field_mappings.get(model_name, []):
|
|
||||||
if field_name in ['seccion_aduanera', 'patente', 'pedimento', 'organizacion_id']:
|
|
||||||
# Los campos de relación ya están llenados o transformados
|
|
||||||
continue
|
|
||||||
prefixed_field_name = f"{model_name}_{field_name}"
|
|
||||||
# 🔥 RENOMBRAR organizacion_id a organizacion_nombre
|
|
||||||
if field_name == 'organizacion_id':
|
|
||||||
prefixed_field_name = prefixed_field_name.replace('organizacion_id', 'organizacion_nombre')
|
|
||||||
row_data[prefixed_field_name] = ''
|
|
||||||
|
|
||||||
combined_rows.append(row_data)
|
combined_rows.append(row_data)
|
||||||
|
|
||||||
# 3. Determinar todos los campos únicos para los encabezados
|
# 4. Encabezados ordenados
|
||||||
all_fields_set = set()
|
all_fields_set = set()
|
||||||
|
|
||||||
# Campos de relación primero
|
|
||||||
common_relation_fields = ['seccion_aduanera', 'patente', 'pedimento']
|
|
||||||
|
|
||||||
# Agregar todos los campos de todas las filas
|
|
||||||
for row in combined_rows:
|
for row in combined_rows:
|
||||||
all_fields_set.update(row.keys())
|
all_fields_set.update(row.keys())
|
||||||
|
|
||||||
# Ordenar campos: relación primero, luego alfabéticamente
|
|
||||||
all_fields = []
|
all_fields = []
|
||||||
for rel_field in common_relation_fields:
|
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||||
if rel_field in all_fields_set:
|
if rel_field in all_fields_set:
|
||||||
all_fields.append(rel_field)
|
all_fields.append(rel_field)
|
||||||
all_fields_set.remove(rel_field)
|
all_fields_set.discard(rel_field)
|
||||||
|
|
||||||
# 🔥 Mover organizacion_nombre cerca de los campos de relación
|
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||||
org_fields = [f for f in all_fields_set if 'organizacion' in f.lower()]
|
for org_field in org_fields:
|
||||||
for org_field in sorted(org_fields):
|
|
||||||
all_fields.append(org_field)
|
all_fields.append(org_field)
|
||||||
all_fields_set.remove(org_field)
|
all_fields_set.discard(org_field)
|
||||||
|
|
||||||
# Agregar el resto de campos ordenados alfabéticamente
|
|
||||||
all_fields.extend(sorted(all_fields_set))
|
all_fields.extend(sorted(all_fields_set))
|
||||||
|
|
||||||
total_records = len(combined_rows)
|
# 5. Filas de título y fecha de generación
|
||||||
|
now_str = datetime.datetime.now().strftime('%d/%m/%Y %H:%M:%S')
|
||||||
|
title_row = ["Reporte Datastage"]
|
||||||
|
date_row = [f"Generado: {now_str}"]
|
||||||
|
|
||||||
# 4. Manejar particionado
|
def _write_sheet(ws, sheet_name, page_rows):
|
||||||
from django.core.paginator import Paginator
|
ws.title = sheet_name[:31]
|
||||||
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
|
ws.append(title_row)
|
||||||
|
ws.append(date_row)
|
||||||
for page_num in paginator.page_range:
|
ws.append([])
|
||||||
page = paginator.page(page_num)
|
ws.append(all_fields)
|
||||||
|
for row_data in page_rows:
|
||||||
# Crear nuevo workbook para cada partición
|
ws.append([row_data.get(field, '') for field in all_fields])
|
||||||
current_wb = openpyxl.Workbook()
|
for column in ws.columns:
|
||||||
current_ws = current_wb.active
|
|
||||||
|
|
||||||
# Nombre de hoja limitado a 31 caracteres
|
|
||||||
sheet_name = f"Datastage_p{page_num}"
|
|
||||||
if len(sheet_name) > 31:
|
|
||||||
sheet_name = sheet_name[:31]
|
|
||||||
current_ws.title = sheet_name
|
|
||||||
|
|
||||||
# Escribir encabezados
|
|
||||||
current_ws.append(all_fields)
|
|
||||||
|
|
||||||
# Escribir datos de esta página
|
|
||||||
for row_data in page.object_list:
|
|
||||||
row_values = [row_data.get(field, '') for field in all_fields]
|
|
||||||
current_ws.append(row_values)
|
|
||||||
|
|
||||||
# Autoajustar anchos de columna
|
|
||||||
for column in current_ws.columns:
|
|
||||||
max_length = 0
|
max_length = 0
|
||||||
column_letter = column[0].column_letter
|
col_letter = column[0].column_letter
|
||||||
|
|
||||||
for cell in column:
|
for cell in column:
|
||||||
try:
|
try:
|
||||||
if len(str(cell.value)) > max_length:
|
if len(str(cell.value)) > max_length:
|
||||||
max_length = len(str(cell.value))
|
max_length = len(str(cell.value))
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
ws.column_dimensions[col_letter].width = min(max_length + 2, 50)
|
||||||
|
|
||||||
adjusted_width = min(max_length + 2, 50)
|
# 6. Excel directo si cabe en un archivo; ZIP solo si se necesita particionar
|
||||||
current_ws.column_dimensions[column_letter].width = adjusted_width
|
from django.core.paginator import Paginator
|
||||||
|
paginator = Paginator(combined_rows, self.MAX_RECORDS_PER_FILE)
|
||||||
|
|
||||||
# Guardar archivo en ZIP
|
if paginator.num_pages == 1:
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
_write_sheet(wb.active, "Datastage", paginator.page(1).object_list)
|
||||||
|
output = io.BytesIO()
|
||||||
|
wb.save(output)
|
||||||
|
output.seek(0)
|
||||||
|
resp = HttpResponse(
|
||||||
|
output.read(),
|
||||||
|
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
|
)
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.xlsx"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
zip_buffer = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
|
||||||
|
for page_num in paginator.page_range:
|
||||||
|
page = paginator.page(page_num)
|
||||||
|
current_wb = openpyxl.Workbook()
|
||||||
|
_write_sheet(current_wb.active, f"Datastage_p{page_num}", page.object_list)
|
||||||
part_buffer = io.BytesIO()
|
part_buffer = io.BytesIO()
|
||||||
current_wb.save(part_buffer)
|
current_wb.save(part_buffer)
|
||||||
part_buffer.seek(0)
|
part_buffer.seek(0)
|
||||||
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
||||||
|
|
||||||
# Información de depuración
|
|
||||||
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
|
|
||||||
print(f"Total de claves únicas: {len(all_models_data)}")
|
|
||||||
print(f"Total de filas expandidas: {total_records}")
|
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
|
resp = HttpResponse(zip_buffer.read(), content_type='application/zip')
|
||||||
response = HttpResponse(zip_buffer.read(), content_type='application/zip')
|
resp['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
|
||||||
response['Content-Disposition'] = 'attachment; filename="datastage_combinado.zip"'
|
return resp
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
error_details = traceback.format_exc()
|
import logging
|
||||||
print(f"Error en exportación: {error_details}")
|
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
|
||||||
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
@@ -782,10 +739,6 @@ class ExportDataStageView(APIView):
|
|||||||
part_buffer.seek(0)
|
part_buffer.seek(0)
|
||||||
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
zip_file.writestr(f"datastage_part{page_num}.xlsx", part_buffer.getvalue())
|
||||||
|
|
||||||
# Información de depuración
|
|
||||||
print(f"Creada partición {page_num} con {len(page.object_list)} registros combinados")
|
|
||||||
print(f"Total de claves únicas: {len(all_models_data)}")
|
|
||||||
print(f"Total de filas expandidas: {total_records}")
|
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
@@ -795,12 +748,11 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
error_details = traceback.format_exc()
|
import logging
|
||||||
print(f"Error en exportación: {error_details}")
|
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
|
||||||
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def export_datastage_multiple_partitioned_excel_test_2(self, request, models_data, global_filters, related_keys):
|
def export_datastage_multiple_partitioned_excel_test_2(self, request, models_data, global_filters, related_keys):
|
||||||
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
"""Exporta múltiples modelos de DataStage agrupados en la misma hoja de Excel, con particionado por límite de registros"""
|
||||||
try:
|
try:
|
||||||
@@ -1009,8 +961,8 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
error_details = traceback.format_exc()
|
import logging
|
||||||
print(f"Error en exportación: {error_details}")
|
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
|
||||||
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
@@ -1126,8 +1078,6 @@ class ExportDataStageView(APIView):
|
|||||||
part_buffer.seek(0)
|
part_buffer.seek(0)
|
||||||
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue())
|
zip_file.writestr(f"datastage_combinado_part{page_num}.xlsx", part_buffer.getvalue())
|
||||||
|
|
||||||
# Información de depuración (opcional)
|
|
||||||
print(f"Creada partición {page_num} con {len(page.object_list)} registros")
|
|
||||||
|
|
||||||
zip_buffer.seek(0)
|
zip_buffer.seek(0)
|
||||||
|
|
||||||
@@ -1137,8 +1087,8 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
error_details = traceback.format_exc()
|
import logging
|
||||||
print(f"Error en exportación: {error_details}")
|
logging.getLogger(__name__).error("Error en exportación combinada: %s", traceback.format_exc())
|
||||||
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Error en exportación combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys):
|
def export_datastage_multiple_partitioned_excel(self, request, models_data, global_filters, related_keys):
|
||||||
@@ -1265,6 +1215,144 @@ class ExportDataStageView(APIView):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': f'Error en exportación particionada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
def export_datastage_multiple_to_csv_combined(self, request, models_data, global_filters, related_keys):
|
||||||
|
"""Exporta múltiples modelos combinados en un único CSV plano (misma lógica de agrupación que el Excel)."""
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
try:
|
||||||
|
from api.organization.models import Organizacion
|
||||||
|
org_mapping = {str(org.id): org.nombre for org in Organizacion.objects.all()}
|
||||||
|
|
||||||
|
all_models_data = {}
|
||||||
|
model_field_mappings = {}
|
||||||
|
|
||||||
|
for model_data in models_data:
|
||||||
|
model_name = model_data.get('model')
|
||||||
|
fields = model_data.get('fields', [])
|
||||||
|
if not model_name or not fields:
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_fields = []
|
||||||
|
for f in fields:
|
||||||
|
key = f.strip() if isinstance(f, str) else f
|
||||||
|
if isinstance(key, str) and key.lower() == 'organizacion':
|
||||||
|
if 'organizacion_id' not in normalized_fields:
|
||||||
|
normalized_fields.append('organizacion_id')
|
||||||
|
else:
|
||||||
|
if key not in normalized_fields:
|
||||||
|
normalized_fields.append(key)
|
||||||
|
fields = normalized_fields
|
||||||
|
|
||||||
|
for req_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||||
|
if req_field not in fields:
|
||||||
|
fields.append(req_field)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = apps.get_model('datastage', model_name)
|
||||||
|
model_field_names = [f.name for f in model._meta.get_fields() if hasattr(f, 'name')]
|
||||||
|
if 'organizacion_id' not in fields and 'organizacion_id' in model_field_names:
|
||||||
|
fields.append('organizacion_id')
|
||||||
|
|
||||||
|
filters = self.apply_related_filters(global_filters, model, related_keys, request.user)
|
||||||
|
queryset = model.objects.filter(**filters).values(*fields) if filters else model.objects.none()
|
||||||
|
if queryset.count() == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
relation_fields = [fn for fn in ['seccion_aduanera', 'patente', 'pedimento'] if fn in fields]
|
||||||
|
if not relation_fields:
|
||||||
|
relation_fields = ['datastage_id'] if 'datastage_id' in fields else [fields[0]]
|
||||||
|
|
||||||
|
if model_name not in model_field_mappings:
|
||||||
|
model_field_mappings[model_name] = fields
|
||||||
|
|
||||||
|
for record in queryset:
|
||||||
|
key_parts = [str(record[rf]) for rf in relation_fields if rf in record and record[rf] is not None]
|
||||||
|
key = "_".join(key_parts) if key_parts else hashlib.md5(str(sorted(record.items())).encode()).hexdigest()[:10]
|
||||||
|
|
||||||
|
processed_record = {}
|
||||||
|
for field_name, value in record.items():
|
||||||
|
if field_name == 'organizacion_id' and value:
|
||||||
|
org_id_str = str(value)
|
||||||
|
processed_value = org_mapping.get(org_id_str, org_id_str)
|
||||||
|
else:
|
||||||
|
processed_value = value
|
||||||
|
|
||||||
|
if field_name in relation_fields:
|
||||||
|
prefixed = field_name
|
||||||
|
else:
|
||||||
|
prefixed = f"{model_name}_{field_name}"
|
||||||
|
if field_name == 'organizacion_id':
|
||||||
|
prefixed = prefixed.replace('organizacion_id', 'organizacion_nombre')
|
||||||
|
processed_record[prefixed] = self.safe_excel_value(processed_value)
|
||||||
|
|
||||||
|
if key not in all_models_data:
|
||||||
|
all_models_data[key] = {'relation_fields': {}, 'model_records': {}}
|
||||||
|
for rel_field in relation_fields:
|
||||||
|
if rel_field in record:
|
||||||
|
all_models_data[key]['relation_fields'][rel_field] = record[rel_field]
|
||||||
|
if model_name not in all_models_data[key]['model_records']:
|
||||||
|
all_models_data[key]['model_records'][model_name] = []
|
||||||
|
all_models_data[key]['model_records'][model_name].append(processed_record)
|
||||||
|
|
||||||
|
except LookupError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sin datos → CSV con mensaje, no error HTTP
|
||||||
|
if not all_models_data:
|
||||||
|
buf = io.StringIO()
|
||||||
|
csv.writer(buf).writerow(['No se encontraron datos para los filtros especificados'])
|
||||||
|
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="datastage_sin_datos.csv"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Construir filas planas
|
||||||
|
combined_rows = []
|
||||||
|
for key, data in all_models_data.items():
|
||||||
|
relation_fields_data = data['relation_fields']
|
||||||
|
model_records = data['model_records']
|
||||||
|
max_records = max((len(recs) for recs in model_records.values()), default=1)
|
||||||
|
for i in range(max_records):
|
||||||
|
row_data = {}
|
||||||
|
for rel_field, rel_value in relation_fields_data.items():
|
||||||
|
row_data[rel_field] = self.safe_excel_value(rel_value)
|
||||||
|
for mn, records in model_records.items():
|
||||||
|
record = records[i] if i < len(records) else records[-1]
|
||||||
|
for field_name, value in record.items():
|
||||||
|
row_data[field_name] = value
|
||||||
|
combined_rows.append(row_data)
|
||||||
|
|
||||||
|
# Encabezados: campos de relación primero, luego org, luego el resto
|
||||||
|
all_fields_set = set()
|
||||||
|
for row in combined_rows:
|
||||||
|
all_fields_set.update(row.keys())
|
||||||
|
|
||||||
|
all_fields = []
|
||||||
|
for rel_field in ['seccion_aduanera', 'patente', 'pedimento']:
|
||||||
|
if rel_field in all_fields_set:
|
||||||
|
all_fields.append(rel_field)
|
||||||
|
all_fields_set.discard(rel_field)
|
||||||
|
org_fields = sorted(f for f in all_fields_set if 'organizacion' in f.lower())
|
||||||
|
for org_field in org_fields:
|
||||||
|
all_fields.append(org_field)
|
||||||
|
all_fields_set.discard(org_field)
|
||||||
|
all_fields.extend(sorted(all_fields_set))
|
||||||
|
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(all_fields)
|
||||||
|
for row_data in combined_rows:
|
||||||
|
writer.writerow([row_data.get(field, '') for field in all_fields])
|
||||||
|
|
||||||
|
resp = HttpResponse(buf.getvalue(), content_type='text/csv; charset=utf-8')
|
||||||
|
resp['Content-Disposition'] = 'attachment; filename="datastage_reporte.csv"'
|
||||||
|
return resp
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error en exportación CSV combinada: %s", traceback.format_exc())
|
||||||
|
return Response({'error': f'Error en exportación CSV combinada: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
|
def export_datastage_multiple_to_csv(self, request, models_data, global_filters, related_keys):
|
||||||
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
|
"""Exporta múltiples modelos de DataStage a múltiples archivos CSV en ZIP"""
|
||||||
zip_buffer = io.BytesIO()
|
zip_buffer = io.BytesIO()
|
||||||
@@ -1472,8 +1560,13 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
def get_related_keys_from_filters(self, global_filters, models_data, user):
|
def get_related_keys_from_filters(self, global_filters, models_data, user):
|
||||||
"""
|
"""
|
||||||
Obtiene patentes, pedimentos y datastages que cumplen EXACTAMENTE con TODOS los filtros globales
|
Construye el conjunto de (patente, pedimento, datastage_id) que servirá como
|
||||||
VERSIÓN SIMPLIFICADA - Usa la MISMA lógica que apply_global_filters_to_model
|
llave de cruce entre modelos.
|
||||||
|
|
||||||
|
Regla clave: si el filtro RFC está activo, solo los modelos que tienen el campo
|
||||||
|
'rfc' pueden contribuir a related_keys. Los modelos sin 'rfc' (ej. 505, 506)
|
||||||
|
no se usan como semilla — solo se filtrarán más tarde usando las claves ya
|
||||||
|
construidas, evitando que contaminen el resultado con pedimentos de otros RFC.
|
||||||
"""
|
"""
|
||||||
related_keys = {
|
related_keys = {
|
||||||
'patentes': set(),
|
'patentes': set(),
|
||||||
@@ -1481,40 +1574,34 @@ class ExportDataStageView(APIView):
|
|||||||
'datastage_ids': set()
|
'datastage_ids': set()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Si no hay filtros, retornar vacío
|
# Sin filtros significativos → sin cruce
|
||||||
if not any(v for v in global_filters.values() if v not in [None, '']):
|
if not any(v for v in global_filters.values() if v not in [None, '']):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
rfc_filter_active = bool(global_filters.get('rfc'))
|
||||||
|
date_filter_active = bool(global_filters.get('fecha_pago_desde') or global_filters.get('fecha_pago_hasta'))
|
||||||
all_records_with_filters = []
|
all_records_with_filters = []
|
||||||
|
|
||||||
for model_data in models_data:
|
for model_data in models_data:
|
||||||
model_name = model_data.get('model')
|
model_name = model_data.get('model')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
model = apps.get_model('datastage', model_name)
|
model = apps.get_model('datastage', model_name)
|
||||||
|
model_field_names = {f.name for f in model._meta.get_fields() if hasattr(f, 'name')}
|
||||||
|
|
||||||
|
# Un modelo puede ser semilla de related_keys SOLO si tiene campos
|
||||||
|
# para aplicar TODOS los filtros activos. Un modelo sin 'rfc' no puede
|
||||||
|
# ser semilla cuando hay filtro de RFC (contaminaría con pedimentos de
|
||||||
|
# otros RFCs). Igual para fecha_pago_real cuando hay filtro de fechas.
|
||||||
|
if rfc_filter_active and 'rfc' not in model_field_names:
|
||||||
|
continue
|
||||||
|
if date_filter_active and 'fecha_pago_real' not in model_field_names:
|
||||||
|
continue
|
||||||
|
|
||||||
# ¡USAR LA MISMA FUNCIÓN QUE EN MODO SINGULAR!
|
|
||||||
filters = self.apply_global_filters_to_model(global_filters, model, user)
|
filters = self.apply_global_filters_to_model(global_filters, model, user)
|
||||||
|
if not filters:
|
||||||
|
continue
|
||||||
|
|
||||||
if filters:
|
records = model.objects.filter(**filters).values('patente', 'pedimento', 'datastage_id')
|
||||||
# EJECUTAR CONSULTA - IDÉNTICO A MODO SINGULAR
|
|
||||||
queryset = model.objects.filter(**filters)
|
|
||||||
total = queryset.count()
|
|
||||||
|
|
||||||
# VERIFICACIÓN ESPECIAL PARA RFC
|
|
||||||
if 'rfc' in filters:
|
|
||||||
rfc_value = filters['rfc']
|
|
||||||
# Doble verificación: contar registros con ese RFC exacto
|
|
||||||
rfc_exact_count = queryset.filter(rfc=rfc_value).count()
|
|
||||||
|
|
||||||
if rfc_exact_count != total:
|
|
||||||
try:
|
|
||||||
other_rfcs = queryset.exclude(rfc=rfc_value).values_list('rfc', flat=True).distinct()[:5]
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Obtener registros
|
|
||||||
records = queryset.values('patente', 'pedimento', 'datastage_id')
|
|
||||||
all_records_with_filters.extend(list(records))
|
all_records_with_filters.extend(list(records))
|
||||||
|
|
||||||
except LookupError:
|
except LookupError:
|
||||||
@@ -1585,9 +1672,17 @@ class ExportDataStageView(APIView):
|
|||||||
filters = {}
|
filters = {}
|
||||||
model_fields = [f.name for f in model._meta.get_fields()]
|
model_fields = [f.name for f in model._meta.get_fields()]
|
||||||
|
|
||||||
# 1. Organización
|
# 1. Organización — convertir a UUID igual que apply_global_filters_to_model
|
||||||
if 'organizacion' in model_fields and global_filters.get('organizacion'):
|
if 'organizacion' in model_fields and global_filters.get('organizacion'):
|
||||||
filters['organizacion'] = global_filters['organizacion']
|
org_value = global_filters['organizacion']
|
||||||
|
try:
|
||||||
|
field = model._meta.get_field('organizacion')
|
||||||
|
if hasattr(field, 'related_model'):
|
||||||
|
filters['organizacion_id'] = uuid.UUID(org_value)
|
||||||
|
else:
|
||||||
|
filters['organizacion'] = org_value
|
||||||
|
except Exception:
|
||||||
|
filters['organizacion_id'] = org_value
|
||||||
|
|
||||||
# 2. RFC (¡ESTO ES LO QUE FALTA!)
|
# 2. RFC (¡ESTO ES LO QUE FALTA!)
|
||||||
if 'rfc' in model_fields and global_filters.get('rfc'):
|
if 'rfc' in model_fields and global_filters.get('rfc'):
|
||||||
@@ -1741,7 +1836,11 @@ class ExportDataStageView(APIView):
|
|||||||
|
|
||||||
class ExportModelView(APIView):
|
class ExportModelView(APIView):
|
||||||
my_tags = ['Reportes']
|
my_tags = ['Reportes']
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
|
def get_permissions(self):
|
||||||
|
if self.request.method == 'GET':
|
||||||
|
return [IsAuthenticated(), require_permission('reportes.view')()]
|
||||||
|
return [IsAuthenticated(), require_permission('reportes.export')()]
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
@@ -1779,6 +1878,8 @@ class ExportModelView(APIView):
|
|||||||
model_name = request.data.get('model')
|
model_name = request.data.get('model')
|
||||||
fields = request.data.get('fields')
|
fields = request.data.get('fields')
|
||||||
filters = request.data.get('filters', {})
|
filters = request.data.get('filters', {})
|
||||||
|
org = get_org_context(request.user)
|
||||||
|
filters['organizacion__id'] = org.id if org else None
|
||||||
export_type = request.data.get('type', 'csv')
|
export_type = request.data.get('type', 'csv')
|
||||||
module = request.data.get('module', 'datastage')
|
module = request.data.get('module', 'datastage')
|
||||||
|
|
||||||
@@ -1790,40 +1891,12 @@ class ExportModelView(APIView):
|
|||||||
else:
|
else:
|
||||||
return export_model_to_csv(request, model_name, fields, module, filters)
|
return export_model_to_csv(request, model_name, fields, module, filters)
|
||||||
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
|
||||||
|
|
||||||
class ExportModelView(APIView):
|
|
||||||
my_tags = ['Reportes']
|
|
||||||
permission_classes = [IsAuthenticated & (
|
|
||||||
IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
|
||||||
|
|
||||||
@swagger_auto_schema(request_body=ExportModelSerializer, esponses={200: 'Archivo generado (Excel o CSV)'})
|
|
||||||
def post(self, request, *args, **kwargs):
|
|
||||||
model_name = request.data.get('model')
|
|
||||||
fields = request.data.get('fields')
|
|
||||||
filters = request.data.get('filters', {})
|
|
||||||
filters['organizacion__id'] = self.request.user.organizacion.id if hasattr(request.user, 'organizacion') and request.user.organizacion else None
|
|
||||||
export_type = request.data.get('type', 'csv')
|
|
||||||
if not model_name or not fields:
|
|
||||||
return Response({'error': 'model and fields are required'}, status=status.HTTP_400_BAD_REQUEST)
|
|
||||||
|
|
||||||
module = request.data.get('module', 'datastage')
|
|
||||||
if export_type == 'excel':
|
|
||||||
return export_model_to_excel(request, model_name, fields, module, filters)
|
|
||||||
else:
|
|
||||||
return export_model_to_csv(request, model_name, fields, module, filters)
|
|
||||||
|
|
||||||
# Resumen general para dashboard
|
# Resumen general para dashboard
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([
|
@permission_classes([IsAuthenticated, require_permission('reportes.view')])
|
||||||
IsAuthenticated
|
|
||||||
])
|
|
||||||
def dashboard_summary(request):
|
def dashboard_summary(request):
|
||||||
org_id = request.query_params.get('organizacion_id')
|
|
||||||
filters = {}
|
filters = {}
|
||||||
user = request.user
|
user = request.user
|
||||||
|
|
||||||
@@ -1837,18 +1910,16 @@ def dashboard_summary(request):
|
|||||||
fecha_pago_lte = request.query_params.get('fecha_pago__lte')
|
fecha_pago_lte = request.query_params.get('fecha_pago__lte')
|
||||||
contribuyente__rfc = request.query_params.get('contribuyente__rfc')
|
contribuyente__rfc = request.query_params.get('contribuyente__rfc')
|
||||||
|
|
||||||
# Si no se especifica organización y el usuario tiene organización, usarla
|
org = get_org_context(user)
|
||||||
if not org_id and hasattr(user, 'organizacion') and user.organizacion:
|
if not org:
|
||||||
org_id = user.organizacion.id
|
return Response({'error': 'Sin organización activa.'}, status=status.HTTP_403_FORBIDDEN)
|
||||||
# Si no es superusuario, filtrar por organización
|
filters['organizacion_id'] = org.id
|
||||||
if org_id and not getattr(user, 'is_superuser', False):
|
|
||||||
filters['organizacion_id'] = org_id
|
|
||||||
|
|
||||||
# Si el usuario pertenece al grupo Importador, filtrar por RFC
|
# Importador: filtrar solo por sus RFC asignados
|
||||||
if user.groups.filter(name='Importador').exists():
|
if user.is_importador:
|
||||||
rfc = getattr(user, 'rfc', None)
|
rfcs = list(user.rfc.values_list('rfc', flat=True))
|
||||||
if rfc:
|
if rfcs:
|
||||||
filters['contribuyente__rfc'] = rfc
|
filters['contribuyente__rfc__in'] = rfcs
|
||||||
|
|
||||||
if pedimento_app:
|
if pedimento_app:
|
||||||
filters['pedimento_app'] = pedimento_app
|
filters['pedimento_app'] = pedimento_app
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,51 +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 mixins.filtrado_organizacion import OrganizacionFiltradaMixin, ProcesosPorOrganizacionMixin
|
from core.permissions import require_permission, user_has_permission, IsInternalService
|
||||||
|
from mixins.filtrado_organizacion import OrganizacionFiltradaMixin
|
||||||
from .models import Task
|
from .models import Task
|
||||||
from .serializers import TaskSerializer
|
from .serializers import TaskSerializer
|
||||||
from .filters import TaskFilter
|
from .filters import TaskFilter
|
||||||
from rest_framework.permissions import IsAuthenticated
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
from core.permissions import (
|
|
||||||
IsSameOrganization,
|
|
||||||
IsSameOrganizationDeveloper,
|
|
||||||
IsSameOrganizationAndAdmin,
|
|
||||||
IsSuperUser
|
|
||||||
)
|
|
||||||
|
|
||||||
class TaskPagination(PageNumberPagination):
|
class TaskPagination(PageNumberPagination):
|
||||||
page_size = 10
|
page_size = 10
|
||||||
page_size_query_param = 'page_size'
|
page_size_query_param = 'page_size'
|
||||||
max_page_size = 100
|
max_page_size = 100
|
||||||
|
|
||||||
|
|
||||||
class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
class TaskViewSet(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltradaMixin):
|
||||||
permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)]
|
# Task se relaciona con pedimento, que tiene contribuyente
|
||||||
|
campo_contribuyente = 'pedimento__contribuyente'
|
||||||
|
|
||||||
queryset = Task.objects.select_related('pedimento', 'servicio').all()
|
queryset = Task.objects.select_related('pedimento', 'servicio').all()
|
||||||
serializer_class = TaskSerializer
|
serializer_class = TaskSerializer
|
||||||
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||||
filterset_class = TaskFilter
|
filterset_class = TaskFilter
|
||||||
pagination_class = TaskPagination
|
pagination_class = TaskPagination
|
||||||
ordering_fields = ['timestamp']
|
ordering_fields = ['timestamp']
|
||||||
ordering = ['-timestamp'] # ordenamiento por defecto, más reciente primero
|
ordering = ['-timestamp']
|
||||||
|
|
||||||
my_tags = ['tasks']
|
my_tags = ['tasks']
|
||||||
|
|
||||||
|
def get_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):
|
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()
|
||||||
|
|
||||||
"""
|
|
||||||
Filtra las tareas según la organización del usuario.
|
from rest_framework.views import APIView
|
||||||
Superusuarios pueden ver todas las tareas.
|
from rest_framework.response import Response
|
||||||
"""
|
from rest_framework import status
|
||||||
queryset = self.get_queryset_filtrado_por_organizacion() # Tambien filtra por importador
|
from celery.result import AsyncResult
|
||||||
# if user.is_superuser:
|
|
||||||
# return self.queryset
|
|
||||||
# # return self.queryset.filter(organizacion_id=user.organizacion.id)
|
class TaskStatusView(APIView):
|
||||||
# else:
|
permission_classes = [IsAuthenticated, require_permission('pedimentos.view')]
|
||||||
# return self.queryset.filter(organizacion_id=user.organizacion.id)
|
|
||||||
return queryset
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
143
api/utils/minio_client.py
Normal file
143
api/utils/minio_client.py
Normal 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()
|
||||||
628
api/utils/storage_service.py
Normal file
628
api/utils/storage_service.py
Normal 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()
|
||||||
23
api/vucem/migrations/0012_alter_vucem_cer_alter_vucem_key.py
Normal file
23
api/vucem/migrations/0012_alter_vucem_cer_alter_vucem_key.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
|
||||||
organizacion_id = self.request.data.get('organizacion_id')
|
|
||||||
|
|
||||||
if not organizacion_id:
|
|
||||||
raise ValueError("Los superusuarios deben especificar una organización")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Importa el modelo Organizacion
|
|
||||||
# from ..organization.models import Organizacion
|
|
||||||
organizacion = Organizacion.objects.get(id=organizacion_id)
|
|
||||||
except Organizacion.DoesNotExist:
|
|
||||||
raise ValueError({"organizacion": "Organización no encontrada"})
|
|
||||||
|
|
||||||
serializer.save(
|
|
||||||
organizacion=organizacion,
|
|
||||||
created_by=self.request.user,
|
|
||||||
updated_by=self.request.user
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
else:
|
org = get_org_context(self.request.user)
|
||||||
|
if not org:
|
||||||
|
raise ValueError("El usuario debe tener una organización activa para crear credenciales VUCEM.")
|
||||||
serializer.save(
|
serializer.save(
|
||||||
organizacion=self.request.user.organizacion,
|
organizacion=org,
|
||||||
created_by=self.request.user,
|
created_by=self.request.user,
|
||||||
updated_by=self.request.user
|
updated_by=self.request.user,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
def perform_update(self, serializer):
|
def perform_update(self, serializer):
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if is_internal_service_request(self.request):
|
||||||
raise ValueError("El usuario debe estar autenticado y tener una organización asignada.")
|
|
||||||
instance = self.get_object()
|
instance = self.get_object()
|
||||||
if self.request.user.is_superuser:
|
|
||||||
serializer.save(
|
serializer.save(
|
||||||
created_by=instance.created_by,
|
created_by=instance.created_by,
|
||||||
updated_by=self.request.user
|
updated_by=self.request.user,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
org = get_org_context(self.request.user)
|
||||||
|
if not org:
|
||||||
|
raise ValueError("El usuario debe tener una organización activa para modificar credenciales VUCEM.")
|
||||||
|
instance = self.get_object()
|
||||||
serializer.save(
|
serializer.save(
|
||||||
organizacion=self.request.user.organizacion,
|
organizacion=org,
|
||||||
created_by=instance.created_by,
|
created_by=instance.created_by,
|
||||||
updated_by=self.request.user
|
updated_by=self.request.user,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
|
|
||||||
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
|
@action(detail=True, methods=["get"])
|
||||||
def download_cer(self, request, pk=None):
|
def download_cer(self, request, pk=None):
|
||||||
"""
|
|
||||||
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)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
29
config/stg/storage.py
Normal 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')
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,100 +1,244 @@
|
|||||||
# permissions.py
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
from api.cuser.models import CustomUser
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
from rest_framework.authentication import TokenAuthentication
|
||||||
|
|
||||||
class IsSameOrganization(permissions.BasePermission):
|
|
||||||
"""
|
|
||||||
Permiso personalizado que solo permite acceder a usuarios de la misma organización
|
|
||||||
o a administradores/staff.
|
|
||||||
"""
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Permite listar/crear solo si el usuario está autenticado
|
|
||||||
return request.user.is_authenticated
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
# ---------------------------------------------------------------------------
|
||||||
# Permite operaciones sobre un objeto específico solo si:
|
# Helpers centrales — toda la lógica de RBAC pasa por aquí
|
||||||
# - El objeto pertenece a la misma organización (acceso por usuario relacionado)
|
# ---------------------------------------------------------------------------
|
||||||
return (getattr(obj, 'dirigido', None) and obj.dirigido.organizacion == request.user.organizacion)
|
|
||||||
|
|
||||||
class IsSameOrganizationAndAdmin(permissions.BasePermission):
|
def is_internal_service_request(request):
|
||||||
"""
|
"""True si la petición proviene de un service account (Token auth + superuser).
|
||||||
Permiso personalizado que solo permite acceder a usuarios de la misma organización
|
Misma lógica que IsInternalService, útil en get_queryset() y perform_* methods."""
|
||||||
o a administradores/staff.
|
user = getattr(request, 'user', None)
|
||||||
"""
|
if not user or not user.is_superuser:
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Permite listar/crear solo si el usuario está autenticado
|
|
||||||
return request.user.is_authenticated
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
# Permite operaciones solo si el usuario es admin, Agente Aduanal o user y la organización coincide
|
|
||||||
allowed_groups = ['admin', 'Agente Aduanal', 'user']
|
|
||||||
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists()
|
|
||||||
if not user_in_group:
|
|
||||||
return False
|
return False
|
||||||
if hasattr(obj, 'organizacion'):
|
return isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication)
|
||||||
return obj.organizacion == request.user.organizacion
|
|
||||||
|
|
||||||
|
def get_org_context(user):
|
||||||
|
"""Retorna la organización activa para filtrado de datos.
|
||||||
|
Superusuarios usan active_organization; usuarios normales usan organizacion."""
|
||||||
|
if user.is_superuser:
|
||||||
|
return getattr(user, 'active_organization', None)
|
||||||
|
return getattr(user, 'organizacion', None)
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_permission(user, codename):
|
||||||
|
"""Verifica si un usuario tiene un permiso RBAC por su codename.
|
||||||
|
|
||||||
|
Orden de evaluación:
|
||||||
|
1. is_superuser → True siempre
|
||||||
|
2. UserPermission deny explícito → False
|
||||||
|
3. UserPermission grant explícito → True
|
||||||
|
4. Algún UserRole en su org tiene el permiso → True
|
||||||
|
5. Denegar
|
||||||
|
"""
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
|
||||||
|
org = getattr(user, 'organizacion', None)
|
||||||
|
if not org:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
class IsSameOrganizationDeveloper(permissions.BasePermission):
|
from api.rbac.models import UserPermission, UserRole
|
||||||
"""
|
|
||||||
Permiso personalizado que solo permite acceder a usuarios de la misma organización
|
|
||||||
o a administradores/staff.
|
|
||||||
"""
|
|
||||||
def has_permission(self, request, view):
|
|
||||||
# Permite listar/crear solo si el usuario está autenticado
|
|
||||||
return request.user.is_authenticated
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
try:
|
||||||
# Permite operaciones solo si el usuario es developer, Agente Aduanal o user y la organización coincide
|
override = UserPermission.objects.get(user=user, permission__codename=codename)
|
||||||
allowed_groups = ['developer', 'Agente Aduanal', 'user']
|
return override.granted
|
||||||
user_in_group = request.user.groups.filter(name__in=allowed_groups).exists()
|
except UserPermission.DoesNotExist:
|
||||||
if not user_in_group:
|
pass
|
||||||
return False
|
|
||||||
if hasattr(obj, 'organizacion'):
|
return UserRole.objects.filter(
|
||||||
return obj.organizacion == request.user.organizacion
|
user=user,
|
||||||
|
role__organizacion=org,
|
||||||
|
role__permissions__codename=codename,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_role(user, role_name):
|
||||||
|
"""Verifica si un usuario tiene un rol por nombre dentro de su organización.
|
||||||
|
Función puente durante la transición — lee desde UserRole en lugar de auth.Group."""
|
||||||
|
from api.rbac.models import UserRole
|
||||||
|
|
||||||
|
org = getattr(user, 'organizacion', None)
|
||||||
|
if not org:
|
||||||
return False
|
return False
|
||||||
|
return UserRole.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role__nombre=role_name,
|
||||||
|
role__organizacion=org,
|
||||||
|
).exists()
|
||||||
|
|
||||||
class IsOwnerOrOrgAdmin(permissions.BasePermission):
|
|
||||||
def has_object_permission(self, request, view, obj):
|
|
||||||
return (
|
|
||||||
obj == request.user or
|
|
||||||
request.user.is_staff or
|
|
||||||
request.user.groups.filter(name='admin').exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
class IsSuperUser(permissions.BasePermission):
|
# ---------------------------------------------------------------------------
|
||||||
def has_object_permission(self, request, view, obj):
|
# Base compartida — aplica el requisito de org activa a superusuarios
|
||||||
return request.user.is_superuser
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class OrgScopedPermission(permissions.BasePermission):
|
||||||
|
"""Base para todas las clases de permiso con scope de organización.
|
||||||
|
Superusuario sin active_organization recibe 403, EXCEPTO service accounts
|
||||||
|
(Token auth + superuser) que pasan sin restricción de org."""
|
||||||
|
|
||||||
|
message = 'No tienes permiso para realizar esta acción.'
|
||||||
|
|
||||||
class HasStoragePermission(permissions.BasePermission):
|
|
||||||
"""
|
|
||||||
Permiso personalizado que permite el acceso a los usuarios que tienen permisos de almacenamiento.
|
|
||||||
"""
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
# Permite el acceso si el usuario tiene el permiso 'can_access_storage'
|
if not request.user.is_authenticated:
|
||||||
return request.user.has_perm('api.cuser.can_access_storage')
|
return False
|
||||||
|
if request.user.is_superuser:
|
||||||
|
from rest_framework.authentication import TokenAuthentication
|
||||||
|
# Service account interno: Token auth + superuser → siempre permitido
|
||||||
|
if isinstance(getattr(request, 'successful_authenticator', None), TokenAuthentication):
|
||||||
|
return True
|
||||||
|
# Superuser JWT: requiere active_organization
|
||||||
|
if not getattr(request.user, 'active_organization', None):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Clases de permiso
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IsSameOrganization(OrgScopedPermission):
|
||||||
|
"""Usuario autenticado con org activa. Cualquier rol pasa (incluyendo Importador)."""
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
# Permite operaciones sobre un objeto específico si el usuario tiene el permiso
|
org = get_org_context(request.user)
|
||||||
return request.user.has_perm('api.cuser.can_access_storage')
|
if not org:
|
||||||
|
return False
|
||||||
|
dirigido = getattr(obj, 'dirigido', None)
|
||||||
|
if dirigido:
|
||||||
|
return getattr(dirigido, 'organizacion', None) == org
|
||||||
|
return getattr(obj, 'organizacion', None) == org
|
||||||
|
|
||||||
class IsSameOrganizationAndInAllowedGroups(permissions.BasePermission):
|
|
||||||
"""
|
class IsSameOrganizationAndAdmin(OrgScopedPermission):
|
||||||
Permite update/delete solo si el usuario está en TODOS los grupos permitidos
|
"""Usuario con rol admin, Agente Aduanal o user en su organización."""
|
||||||
y pertenece a la misma organización que el registro, o es superuser.
|
|
||||||
"""
|
|
||||||
allowed_groups = ['admin', 'Agente Aduanal', 'user']
|
|
||||||
|
|
||||||
def has_object_permission(self, request, view, obj):
|
def has_object_permission(self, request, view, obj):
|
||||||
user = request.user
|
user = request.user
|
||||||
if not user.is_authenticated:
|
|
||||||
return False
|
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return True
|
return True
|
||||||
if not hasattr(user, 'organizacion') or not user.organizacion:
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
return False
|
return False
|
||||||
# Debe tener los tres grupos asignados
|
tiene_rol = (
|
||||||
for group in self.allowed_groups:
|
user_has_role(user, 'admin') or
|
||||||
if not user.groups.filter(name=group).exists():
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
)
|
||||||
|
if not tiene_rol:
|
||||||
return False
|
return False
|
||||||
return obj.organizacion == user.organizacion
|
return getattr(obj, 'organizacion', None) == org
|
||||||
|
|
||||||
|
|
||||||
|
class IsSameOrganizationDeveloper(OrgScopedPermission):
|
||||||
|
"""Usuario con rol developer, Agente Aduanal o user en su organización."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
user = request.user
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return False
|
||||||
|
tiene_rol = (
|
||||||
|
user_has_role(user, 'developer') or
|
||||||
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
)
|
||||||
|
if not tiene_rol:
|
||||||
|
return False
|
||||||
|
return getattr(obj, 'organizacion', None) == org
|
||||||
|
|
||||||
|
|
||||||
|
class IsOwnerOrOrgAdmin(OrgScopedPermission):
|
||||||
|
"""El propio usuario, staff de Django o usuario con rol admin en la org."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
user = request.user
|
||||||
|
return (
|
||||||
|
obj == user or
|
||||||
|
user.is_staff or
|
||||||
|
user.is_superuser or
|
||||||
|
user_has_role(user, 'admin')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IsSuperUser(permissions.BasePermission):
|
||||||
|
"""Solo superusuarios de Django. No requiere org activa (para endpoints de gestión global)."""
|
||||||
|
|
||||||
|
message = 'No tienes permiso para realizar esta acción.'
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return request.user.is_authenticated and request.user.is_superuser
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
return request.user.is_superuser
|
||||||
|
|
||||||
|
|
||||||
|
class IsInternalService(permissions.BasePermission):
|
||||||
|
"""
|
||||||
|
Identifica llamadas internas de microservicio → backend.
|
||||||
|
|
||||||
|
Criterio: autenticación via Token (no JWT) + usuario superuser.
|
||||||
|
Esto garantiza que solo cuentas de servicio predefinidas pasan,
|
||||||
|
sin depender de flags manuales como is_staff que pueden no estar
|
||||||
|
configurados en producción.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message = 'Acceso reservado para servicios internos.'
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not request.user or not request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
from rest_framework.authentication import TokenAuthentication
|
||||||
|
return (
|
||||||
|
isinstance(request.successful_authenticator, TokenAuthentication)
|
||||||
|
and request.user.is_superuser
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class HasStoragePermission(OrgScopedPermission):
|
||||||
|
"""Usuarios con acceso a operaciones de almacenamiento (organizacion.view)."""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not super().has_permission(request, view):
|
||||||
|
return False
|
||||||
|
return user_has_permission(request.user, 'organizacion.view')
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
return user_has_permission(request.user, 'organizacion.view')
|
||||||
|
|
||||||
|
|
||||||
|
def require_permission(codename):
|
||||||
|
"""
|
||||||
|
Devuelve una clase de permiso DRF que exige el codename RBAC indicado.
|
||||||
|
Uso en permission_classes: require_permission('pedimentos.view')
|
||||||
|
Uso en get_permissions(): require_permission('pedimentos.create')()
|
||||||
|
"""
|
||||||
|
class _RbacPerm(OrgScopedPermission):
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if not super().has_permission(request, view):
|
||||||
|
return False
|
||||||
|
return user_has_permission(request.user, codename)
|
||||||
|
_RbacPerm.__name__ = f'HasPerm_{codename.replace(".", "_")}'
|
||||||
|
_RbacPerm.__qualname__ = _RbacPerm.__name__
|
||||||
|
return _RbacPerm
|
||||||
|
|
||||||
|
|
||||||
|
class IsSameOrganizationAndInAllowedGroups(OrgScopedPermission):
|
||||||
|
"""Usuario con permiso vucem.manage en su organización.
|
||||||
|
Reemplaza la lógica rota que requería 3 grupos simultáneamente."""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
user = request.user
|
||||||
|
if user.is_superuser:
|
||||||
|
return True
|
||||||
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return False
|
||||||
|
if not user_has_permission(user, 'vucem.manage'):
|
||||||
|
return False
|
||||||
|
return getattr(obj, 'organizacion', None) == org
|
||||||
|
|||||||
42
core/redis_events.py
Normal file
42
core/redis_events.py
Normal 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}")
|
||||||
@@ -1,142 +1,179 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from core.permissions import get_org_context, user_has_role, is_internal_service_request
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_internal_service(request):
|
||||||
|
return is_internal_service_request(request)
|
||||||
|
|
||||||
|
|
||||||
class FiltroPorOrganizacionMixin:
|
class FiltroPorOrganizacionMixin:
|
||||||
model = None
|
model = None
|
||||||
campo_usuario = 'user'
|
campo_usuario = 'user'
|
||||||
campo_organizacion = 'organizacion'
|
campo_organizacion = 'organizacion'
|
||||||
campo_rfc = 'rfc__id'
|
campo_contribuyente = 'pedimento__contribuyente'
|
||||||
campo_contribuyente = 'pedimento__contribuyente' # solo si aplica
|
|
||||||
|
|
||||||
def get_queryset_filtrado(self):
|
def get_queryset_filtrado(self):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
||||||
if not user.is_authenticated or not hasattr(user, self.campo_organizacion):
|
if not user.is_authenticated:
|
||||||
return self.model.objects.none()
|
return self.model.objects.none()
|
||||||
|
|
||||||
if user.is_superuser:
|
if _is_internal_service(self.request):
|
||||||
return self.model.objects.all()
|
return self.model.objects.all()
|
||||||
|
|
||||||
if (user.groups.filter(name='admin').exists() or user.groups.filter(name='developer').exists()) and user.is_authenticated and user.groups.filter(name='Agente Aduanal').exists():
|
org = get_org_context(user)
|
||||||
model_fields = [f.name for f in self.model._meta.get_fields()]
|
if not org:
|
||||||
if self.campo_organizacion in model_fields:
|
|
||||||
filtro = {f"{self.campo_organizacion}": getattr(user, self.campo_organizacion)}
|
|
||||||
else:
|
|
||||||
return self.model.objects.none()
|
return self.model.objects.none()
|
||||||
|
|
||||||
|
filtro = {self.campo_organizacion: org}
|
||||||
|
|
||||||
|
# Superuser y usuarios con rol operativo ven todo lo de su org activa
|
||||||
|
if user.is_superuser:
|
||||||
return self.model.objects.filter(**filtro)
|
return self.model.objects.filter(**filtro)
|
||||||
|
|
||||||
if user.groups.filter(name='Importador').exists() and getattr(user, 'is_importador', False):
|
if (
|
||||||
filtro = {
|
user_has_role(user, 'admin') or
|
||||||
f"{self.campo_contribuyente}__{self.campo_rfc}": getattr(user, self.campo_rfc),
|
user_has_role(user, 'developer') or
|
||||||
}
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
):
|
||||||
|
return self.model.objects.filter(**filtro)
|
||||||
|
|
||||||
|
# Importador: acceso filtrado por org + RFC como contribuyente
|
||||||
|
if user.is_importador:
|
||||||
|
filtro[f"{self.campo_contribuyente}__in"] = user.rfc.all()
|
||||||
return self.model.objects.filter(**filtro)
|
return self.model.objects.filter(**filtro)
|
||||||
|
|
||||||
return self.model.objects.none()
|
return self.model.objects.none()
|
||||||
|
|
||||||
# en core/mixins/organizacion.py o similar
|
|
||||||
class OrganizacionFiltradaMixin:
|
class OrganizacionFiltradaMixin:
|
||||||
model = None # Puedes sobreescribir esto en la vista
|
model = None
|
||||||
campo_organizacion = 'organizacion'
|
campo_organizacion = 'organizacion'
|
||||||
campo_contribuyente = 'contribuyente' # solo si aplica
|
campo_contribuyente = 'contribuyente'
|
||||||
|
|
||||||
def get_queryset_filtrado_por_organizacion(self):
|
def get_queryset_filtrado_por_organizacion(self):
|
||||||
model = self.model or self.queryset.model
|
model = self.model or self.queryset.model
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not user.is_authenticated:
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if _is_internal_service(self.request):
|
||||||
return model.objects.all()
|
return model.objects.all()
|
||||||
|
|
||||||
org = self.request.user.organizacion
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return model.objects.none()
|
||||||
|
|
||||||
filtros_base = {
|
filtros_base = {
|
||||||
f"{self.campo_organizacion}": org,
|
self.campo_organizacion: org,
|
||||||
f"{self.campo_organizacion}__is_active": True,
|
f'{self.campo_organizacion}__is_active': True,
|
||||||
f"{self.campo_organizacion}__is_verified": True,
|
f'{self.campo_organizacion}__is_verified': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
grupos = self.request.user.groups.values_list('name', flat=True)
|
if user.is_superuser:
|
||||||
|
|
||||||
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and (('admin' in grupos or 'developer' in grupos) and 'user' in grupos) :
|
|
||||||
if 'Agente Aduanal' in grupos:
|
|
||||||
return model.objects.filter(**filtros_base)
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
# if hasattr(model, self.campo_contribuyente):
|
if (
|
||||||
if self.request.user.is_authenticated and 'Importador' in grupos :
|
user_has_role(user, 'admin') or
|
||||||
filtros_base[f"{self.campo_contribuyente}__rfc"] = self.request.user.rfc.rfc
|
user_has_role(user, 'developer') or
|
||||||
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
):
|
||||||
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
|
if user.is_importador:
|
||||||
|
filtros_base[f'{self.campo_contribuyente}__in'] = user.rfc.all()
|
||||||
return model.objects.filter(**filtros_base)
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
# Si no entra en los roles válidos
|
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
|
|
||||||
class DocumentosFiltradosMixin:
|
class DocumentosFiltradosMixin:
|
||||||
model = None
|
model = None
|
||||||
campo_organizacion = 'organizacion'
|
campo_organizacion = 'organizacion'
|
||||||
campo_contribuyente = 'pedimento' # solo si aplica
|
campo_contribuyente = 'pedimento'
|
||||||
|
|
||||||
def get_queryset_filtrado_por_organizacion(self):
|
def get_queryset_filtrado_por_organizacion(self):
|
||||||
model = self.model or self.queryset.model
|
model = self.model or self.queryset.model
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not user.is_authenticated:
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if _is_internal_service(self.request):
|
||||||
return model.objects.all()
|
return model.objects.all()
|
||||||
|
|
||||||
org = self.request.user.organizacion
|
org = get_org_context(user)
|
||||||
|
if not org:
|
||||||
|
return model.objects.none()
|
||||||
|
|
||||||
filtros_base = {
|
filtros_base = {
|
||||||
f"{self.campo_organizacion}": org.id,
|
f'{self.campo_organizacion}': org.id,
|
||||||
f"{self.campo_organizacion}__is_active": True,
|
f'{self.campo_organizacion}__is_active': True,
|
||||||
f"{self.campo_organizacion}__is_verified": True,
|
f'{self.campo_organizacion}__is_verified': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
grupos = self.request.user.groups.values_list('name', flat=True)
|
if user.is_superuser:
|
||||||
|
|
||||||
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos):
|
|
||||||
if 'Agente Aduanal' in grupos:
|
|
||||||
return model.objects.filter(**filtros_base)
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
if hasattr(model, self.campo_contribuyente):
|
if (
|
||||||
if self.request.user.is_authenticated and 'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
|
user_has_role(user, 'admin') or
|
||||||
filtros_base[f"{self.campo_contribuyente}__contribuyente"] = self.request.user.rfc
|
user_has_role(user, 'developer') or
|
||||||
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
):
|
||||||
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
|
if user.is_importador:
|
||||||
|
filtros_base[f'{self.campo_contribuyente}__contribuyente__in'] = user.rfc.all()
|
||||||
return model.objects.filter(**filtros_base)
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
# Si no entra en los roles válidos
|
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
|
|
||||||
class ProcesosPorOrganizacionMixin:
|
class ProcesosPorOrganizacionMixin:
|
||||||
model = None # Puedes sobreescribir esto en la vista
|
model = None
|
||||||
campo_organizacion = 'organizacion'
|
campo_organizacion = 'organizacion'
|
||||||
campo_pedimento = 'pedimento' # solo si aplica
|
campo_pedimento = 'pedimento'
|
||||||
|
|
||||||
def get_queryset_filtrado_por_organizacion(self):
|
def get_queryset_filtrado_por_organizacion(self):
|
||||||
model = self.model or self.queryset.model
|
model = self.model or self.queryset.model
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
if not self.request.user.is_authenticated or not hasattr(self.request.user, 'organizacion'):
|
if not user.is_authenticated:
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
if self.request.user.is_superuser:
|
if _is_internal_service(self.request):
|
||||||
return model.objects.all()
|
return model.objects.all()
|
||||||
|
|
||||||
org = self.request.user.organizacion
|
org = get_org_context(user)
|
||||||
filtros_base = {
|
if not org:
|
||||||
f"{self.campo_organizacion}": org,
|
|
||||||
f"{self.campo_organizacion}__is_active": True,
|
|
||||||
f"{self.campo_organizacion}__is_verified": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
grupos = self.request.user.groups.values_list('name', flat=True)
|
|
||||||
|
|
||||||
|
|
||||||
if self.request.user.is_authenticated and 'Agente Aduanal' in grupos and ('admin' in grupos or 'developer' in grupos or 'user' in grupos) :
|
|
||||||
if 'Agente Aduanal' in grupos:
|
|
||||||
return model.objects.filter(**filtros_base)
|
|
||||||
|
|
||||||
if hasattr(model, self.campo_pedimento):
|
|
||||||
if self.request.user.is_authenticated and'Importador' in grupos and getattr(self.request.user, 'is_importador', False):
|
|
||||||
filtros_base[f"{self.campo_pedimento}__contribuyente"] = self.request.user.rfc
|
|
||||||
return model.objects.filter(**filtros_base)
|
|
||||||
|
|
||||||
# Si no entra en los roles válidos
|
|
||||||
return model.objects.none()
|
return model.objects.none()
|
||||||
|
|
||||||
|
filtros_base = {
|
||||||
|
self.campo_organizacion: org,
|
||||||
|
f'{self.campo_organizacion}__is_active': True,
|
||||||
|
f'{self.campo_organizacion}__is_verified': True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.is_superuser:
|
||||||
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
|
if (
|
||||||
|
user_has_role(user, 'admin') or
|
||||||
|
user_has_role(user, 'developer') or
|
||||||
|
user_has_role(user, 'Agente Aduanal') or
|
||||||
|
user_has_role(user, 'user')
|
||||||
|
):
|
||||||
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
|
if user.is_importador:
|
||||||
|
filtros_base[f'{self.campo_pedimento}__contribuyente__in'] = user.rfc.all()
|
||||||
|
return model.objects.filter(**filtros_base)
|
||||||
|
|
||||||
|
return model.objects.none()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user