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:
2025-11-15 22:28:41 +03:00
parent 53fbb6d3c1
commit 9363527e50
9 changed files with 275 additions and 18 deletions

View File

@@ -193,6 +193,11 @@ class BaseProductEntity(models.Model):
"""Физическое удаление из БД (необратимо! только для старых товаров)""" """Физическое удаление из БД (необратимо! только для старых товаров)"""
super().delete() super().delete()
@property
def is_active(self):
"""Возвращает True если товар активен"""
return self.status == 'active'
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" """
Автогенерация slug из name если не задан. Автогенерация slug из name если не задан.

View File

@@ -124,10 +124,14 @@ class BasePhoto(models.Model):
Используется только если Celery недоступен. Используется только если Celery недоступен.
""" """
from ..utils.image_processor import ImageProcessor from ..utils.image_processor import ImageProcessor
from django.core.files.storage import default_storage
entity = self.get_entity() entity = self.get_entity()
entity_type = self.get_entity_type() entity_type = self.get_entity_type()
# Сохраняем путь к временному файлу до перезаписи поля image
temp_path = getattr(temp_image, 'name', None)
processed_paths = ImageProcessor.process_image( processed_paths = ImageProcessor.process_image(
temp_image, temp_image,
entity_type, entity_type,
@@ -141,10 +145,18 @@ class BasePhoto(models.Model):
super().save(update_fields=['image', 'quality_level', 'quality_warning']) 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): def delete(self, *args, **kwargs):
"""Удаляет все версии изображения при удалении фото""" """Удаляет все версии изображения при удалении фото"""
import logging import logging
from ..utils.image_processor import ImageProcessor from ..utils.image_processor import ImageProcessor
from django.core.files.storage import default_storage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -159,6 +171,15 @@ class BasePhoto(models.Model):
entity_id=entity.id, entity_id=entity.id,
photo_id=self.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] ✓ Все версии изображения удалены") logger.info(f"[{self.__class__.__name__}.delete] ✓ Все версии изображения удалены")
except Exception as e: except Exception as e:
logger.error( logger.error(

View File

@@ -8,6 +8,7 @@ import logging
from celery import shared_task from celery import shared_task
from django.db import connection from django.db import connection
from django.apps import apps from django.apps import apps
from django.core.files.storage import default_storage
logger = logging.getLogger(__name__) 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") logger.warning(f"[Celery] Photo {photo_id} has no image file")
return {'status': 'error', 'reason': 'no_image'} return {'status': 'error', 'reason': 'no_image'}
# Сохраняем путь к временному файлу до перезаписи поля image
temp_path = photo_obj.image.name
# Получаем entity type для правильного пути сохранения # Получаем entity type для правильного пути сохранения
entity_type = photo_obj.get_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.quality_warning = processed_paths.get('quality_warning', False)
photo_obj.save(update_fields=['image', 'quality_level', 'quality_warning']) 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 " logger.info(f"[Celery] ✓ Photo {photo_id} processed successfully "
f"(quality: {processed_paths.get('quality_level')})") f"(quality: {processed_paths.get('quality_level')})")

View File

@@ -72,10 +72,14 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if product.is_active %} {% if product.status == 'active' %}
<span class="badge bg-info">Активен</span> <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 %} {% else %}
<span class="badge bg-secondary">Неактивен</span> <span class="badge bg-secondary">{{ product.status }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>

View File

@@ -69,10 +69,14 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if kit.is_active %} {% if kit.status == 'active' %}
<span class="badge bg-info">Активен</span> <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 %} {% else %}
<span class="badge bg-secondary">Неактивен</span> <span class="badge bg-secondary">{{ kit.status }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>

View 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 %}

View File

@@ -43,10 +43,17 @@ class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
if category_id: if category_id:
queryset = queryset.filter(categories__id=category_id) queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу # Фильтр по статусу (новая система)
status_filter = self.request.GET.get('status') status_filter = self.request.GET.get('status')
if status_filter: if status_filter:
queryset = queryset.filter(status=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') tags = self.request.GET.getlist('tags')
@@ -250,6 +257,7 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
search_query = self.request.GET.get('search') search_query = self.request.GET.get('search')
category_id = self.request.GET.get('category') category_id = self.request.GET.get('category')
status_filter = self.request.GET.get('status') status_filter = self.request.GET.get('status')
is_active_filter = self.request.GET.get('is_active')
# Фильтрация по поиску # Фильтрация по поиску
if search_query: if search_query:
@@ -273,10 +281,18 @@ class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListV
products = products.filter(categories__id=category_id) products = products.filter(categories__id=category_id)
kits = kits.filter(categories__id=category_id) kits = kits.filter(categories__id=category_id)
# Фильтрация по статусу # Фильтрация по статусу (новая система)
if status_filter: if status_filter:
products = products.filter(status=status_filter) products = products.filter(status=status_filter)
kits = kits.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 для различения в шаблоне # Добавляем type для различения в шаблоне
products_list = list(products.order_by('-created_at')) products_list = list(products.order_by('-created_at'))

View File

@@ -37,12 +37,17 @@ class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
if category_id: if category_id:
queryset = queryset.filter(categories__id=category_id) queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу # Фильтр по статусу (новая система)
is_active = self.request.GET.get('is_active') status_filter = self.request.GET.get('status')
if is_active == '1': if status_filter:
queryset = queryset.filter(is_active=True) queryset = queryset.filter(status=status_filter)
elif is_active == '0': else:
queryset = queryset.filter(is_active=False) # Фильтр по 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') return queryset.order_by('-created_at')

View File

@@ -88,13 +88,14 @@
<!-- Фильтр по статусу --> <!-- Фильтр по статусу -->
{% if show_status|default:True %} {% if show_status|default:True %}
<div class="col-12 col-md-2"> <div class="col-12 col-md-2">
<label for="is_active" class="form-label"> <label for="status" class="form-label">
<i class="bi bi-toggle-on"></i> Статус <i class="bi bi-toggle-on"></i> Статус
</label> </label>
<select class="form-select" id="is_active" name="is_active"> <select class="form-select" id="status" name="status">
<option value="">Все</option> <option value="">Все</option>
<option value="1" {% if filters.current.is_active == '1' %}selected{% endif %}>Активные</option> <option value="active" {% if filters.current.status == 'active' %}selected{% endif %}>Активный</option>
<option value="0" {% if filters.current.is_active == '0' %}selected{% endif %}>Неактивные</option> <option value="archived" {% if filters.current.status == 'archived' %}selected{% endif %}>Архивный</option>
<option value="discontinued" {% if filters.current.status == 'discontinued' %}selected{% endif %}>Снят</option>
</select> </select>
</div> </div>
{% endif %} {% endif %}