Se agregaron partidas y modelos
This commit is contained in:
0
api/customs/management/commands/__init__.py
Normal file
0
api/customs/management/commands/__init__.py
Normal file
243
api/customs/management/commands/cleanup_media.py
Normal file
243
api/customs/management/commands/cleanup_media.py
Normal file
@@ -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
|
||||
284
api/customs/management/commands/cleanup_media_fast.py
Normal file
284
api/customs/management/commands/cleanup_media_fast.py
Normal file
@@ -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)'
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user