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:
309
myproject/static/js/photo-progress.js
Normal file
309
myproject/static/js/photo-progress.js
Normal 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>
|
||||
*/
|
||||
Reference in New Issue
Block a user