diff --git a/MEDIA_CLEANUP_GUIDE.md b/MEDIA_CLEANUP_GUIDE.md new file mode 100644 index 0000000..bb530b7 --- /dev/null +++ b/MEDIA_CLEANUP_GUIDE.md @@ -0,0 +1,184 @@ +# Comando de Limpieza de Archivos Media + +Este documento explica cómo utilizar los comandos de limpieza de archivos media huérfanos en el proyecto Django. + +## ¿Qué son los archivos huérfanos? + +Los archivos huérfanos son archivos que existen físicamente en el directorio `/media/` pero que no están referenciados en ningún campo de archivo (`FileField` o `ImageField`) de la base de datos. Esto puede ocurrir por: + +- Eliminación de registros sin eliminar sus archivos asociados +- Errores en procesos de carga de archivos +- Archivos temporales que nunca se limpiaron +- Migraciones de datos incorrectas + +## Comandos Disponibles + +### 1. cleanup_media (Comando Original) +```bash +python manage.py cleanup_media [opciones] +``` + +### 2. cleanup_media_fast (Comando Optimizado) +```bash +python manage.py cleanup_media_fast [opciones] +``` + +## Opciones Disponibles + +### Opciones Comunes (ambos comandos): +- `--dry-run`: Muestra qué archivos se eliminarían sin eliminarlos realmente +- `--verbose`: Muestra información detallada del proceso +- `--help`: Muestra ayuda del comando + +### Opciones del cleanup_media: +- `--batch-size N`: Tamaño del lote para procesar archivos (default: 10000) + +### Opciones del cleanup_media_fast: +- `--limit N`: Limitar el número de archivos huérfanos a procesar +- `--sample-size N`: Tamaño de muestra para análisis rápido (default: 10000) +- `--quick-scan`: Hacer un escaneo rápido con muestra limitada + +## Uso Recomendado + +### 1. Análisis Inicial (Siempre hacer primero) +```bash +# Escaneo rápido para obtener una estimación +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --dry-run --verbose" + +# O escaneo completo para números exactos (puede tomar tiempo) +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --dry-run --verbose" +``` + +### 2. Limpieza por Lotes (Recomendado para grandes volúmenes) +```bash +# Limpiar muestra de 10,000 archivos +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --verbose" + +# Limpiar hasta 5,000 archivos específicos +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --limit 5000 --verbose" +``` + +### 3. Limpieza Completa +```bash +# Eliminar todos los archivos huérfanos (CUIDADO: puede tomar mucho tiempo) +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --verbose" +``` + +## Ejemplos de Salida + +### Dry Run (Modo Prueba): +``` +Iniciando análisis en: /app/media +Modo escaneo rápido - muestra de 10000 archivos +Obteniendo muestra de archivos... +Muestra obtenida: 10000 archivos +Encontrados 5 modelos con campos de archivo +Procesando Document... + Document: 2059405 registros procesados +Archivos huérfanos encontrados: 2763 (72.57 MB) - Tiempo: 7.81s + +--- MODO PRUEBA: Los siguientes archivos se eliminarían --- + - documents/vu_AC_0101_230_1703_3004804_4.pdf + - documents/vu_AC_0101_300_3172_4001419_2.pdf + ... y 2743 archivos más +``` + +### Eliminación Real: +``` +¿Estás seguro de que quieres eliminar 2763 archivos? (s/N): s +Progreso: 36.2% (1000 eliminados) +Progreso: 72.4% (2000 eliminados) +Eliminados 2763 archivos huérfanos (51.32 MB) +``` + +## Casos de Uso + +### Caso 1: Primera vez ejecutando el comando +```bash +# 1. Hacer análisis inicial +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --dry-run" + +# 2. Si hay pocos archivos (<5000), eliminar todos +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan" + +# 3. Si hay muchos archivos, eliminar por lotes +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --limit 10000" +``` + +### Caso 2: Mantenimiento regular +```bash +# Escaneo rápido y limpieza +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan" +``` + +### Caso 3: Limpieza masiva después de migración de datos +```bash +# 1. Análisis completo primero +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --dry-run" + +# 2. Limpieza por lotes grandes +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --limit 50000" +``` + +## Consideraciones de Seguridad + +### ⚠️ IMPORTANTE - Hacer Backup +Siempre haz un backup de tu directorio `/media/` antes de ejecutar eliminaciones masivas: +```bash +# Backup del directorio media +docker exec EFC_backend_dev tar -czf /tmp/media_backup.tar.gz /app/media/ + +# Copiar backup al host +docker cp EFC_backend_dev:/tmp/media_backup.tar.gz ./media_backup_$(date +%Y%m%d_%H%M%S).tar.gz +``` + +### ✅ Buenas Prácticas +1. **Siempre usar `--dry-run` primero** para ver qué se va a eliminar +2. **Empezar con lotes pequeños** (`--limit`) en lugar de eliminar todo de una vez +3. **Verificar la aplicación** después de cada limpieza para asegurar que no se rompió nada +4. **Ejecutar en horarios de bajo tráfico** para grandes volúmenes +5. **Monitorear el espacio en disco** antes y después + +### 🚨 Advertencias +- Los archivos eliminados **NO SE PUEDEN RECUPERAR** fácilmente +- El comando solo verifica referencias en la BD, no enlaces desde código o templates +- Archivos muy recientes podrían estar en proceso de ser referenciados + +## Troubleshooting + +### Error: "Too many open files" +```bash +# Aumentar límite de archivos abiertos +docker exec -it EFC_backend_dev bash -c "ulimit -n 65536 && cd /app && python manage.py cleanup_media_fast --quick-scan" +``` + +### Proceso muy lento +```bash +# Usar modo quick-scan en lugar de escaneo completo +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --dry-run" +``` + +### Verificar que el comando existe +```bash +# Listar comandos disponibles +docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py help" +``` + +## Automatización + +Para automatizar la limpieza regular, puedes crear un cron job: +```bash +# Ejecutar limpieza semanal los domingos a las 2 AM +0 2 * * 0 docker exec EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan" >> /var/log/media_cleanup.log 2>&1 +``` + +## Monitoreo + +Para monitorear el efecto de la limpieza: +```bash +# Antes de limpiar +docker exec EFC_backend_dev du -sh /app/media/ + +# Después de limpiar (verificar el cambio) +docker exec EFC_backend_dev du -sh /app/media/ +``` \ No newline at end of file diff --git a/api/customs/management/__init__.py b/api/customs/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/customs/management/commands/__init__.py b/api/customs/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/customs/management/commands/cleanup_media.py b/api/customs/management/commands/cleanup_media.py new file mode 100644 index 0000000..bca217d --- /dev/null +++ b/api/customs/management/commands/cleanup_media.py @@ -0,0 +1,243 @@ +import os +import gc +from django.core.management.base import BaseCommand +from django.conf import settings +from django.apps import apps +from django.db import models +from django.db import connection + + +class Command(BaseCommand): + help = 'Elimina archivos de media que no están referenciados en la base de datos' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Muestra qué archivos se eliminarían sin eliminarlos', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Muestra información detallada del proceso', + ) + parser.add_argument( + '--batch-size', + type=int, + default=10000, + help='Tamaño del lote para procesar archivos (default: 10000)', + ) + parser.add_argument( + '--limit', + type=int, + help='Limitar el número de archivos huérfanos a procesar', + ) + + def handle(self, *args, **options): + media_root = settings.MEDIA_ROOT + + if not os.path.exists(media_root): + self.stdout.write( + self.style.ERROR(f'El directorio MEDIA_ROOT no existe: {media_root}') + ) + return + + if options['verbose']: + self.stdout.write(f'Analizando archivos en: {media_root}') + + # Obtener archivos de BD de forma más eficiente + files_in_db = self._get_db_files_optimized(options) + + if options['verbose']: + self.stdout.write(f'Archivos referenciados en BD: {len(files_in_db)}') + + # Procesar archivos en lotes para evitar problemas de memoria + orphaned_files = self._find_orphaned_files_batch(media_root, files_in_db, options) + + if not orphaned_files: + self.stdout.write( + self.style.SUCCESS('No se encontraron archivos huérfanos') + ) + return + + # Aplicar límite si se especifica + if options['limit'] and len(orphaned_files) > options['limit']: + orphaned_files = list(orphaned_files)[:options['limit']] + self.stdout.write(f'Limitando a {options["limit"]} archivos') + + # Calcula el tamaño total de archivos huérfanos + total_size = self._calculate_total_size(orphaned_files, options) + size_mb = total_size / (1024 * 1024) + + self.stdout.write( + f'Archivos huérfanos encontrados: {len(orphaned_files)} ' + f'({size_mb:.2f} MB)' + ) + + if options['dry_run']: + self.stdout.write('\n--- MODO PRUEBA: Los siguientes archivos se eliminarían ---') + for i, file_path in enumerate(sorted(orphaned_files)): + if i >= 50: # Limitar salida en dry-run + remaining = len(orphaned_files) - 50 + self.stdout.write(f' ... y {remaining} archivos más') + break + relative_path = os.path.relpath(file_path, media_root) + self.stdout.write(f' - {relative_path}') + else: + # Pide confirmación antes de eliminar + confirm = input( + f'\n¿Estás seguro de que quieres eliminar {len(orphaned_files)} archivos? (s/N): ' + ) + + if confirm.lower() not in ['s', 'si', 'sí', 'y', 'yes']: + self.stdout.write('Operación cancelada') + return + + self._delete_files_batch(orphaned_files, media_root, options) + + def _get_db_files_optimized(self, options): + """Obtiene archivos de BD de forma optimizada""" + files_in_db = set() + media_root = settings.MEDIA_ROOT + + for model in apps.get_models(): + file_fields = [ + field for field in model._meta.get_fields() + if isinstance(field, (models.FileField, models.ImageField)) + ] + + if not file_fields: + continue + + if options['verbose']: + self.stdout.write(f'Procesando modelo {model.__name__}...') + + # Procesar en lotes para evitar cargar todo en memoria + batch_size = options['batch_size'] + offset = 0 + + while True: + field_names = [field.name for field in file_fields] + queryset = model.objects.values_list(*field_names)[offset:offset + batch_size] + batch = list(queryset) + + if not batch: + break + + for row in batch: + for file_path in row: + if file_path: + full_path = os.path.join(media_root, str(file_path)) + files_in_db.add(full_path) + + offset += batch_size + + if options['verbose'] and offset % (batch_size * 10) == 0: + self.stdout.write(f' Procesados {offset} registros...') + + # Forzar liberación de memoria + gc.collect() + + return files_in_db + + def _find_orphaned_files_batch(self, media_root, files_in_db, options): + """Encuentra archivos huérfanos procesando en lotes""" + orphaned_files = [] + processed_count = 0 + + self.stdout.write('Buscando archivos huérfanos...') + + for root, dirs, files in os.walk(media_root): + for file in files: + if file.startswith('.'): + continue + + full_path = os.path.join(root, file) + + if full_path not in files_in_db: + orphaned_files.append(full_path) + + processed_count += 1 + + if options['verbose'] and processed_count % 50000 == 0: + self.stdout.write(f'Procesados {processed_count} archivos, encontrados {len(orphaned_files)} huérfanos...') + + # Liberar memoria periódicamente + if processed_count % 100000 == 0: + gc.collect() + + return orphaned_files + + def _calculate_total_size(self, orphaned_files, options): + """Calcula el tamaño total de archivos huérfanos""" + total_size = 0 + count = 0 + + for file_path in orphaned_files: + try: + total_size += os.path.getsize(file_path) + count += 1 + + if options['verbose'] and count % 10000 == 0: + self.stdout.write(f'Calculando tamaño... {count}/{len(orphaned_files)}') + + except OSError: + pass + + return total_size + + def _delete_files_batch(self, orphaned_files, media_root, options): + """Elimina archivos en lotes""" + deleted_count = 0 + deleted_size = 0 + batch_size = 1000 + + total_files = len(orphaned_files) + + for i in range(0, total_files, batch_size): + batch = orphaned_files[i:i + batch_size] + + for file_path in batch: + try: + file_size = os.path.getsize(file_path) + os.remove(file_path) + deleted_count += 1 + deleted_size += file_size + + if options['verbose'] and deleted_count % 5000 == 0: + relative_path = os.path.relpath(file_path, media_root) + self.stdout.write(f'Progreso: {deleted_count}/{total_files} - Eliminado: {relative_path}') + + except OSError as e: + relative_path = os.path.relpath(file_path, media_root) + self.stdout.write( + self.style.ERROR(f'Error eliminando {relative_path}: {e}') + ) + + # Mostrar progreso por lotes + progress = (i + batch_size) / total_files * 100 + self.stdout.write(f'Progreso: {min(progress, 100):.1f}% ({deleted_count} eliminados)') + + # Forzar liberación de memoria + gc.collect() + + # Elimina directorios vacíos + self._remove_empty_dirs(media_root) + + deleted_mb = deleted_size / (1024 * 1024) + self.stdout.write( + self.style.SUCCESS( + f'Eliminados {deleted_count} archivos huérfanos ({deleted_mb:.2f} MB)' + ) + ) + + def _remove_empty_dirs(self, path): + """Elimina directorios vacíos recursivamente""" + for root, dirs, files in os.walk(path, topdown=False): + for dir_name in dirs: + dir_path = os.path.join(root, dir_name) + try: + if not os.listdir(dir_path): + os.rmdir(dir_path) + except OSError: + pass \ No newline at end of file diff --git a/api/customs/management/commands/cleanup_media_fast.py b/api/customs/management/commands/cleanup_media_fast.py new file mode 100644 index 0000000..d30232c --- /dev/null +++ b/api/customs/management/commands/cleanup_media_fast.py @@ -0,0 +1,284 @@ +import os +import gc +import time +from collections import defaultdict +from django.core.management.base import BaseCommand +from django.conf import settings +from django.apps import apps +from django.db import models, connection + + +class Command(BaseCommand): + help = 'Elimina archivos de media que no están referenciados en la base de datos (versión optimizada)' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Muestra qué archivos se eliminarían sin eliminarlos', + ) + parser.add_argument( + '--verbose', + action='store_true', + help='Muestra información detallada del proceso', + ) + parser.add_argument( + '--limit', + type=int, + help='Limitar el número de archivos huérfanos a procesar', + ) + parser.add_argument( + '--sample-size', + type=int, + default=10000, + help='Tamaño de muestra para análisis rápido (default: 10000)', + ) + parser.add_argument( + '--quick-scan', + action='store_true', + help='Hacer un escaneo rápido con muestra limitada', + ) + + def handle(self, *args, **options): + media_root = settings.MEDIA_ROOT + + if not os.path.exists(media_root): + self.stdout.write( + self.style.ERROR(f'El directorio MEDIA_ROOT no existe: {media_root}') + ) + return + + start_time = time.time() + self.stdout.write(f'Iniciando análisis en: {media_root}') + + if options['quick_scan']: + self.stdout.write(f'Modo escaneo rápido - muestra de {options["sample_size"]} archivos') + orphaned_files = self._quick_scan(media_root, options) + else: + # Obtener archivos de BD usando SQL directo + files_in_db = self._get_db_files_sql(options) + + if options['verbose']: + self.stdout.write(f'Archivos referenciados en BD: {len(files_in_db)}') + + # Encontrar archivos huérfanos + orphaned_files = self._find_orphaned_files(media_root, files_in_db, options) + + if not orphaned_files: + self.stdout.write( + self.style.SUCCESS('No se encontraron archivos huérfanos') + ) + return + + # Aplicar límite si se especifica + if options['limit'] and len(orphaned_files) > options['limit']: + orphaned_files = list(orphaned_files)[:options['limit']] + self.stdout.write(f'Limitando a {options["limit"]} archivos') + + # Calcula el tamaño total + total_size = self._calculate_size_sample(orphaned_files, options) + size_mb = total_size / (1024 * 1024) + + elapsed_time = time.time() - start_time + self.stdout.write( + f'Archivos huérfanos encontrados: {len(orphaned_files)} ' + f'({size_mb:.2f} MB) - Tiempo: {elapsed_time:.2f}s' + ) + + if options['dry_run']: + self._show_dry_run_results(orphaned_files, media_root) + else: + self._delete_files(orphaned_files, media_root, options) + + def _get_db_files_sql(self, options): + """Obtiene archivos usando consultas SQL directas""" + files_in_db = set() + media_root = settings.MEDIA_ROOT + + # Mapear modelos a sus campos de archivo + file_field_map = {} + for model in apps.get_models(): + file_fields = [ + field for field in model._meta.get_fields() + if isinstance(field, (models.FileField, models.ImageField)) + ] + if file_fields: + file_field_map[model] = file_fields + + if options['verbose']: + self.stdout.write(f'Encontrados {len(file_field_map)} modelos con campos de archivo') + + with connection.cursor() as cursor: + for model, fields in file_field_map.items(): + if options['verbose']: + self.stdout.write(f'Procesando {model.__name__}...') + + table_name = model._meta.db_table + field_names = [field.column for field in fields] + + # Construir query SQL + field_selects = ', '.join(field_names) + where_conditions = [] + for field in field_names: + where_conditions.append(f'{field} IS NOT NULL AND {field} != \'\'') + where_clause = ' OR '.join(where_conditions) + + query = f""" + SELECT {field_selects} + FROM {table_name} + WHERE {where_clause} + """ + + cursor.execute(query) + rows = cursor.fetchall() + + for row in rows: + for file_path in row: + if file_path: + full_path = os.path.join(media_root, str(file_path)) + files_in_db.add(full_path) + + if options['verbose']: + self.stdout.write(f' {model.__name__}: {len(rows)} registros procesados') + + return files_in_db + + def _quick_scan(self, media_root, options): + """Escaneo rápido con muestra limitada""" + sample_files = [] + count = 0 + target_size = options['sample_size'] + + self.stdout.write('Obteniendo muestra de archivos...') + + for root, dirs, files in os.walk(media_root): + for file in files: + if file.startswith('.'): + continue + + sample_files.append(os.path.join(root, file)) + count += 1 + + if count >= target_size: + break + + if count >= target_size: + break + + self.stdout.write(f'Muestra obtenida: {len(sample_files)} archivos') + + # Obtener archivos de BD para comparar + files_in_db = self._get_db_files_sql(options) + + # Encontrar huérfanos en la muestra + orphaned_files = [f for f in sample_files if f not in files_in_db] + + return orphaned_files + + def _find_orphaned_files(self, media_root, files_in_db, options): + """Encuentra archivos huérfanos optimizado""" + orphaned_files = [] + processed_count = 0 + + self.stdout.write('Buscando archivos huérfanos...') + + for root, dirs, files in os.walk(media_root): + for file in files: + if file.startswith('.'): + continue + + full_path = os.path.join(root, file) + + if full_path not in files_in_db: + orphaned_files.append(full_path) + + processed_count += 1 + + if options['verbose'] and processed_count % 100000 == 0: + self.stdout.write(f'Procesados {processed_count} archivos, encontrados {len(orphaned_files)} huérfanos...') + + # Liberar memoria cada 500k archivos + if processed_count % 500000 == 0: + gc.collect() + + return orphaned_files + + def _calculate_size_sample(self, orphaned_files, options): + """Calcula tamaño usando muestra si hay muchos archivos""" + if len(orphaned_files) <= 1000: + # Si hay pocos archivos, calcular tamaño exacto + total_size = 0 + for file_path in orphaned_files: + try: + total_size += os.path.getsize(file_path) + except OSError: + pass + return total_size + else: + # Si hay muchos archivos, usar muestra para estimar + sample_size = min(1000, len(orphaned_files)) + sample_files = orphaned_files[:sample_size] + sample_total = 0 + + for file_path in sample_files: + try: + sample_total += os.path.getsize(file_path) + except OSError: + pass + + # Extrapolar al total + avg_size = sample_total / sample_size if sample_size > 0 else 0 + estimated_total = avg_size * len(orphaned_files) + + self.stdout.write(f'Tamaño estimado basado en muestra de {sample_size} archivos') + return estimated_total + + def _show_dry_run_results(self, orphaned_files, media_root): + """Muestra resultados del dry run""" + self.stdout.write('\n--- MODO PRUEBA: Los siguientes archivos se eliminarían ---') + + show_limit = 20 + for i, file_path in enumerate(sorted(orphaned_files)): + if i >= show_limit: + remaining = len(orphaned_files) - show_limit + self.stdout.write(f' ... y {remaining} archivos más') + break + relative_path = os.path.relpath(file_path, media_root) + self.stdout.write(f' - {relative_path}') + + def _delete_files(self, orphaned_files, media_root, options): + """Elimina archivos con confirmación""" + confirm = input( + f'\n¿Estás seguro de que quieres eliminar {len(orphaned_files)} archivos? (s/N): ' + ) + + if confirm.lower() not in ['s', 'si', 'sí', 'y', 'yes']: + self.stdout.write('Operación cancelada') + return + + deleted_count = 0 + deleted_size = 0 + + for i, file_path in enumerate(orphaned_files): + try: + file_size = os.path.getsize(file_path) + os.remove(file_path) + deleted_count += 1 + deleted_size += file_size + + if options['verbose'] and deleted_count % 1000 == 0: + progress = (i + 1) / len(orphaned_files) * 100 + self.stdout.write(f'Progreso: {progress:.1f}% ({deleted_count} eliminados)') + + except OSError as e: + relative_path = os.path.relpath(file_path, media_root) + self.stdout.write( + self.style.ERROR(f'Error eliminando {relative_path}: {e}') + ) + + deleted_mb = deleted_size / (1024 * 1024) + self.stdout.write( + self.style.SUCCESS( + f'Eliminados {deleted_count} archivos huérfanos ({deleted_mb:.2f} MB)' + ) + ) \ No newline at end of file diff --git a/api/customs/migrations/0011_cove_acuse_cove_descargado_cove_cove_descargado_and_more.py b/api/customs/migrations/0011_cove_acuse_cove_descargado_cove_cove_descargado_and_more.py new file mode 100644 index 0000000..c30d3e0 --- /dev/null +++ b/api/customs/migrations/0011_cove_acuse_cove_descargado_cove_cove_descargado_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.3 on 2025-10-02 00:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0010_alter_pedimento_contribuyente'), + ] + + operations = [ + migrations.AddField( + model_name='cove', + name='acuse_cove_descargado', + field=models.BooleanField(default=False, help_text='Indica si el acuse de la cove ha sido descargado'), + ), + migrations.AddField( + model_name='cove', + name='cove_descargado', + field=models.BooleanField(default=False, help_text='Indica si la cove ha sido descargada'), + ), + migrations.AddField( + model_name='edocument', + name='acuse_descargado', + field=models.BooleanField(default=False, help_text='Indica si el acuse del e-documento ha sido descargado'), + ), + migrations.AddField( + model_name='edocument', + name='edocument_descargado', + field=models.BooleanField(default=False, help_text='Indica si el e-documento ha sido descargado'), + ), + ] diff --git a/api/customs/migrations/0012_partida.py b/api/customs/migrations/0012_partida.py new file mode 100644 index 0000000..08c3b2e --- /dev/null +++ b/api/customs/migrations/0012_partida.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.3 on 2025-10-03 01:18 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0011_cove_acuse_cove_descargado_cove_cove_descargado_and_more'), + ('organization', '0002_remove_organizacion_membretado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Partida', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('numero_partida', models.PositiveIntegerField(help_text='Número de la partida dentro del pedimento')), + ('descargado', models.BooleanField(default=False, help_text='Indica si la partida ha sido descargada')), + ('organizacion', models.ForeignKey(help_text='Organización a la que pertenece la partida', on_delete=django.db.models.deletion.CASCADE, related_name='partidas', to='organization.organizacion')), + ('pedimento', models.ForeignKey(help_text='Pedimento asociado a la partida', on_delete=django.db.models.deletion.CASCADE, related_name='partidas', to='customs.pedimento')), + ], + options={ + 'verbose_name': 'Partida', + 'verbose_name_plural': 'Partidas', + 'db_table': 'partida', + 'ordering': ['pedimento', 'numero_partida'], + }, + ), + ] diff --git a/api/customs/migrations/0013_alter_partida_unique_together.py b/api/customs/migrations/0013_alter_partida_unique_together.py new file mode 100644 index 0000000..e4a4ac7 --- /dev/null +++ b/api/customs/migrations/0013_alter_partida_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.3 on 2025-10-03 02:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0012_partida'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='partida', + unique_together={('pedimento', 'numero_partida')}, + ), + ] diff --git a/api/customs/migrations/0014_partida_created_at.py b/api/customs/migrations/0014_partida_created_at.py new file mode 100644 index 0000000..89247b8 --- /dev/null +++ b/api/customs/migrations/0014_partida_created_at.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.3 on 2025-10-03 03:20 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0013_alter_partida_unique_together'), + ] + + operations = [ + migrations.AddField( + model_name='partida', + name='created_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Fecha de creación del registro'), + preserve_default=False, + ), + ] diff --git a/api/customs/migrations/0015_partida_updated_at.py b/api/customs/migrations/0015_partida_updated_at.py new file mode 100644 index 0000000..d659fd6 --- /dev/null +++ b/api/customs/migrations/0015_partida_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.3 on 2025-10-03 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customs', '0014_partida_created_at'), + ] + + operations = [ + migrations.AddField( + model_name='partida', + name='updated_at', + field=models.DateTimeField(auto_now=True, help_text='Fecha de última actualización del registro'), + ), + ] diff --git a/api/customs/models.py b/api/customs/models.py index 53dfffe..01e993c 100644 --- a/api/customs/models.py +++ b/api/customs/models.py @@ -61,6 +61,24 @@ class Pedimento(models.Model): db_table = 'pedimento' ordering = ['pedimento'] +class Partida(models.Model): + pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='partidas', help_text="Pedimento asociado a la partida") + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='partidas', help_text="Organización a la que pertenece la partida") + numero_partida = models.PositiveIntegerField(help_text="Número de la partida dentro del pedimento") + descargado = models.BooleanField(default=False, help_text="Indica si la partida ha sido descargada") + created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del registro") + updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del registro") + + def __str__(self): + return f"Partida {self.numero_partida} del Pedimento {self.pedimento.pedimento}" + + class Meta: + verbose_name = "Partida" + verbose_name_plural = "Partidas" + db_table = 'partida' + ordering = ['pedimento', 'numero_partida'] + unique_together = ['pedimento', 'numero_partida'] # No puede existir el mismo número de partida para un pedimento + class EDocument(models.Model): pedimento = models.ForeignKey(Pedimento, on_delete=models.CASCADE, related_name='documentos', help_text="Pedimento asociado al documento") organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE, related_name='edocuments', help_text="Organización a la que pertenece el EDocument") @@ -71,6 +89,8 @@ class EDocument(models.Model): descripcion = models.CharField(max_length=200, blank=True, null=True, help_text="Descripción del documento") created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación del documento") updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización del documento") + edocument_descargado = models.BooleanField(default=False, help_text="Indica si el e-documento ha sido descargado") + acuse_descargado = models.BooleanField(default=False, help_text="Indica si el acuse del e-documento ha sido descargado") def __str__(self): return f"{self.descripcion} - {self.pedimento.pedimento}" @@ -87,6 +107,8 @@ class Cove(models.Model): numero_cove = models.CharField(max_length=20, unique=True, help_text="Número único de la cove") created_at = models.DateTimeField(auto_now_add=True, help_text="Fecha de creación de la cove") updated_at = models.DateTimeField(auto_now=True, help_text="Fecha de última actualización de la cove") + cove_descargado = models.BooleanField(default=False, help_text="Indica si la cove ha sido descargada") + acuse_cove_descargado = models.BooleanField(default=False, help_text="Indica si el acuse de la cove ha sido descargado") def __str__(self): return f"{self.numero_cove} - {self.pedimento.pedimento}" diff --git a/api/customs/serializers.py b/api/customs/serializers.py index 078801e..0da90a0 100644 --- a/api/customs/serializers.py +++ b/api/customs/serializers.py @@ -5,7 +5,8 @@ from api.customs.models import ( ProcesamientoPedimento, EDocument, Cove, - Importador + Importador, + Partida ) from django.db import models from api.record.models import Document # Asegúrate de importar el modelo Documento @@ -41,7 +42,10 @@ class PedimentoSerializer(serializers.ModelSerializer): rep['documentos_peso_total'] = self.get_documentos_peso_total(instance) return rep - +class PartidaSerializer(serializers.ModelSerializer): + class Meta: + model = Partida + fields = '__all__' class TipoOperacionSerializer(serializers.ModelSerializer): class Meta: diff --git a/api/customs/tasks/auditoria.py b/api/customs/tasks/auditoria.py index 6ed98d0..50ce843 100644 --- a/api/customs/tasks/auditoria.py +++ b/api/customs/tasks/auditoria.py @@ -71,8 +71,39 @@ def auditar_acuse_coves(organizacion_id): pass + + @shared_task def auditar_acuse_edocuments(organizacion_id): # crear servicio individual para cada acuse de edocument faltante en microservicios - pass + pedimentos = obtener_pedimentos(organizacion_id) + for pedimento in pedimentos: + acuses_descargados = pedimento.documents.filter(document_type=4) + edocs = pedimento.documentos.all() + + + +@shared_task +def crear_partidas(organizacion_id): + pedimentos = obtener_pedimentos(organizacion_id) + + for pedimento in pedimentos: + + crear_partidas_por_pedimento(pedimento.id) + +@shared_task +def crear_partidas_por_pedimento(pedimento_id): + try: + pedimento = Pedimento.objects.get(id=pedimento_id) + except Pedimento.DoesNotExist: + return + + if pedimento.numero_partidas > pedimento.partidas.count(): + for i in range(1, pedimento.numero_partidas + 1): + from api.customs.models import Partida + Partida.objects.get_or_create( + pedimento=pedimento, + numero_partida=i, + organizacion_id=pedimento.organizacion_id + ) \ No newline at end of file diff --git a/api/customs/tasks/internal_services.py b/api/customs/tasks/internal_services.py index 872d91b..4993355 100644 --- a/api/customs/tasks/internal_services.py +++ b/api/customs/tasks/internal_services.py @@ -185,8 +185,7 @@ def auditar_pedimento(organizacion_id): pedimento.remesas = xml_data.get('remesas') pedimento.tipo_operacion__id = xml_data.get('tipo_operacion') pedimento.fecha_pago = xml_data.get('fecha_pago') - pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimento - pedimento.save() + pedimento.pedimento_app = xml_data.get('fecha_pago')[2:4] + "-" + pedimento.aduana[:2] + "-" + pedimento.patente + "-" + pedimento.pedimentodd for edoc in xml_data.get('edocuments', []): EDocument.objects.get_or_create( diff --git a/api/customs/urls.py b/api/customs/urls.py index f1eaf9d..a78e053 100644 --- a/api/customs/urls.py +++ b/api/customs/urls.py @@ -9,7 +9,8 @@ from .views import ( ViewSetProcesamientoPedimento, ViewSetEDocument, ViewSetCove, - ImportadorViewSet + ImportadorViewSet, + PartidaViewSet ) # from .views import YourViewSet # Import your viewsets here @@ -26,6 +27,7 @@ router.register(r'procesamientopedimentos', ViewSetProcesamientoPedimento, basen router.register(r'edocuments', ViewSetEDocument, basename='EDocument') router.register(r'coves', ViewSetCove, basename='Cove') router.register(r'importadores', ImportadorViewSet, basename='Importador') +router.register(r'partidas', PartidaViewSet, basename='Partida') # Import your viewsets here diff --git a/api/customs/views.py b/api/customs/views.py index 840690d..1cdd2fa 100644 --- a/api/customs/views.py +++ b/api/customs/views.py @@ -21,7 +21,8 @@ from api.customs.models import ( ProcesamientoPedimento, EDocument, Cove, - Importador + Importador, + Partida ) from api.customs.serializers import ( PedimentoSerializer, @@ -29,7 +30,8 @@ from api.customs.serializers import ( ProcesamientoPedimentoSerializer, EDocumentSerializer, CoveSerializer, - ImportadorSerializer + ImportadorSerializer, + PartidaSerializer ) from api.logger.mixins import LoggingMixin @@ -202,6 +204,36 @@ class ViewSetPedimento(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada my_tags = ['Pedimentos'] +class PartidaViewSet(viewsets.ModelViewSet): + """ + ViewSet for Partida model. + Permite filtrar por: + - pedimento: UUID del pedimento (query parameter principal) + - pedimento__id: UUID del pedimento (alternativo) + + Ejemplo: GET /api/partidas/?pedimento=6782d22e-5e97-4efc-87c9-bd8497c8ac7e + """ + permission_classes = [IsAuthenticated & (IsSameOrganization | IsSameOrganizationAndAdmin | IsSameOrganizationDeveloper | IsSuperUser)] + queryset = Partida.objects.all() + serializer_class = PartidaSerializer + pagination_class = CustomPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_fields = { + 'pedimento': ['exact'], # Filtro directo por UUID del pedimento + 'pedimento__id': ['exact'], # Filtro alternativo + 'numero_partida': ['exact', 'gte', 'lte'], # Filtros por número de partida + 'descargado': ['exact'], # Filtro por estado de descarga + 'created_at': ['exact', 'gte', 'lte'], # Filtros por fecha de creación + 'updated_at': ['exact', 'gte', 'lte'] # Filtros por fecha de actualización + } + search_fields = ['pedimento__pedimento', 'pedimento__pedimento_app'] + ordering_fields = ['numero_partida', 'pedimento__pedimento', 'id', 'created_at', 'updated_at'] + ordering = ['numero_partida'] # Ordenar por número de partida por defecto + + my_tags = ['Partidas'] + + + class ViewSetTipoOperacion(LoggingMixin, viewsets.ModelViewSet): """ ViewSet for TipoOperacion model. diff --git a/api/tasks/__init__.py b/api/tasks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tasks/admin.py b/api/tasks/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/api/tasks/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/api/tasks/apps.py b/api/tasks/apps.py new file mode 100644 index 0000000..067a64c --- /dev/null +++ b/api/tasks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TasksConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'api.tasks' diff --git a/api/tasks/migrations/0001_initial.py b/api/tasks/migrations/0001_initial.py new file mode 100644 index 0000000..54937ca --- /dev/null +++ b/api/tasks/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.3 on 2025-10-02 15:04 + +import django.db.models.deletion +import django.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('customs', '0011_cove_acuse_cove_descargado_cove_cove_descargado_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Task', + fields=[ + ('task_id', models.UUIDField(default=django.db.models.fields.UUIDField, primary_key=True, serialize=False)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('message', models.TextField()), + ('status', models.CharField(max_length=50)), + ('result', models.TextField(blank=True, null=True)), + ('pedimento', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='customs.pedimento')), + ], + ), + ] diff --git a/api/tasks/migrations/0002_task_organizacion.py b/api/tasks/migrations/0002_task_organizacion.py new file mode 100644 index 0000000..5a66fa7 --- /dev/null +++ b/api/tasks/migrations/0002_task_organizacion.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.3 on 2025-10-02 15:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0002_remove_organizacion_membretado_and_more'), + ('tasks', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='task', + name='organizacion', + field=models.ForeignKey(default='', on_delete=django.db.models.deletion.CASCADE, to='organization.organizacion'), + preserve_default=False, + ), + ] diff --git a/api/tasks/migrations/__init__.py b/api/tasks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tasks/models.py b/api/tasks/models.py new file mode 100644 index 0000000..c7f4085 --- /dev/null +++ b/api/tasks/models.py @@ -0,0 +1,11 @@ +from django.db import models + +# Create your models here. +class Task(models.Model): + task_id = models.UUIDField(primary_key=True, default=models.UUIDField) + pedimento = models.ForeignKey('customs.Pedimento', on_delete=models.CASCADE) + organizacion = models.ForeignKey('organization.Organizacion', on_delete=models.CASCADE) + timestamp = models.DateTimeField(auto_now_add=True) + message = models.TextField() + status = models.CharField(max_length=50) + result = models.TextField(null=True, blank=True) diff --git a/api/tasks/serializers.py b/api/tasks/serializers.py new file mode 100644 index 0000000..c1179ab --- /dev/null +++ b/api/tasks/serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers +from .models import Task + +class TaskSerializer(serializers.ModelSerializer): + class Meta: + model = Task + fields = '__all__' \ No newline at end of file diff --git a/api/tasks/tests.py b/api/tasks/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/api/tasks/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/api/tasks/urls.py b/api/tasks/urls.py new file mode 100644 index 0000000..7040e1a --- /dev/null +++ b/api/tasks/urls.py @@ -0,0 +1,10 @@ +from rest_framework.routers import DefaultRouter +from .views import TaskViewSet +from django.urls import path, include + +router = DefaultRouter() +router.register(r'tasks', TaskViewSet) + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/api/tasks/views.py b/api/tasks/views.py new file mode 100644 index 0000000..2cff353 --- /dev/null +++ b/api/tasks/views.py @@ -0,0 +1,10 @@ +from django.shortcuts import render +from rest_framework import viewsets +from .models import Task +from .serializers import TaskSerializer +# Create your views here. + + +class TaskViewSet(viewsets.ModelViewSet): + queryset = Task.objects.all() + serializer_class = TaskSerializer \ No newline at end of file diff --git a/cleanup_all_media.sh b/cleanup_all_media.sh new file mode 100644 index 0000000..f494310 --- /dev/null +++ b/cleanup_all_media.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +echo "Iniciando limpieza completa de archivos media huérfanos..." + +# Función para ejecutar limpieza +cleanup_batch() { + echo "Ejecutando limpieza de lote..." + docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --verbose" 2>/dev/null + return $? +} + +# Contador de iteraciones +iteration=1 +total_cleaned=0 + +while true; do + echo "=== Iteración $iteration ===" + + # Ejecutar dry-run para ver si hay archivos huérfanos + result=$(docker exec -it EFC_backend_dev bash -c "cd /app && python manage.py cleanup_media_fast --quick-scan --dry-run" 2>/dev/null) + + # Extraer número de archivos huérfanos + orphaned_count=$(echo "$result" | grep -o "Archivos huérfanos encontrados: [0-9]*" | grep -o "[0-9]*") + + if [ -z "$orphaned_count" ] || [ "$orphaned_count" -eq 0 ]; then + echo "✅ No se encontraron más archivos huérfanos." + echo "🎉 Limpieza completa finalizada después de $iteration iteraciones." + echo "📊 Total estimado de archivos eliminados: $total_cleaned" + break + fi + + echo "📁 Encontrados $orphaned_count archivos huérfanos en esta iteración" + + # Preguntar confirmación en la primera iteración + if [ $iteration -eq 1 ]; then + echo "¿Continuar con la limpieza automática? (s/N):" + read -r response + if [[ ! "$response" =~ ^[sS]([iI]|í)?$ ]]; then + echo "❌ Limpieza cancelada por el usuario." + exit 0 + fi + fi + + # Ejecutar limpieza + cleanup_result=$(docker exec -it EFC_backend_dev bash -c "cd /app && echo 's' | python manage.py cleanup_media_fast --quick-scan --verbose" 2>/dev/null) + + # Extraer archivos eliminados + deleted_count=$(echo "$cleanup_result" | grep -o "Eliminados [0-9]* archivos" | grep -o "[0-9]*") + + if [ -n "$deleted_count" ]; then + total_cleaned=$((total_cleaned + deleted_count)) + echo "✅ Eliminados $deleted_count archivos en esta iteración" + fi + + iteration=$((iteration + 1)) + + # Pausa pequeña para evitar sobrecarga + sleep 2 +done + +echo "🏁 Proceso completado exitosamente." \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 1b44c08..8012be0 100644 --- a/config/settings.py +++ b/config/settings.py @@ -133,6 +133,7 @@ OWN_APPS = [ 'api.notificaciones', 'api.reports', 'api.cards', + 'api.tasks', ] INSTALLED_APPS = BASE_APPS + THIRD_APPS + OWN_APPS diff --git a/config/urls.py b/config/urls.py index 90aaa80..0b3dd16 100644 --- a/config/urls.py +++ b/config/urls.py @@ -50,6 +50,7 @@ urlpatterns = [ path('api/v1/notificaciones/', include('api.notificaciones.urls')), # Notificaciones app path('api/v1/cards/', include('api.cards.urls')), # Cards app path('api/v1/reports/', include('api.reports.urls')), # Reports app + path('api/v1/tasks/', include('api.tasks.urls')), # Tasks app ] # En producción, los archivos media son servidos por Nginx if settings.DEBUG: