fix: Сохранять файл фото ДО запуска Celery task

При асинхронной обработке фото нужно сначала сохранить файл в БД,
потом запустить Celery task. Иначе task не найдет файл.

Изменения:
- BasePhoto.save() теперь сохраняет файл перед запуском task
- Исправлена проблема 'Photo has no image file' в Celery worker

🤖 Generated with Claude Code
This commit is contained in:
2025-11-15 11:11:08 +03:00
parent a03f4c3047
commit 0791ebb13b
11 changed files with 968 additions and 67 deletions

View File

@@ -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 = `
<div class="progress-container">
<div class="progress-fill" style="width: 0%"></div>
</div>
<div class="progress-message"></div>
`;
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 формой загрузки фото
*
* Добавьте этот скрипт в шаблон формы создания товара:
*
* <form method="post" enctype="multipart/form-data" id="product-form">
* ...
* <input type="file" name="photos" multiple accept="image/*">
* ...
* </form>
*
* <script>
* document.getElementById('product-form').addEventListener('submit', function(e) {
* // После сохранения товара, если есть task_id в response
* // Можно запустить отслеживание:
* // window.photoProgressTracker.trackPhoto(taskId, photoElement);
* });
* </script>
*/