From 46ac00255ee0be3938e8991a838c97dcaa4150c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 30 Nov 2025 10:56:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B0=D1=8F=20=D0=BE=D0=BF=D1=82=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=BE=D0=B2:=20=D1=81=D0=B6=D0=B0=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B4=D0=BE=20512x512=20(=D0=B4=D0=BE=20500KB)?= =?UTF-8?q?=20+=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/common/image_utils.py | 122 ++++++++++++ .../management/commands/optimize_avatars.py | 180 ++++++++++++++++++ backend/employees/signals.py | 39 +++- 3 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 backend/common/image_utils.py create mode 100644 backend/employees/management/commands/optimize_avatars.py diff --git a/backend/common/image_utils.py b/backend/common/image_utils.py new file mode 100644 index 00000000..d7d134da --- /dev/null +++ b/backend/common/image_utils.py @@ -0,0 +1,122 @@ +"""Утилиты для обработки изображений. + +Функции для сжатия и оптимизации изображений при загрузке. +""" + +import io + +try: + from PIL import Image +except ImportError: + Image = None + + +def compress_image( + data: bytes, + *, + max_width: int = 512, + max_height: int = 512, + max_kb: int = 500, + quality: int = 85, + format: str = "JPEG", +) -> bytes: + """Сжимает изображение с сохранением пропорций. + + Функция оптимизирована для аватаров, которые будут использоваться + в системах распознавания лиц. Размер 512x512 обеспечивает достаточное + качество для нейросетей при разумном размере файла. + + Args: + data: Входные данные изображения (любой формат, поддерживаемый PIL) + max_width: Максимальная ширина в пикселях (по умолчанию 512) + max_height: Максимальная высота в пикселях (по умолчанию 512) + max_kb: Максимальный размер файла в килобайтах (по умолчанию 500) + quality: Начальное качество JPEG (по умолчанию 85) + format: Формат выходного файла (по умолчанию JPEG) + + Returns: + bytes: Сжатые данные изображения + + Raises: + RuntimeError: Если Pillow не установлен или ошибка обработки + ValueError: Если входные данные пусты + """ + if not data: + raise ValueError("Пустые данные изображения") + + if Image is None: + raise RuntimeError( + "Pillow не установлен. Выполните: pip install Pillow" + ) + + try: + # Открываем изображение + img = Image.open(io.BytesIO(data)) + + # Конвертируем в RGB (для JPEG) + if img.mode in ("RGBA", "LA", "P"): + # Создаем белый фон для прозрачных изображений + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + mask = img.split()[-1] if img.mode == "RGBA" else None + background.paste(img, mask=mask) + img = background + elif img.mode != "RGB": + img = img.convert("RGB") + + # Изменяем размер с сохранением пропорций + img.thumbnail((max_width, max_height), Image.Resampling.LANCZOS) + + # Бинарный поиск по качеству для соблюдения max_kb + lo, hi = 50, quality + best = None + + while lo <= hi: + mid = (lo + hi) // 2 + buf = io.BytesIO() + img.save(buf, format=format, quality=mid, optimize=True) + size_kb = len(buf.getvalue()) / 1024 + + if size_kb <= max_kb: + best = buf.getvalue() + lo = mid + 1 + else: + hi = mid - 1 + + # Если не удалось уложиться в лимит, используем минимальное качество + if best is None: + buf = io.BytesIO() + img.save(buf, format=format, quality=50, optimize=True) + best = buf.getvalue() + + return best + + except Exception as exc: + raise RuntimeError(f"Ошибка обработки изображения: {exc}") from exc + + +def compress_avatar(data: bytes) -> bytes: + """Сжимает аватар пользователя с оптимальными параметрами. + + Специализированная версия compress_image для аватаров: + - Размер 512x512 (достаточно для распознавания лиц) + - Максимум 500KB (баланс между качеством и размером) + - Качество 85 (хорошее качество для лиц) + + Args: + data: Входные данные изображения + + Returns: + bytes: Сжатые данные аватара + """ + return compress_image( + data, + max_width=512, + max_height=512, + max_kb=500, + quality=85, + ) + + +__all__ = ["compress_image", "compress_avatar"] diff --git a/backend/employees/management/commands/optimize_avatars.py b/backend/employees/management/commands/optimize_avatars.py new file mode 100644 index 00000000..06e48aed --- /dev/null +++ b/backend/employees/management/commands/optimize_avatars.py @@ -0,0 +1,180 @@ +""" +Management команда для очистки и оптимизации аватаров сотрудников. + +Функции: +1. Удаление неиспользуемых файлов аватаров +2. Сжатие существующих аватаров до оптимального размера +""" + +import os +from django.core.management.base import BaseCommand +from django.core.files.base import ContentFile +from employees.models import Employee +from common.image_utils import compress_avatar + + +class Command(BaseCommand): + help = 'Очистка неиспользуемых аватаров и оптимизация существующих' + + def add_arguments(self, parser): + parser.add_argument( + '--compress', + action='store_true', + help='Сжать существующие аватары', + ) + parser.add_argument( + '--cleanup', + action='store_true', + help='Удалить неиспользуемые файлы', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Показать что будет сделано без реального выполнения', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + do_compress = options['compress'] + do_cleanup = options['cleanup'] + + # Если не указаны опции, делаем всё + if not do_compress and not do_cleanup: + do_compress = True + do_cleanup = True + + if dry_run: + self.stdout.write( + self.style.WARNING( + 'РЕЖИМ ПРОСМОТРА - изменения не применяются' + ) + ) + + # Получаем все используемые файлы аватаров + employees = Employee.objects.exclude(avatar='').exclude(avatar=None) + used_files = set() + + for emp in employees: + if emp.avatar: + try: + used_files.add(os.path.basename(emp.avatar.path)) + except Exception: + pass + + self.stdout.write( + f'Найдено {len(used_files)} используемых аватаров' + ) + + # Очистка неиспользуемых файлов + if do_cleanup: + self.stdout.write('\n=== ОЧИСТКА НЕИСПОЛЬЗУЕМЫХ ФАЙЛОВ ===') + avatar_dir = os.path.join('media', 'users', 'avatars') + + if not os.path.exists(avatar_dir): + self.stdout.write( + self.style.WARNING(f'Директория {avatar_dir} не найдена') + ) + else: + deleted_count = 0 + freed_mb = 0 + + for filename in os.listdir(avatar_dir): + filepath = os.path.join(avatar_dir, filename) + + if not os.path.isfile(filepath): + continue + + if filename not in used_files: + size_mb = os.path.getsize(filepath) / (1024 * 1024) + freed_mb += size_mb + + self.stdout.write( + f' Удаление: {filename} ({size_mb:.2f} MB)' + ) + + if not dry_run: + try: + os.remove(filepath) + deleted_count += 1 + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' Ошибка удаления: {e}' + ) + ) + + self.stdout.write( + self.style.SUCCESS( + f'\nУдалено файлов: {deleted_count}, ' + f'освобождено: {freed_mb:.2f} MB' + ) + ) + + # Сжатие существующих аватаров + if do_compress: + self.stdout.write('\n=== СЖАТИЕ СУЩЕСТВУЮЩИХ АВАТАРОВ ===') + compressed_count = 0 + saved_mb = 0 + + for emp in employees: + if not emp.avatar: + continue + + try: + original_path = emp.avatar.path + original_size = os.path.getsize(original_path) + + # Читаем оригинальный файл + with open(original_path, 'rb') as f: + original_data = f.read() + + # Сжимаем + compressed_data = compress_avatar(original_data) + compressed_size = len(compressed_data) + + # Проверяем, дало ли сжатие выигрыш + if compressed_size < original_size * 0.95: # минимум 5% + size_diff = original_size - compressed_size + saved = size_diff / (1024 * 1024) + saved_mb += saved + + self.stdout.write( + f' Сжатие: {emp.get_full_name()} ' + f'({original_size/1024:.1f}KB → ' + f'{compressed_size/1024:.1f}KB, ' + f'экономия {saved*1024:.1f}KB)' + ) + + if not dry_run: + # Сохраняем сжатый файл + filename = os.path.basename(original_path) + if not filename.lower().endswith('.jpg'): + filename = filename.rsplit('.', 1)[0] + '.jpg' + + emp.avatar.save( + filename, + ContentFile(compressed_data), + save=True + ) + compressed_count += 1 + + except Exception as e: + self.stdout.write( + self.style.ERROR( + f' Ошибка сжатия для {emp.get_full_name()}: {e}' + ) + ) + + self.stdout.write( + self.style.SUCCESS( + f'\nСжато файлов: {compressed_count}, ' + f'сэкономлено: {saved_mb:.2f} MB' + ) + ) + + if dry_run: + self.stdout.write( + self.style.WARNING( + '\nДля применения изменений запустите без --dry-run' + ) + ) diff --git a/backend/employees/signals.py b/backend/employees/signals.py index 429e06ce..75f3535a 100644 --- a/backend/employees/signals.py +++ b/backend/employees/signals.py @@ -1,5 +1,6 @@ # users/signals.py import os +from django.core.files.base import ContentFile from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils import timezone @@ -10,10 +11,41 @@ from bots.models import BotSubscriber @receiver(pre_save, sender=Employee) -def delete_old_avatar_on_change(sender, instance: Employee, **kwargs): - """Удаляем старый файл аватара при замене на новый или при удалении.""" +def compress_and_cleanup_avatar(sender, instance: Employee, **kwargs): + """Сжимает новый аватар и удаляет старый файл при замене.""" + # Импортируем здесь, чтобы избежать циклических импортов + from common.image_utils import compress_avatar + + # Проверяем, загружен ли новый файл аватара + if instance.avatar and hasattr(instance.avatar, 'file'): + try: + # Читаем данные нового аватара + instance.avatar.seek(0) + original_data = instance.avatar.read() + + # Сжимаем изображение + compressed_data = compress_avatar(original_data) + + # Если сжатие дало результат меньшего размера, заменяем + if len(compressed_data) < len(original_data): + # Получаем имя файла + filename = instance.avatar.name.split('/')[-1] + if not filename.lower().endswith('.jpg'): + # Меняем расширение на .jpg + filename = filename.rsplit('.', 1)[0] + '.jpg' + + # Заменяем файл на сжатый + instance.avatar.save( + filename, + ContentFile(compressed_data), + save=False + ) + except Exception as e: + # Логируем ошибку, но не прерываем сохранение + print(f"Error compressing avatar: {e}") + + # Удаляем старый файл если он был заменен if instance.pk is None: - # Новый пользователь - нечего удалять return try: @@ -28,7 +60,6 @@ def delete_old_avatar_on_change(sender, instance: Employee, **kwargs): try: os.remove(old.avatar.path) except Exception as e: - # Логируем ошибку, но не прерываем сохранение print(f"Error deleting old avatar: {e}")