From e723c26e6cd046124e7b609015bfb14e62067369 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sun, 30 Nov 2025 21:20:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A3=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=84=D0=BE=D1=82=D0=BE=20=D0=BF=D0=BE=20=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20temp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- myproject/myproject/settings.py | 13 ++++ myproject/products/tasks.py | 125 +++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index dd0951f..e08cbcd 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -211,6 +211,9 @@ MEDIA_ROOT = BASE_DIR / 'media' # Custom file storage for tenant-aware file organization DEFAULT_FILE_STORAGE = 'products.utils.storage.TenantAwareFileSystemStorage' +# Время жизни временных файлов фото (TTL) до авто-удаления, в часах +TEMP_MEDIA_TTL_HOURS = 24 + # ============================================ # IMAGE PROCESSING SETTINGS @@ -398,6 +401,8 @@ CELERY_TIMEZONE = TIME_ZONE CELERY_TASK_ROUTES = { 'products.tasks.process_product_photo_async': {'queue': 'photo_processing'}, 'products.tasks.process_multiple_photos_async': {'queue': 'photo_processing'}, + 'products.tasks.cleanup_temp_media_for_schema': {'queue': 'photo_processing'}, + 'products.tasks.cleanup_temp_media_all': {'queue': 'photo_processing'}, } # Worker настройки для обработки длительных задач @@ -430,4 +435,12 @@ CELERY_BEAT_SCHEDULE = { 'expires': 240, # Задача устаревает через 4 минуты (меньше интервала) }, }, + # Очистка временных файлов фото каждые 6 часов + 'cleanup-temp-media-all-tenants': { + 'task': 'products.tasks.cleanup_temp_media_all', + 'schedule': crontab(minute=0, hour='*/6'), # Каждые 6 часов + 'options': { + 'queue': 'photo_processing', + }, + }, } diff --git a/myproject/products/tasks.py b/myproject/products/tasks.py index 01435c6..67d531c 100644 --- a/myproject/products/tasks.py +++ b/myproject/products/tasks.py @@ -1,5 +1,4 @@ -""" -Celery задачи для асинхронной обработки фото товаров. +"""Celery задачи для асинхронной обработки фото товаров. ВАЖНО: django-tenants мультитенантность! Все задачи получают schema_name и активируют нужную схему для изоляции данных. @@ -9,6 +8,8 @@ Celery задачи для асинхронной обработки фото т TenantAwareFileSystemStorage добавляет tenant_id при сохранении/удалении файлов. Безопасность: Каждый Celery worker активирует правильную схему БД через connection.set_schema(). """ +import os +import time import logging from celery import shared_task from django.db import connection @@ -145,6 +146,19 @@ def process_product_photo_async(self, photo_id, photo_model_class, schema_name): raise self.retry(exc=exc, countdown=60) except self.MaxRetriesExceededError: logger.error(f"[Celery] Max retries exceeded for photo {photo_id}. Task failed permanently.") + # Попытка удалить temp файл при окончательном провале + try: + from .models.photos import PhotoProcessingStatus + status = (PhotoProcessingStatus.objects + .filter(photo_id=photo_id, photo_model=photo_model_class) + .order_by('-created_at') + .first()) + temp_path = (status.result_data or {}).get('temp_path') if status else None + if temp_path and default_storage.exists(temp_path): + default_storage.delete(temp_path) + logger.info(f"[Celery] Deleted orphaned temp file (max_retries): {temp_path}") + except Exception as del_exc: + logger.warning(f"[Celery] Could not delete temp file for photo {photo_id} on max_retries: {del_exc}") return { 'status': 'error', 'reason': 'max_retries_exceeded', @@ -189,3 +203,110 @@ def process_multiple_photos_async(photo_ids, photo_model_class, schema_name): 'group_id': result.id, 'schema_name': schema_name } + + +@shared_task(name='products.tasks.cleanup_temp_media_for_schema') +def cleanup_temp_media_for_schema(schema_name, ttl_hours=None): + """ + Очистка временных файлов изображений для указанной схемы тенанта. + Удаляет файлы старше TTL из папок: products/temp, kits/temp, categories/temp. + + Args: + schema_name: Имя схемы тенанта + ttl_hours: Время жизни файла в часах (по умолчанию из settings.TEMP_MEDIA_TTL_HOURS) + + Returns: + dict: Результат очистки с количеством удаленных файлов + """ + from django.conf import settings + + try: + # Активируем схему тенанта + connection.set_schema(schema_name) + + ttl = int(ttl_hours or getattr(settings, 'TEMP_MEDIA_TTL_HOURS', 24)) + cutoff_seconds = ttl * 3600 + now = time.time() + + temp_dirs = ['products/temp', 'kits/temp', 'categories/temp'] + deleted_count = 0 + scanned_count = 0 + + for rel_dir in temp_dirs: + try: + # Получаем полный путь с учётом tenant_id + full_dir = default_storage.path(rel_dir) + if not os.path.isdir(full_dir): + continue + + for filename in os.listdir(full_dir): + scanned_count += 1 + full_path = os.path.join(full_dir, filename) + + # Пропускаем поддиректории, работаем только с файлами + if not os.path.isfile(full_path): + continue + + # Проверяем возраст файла + age_seconds = now - os.path.getmtime(full_path) + if age_seconds >= cutoff_seconds: + # Формируем относительный путь для storage.delete + storage_rel_path = os.path.join(rel_dir, filename).replace('\\', '/') + try: + if default_storage.exists(storage_rel_path): + default_storage.delete(storage_rel_path) + deleted_count += 1 + logger.info(f"[Cleanup:{schema_name}] Deleted: {storage_rel_path} (age: {age_seconds/3600:.1f}h)") + except Exception as del_exc: + logger.warning(f"[Cleanup:{schema_name}] Could not delete {storage_rel_path}: {del_exc}") + except Exception as dir_exc: + logger.warning(f"[Cleanup:{schema_name}] Error scanning {rel_dir}: {dir_exc}") + + logger.info(f"[Cleanup:{schema_name}] Complete. scanned={scanned_count}, deleted={deleted_count}, ttl_hours={ttl}") + return { + 'status': 'success', + 'schema_name': schema_name, + 'deleted': deleted_count, + 'scanned': scanned_count, + 'ttl_hours': ttl + } + except Exception as exc: + logger.error(f"[Cleanup:{schema_name}] Failed: {exc}", exc_info=True) + return {'status': 'error', 'schema_name': schema_name, 'error': str(exc)} + + +@shared_task(name='products.tasks.cleanup_temp_media_all') +def cleanup_temp_media_all(ttl_hours=None): + """ + Мастер-задача: перечисляет всех тенантов и запускает очистку temp для каждого. + Запускается периодически через Celery Beat. + + Args: + ttl_hours: Время жизни файла в часах (передается в подзадачи) + + Returns: + dict: Информация о количестве тенантов и запущенных задачах + """ + from django.conf import settings + + try: + # Работаем из public для списка тенантов + connection.set_schema('public') + from tenants.models import Client + + schemas = list(Client.objects.values_list('schema_name', flat=True)) + ttl = ttl_hours or getattr(settings, 'TEMP_MEDIA_TTL_HOURS', 24) + + logger.info(f"[CleanupAll] Scheduling cleanup for {len(schemas)} tenants (TTL: {ttl}h)") + + for schema in schemas: + cleanup_temp_media_for_schema.delay(schema, ttl) + + return { + 'status': 'submitted', + 'tenants_count': len(schemas), + 'ttl_hours': ttl + } + except Exception as exc: + logger.error(f"[CleanupAll] Failed to schedule: {exc}", exc_info=True) + return {'status': 'error', 'error': str(exc)}