Fix: Auto-cleanup temp files after photo processing
- Added temp file deletion in Celery task after successful processing - Added temp file cleanup in sync fallback method - Added temp file removal in delete() if processing never completed - Prevents accumulation of orphaned files in media/<entity>/temp/ folders
This commit is contained in:
@@ -193,6 +193,11 @@ class BaseProductEntity(models.Model):
|
||||
"""Физическое удаление из БД (необратимо! только для старых товаров)"""
|
||||
super().delete()
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
"""Возвращает True если товар активен"""
|
||||
return self.status == 'active'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""
|
||||
Автогенерация slug из name если не задан.
|
||||
|
||||
@@ -124,10 +124,14 @@ class BasePhoto(models.Model):
|
||||
Используется только если Celery недоступен.
|
||||
"""
|
||||
from ..utils.image_processor import ImageProcessor
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
entity = self.get_entity()
|
||||
entity_type = self.get_entity_type()
|
||||
|
||||
# Сохраняем путь к временному файлу до перезаписи поля image
|
||||
temp_path = getattr(temp_image, 'name', None)
|
||||
|
||||
processed_paths = ImageProcessor.process_image(
|
||||
temp_image,
|
||||
entity_type,
|
||||
@@ -141,10 +145,18 @@ class BasePhoto(models.Model):
|
||||
|
||||
super().save(update_fields=['image', 'quality_level', 'quality_warning'])
|
||||
|
||||
# Удаляем временный файл из temp после успешной обработки
|
||||
try:
|
||||
if temp_path and default_storage.exists(temp_path):
|
||||
default_storage.delete(temp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Удаляет все версии изображения при удалении фото"""
|
||||
import logging
|
||||
from ..utils.image_processor import ImageProcessor
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -159,6 +171,15 @@ class BasePhoto(models.Model):
|
||||
entity_id=entity.id,
|
||||
photo_id=self.id
|
||||
)
|
||||
|
||||
# Если фото так и осталось во временном пути (обработка не завершилась) — удаляем temp файл
|
||||
try:
|
||||
if '/temp/' in self.image.name and default_storage.exists(self.image.name):
|
||||
default_storage.delete(self.image.name)
|
||||
logger.info(f"[{self.__class__.__name__}.delete] Deleted temp file: {self.image.name}")
|
||||
except Exception as del_exc:
|
||||
logger.warning(f"[{self.__class__.__name__}.delete] Could not delete temp file {self.image.name}: {del_exc}")
|
||||
|
||||
logger.info(f"[{self.__class__.__name__}.delete] ✓ Все версии изображения удалены")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from celery import shared_task
|
||||
from django.db import connection
|
||||
from django.apps import apps
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,6 +54,9 @@ def process_product_photo_async(self, photo_id, photo_model_class, schema_name):
|
||||
logger.warning(f"[Celery] Photo {photo_id} has no image file")
|
||||
return {'status': 'error', 'reason': 'no_image'}
|
||||
|
||||
# Сохраняем путь к временному файлу до перезаписи поля image
|
||||
temp_path = photo_obj.image.name
|
||||
|
||||
# Получаем entity type для правильного пути сохранения
|
||||
entity_type = photo_obj.get_entity_type()
|
||||
|
||||
@@ -73,6 +77,14 @@ def process_product_photo_async(self, photo_id, photo_model_class, schema_name):
|
||||
photo_obj.quality_warning = processed_paths.get('quality_warning', False)
|
||||
photo_obj.save(update_fields=['image', 'quality_level', 'quality_warning'])
|
||||
|
||||
# Удаляем временный файл из temp после успешной обработки
|
||||
try:
|
||||
if temp_path and default_storage.exists(temp_path):
|
||||
default_storage.delete(temp_path)
|
||||
logger.info(f"[Celery] Deleted temp file: {temp_path}")
|
||||
except Exception as del_exc:
|
||||
logger.warning(f"[Celery] Could not delete temp file {temp_path}: {del_exc}")
|
||||
|
||||
logger.info(f"[Celery] ✓ Photo {photo_id} processed successfully "
|
||||
f"(quality: {processed_paths.get('quality_level')})")
|
||||
|
||||
|
||||
@@ -72,10 +72,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if product.is_active %}
|
||||
<span class="badge bg-info">Активен</span>
|
||||
{% if product.status == 'active' %}
|
||||
<span class="badge bg-success">Активный</span>
|
||||
{% elif product.status == 'archived' %}
|
||||
<span class="badge bg-warning text-dark">Архивный</span>
|
||||
{% elif product.status == 'discontinued' %}
|
||||
<span class="badge bg-danger">Снят</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
<span class="badge bg-secondary">{{ product.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -69,10 +69,14 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if kit.is_active %}
|
||||
<span class="badge bg-info">Активен</span>
|
||||
{% if kit.status == 'active' %}
|
||||
<span class="badge bg-success">Активный</span>
|
||||
{% elif kit.status == 'archived' %}
|
||||
<span class="badge bg-warning text-dark">Архивный</span>
|
||||
{% elif kit.status == 'discontinued' %}
|
||||
<span class="badge bg-danger">Снят</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
<span class="badge bg-secondary">{{ kit.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
189
myproject/products/templates/products/products_unified_list.html
Normal file
189
myproject/products/templates/products/products_unified_list.html
Normal file
@@ -0,0 +1,189 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load quality_tags %}
|
||||
|
||||
{% block title %}Список товаров и комплектов{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<h2 class="mb-4">Товары и Комплекты</h2>
|
||||
|
||||
<!-- Панель фильтрации и действий -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
|
||||
<h5 class="card-title mb-0 me-3">
|
||||
<i class="bi bi-funnel-fill"></i> Фильтры
|
||||
</h5>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
{% if perms.products.add_product %}
|
||||
<a href="{% url 'products:product-create' %}" class="btn btn-primary btn-sm me-2 mb-2 mb-md-0">
|
||||
<i class="bi bi-plus-circle"></i> Создать товар
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.products.add_productkit %}
|
||||
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary btn-sm mb-2 mb-md-0">
|
||||
<i class="bi bi-box-seam"></i> Создать комплект
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<form method="get" id="filterForm">
|
||||
<div class="row g-3">
|
||||
<!-- Поиск -->
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="search" class="form-label"><i class="bi bi-search"></i> Поиск</label>
|
||||
<input type="text" class="form-control" id="search" name="search" placeholder="Название, артикул, описание..." value="{{ filters.current.search|default:'' }}">
|
||||
</div>
|
||||
|
||||
<!-- Тип -->
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="type" class="form-label"><i class="bi bi-box-seam"></i> Тип</label>
|
||||
<select class="form-select" id="type" name="type">
|
||||
<option value="all" {% if filters.current.type == 'all' %}selected{% endif %}>Все</option>
|
||||
<option value="products" {% if filters.current.type == 'products' %}selected{% endif %}>Только товары</option>
|
||||
<option value="kits" {% if filters.current.type == 'kits' %}selected{% endif %}>Только комплекты</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Категория -->
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="category" class="form-label"><i class="bi bi-bookmark"></i> Категория</label>
|
||||
<select class="form-select" id="category" name="category">
|
||||
<option value="">Все категории</option>
|
||||
{% for category in filters.categories %}
|
||||
<option value="{{ category.id }}" {% if filters.current.category == category.id|stringformat:"s" %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Статус -->
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="status" class="form-label"><i class="bi bi-toggle-on"></i> Статус</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="">Все статусы</option>
|
||||
{% for status_value, status_name in item_statuses %}
|
||||
<option value="{{ status_value }}" {% if filters.current.status == status_value %}selected{% endif %}>{{ status_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-2">
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-check-circle"></i> Применить</button>
|
||||
<a href="{% url 'products:product-list' %}" class="btn btn-outline-secondary"><i class="bi bi-x-circle"></i> Сброс</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Фото</th>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
<th>Тип</th>
|
||||
<th>Категория</th>
|
||||
<th>Цена</th>
|
||||
<th>В наличии</th>
|
||||
<th>Компоненты</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>
|
||||
{% with photo=item.photos.first %}
|
||||
<div class="photo-list-item">
|
||||
<img src="{{ photo.get_thumbnail_url }}" alt="{{ item.name }}" class="img-thumbnail rounded">
|
||||
{% if item.item_type == 'product' and photo %}
|
||||
<span class="quality-icon" title="{{ photo.get_quality_level_display }}">{{ photo|quality_icon_only }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ item.get_absolute_url }}">{{ item.name }}</a>
|
||||
</td>
|
||||
<td>{{ item.sku }}</td>
|
||||
<td>
|
||||
{% if item.item_type == 'product' %}
|
||||
<span class="badge bg-success" title="Товар"><i class="bi bi-box"></i> Товар</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info" title="Комплект"><i class="bi bi-box-seam"></i> Комплект</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% for category in item.categories.all %}
|
||||
<span class="badge bg-secondary">{{ category.name }}</span>
|
||||
{% empty %}
|
||||
-
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.sale_price %}
|
||||
<span class="text-decoration-line-through text-muted small">{{ item.price|floatformat:2 }} руб.</span><br>
|
||||
<strong class="text-danger">{{ item.sale_price|floatformat:2 }} руб.</strong>
|
||||
{% else %}
|
||||
<strong>{{ item.price|floatformat:2 }} руб.</strong>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.item_type == 'product' %}
|
||||
{% if item.in_stock %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Да</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle"></i> Нет</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.item_type == 'kit' %}
|
||||
<span class="badge bg-secondary">{{ item.get_total_components_count }} шт</span>
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {{ item.get_status_color }}">{{ item.get_status_display }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="{{ item.get_absolute_url }}" class="btn btn-outline-info" title="Просмотр"><i class="bi bi-eye"></i></a>
|
||||
{% if item.item_type == 'product' and perms.products.change_product %}
|
||||
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-outline-primary" title="Изменить"><i class="bi bi-pencil"></i></a>
|
||||
{% elif item.item_type == 'kit' and perms.products.change_productkit %}
|
||||
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-outline-primary" title="Изменить"><i class="bi bi-pencil"></i></a>
|
||||
{% endif %}
|
||||
{% if item.item_type == 'product' and perms.products.delete_product %}
|
||||
<a href="{% url 'products:product-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить"><i class="bi bi-trash"></i></a>
|
||||
{% elif item.item_type == 'kit' and perms.products.delete_productkit %}
|
||||
<a href="{% url 'products:productkit-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить"><i class="bi bi-trash"></i></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% include 'components/pagination.html' %}
|
||||
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p>Товары или комплекты не найдены.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -43,10 +43,17 @@ class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
if category_id:
|
||||
queryset = queryset.filter(categories__id=category_id)
|
||||
|
||||
# Фильтр по статусу
|
||||
# Фильтр по статусу (новая система)
|
||||
status_filter = self.request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
else:
|
||||
# Фильтр по is_active для обратной совместимости (старая система)
|
||||
is_active_filter = self.request.GET.get('is_active')
|
||||
if is_active_filter == '1':
|
||||
queryset = queryset.filter(status='active')
|
||||
elif is_active_filter == '0':
|
||||
queryset = queryset.filter(status__in=['archived', 'discontinued'])
|
||||
|
||||
# Фильтр по тегам
|
||||
tags = self.request.GET.getlist('tags')
|
||||
@@ -250,6 +257,7 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
|
||||
search_query = self.request.GET.get('search')
|
||||
category_id = self.request.GET.get('category')
|
||||
status_filter = self.request.GET.get('status')
|
||||
is_active_filter = self.request.GET.get('is_active')
|
||||
|
||||
# Фильтрация по поиску
|
||||
if search_query:
|
||||
@@ -273,10 +281,18 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
|
||||
products = products.filter(categories__id=category_id)
|
||||
kits = kits.filter(categories__id=category_id)
|
||||
|
||||
# Фильтрация по статусу
|
||||
# Фильтрация по статусу (новая система)
|
||||
if status_filter:
|
||||
products = products.filter(status=status_filter)
|
||||
kits = kits.filter(status=status_filter)
|
||||
else:
|
||||
# Фильтрация по is_active для обратной совместимости (старая система)
|
||||
if is_active_filter == '1':
|
||||
products = products.filter(status='active')
|
||||
kits = kits.filter(status='active')
|
||||
elif is_active_filter == '0':
|
||||
products = products.filter(status__in=['archived', 'discontinued'])
|
||||
kits = kits.filter(status__in=['archived', 'discontinued'])
|
||||
|
||||
# Добавляем type для различения в шаблоне
|
||||
products_list = list(products.order_by('-created_at'))
|
||||
|
||||
@@ -37,12 +37,17 @@ class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||
if category_id:
|
||||
queryset = queryset.filter(categories__id=category_id)
|
||||
|
||||
# Фильтр по статусу
|
||||
is_active = self.request.GET.get('is_active')
|
||||
if is_active == '1':
|
||||
queryset = queryset.filter(is_active=True)
|
||||
elif is_active == '0':
|
||||
queryset = queryset.filter(is_active=False)
|
||||
# Фильтр по статусу (новая система)
|
||||
status_filter = self.request.GET.get('status')
|
||||
if status_filter:
|
||||
queryset = queryset.filter(status=status_filter)
|
||||
else:
|
||||
# Фильтр по is_active для обратной совместимости (старая система)
|
||||
is_active = self.request.GET.get('is_active')
|
||||
if is_active == '1':
|
||||
queryset = queryset.filter(status='active')
|
||||
elif is_active == '0':
|
||||
queryset = queryset.filter(status__in=['archived', 'discontinued'])
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user