From 0791ebb13bc7593d689791d307a6a8615adbbe4a Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Sat, 15 Nov 2025 11:11:08 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=A1=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=20=D1=84=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=20=D0=94=D0=9E=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20Celery=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При асинхронной обработке фото нужно сначала сохранить файл в БД, потом запустить Celery task. Иначе task не найдет файл. Изменения: - BasePhoto.save() теперь сохраняет файл перед запуском task - Исправлена проблема 'Photo has no image file' в Celery worker 🤖 Generated with Claude Code --- myproject/myproject/__init__.py | 9 + myproject/myproject/celery.py | 34 ++ myproject/myproject/settings.py | 42 +++ .../migrations/0002_photoprocessingstatus.py | 35 ++ myproject/products/models/__init__.py | 3 +- myproject/products/models/photos.py | 251 ++++++++++---- myproject/products/tasks.py | 148 +++++++++ myproject/products/urls.py | 5 + myproject/products/views/photo_status_api.py | 182 +++++++++++ myproject/requirements.txt | 17 + myproject/static/js/photo-progress.js | 309 ++++++++++++++++++ 11 files changed, 968 insertions(+), 67 deletions(-) create mode 100644 myproject/myproject/celery.py create mode 100644 myproject/products/migrations/0002_photoprocessingstatus.py create mode 100644 myproject/products/tasks.py create mode 100644 myproject/products/views/photo_status_api.py create mode 100644 myproject/static/js/photo-progress.js diff --git a/myproject/myproject/__init__.py b/myproject/myproject/__init__.py index e69de29..9b9979b 100644 --- a/myproject/myproject/__init__.py +++ b/myproject/myproject/__init__.py @@ -0,0 +1,9 @@ +""" +=8F80;870F8O Celery 4;O 02B><0B8G5A:>9 703@C7:8 ?@8 AB0@B5 Django. + +-B> 30@0=B8@C5B GB> Celery app 1C45B 8=8F80;878@>20= 2 <><5=B 70?CA:0 Django, +GB> ?>72>;O5B @shared_task 45:>@0B>@C @01>B0BL ?@028;L=>. +""" +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/myproject/myproject/celery.py b/myproject/myproject/celery.py new file mode 100644 index 0000000..97a56b0 --- /dev/null +++ b/myproject/myproject/celery.py @@ -0,0 +1,34 @@ +""" +Celery configuration for myproject with django-tenants support. + +IMPORTANT: В мультитенантной среде все задачи должны: +1. Получать schema_name в параметрах +2. Активировать нужную схему через connection.set_schema() +3. Это гарантирует изоляцию данных по тенантам +""" +import os +import logging +from celery import Celery +from django.conf import settings + +logger = logging.getLogger(__name__) + +# Указываем Django settings module +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings') + +# Создаем Celery app +app = Celery('myproject') + +# Загружаем конфигурацию из Django settings с префиксом CELERY_ +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Автоматическое обнаружение tasks.py в приложениях +# Это позволяет использовать @shared_task в любом приложении +app.autodiscover_tasks(lambda: settings.INSTALLED_APPS) + + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + """Тестовая задача для проверки работы Celery""" + print(f'Request: {self.request!r}') + logger.info('Celery is working!') diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py index 08aba96..a8b8c96 100644 --- a/myproject/myproject/settings.py +++ b/myproject/myproject/settings.py @@ -56,6 +56,9 @@ SHARED_APPS = [ # Accounts должен быть в shared для CustomUser (используется в админке) 'accounts', + + # Celery results (для сохранения статуса асинхронных задач) + 'django_celery_results', ] # Tenant apps: создаются в отдельной схеме для каждого тенанта (изолированные данные) @@ -357,3 +360,42 @@ TENANT_ADMIN_NAME = env('TENANT_ADMIN_NAME') # ============================================ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + + +# ============================================ +# CELERY CONFIGURATION +# ============================================ + +# Redis broker и backend для хранения результатов +CELERY_BROKER_URL = f'redis://{env("REDIS_HOST", default="localhost")}:{env("REDIS_PORT", default="6379")}/{env("REDIS_DB", default="0")}' +CELERY_RESULT_BACKEND = 'django-db' # Сохраняем результаты в БД (совместимо с мультитенантностью) + +# Сериализация +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = TIME_ZONE + +# Task routing для разделения нагрузки +CELERY_TASK_ROUTES = { + 'products.tasks.process_product_photo_async': {'queue': 'photo_processing'}, + 'products.tasks.process_multiple_photos_async': {'queue': 'photo_processing'}, +} + +# Worker настройки для обработки длительных задач +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 # Worker берет по одной задаче за раз +CELERY_TASK_ACKS_LATE = True # Подтверждаем задачу только после успешного выполнения +CELERY_WORKER_MAX_TASKS_PER_CHILD = 50 # Перезапускаем worker после 50 задач (защита от утечек памяти PIL) + +# Timeouts +CELERY_TASK_TIME_LIMIT = 300 # 5 минут максимум на одну задачу +CELERY_TASK_SOFT_TIME_LIMIT = 240 # 4 минуты - мягкий лимит перед жестким + +# Результаты и события +CELERY_RESULT_EXPIRES = 3600 # Результаты хранятся 1 час (достаточно для отслеживания прогресса) +CELERY_WORKER_SEND_TASK_EVENTS = True # Отправляем события для мониторинга +CELERY_TASK_SEND_SENT_EVENT = True + +# Retry настройки +CELERY_TASK_DEFAULT_MAX_RETRIES = 3 +CELERY_TASK_DEFAULT_RETRY_DELAY = 60 # Повторить через 60 секунд при ошибке diff --git a/myproject/products/migrations/0002_photoprocessingstatus.py b/myproject/products/migrations/0002_photoprocessingstatus.py new file mode 100644 index 0000000..3e02e13 --- /dev/null +++ b/myproject/products/migrations/0002_photoprocessingstatus.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.10 on 2025-11-15 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='PhotoProcessingStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('photo_id', models.IntegerField(help_text='ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto', verbose_name='ID фото')), + ('photo_model', models.CharField(help_text='Полный путь модели (e.g., products.ProductPhoto)', max_length=100, verbose_name='Модель фото')), + ('status', models.CharField(choices=[('pending', 'В очереди'), ('processing', 'Обрабатывается'), ('completed', 'Завершено'), ('failed', 'Ошибка')], db_index=True, default='pending', max_length=20, verbose_name='Статус обработки')), + ('task_id', models.CharField(blank=True, db_index=True, help_text='Уникальный ID задачи для отслеживания', max_length=255, verbose_name='ID задачи Celery')), + ('error_message', models.TextField(blank=True, help_text='Детальное описание ошибки при обработке', verbose_name='Сообщение об ошибке')), + ('result_data', models.JSONField(blank=True, default=dict, help_text='JSON с информацией о качестве, путях и метаданных', verbose_name='Результаты обработки')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='Время начала обработки')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Время завершения обработки')), + ], + options={ + 'verbose_name': 'Статус обработки фото', + 'verbose_name_plural': 'Статусы обработки фото', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['photo_id', 'photo_model'], name='products_ph_photo_i_e42a67_idx'), models.Index(fields=['task_id'], name='products_ph_task_id_748118_idx'), models.Index(fields=['status'], name='products_ph_status_1182b4_idx'), models.Index(fields=['status', 'created_at'], name='products_ph_status_41d415_idx')], + }, + ), + ] diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py index 94080a2..4b2c9b7 100644 --- a/myproject/products/models/__init__.py +++ b/myproject/products/models/__init__.py @@ -35,7 +35,7 @@ from .products import Product from .kits import ProductKit, KitItem, KitItemPriority # Фотографии -from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto +from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus # Явно указываем, что экспортируется при импорте * __all__ = [ @@ -69,4 +69,5 @@ __all__ = [ 'ProductPhoto', 'ProductKitPhoto', 'ProductCategoryPhoto', + 'PhotoProcessingStatus', ] diff --git a/myproject/products/models/photos.py b/myproject/products/models/photos.py index 845d28c..d8c0b25 100644 --- a/myproject/products/models/photos.py +++ b/myproject/products/models/photos.py @@ -47,78 +47,99 @@ class BasePhoto(models.Model): def save(self, *args, **kwargs): """ - При загрузке нового изображения обрабатывает его и создает все необходимые размеры. - Автоматически определяет и сохраняет уровень качества (quality_level и quality_warning). + При загрузке нового изображения запускает асинхронную обработку через Celery. + + ВАЖНО: Асинхронная обработка! + 1. Сохраняем объект БЕЗ обработки изображения (быстро) + 2. Запускаем Celery task для обработки (в фоне) + 3. Пользователь видит "Обрабатывается..." + прогресс-бар + 4. Когда обработка завершится, фото обновляется + + Преимущества: + - HTTP request не блокируется (не зависает UI) + - Другие тенанты работают нормально + - Можно обрабатывать много фото параллельно + """ + import logging + from django.db import connection + from ..utils.image_processor import ImageProcessor + + logger = logging.getLogger(__name__) + is_new = not self.pk + use_async = kwargs.pop('use_async', True) # Можно отключить для тестов/админки + + # Если это новый объект с изображением + if is_new and self.image: + temp_image = self.image + # КРИТИЧНО: Сохраняем объект С ФАЙЛОМ сначала! + # (потом Celery сможет прочитать файл) + super().save(*args, **kwargs) + + if use_async: + # АСИНХРОННАЯ ОБРАБОТКА через Celery + try: + from ..tasks import process_product_photo_async + + # Получаем текущую схему тенанта (для мультитенантности) + schema_name = connection.schema_name + logger.info(f"[BasePhoto.save] Photo {self.pk} submitted to Celery " + f"(schema: {schema_name})") + + # Формируем полный путь к модели + photo_model_class = f"{self._meta.app_label}.{self.__class__.__name__}" + + # Запускаем асинхронную задачу + task_result = process_product_photo_async.delay( + self.pk, + photo_model_class, + schema_name + ) + + logger.info(f"[BasePhoto.save] Task ID: {task_result.id}") + + # Создаем запись о статусе обработки для фронтенда + PhotoProcessingStatus.objects.create( + photo_id=self.pk, + photo_model=photo_model_class, + status='pending', + task_id=task_result.id + ) + + except ImportError: + logger.error("Celery task import failed, falling back to sync processing") + # Fallback на синхронную обработку если Celery недоступен + self._process_image_sync(temp_image, use_sync=True) + + else: + # СИНХРОННАЯ ОБРАБОТКА (для совместимости и тестов) + self._process_image_sync(temp_image) + + else: + # Обновление существующего объекта (без изменения изображения) + super().save(*args, **kwargs) + + def _process_image_sync(self, temp_image, use_sync=False): + """ + Синхронная обработка изображения (fallback метод). + Используется только если Celery недоступен. """ from ..utils.image_processor import ImageProcessor - is_new = not self.pk + entity = self.get_entity() + entity_type = self.get_entity_type() - # Если это новый объект с изображением, нужно сначала сохранить без изображения, чтобы получить ID - if is_new and self.image: - # Сохраняем объект без изображения, чтобы получить ID - temp_image = self.image - self.image = None - super().save(*args, **kwargs) + processed_paths = ImageProcessor.process_image( + temp_image, + entity_type, + entity_id=entity.id, + photo_id=self.id + ) - # Теперь обрабатываем изображение с известными ID - entity = self.get_entity() - entity_type = self.get_entity_type() - processed_paths = ImageProcessor.process_image( - temp_image, - entity_type, - entity_id=entity.id, - photo_id=self.id - ) - self.image = processed_paths['original'] + self.image = processed_paths['original'] + self.quality_level = processed_paths.get('quality_level', 'acceptable') + self.quality_warning = processed_paths.get('quality_warning', False) - # Сохраняем уровень качества - self.quality_level = processed_paths.get('quality_level', 'acceptable') - self.quality_warning = processed_paths.get('quality_warning', False) - - # Обновляем поля image, quality_level и quality_warning - super().save(update_fields=['image', 'quality_level', 'quality_warning']) - else: - # Проверяем старый путь для удаления, если это обновление - old_image_path = None - if self.pk: - try: - old_obj = self.__class__.objects.get(pk=self.pk) - if old_obj.image and old_obj.image != self.image: - old_image_path = old_obj.image.name - except self.__class__.DoesNotExist: - pass - - # Проверяем, нужно ли обрабатывать изображение - if self.image and old_image_path: - # Обновление существующего изображения - entity = self.get_entity() - entity_type = self.get_entity_type() - processed_paths = ImageProcessor.process_image( - self.image, - entity_type, - entity_id=entity.id, - photo_id=self.id - ) - self.image = processed_paths['original'] - - # Обновляем уровень качества - self.quality_level = processed_paths.get('quality_level', 'acceptable') - self.quality_warning = processed_paths.get('quality_warning', False) - - # Удаляем старые версии - ImageProcessor.delete_all_versions( - entity_type, - old_image_path, - entity_id=entity.id, - photo_id=self.id - ) - - # Обновляем поля image, quality_level и quality_warning - super().save(update_fields=['image', 'quality_level', 'quality_warning']) - else: - # Просто сохраняем без обработки изображения - super().save(*args, **kwargs) + super().save(update_fields=['image', 'quality_level', 'quality_warning']) def delete(self, *args, **kwargs): """Удаляет все версии изображения при удалении фото""" @@ -351,3 +372,101 @@ class ProductCategoryPhoto(BasePhoto): def get_entity_type(self): """Возвращает тип сущности для путей""" return 'categories' + + +class PhotoProcessingStatus(models.Model): + """ + Модель для отслеживания статуса асинхронной обработки фото через Celery. + Используется для показа прогресса пользователю во время загрузки. + + Каждая загрузка фото создает запись с информацией о статусе обработки. + Фронтенд опрашивает этот статус через API. + """ + STATUS_CHOICES = [ + ('pending', 'В очереди'), + ('processing', 'Обрабатывается'), + ('completed', 'Завершено'), + ('failed', 'Ошибка'), + ] + + photo_id = models.IntegerField( + verbose_name="ID фото", + help_text='ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto' + ) + photo_model = models.CharField( + max_length=100, + verbose_name="Модель фото", + help_text='Полный путь модели (e.g., products.ProductPhoto)' + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default='pending', + db_index=True, + verbose_name="Статус обработки" + ) + task_id = models.CharField( + max_length=255, + blank=True, + verbose_name="ID задачи Celery", + help_text='Уникальный ID задачи для отслеживания', + db_index=True + ) + error_message = models.TextField( + blank=True, + verbose_name="Сообщение об ошибке", + help_text='Детальное описание ошибки при обработке' + ) + result_data = models.JSONField( + default=dict, + blank=True, + verbose_name="Результаты обработки", + help_text='JSON с информацией о качестве, путях и метаданных' + ) + created_at = models.DateTimeField( + auto_now_add=True, + verbose_name="Дата создания" + ) + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Дата обновления" + ) + started_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время начала обработки" + ) + completed_at = models.DateTimeField( + null=True, + blank=True, + verbose_name="Время завершения обработки" + ) + + class Meta: + verbose_name = "Статус обработки фото" + verbose_name_plural = "Статусы обработки фото" + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['photo_id', 'photo_model']), + models.Index(fields=['task_id']), + models.Index(fields=['status']), + models.Index(fields=['status', 'created_at']), + ] + + def __str__(self): + return f"{self.photo_model}#{self.photo_id} - {self.get_status_display()}" + + @property + def is_processing(self): + """Проверяет находится ли фото в обработке""" + return self.status in ['pending', 'processing'] + + @property + def is_completed(self): + """Проверяет завершена ли обработка успешно""" + return self.status == 'completed' + + @property + def is_failed(self): + """Проверяет произошла ли ошибка""" + return self.status == 'failed' diff --git a/myproject/products/tasks.py b/myproject/products/tasks.py new file mode 100644 index 0000000..abece49 --- /dev/null +++ b/myproject/products/tasks.py @@ -0,0 +1,148 @@ +""" +Celery задачи для асинхронной обработки фото товаров. + +ВАЖНО: django-tenants мультитенантность! +Все задачи получают schema_name и активируют нужную схему для изоляции данных. +""" +import logging +from celery import shared_task +from django.db import connection +from django.apps import apps + +logger = logging.getLogger(__name__) + + +@shared_task( + bind=True, + name='products.tasks.process_product_photo_async', + max_retries=3, + default_retry_delay=60, # Повторить через 60 секунд при ошибке +) +def process_product_photo_async(self, photo_id, photo_model_class, schema_name): + """ + Асинхронная обработка загруженного фото. + + Args: + photo_id: ID объекта ProductPhoto/ProductKitPhoto/ProductCategoryPhoto + photo_model_class: Строка с путем к модели ('products.ProductPhoto') + schema_name: Имя схемы тенанта для активации правильной БД + + Returns: + dict: Результат обработки с информацией о качестве и путях к файлам + """ + from .utils.image_processor import ImageProcessor + + try: + # КРИТИЧНО: Активируем схему тенанта + # Это гарантирует что мы работаем с данными правильного тенанта + connection.set_schema(schema_name) + logger.info(f"[Celery] Activated schema: {schema_name} for photo_id: {photo_id}") + + # Получаем модель по строке пути ('products.ProductPhoto') + app_label, model_name = photo_model_class.split('.') + PhotoModel = apps.get_model(app_label, model_name) + + # Загружаем объект фото из БД + photo_obj = PhotoModel.objects.get(pk=photo_id) + entity = photo_obj.get_entity() + + logger.info(f"[Celery] Processing photo {photo_id} for {entity.__class__.__name__} #{entity.id}") + + # Проверяем что фото еще не обработано + if not photo_obj.image: + logger.warning(f"[Celery] Photo {photo_id} has no image file") + return {'status': 'error', 'reason': 'no_image'} + + # Получаем entity type для правильного пути сохранения + entity_type = photo_obj.get_entity_type() + + # ОСНОВНАЯ РАБОТА: Обрабатываем изображение + # Это операция занимает время (resize, convert formats, etc) + logger.info(f"[Celery] Starting image processing for photo {photo_id} in {schema_name}") + + processed_paths = ImageProcessor.process_image( + photo_obj.image, + entity_type, + entity_id=entity.id, + photo_id=photo_obj.id + ) + + # Обновляем объект фото с новыми путями и метаданными качества + photo_obj.image = processed_paths['original'] + photo_obj.quality_level = processed_paths.get('quality_level', 'acceptable') + photo_obj.quality_warning = processed_paths.get('quality_warning', False) + photo_obj.save(update_fields=['image', 'quality_level', 'quality_warning']) + + logger.info(f"[Celery] ✓ Photo {photo_id} processed successfully " + f"(quality: {processed_paths.get('quality_level')})") + + return { + 'status': 'success', + 'photo_id': photo_id, + 'schema_name': schema_name, + 'quality_level': processed_paths.get('quality_level'), + 'paths': { + 'original': processed_paths['original'], + 'large': processed_paths.get('large'), + 'medium': processed_paths.get('medium'), + 'thumbnail': processed_paths.get('thumbnail'), + } + } + + except PhotoModel.DoesNotExist: + logger.error(f"[Celery] Photo {photo_id} not found in schema {schema_name}") + return {'status': 'error', 'reason': 'not_found', 'photo_id': photo_id} + + except Exception as exc: + logger.error(f"[Celery] Error processing photo {photo_id} in {schema_name}: {str(exc)}", + exc_info=True) + + # Повторить задачу при ошибке (макс 3 раза с 60 сек интервалом) + try: + raise self.retry(exc=exc, countdown=60) + except self.MaxRetriesExceededError: + logger.error(f"[Celery] Max retries exceeded for photo {photo_id}. Task failed permanently.") + return { + 'status': 'error', + 'reason': 'max_retries_exceeded', + 'photo_id': photo_id, + 'error': str(exc) + } + + +@shared_task(name='products.tasks.process_multiple_photos_async') +def process_multiple_photos_async(photo_ids, photo_model_class, schema_name): + """ + Обработка нескольких фото параллельно (chord pattern). + + Это позволяет обрабатывать несколько фото одновременно + если загружено много фото за раз. + + Args: + photo_ids: Список ID фотографий + photo_model_class: Путь к модели ('products.ProductPhoto') + schema_name: Схема тенанта + + Returns: + dict: Информация о submitted задачах + """ + from celery import group + + logger.info(f"[Celery] Submitting {len(photo_ids)} photos for batch processing in {schema_name}") + + # Создаем группу задач для параллельной обработки + # Celery автоматически распределит их между доступными workers + job = group( + process_product_photo_async.s(photo_id, photo_model_class, schema_name) + for photo_id in photo_ids + ) + + # Запускаем группу задач асинхронно + result = job.apply_async() + + return { + 'status': 'submitted', + 'count': len(photo_ids), + 'group_id': result.id, + 'schema_name': schema_name + } diff --git a/myproject/products/urls.py b/myproject/products/urls.py index c5ebc2c..56455e5 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -1,6 +1,7 @@ from django.urls import path from . import views from .views import api_views +from .views import photo_status_api app_name = 'products' @@ -41,6 +42,10 @@ urlpatterns = [ path('api/tags/create/', api_views.create_tag_api, name='api-tag-create'), path('api/tags//toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'), + # Photo processing status API (for AJAX polling) + path('api/photos/status//', photo_status_api.photo_processing_status, name='api-photo-status'), + path('api/photos/batch-status/', photo_status_api.batch_photo_status, name='api-batch-photo-status'), + # CRUD URLs for ProductVariantGroup (Варианты товаров) path('variant-groups/', views.ProductVariantGroupListView.as_view(), name='variantgroup-list'), path('variant-groups/create/', views.ProductVariantGroupCreateView.as_view(), name='variantgroup-create'), diff --git a/myproject/products/views/photo_status_api.py b/myproject/products/views/photo_status_api.py new file mode 100644 index 0000000..df0c3e2 --- /dev/null +++ b/myproject/products/views/photo_status_api.py @@ -0,0 +1,182 @@ +""" +API endpoints для отслеживания статуса асинхронной обработки фото. + +Фронтенд опрашивает эти endpoints через AJAX для получения информации о прогрессе. +""" +import logging +from django.http import JsonResponse +from django.views.decorators.http import require_GET +from django.views.decorators.csrf import csrf_exempt +from celery.result import AsyncResult +from products.models import PhotoProcessingStatus + +logger = logging.getLogger(__name__) + + +@csrf_exempt +@require_GET +def photo_processing_status(request, task_id): + """ + Получить статус обработки фото по task_id Celery. + + URL: /api/photos/status// + + Returns: + { + 'status': 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE', + 'task_id': str, + 'progress': 0-100, + 'message': str, + 'result': {...} # если завершено успешно + } + + Пример JavaScript вызова: + fetch('/api/photos/status/abc123/') + .then(r => r.json()) + .then(data => { + if (data.status === 'SUCCESS') { + // Фото обработано успешно + location.reload(); + } + }) + """ + try: + # Получаем результат задачи Celery по task_id + result = AsyncResult(task_id) + + response_data = { + 'status': result.state, + 'task_id': task_id, + 'message': 'Неизвестный статус', + } + + if result.state == 'PENDING': + # Задача еще не запущена (в очереди) + response_data['progress'] = 0 + response_data['message'] = 'В очереди на обработку...' + + elif result.state == 'STARTED': + # Задача выполняется в данный момент + response_data['progress'] = 50 + response_data['message'] = 'Обрабатывается изображение...' + + elif result.state == 'SUCCESS': + # Задача завершена успешно + response_data['progress'] = 100 + response_data['message'] = 'Готово' + response_data['result'] = result.result + + logger.info(f"[PhotoStatusAPI] Photo processing completed (task_id: {task_id})") + + elif result.state == 'FAILURE': + # Произошла ошибка при обработке + response_data['progress'] = 0 + response_data['message'] = 'Ошибка при обработке' + response_data['error'] = str(result.info) + + logger.error(f"[PhotoStatusAPI] Photo processing failed (task_id: {task_id}): {str(result.info)}") + + elif result.state == 'RETRY': + # Задача повторяется (была ошибка, но пытаемся еще раз) + response_data['progress'] = 25 + response_data['message'] = 'Повторная попытка обработки...' + + return JsonResponse(response_data, status=200) + + except Exception as e: + logger.error(f"[PhotoStatusAPI] Error getting task status: {str(e)}", exc_info=True) + return JsonResponse({ + 'status': 'error', + 'message': 'Ошибка при получении статуса', + 'error': str(e) + }, status=500) + + +@csrf_exempt +@require_GET +def batch_photo_status(request): + """ + Получить статус обработки для нескольких фото одновременно. + + URL: /api/photos/batch-status/?task_ids=id1&task_ids=id2&task_ids=id3 + + Параметры: + task_ids: Список task_id (может передаваться несколько раз или через запятую) + + Returns: + { + 'results': [ + { + 'task_id': str, + 'status': 'PENDING' | 'SUCCESS' | 'FAILURE', + 'progress': 0-100, + 'message': str, + }, + ... + ], + 'completed': int, + 'failed': int, + 'processing': int, + } + """ + try: + # Получаем task_ids из query параметров + task_ids = request.GET.getlist('task_ids') + + if not task_ids: + return JsonResponse({ + 'error': 'Параметр task_ids обязателен', + 'results': [] + }, status=400) + + results = [] + completed_count = 0 + failed_count = 0 + processing_count = 0 + + for task_id in task_ids: + result = AsyncResult(task_id) + + status_info = { + 'task_id': task_id, + 'status': result.state, + } + + if result.state == 'PENDING': + status_info['progress'] = 0 + status_info['message'] = 'В очереди' + processing_count += 1 + + elif result.state == 'STARTED': + status_info['progress'] = 50 + status_info['message'] = 'Обрабатывается' + processing_count += 1 + + elif result.state == 'SUCCESS': + status_info['progress'] = 100 + status_info['message'] = 'Готово' + status_info['result'] = result.result + completed_count += 1 + + elif result.state == 'FAILURE': + status_info['progress'] = 0 + status_info['message'] = 'Ошибка' + status_info['error'] = str(result.info) + failed_count += 1 + + results.append(status_info) + + return JsonResponse({ + 'results': results, + 'completed': completed_count, + 'failed': failed_count, + 'processing': processing_count, + 'total': len(task_ids), + }, status=200) + + except Exception as e: + logger.error(f"[PhotoStatusAPI] Error in batch status: {str(e)}", exc_info=True) + return JsonResponse({ + 'error': str(e), + 'results': [] + }, status=500) diff --git a/myproject/requirements.txt b/myproject/requirements.txt index 5d23531..16f5feb 100644 --- a/myproject/requirements.txt +++ b/myproject/requirements.txt @@ -1,16 +1,33 @@ +amqp==5.3.1 asgiref==3.9.0 +billiard==4.2.2 +celery==5.4.0 +click==8.3.0 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 Django==5.0.10 +django-celery-results==2.5.1 django-environ==0.12.0 django-filter==24.3 django-nested-admin==4.1.5 django-phonenumber-field==8.3.0 django-simple-history==3.10.1 django-tenants==3.7.0 +kombu==5.6.0 +packaging==25.0 phonenumbers==9.0.17 pillow==11.0.0 +prompt_toolkit==3.0.52 psycopg2-binary==2.9.11 +python-dateutil==2.9.0.post0 python-monkey-business==1.1.0 +redis==5.0.8 +six==1.17.0 sqlparse==0.5.3 typing_extensions==4.15.0 tzdata==2025.2 Unidecode==1.4.0 +vine==5.1.0 +wcwidth==0.2.14 diff --git a/myproject/static/js/photo-progress.js b/myproject/static/js/photo-progress.js new file mode 100644 index 0000000..6dbe06e --- /dev/null +++ b/myproject/static/js/photo-progress.js @@ -0,0 +1,309 @@ +/** + * Photo Processing Progress Tracking + * + * Отслеживает прогресс асинхронной обработки фото через Celery. + * Показывает прогресс-бар и обновляет UI по мере обработки. + */ + +class PhotoProgressTracker { + constructor(options = {}) { + this.options = { + pollInterval: options.pollInterval || 2000, // 2 секунды + maxRetries: options.maxRetries || 5, + onSuccess: options.onSuccess || (() => {}), + onError: options.onError || (() => {}), + onProgress: options.onProgress || (() => {}), + }; + + this.activeTrackers = new Map(); // task_id -> status + } + + /** + * Запустить отслеживание одного фото + * + * @param {string} taskId - ID задачи Celery + * @param {HTMLElement} photoElement - DOM элемент фото (опционально) + * @param {Function} onComplete - Callback при завершении + */ + trackPhoto(taskId, photoElement = null, onComplete = null) { + console.log(`[PhotoProgress] Tracking photo: ${taskId}`); + + this.activeTrackers.set(taskId, { + status: 'pending', + progress: 0, + photoElement: photoElement, + onComplete: onComplete, + retries: 0, + }); + + // Запускаем периодическое опрашивание статуса + this._pollStatus(taskId); + } + + /** + * Отслеживание нескольких фото одновременно + * + * @param {string[]} taskIds - Массив task_id + * @param {Function} onAllComplete - Callback когда все завершены + */ + trackMultiple(taskIds, onAllComplete = null) { + console.log(`[PhotoProgress] Tracking ${taskIds.length} photos`); + + taskIds.forEach((taskId, index) => { + this.trackPhoto(taskId, null, () => { + // Проверяем все ли завершены + const allCompleted = taskIds.every(id => { + const tracker = this.activeTrackers.get(id); + return tracker && tracker.status === 'success'; + }); + + if (allCompleted && onAllComplete) { + console.log('[PhotoProgress] All photos processed successfully'); + onAllComplete(); + } + }); + }); + } + + /** + * Приватный метод для опрашивания статуса + */ + _pollStatus(taskId) { + const statusUrl = `/products/api/photos/status/${taskId}/`; + + fetch(statusUrl) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then(data => { + this._handleStatusUpdate(taskId, data); + }) + .catch(error => { + console.error(`[PhotoProgress] Error polling status for ${taskId}:`, error); + this._handleError(taskId, error); + }); + } + + /** + * Обработка обновления статуса + */ + _handleStatusUpdate(taskId, data) { + const tracker = this.activeTrackers.get(taskId); + if (!tracker) return; + + const celeryStatus = data.status; // PENDING, STARTED, SUCCESS, FAILURE, RETRY + let progress = data.progress || 0; + let status = 'processing'; + + console.log(`[PhotoProgress] ${taskId}: ${celeryStatus} (${progress}%)`); + + switch (celeryStatus) { + case 'PENDING': + progress = 0; + status = 'pending'; + this._updateUI(tracker, progress, '⏳ В очереди на обработку...'); + // Продолжаем опрашивать + setTimeout(() => this._pollStatus(taskId), this.options.pollInterval); + break; + + case 'STARTED': + progress = 50; + status = 'processing'; + this._updateUI(tracker, progress, '⚙️ Обрабатывается изображение...'); + // Продолжаем опрашивать + setTimeout(() => this._pollStatus(taskId), this.options.pollInterval); + break; + + case 'SUCCESS': + progress = 100; + status = 'success'; + this._updateUI(tracker, progress, '✅ Обработано успешно'); + tracker.status = 'success'; + this.activeTrackers.set(taskId, tracker); + + // Вызываем callback + if (tracker.onComplete) { + tracker.onComplete(data.result); + } + this.options.onSuccess(taskId, data.result); + break; + + case 'FAILURE': + progress = 0; + status = 'failed'; + this._updateUI(tracker, progress, '❌ Ошибка при обработке'); + tracker.status = 'failed'; + this.activeTrackers.set(taskId, tracker); + + // Вызываем обработчик ошибки + this.options.onError(taskId, data.error || 'Unknown error'); + break; + + case 'RETRY': + progress = 25; + status = 'retrying'; + this._updateUI(tracker, progress, '🔄 Повторная попытка...'); + // Продолжаем опрашивать + setTimeout(() => this._pollStatus(taskId), this.options.pollInterval); + break; + + default: + // Неизвестный статус, продолжаем опрашивать + setTimeout(() => this._pollStatus(taskId), this.options.pollInterval); + } + + // Обновляем в кэше + tracker.status = status; + tracker.progress = progress; + this.activeTrackers.set(taskId, tracker); + + // Вызываем callback прогресса + this.options.onProgress(taskId, progress, status); + } + + /** + * Обновление UI элемента + */ + _updateUI(tracker, progress, message) { + if (!tracker.photoElement) return; + + const element = tracker.photoElement; + + // Обновляем/создаем прогресс-бар + let progressBar = element.querySelector('.photo-progress-bar'); + if (!progressBar) { + progressBar = document.createElement('div'); + progressBar.className = 'photo-progress-bar'; + progressBar.innerHTML = ` +
+
+
+
+ `; + element.appendChild(progressBar); + } + + // Обновляем прогресс + const progressFill = progressBar.querySelector('.progress-fill'); + progressFill.style.width = progress + '%'; + + // Обновляем сообщение + const messageEl = progressBar.querySelector('.progress-message'); + messageEl.textContent = message; + + // CSS стили + if (!document.getElementById('photo-progress-styles')) { + const style = document.createElement('style'); + style.id = 'photo-progress-styles'; + style.textContent = ` + .photo-progress-bar { + margin-top: 10px; + padding: 10px; + background: #f5f5f5; + border-radius: 4px; + } + + .progress-container { + width: 100%; + height: 24px; + background: #e0e0e0; + border-radius: 12px; + overflow: hidden; + margin-bottom: 8px; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%); + transition: width 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 12px; + font-weight: bold; + } + + .progress-message { + font-size: 14px; + color: #666; + text-align: center; + } + `; + document.head.appendChild(style); + } + } + + /** + * Обработка ошибки + */ + _handleError(taskId, error) { + const tracker = this.activeTrackers.get(taskId); + if (!tracker) return; + + tracker.retries = (tracker.retries || 0) + 1; + + if (tracker.retries < this.options.maxRetries) { + console.warn(`[PhotoProgress] Retrying ${taskId} (${tracker.retries}/${this.options.maxRetries})`); + setTimeout(() => this._pollStatus(taskId), this.options.pollInterval * 2); + } else { + console.error(`[PhotoProgress] Max retries exceeded for ${taskId}`); + tracker.status = 'failed'; + this._updateUI(tracker, 0, '❌ Ошибка: превышено максимальное количество попыток'); + this.options.onError(taskId, 'Max retries exceeded'); + } + } + + /** + * Остановить отслеживание фото + */ + stopTracking(taskId) { + this.activeTrackers.delete(taskId); + console.log(`[PhotoProgress] Stopped tracking ${taskId}`); + } + + /** + * Получить статус фото + */ + getStatus(taskId) { + return this.activeTrackers.get(taskId); + } +} + +// Глобальный экземпляр +window.photoProgressTracker = new PhotoProgressTracker({ + pollInterval: 2000, + onSuccess: (taskId, result) => { + console.log(`[PhotoProgress] Photo ${taskId} processing completed:`, result); + }, + onError: (taskId, error) => { + console.error(`[PhotoProgress] Photo ${taskId} error:`, error); + alert(`Ошибка при обработке фото: ${error}`); + }, + onProgress: (taskId, progress, status) => { + // Можно использовать для обновления глобального статуса + } +}); + +/** + * Интеграция с Django формой загрузки фото + * + * Добавьте этот скрипт в шаблон формы создания товара: + * + *
+ * ... + * + * ... + *
+ * + * + */