1242 lines
40 KiB
Python
1242 lines
40 KiB
Python
import os
|
||
import uuid
|
||
|
||
from django.core.files.storage import default_storage
|
||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||
from django.db import models
|
||
from django.db.models.signals import pre_delete
|
||
from django.dispatch import receiver
|
||
from django.utils.html import format_html
|
||
from django.utils.safestring import mark_safe
|
||
from django.utils.text import slugify
|
||
from django.conf import settings
|
||
from imagekit.models import ProcessedImageField
|
||
from imagekit.processors import ResizeToFit
|
||
from PIL import Image
|
||
|
||
|
||
def upload_to(instance, filename):
|
||
return f"headphones/{instance.product.id}/{filename}"
|
||
|
||
def upload_to_case_image(instance, filename):
|
||
"""Путь для сохранения: headphones/<product_id>/case_images/<slug>.<ext>"""
|
||
ext = filename.split('.')[-1]
|
||
return f"headphones/{instance.headphones.id}/case_images/{instance.slug}.{ext}"
|
||
|
||
def upload_to_frequency_response(instance, filename):
|
||
"""Путь для сохранения: headphones/<headphones_id>/frequency_response/<filename>"""
|
||
ext = filename.split('.')[-1]
|
||
unique_filename = f"{uuid.uuid4().hex}.{ext}"
|
||
return f"headphones/{instance.headphones.id}/frequency_response/{unique_filename}"
|
||
|
||
# ======================
|
||
# Базовые справочники
|
||
# ======================
|
||
class BrandCountry(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название страны")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Страна производитель"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class CaseColors(models.Model):
|
||
headphones = models.ForeignKey(
|
||
'Headphones',
|
||
on_delete=models.CASCADE,
|
||
related_name='case_colors',
|
||
verbose_name='Наушники'
|
||
)
|
||
name_ru = models.CharField('Название цвета (рус)', max_length=100)
|
||
name_en = models.CharField('Название цвета (англ)', max_length=100)
|
||
slug = models.SlugField(max_length=150, unique=True, blank=True, verbose_name="URL-идентификатор")
|
||
image = models.ImageField('Изображение', upload_to=upload_to_case_image)
|
||
|
||
def save(self, *args, **kwargs):
|
||
if not self.slug:
|
||
# Генерируем slug в формате "модельнаушников_цветангл"
|
||
base_slug = slugify(f"{self.headphones.model}_{self.name_en}")
|
||
self.slug = base_slug
|
||
|
||
# Проверяем уникальность slug
|
||
counter = 1
|
||
while CaseColors.objects.filter(slug=self.slug).exists():
|
||
self.slug = f"{base_slug}-{counter}"
|
||
counter += 1
|
||
super().save(*args, **kwargs)
|
||
|
||
def image_preview(self):
|
||
if self.image:
|
||
return format_html('<img src="{}" width="50" height="50" />', self.image.url)
|
||
return "Нет изображения"
|
||
|
||
image_preview.short_description = 'Превью'
|
||
image_preview.allow_tags = True
|
||
|
||
def __str__(self):
|
||
return f"{self.name_ru} ({self.headphones.model})"
|
||
|
||
class Meta:
|
||
verbose_name = 'Цвет корпуса'
|
||
verbose_name_plural = 'Цвета корпусов'
|
||
ordering = ['headphones__model', 'name_ru']
|
||
|
||
# ======================
|
||
# Основные модели брендов и категорий
|
||
# ======================
|
||
class Brand(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название бренда")
|
||
country = models.ForeignKey(
|
||
BrandCountry,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Страна производитель"
|
||
)
|
||
year_founded = models.PositiveSmallIntegerField(verbose_name="Год основания")
|
||
is_year_approx = models.BooleanField(
|
||
verbose_name="Год указан приблизительно",
|
||
default=False
|
||
)
|
||
|
||
site = models.URLField(verbose_name="Официальный сайт")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
# Рекурсивная связь (может быть null, если это родительская компания)
|
||
parent_company = models.ForeignKey(
|
||
'self',
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
related_name='subsidiaries',
|
||
verbose_name="Родительская компания"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Бренд"
|
||
verbose_name_plural = "Бренды"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class CaseType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип корпуса")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Тип корпуса"
|
||
verbose_name_plural = "Типы корпусов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class CaseMaterial(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Материал корпуса")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Материал корпуса"
|
||
verbose_name_plural = "Материалы корпусов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class HeadphonePurpose(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Назначение")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Назначение наушников"
|
||
verbose_name_plural = "Назначения наушников"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
# ======================
|
||
# Теги и категории
|
||
# ======================
|
||
class TagCategory(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название категории")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Категория тегов"
|
||
verbose_name_plural = "Категории тегов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Tags(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название тега")
|
||
category = models.ForeignKey(
|
||
TagCategory,
|
||
on_delete=models.SET_NULL,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Категория"
|
||
)
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Тег"
|
||
verbose_name_plural = "Теги"
|
||
ordering = ['category__name', 'name']
|
||
|
||
def __str__(self):
|
||
return f"{self.category.name}: {self.name}" if self.category else self.name
|
||
|
||
|
||
# ======================
|
||
# Аудио компоненты
|
||
# ======================
|
||
class DriverModel(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Модель драйвера")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Модель драйвера"
|
||
verbose_name_plural = "Модели драйверов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class DriverType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип драйвера")
|
||
encoding = models.CharField(blank=True, max_length=10, verbose_name="Буквенное обозначение")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Тип драйвера"
|
||
verbose_name_plural = "Типы драйверов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class Driver(models.Model):
|
||
driver_type = models.ForeignKey(
|
||
DriverType,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Тип драйвера"
|
||
)
|
||
driver_model = models.ForeignKey(
|
||
DriverModel,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Модель драйвера",
|
||
blank=True,
|
||
null=True
|
||
)
|
||
size = models.DecimalField(
|
||
verbose_name="Размер (мм)",
|
||
max_digits=4,
|
||
decimal_places=1,
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
frequency_range = models.CharField(
|
||
verbose_name="Частотный диапазон (Гц)",
|
||
max_length=50,
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Драйвер"
|
||
verbose_name_plural = "Драйверы"
|
||
|
||
def __str__(self):
|
||
parts = [self.driver_type.name]
|
||
|
||
if self.driver_model:
|
||
parts.append(self.driver_model.name)
|
||
|
||
if self.size:
|
||
parts.append(f"{self.size}mm")
|
||
|
||
return " ".join(parts)
|
||
|
||
|
||
class NoiseCancellationType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип шумоподавления")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Тип шумоподавления"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class CodecType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип кодека")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Тип аудиокодека"
|
||
verbose_name_plural = "Типы аудиокодеков"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class AudioCodec(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название кодека")
|
||
codec_type = models.ForeignKey(
|
||
CodecType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Тип кодека"
|
||
)
|
||
max_bitrate = models.PositiveIntegerField(
|
||
verbose_name="Макс. битрейт (кбит/с)",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
max_sample_rate = models.PositiveIntegerField(
|
||
verbose_name="Макс. частота (кГц)",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
bit_depth = models.PositiveSmallIntegerField(
|
||
verbose_name="Битовая глубина",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
latency = models.PositiveSmallIntegerField(
|
||
verbose_name="Задержка (мс)",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Аудиокодек"
|
||
verbose_name_plural = "Аудиокодеки"
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.codec_type})"
|
||
|
||
|
||
# ======================
|
||
# Разъемы и подключения
|
||
# ======================
|
||
class CableConnectionType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название разъема")
|
||
description = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name="Описание"
|
||
)
|
||
image = models.ImageField(
|
||
upload_to='connectors/',
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Изображение"
|
||
)
|
||
is_detachable = models.BooleanField(
|
||
default=True,
|
||
verbose_name="Съемный разъем"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Тип разъема кабеля"
|
||
verbose_name_plural = "Типы разъемов кабелей"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class CableMaterial(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Материал кабеля")
|
||
description = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name="Описание"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Материал кабеля"
|
||
verbose_name_plural = "Материал кабелей"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class DeviceConnectorType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип разъема")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Тип разъема устройства"
|
||
verbose_name_plural = "Типы разъемов устройств"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class WirelessTechnology(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Технология")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Беспроводная технология"
|
||
verbose_name_plural = "Беспроводные технологии"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class ChargingInterface(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Интерфейс зарядки")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Интерфейс зарядки"
|
||
verbose_name_plural = "Интерфейсы зарядки"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
# ======================
|
||
# Отзывы и медиа
|
||
# ======================
|
||
class HeadphonesReviewType(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Тип обзора")
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор") # Добавлено slug поле
|
||
|
||
class Meta:
|
||
verbose_name = "Тип обзора"
|
||
verbose_name_plural = "Типы обзоров"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class HeadphonesReviewAuthorResource(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название ресурса")
|
||
url = models.URLField(verbose_name="URL ресурса", blank=True)
|
||
|
||
class Meta:
|
||
verbose_name = "Ресурс автора"
|
||
verbose_name_plural = "Ресурсы авторов"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
class HeadphonesReviewAuthor(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Имя автора")
|
||
nickname = models.CharField(max_length=50, verbose_name="Никнейм автора")
|
||
resources = models.ManyToManyField(HeadphonesReviewAuthorResource,
|
||
related_name='author_resources',
|
||
blank=True,
|
||
verbose_name="Ресурсы автора")
|
||
|
||
class Meta:
|
||
verbose_name = "Автор обзора"
|
||
verbose_name_plural = "Авторы обзоров"
|
||
ordering = ['name']
|
||
|
||
def __str__(self):
|
||
return self.nickname if self.nickname else self.name
|
||
|
||
|
||
class HeadphonesReviewResource(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Ресурс обзора")
|
||
type = models.ForeignKey(
|
||
HeadphonesReviewType,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Тип обзора"
|
||
)
|
||
url = models.URLField(verbose_name="URL ресурса", blank=True) # Добавлено поле для URL ресурса
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
|
||
class Meta:
|
||
verbose_name = "Ресурс обзора"
|
||
verbose_name_plural = "Ресурсы обзоров"
|
||
ordering = ['name'] # Добавлена сортировка
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.type.name if self.type else 'без типа'})"
|
||
|
||
|
||
class HeadphonesReviewLanguage(models.Model):
|
||
language = models.CharField(max_length=50, verbose_name="Язык обзора")
|
||
|
||
class Meta:
|
||
verbose_name = "Язык обзора"
|
||
|
||
def __str__(self):
|
||
return self.language
|
||
|
||
|
||
class HeadphonesReview(models.Model):
|
||
headphones = models.ForeignKey(
|
||
'Headphones',
|
||
on_delete=models.CASCADE,
|
||
related_name='reviews',
|
||
verbose_name="Наушники",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
# Изменено на связь с HeadphonesReviewResource (была ошибка в логике)
|
||
resource = models.ForeignKey(
|
||
HeadphonesReviewResource,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Ресурс обзора"
|
||
)
|
||
author = models.ForeignKey(
|
||
HeadphonesReviewAuthor,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Автор обзора",
|
||
null = True,
|
||
blank = True,
|
||
related_name='reviews'
|
||
)
|
||
|
||
language = models.ForeignKey(
|
||
HeadphonesReviewLanguage,
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
|
||
title = models.CharField(max_length=100, verbose_name="Заголовок обзора") # Изменено name на title
|
||
url = models.URLField(verbose_name="Ссылка на обзор", unique=True) # Добавлен unique
|
||
date = models.DateField(
|
||
verbose_name="Дата публикации",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Обзор наушников"
|
||
verbose_name_plural = "Обзоры наушников"
|
||
ordering = ['-date'] # Сортировка по дате
|
||
indexes = [
|
||
models.Index(fields=['headphones', 'date']), # Добавлен индекс
|
||
]
|
||
|
||
def __str__(self):
|
||
# Более информативное представление
|
||
return f"{self.title} ({self.headphones.model if self.headphones else 'No headphones'})"
|
||
|
||
|
||
class HeadphonesImages(models.Model):
|
||
product = models.ForeignKey(
|
||
'Headphones',
|
||
on_delete=models.CASCADE,
|
||
related_name='images',
|
||
verbose_name="Наушники"
|
||
)
|
||
|
||
photo = ProcessedImageField(
|
||
upload_to=upload_to,
|
||
processors=[ResizeToFit(1000, 1000)], # Макс. ширина/высота = 1000px
|
||
format='WEBP',
|
||
options={'quality': 85},
|
||
)
|
||
caption = models.CharField(
|
||
max_length=200,
|
||
blank=True,
|
||
verbose_name="Подпись"
|
||
)
|
||
is_main = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Основное изображение",
|
||
help_text="Используется как превью товара"
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Если это основное изображение, снимаем флаг is_main у других изображений
|
||
if self.is_main:
|
||
HeadphonesImages.objects.filter(product=self.product).exclude(id=self.id).update(is_main=False)
|
||
super().save(*args, **kwargs)
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Удаление файла при удалении записи"""
|
||
if self.photo:
|
||
try:
|
||
if default_storage.exists(self.photo.name):
|
||
default_storage.delete(self.photo.name)
|
||
except Exception as e:
|
||
# Логируем ошибку, но не прерываем удаление
|
||
print(f"Ошибка при удалении файла: {e}")
|
||
super().delete(*args, **kwargs)
|
||
|
||
@property
|
||
def image_info(self):
|
||
"""Вычисляемое поле с информацией о размере изображения"""
|
||
if not self.photo:
|
||
return mark_safe("—")
|
||
|
||
try:
|
||
img_path = self.photo.path
|
||
if not os.path.exists(img_path):
|
||
return mark_safe("Файл не найден")
|
||
|
||
with Image.open(img_path) as img:
|
||
width, height = img.size
|
||
|
||
file_size = os.path.getsize(img_path)
|
||
size_kb = file_size / 1024
|
||
file_ext = os.path.splitext(self.photo.name)[1][1:].upper()
|
||
|
||
# Создаем отдельные безопасные строки
|
||
dimensions = mark_safe(f"{width}×{height}")
|
||
size = mark_safe(f"{size_kb:.1f} kb")
|
||
ext = mark_safe(file_ext)
|
||
|
||
# Комбинируем с помощью format_html
|
||
return format_html(
|
||
"{}<br>{}<br>{}",
|
||
dimensions,
|
||
size,
|
||
ext
|
||
)
|
||
except Exception as e:
|
||
return mark_safe(f"Ошибка: {str(e)}")
|
||
class Meta:
|
||
verbose_name = "Изображение наушников"
|
||
verbose_name_plural = "Изображения наушников"
|
||
|
||
|
||
@receiver(pre_delete, sender=HeadphonesImages)
|
||
def delete_image_file(sender, instance, **kwargs):
|
||
"""Обработчик сигнала для удаления файла при каскадном удалении"""
|
||
if instance.photo:
|
||
try:
|
||
if os.path.isfile(instance.photo.path):
|
||
os.remove(instance.photo.path)
|
||
except Exception as e:
|
||
print(f"Ошибка при удалении файла (сигнал): {e}")
|
||
|
||
|
||
class HeadphonesMicrophoneType(models.Model):
|
||
type = models.CharField(max_length=50, verbose_name="Тип микрофона")
|
||
description = models.CharField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Ресурс обзора"
|
||
|
||
def __str__(self):
|
||
return self.type
|
||
|
||
|
||
class HeadphonesCaseIP(models.Model):
|
||
code = models.CharField(max_length=50, verbose_name="Код степени защиты")
|
||
description = models.CharField(blank=True, verbose_name="Описание степени защиты")
|
||
|
||
class Meta:
|
||
verbose_name = "Степень защиты"
|
||
|
||
def __str__(self):
|
||
return self.code
|
||
|
||
|
||
class FrequencyResponse(models.Model):
|
||
headphones = models.ForeignKey(
|
||
'Headphones',
|
||
on_delete=models.CASCADE,
|
||
related_name='frequency_responses',
|
||
verbose_name="Наушники"
|
||
)
|
||
file = models.FileField(
|
||
upload_to=upload_to_frequency_response,
|
||
verbose_name="Файл АЧХ"
|
||
)
|
||
author = models.CharField(
|
||
max_length=100,
|
||
verbose_name="Автор измерения",
|
||
blank=True,
|
||
null=True
|
||
)
|
||
source_link = models.URLField(
|
||
verbose_name="Ссылка на источник",
|
||
blank=True,
|
||
null=True
|
||
)
|
||
notes = models.TextField(
|
||
verbose_name="Примечания",
|
||
blank=True,
|
||
null=True
|
||
)
|
||
is_primary = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Основная АЧХ"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "АЧХ наушников"
|
||
verbose_name_plural = "АЧХ наушников"
|
||
ordering = ['-is_primary', 'headphones__model']
|
||
|
||
def __str__(self):
|
||
return f"АЧХ {self.headphones.model} ({self.author or 'без автора'})"
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.is_primary:
|
||
# Снимаем флаг is_primary у других АЧХ этих наушников
|
||
FrequencyResponse.objects.filter(
|
||
headphones=self.headphones
|
||
).exclude(
|
||
pk=self.pk
|
||
).update(is_primary=False)
|
||
super().save(*args, **kwargs)
|
||
|
||
def delete(self, *args, **kwargs):
|
||
"""Удаление файла при удалении записи"""
|
||
if self.file:
|
||
try:
|
||
if default_storage.exists(self.file.name):
|
||
default_storage.delete(self.file.name)
|
||
except Exception as e:
|
||
print(f"Ошибка при удалении файла АЧХ: {e}")
|
||
super().delete(*args, **kwargs)
|
||
|
||
|
||
# ======================
|
||
# Основная модель наушников
|
||
# ======================
|
||
class Headphones(models.Model):
|
||
# Идентификация и базовые данные
|
||
id = models.UUIDField(
|
||
primary_key=True,
|
||
default=uuid.uuid4,
|
||
editable=False,
|
||
verbose_name="Идентификатор"
|
||
)
|
||
slug = models.SlugField(unique=True, verbose_name="URL-идентификатор")
|
||
model = models.CharField(max_length=50, verbose_name="Модель")
|
||
description = models.TextField(verbose_name="Описание")
|
||
|
||
CONNECTION_TYPE_CHOICES = [
|
||
('wired', 'Проводные'),
|
||
('wireless', 'Беспроводные'),
|
||
('hybrid', 'Гибридные (проводные + беспроводные)'),
|
||
]
|
||
connection_type = models.CharField(
|
||
max_length=10,
|
||
choices=CONNECTION_TYPE_CHOICES,
|
||
default='wired',
|
||
verbose_name="Тип подключения"
|
||
)
|
||
case_type = models.ForeignKey(
|
||
CaseType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Тип корпуса"
|
||
)
|
||
case_material = models.ForeignKey(
|
||
CaseMaterial,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Материал корпуса",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
brand = models.ForeignKey(
|
||
Brand,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Производитель"
|
||
)
|
||
headphone_purpose = models.ForeignKey(
|
||
HeadphonePurpose,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Назначение"
|
||
)
|
||
|
||
# Медиа и ссылки
|
||
product_url = models.URLField(
|
||
blank=True,
|
||
verbose_name="Ссылка на товар"
|
||
)
|
||
|
||
# Теги и компоненты
|
||
tags = models.ManyToManyField(
|
||
Tags,
|
||
related_name='headphones',
|
||
blank=True,
|
||
verbose_name="Теги"
|
||
)
|
||
drivers = models.ManyToManyField(
|
||
Driver,
|
||
through='HeadphonesDriver',
|
||
related_name='headphones',
|
||
verbose_name="Драйверы",
|
||
blank=True,
|
||
null=True
|
||
)
|
||
|
||
# Технические характеристики
|
||
# frequency_response_chart = models.FileField(
|
||
# verbose_name="АЧХ (CSV)",
|
||
# upload_to = upload_to_frequency_response,
|
||
# null=True,
|
||
# blank=True,
|
||
# help_text="Данные в формате [{'frequency': 20, 'amplitude': -2.5}, ...]"
|
||
# )
|
||
|
||
frequency_response_chart_author = models.CharField(
|
||
verbose_name="Автор АЧХ",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
|
||
frequency_response_chart_link = models.CharField(
|
||
verbose_name="Ссылка на АЧХ",
|
||
null=True,
|
||
blank=True,
|
||
)
|
||
|
||
impedance = models.DecimalField(
|
||
verbose_name="Импеданс (Ом)",
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
validators=[
|
||
MinValueValidator(4), # Минимум 4 Ом
|
||
MaxValueValidator(600) # Максимум 600 Ом
|
||
]
|
||
)
|
||
sensitivity = models.DecimalField(
|
||
verbose_name="Чувствительность (дБ/мВт)",
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True,
|
||
help_text="Уровень звукового давления"
|
||
)
|
||
frequency_range = models.CharField(
|
||
verbose_name="Частотный диапазон (Гц)",
|
||
max_length=50,
|
||
null=True,
|
||
blank=True,
|
||
help_text="Пример: 20-20000 Гц"
|
||
)
|
||
|
||
# Дополнительные функции
|
||
microphone = models.ForeignKey(
|
||
HeadphonesMicrophoneType,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Тип микрофона",
|
||
)
|
||
noise_cancellation = models.ForeignKey(
|
||
NoiseCancellationType,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Шумоподавление",
|
||
)
|
||
ip_rating = models.ForeignKey(
|
||
HeadphonesCaseIP,
|
||
on_delete=models.PROTECT,
|
||
null=True,
|
||
blank=True,
|
||
help_text="Например: IPX4",
|
||
verbose_name="Степень защиты (IP)",
|
||
)
|
||
official_price = models.DecimalField(
|
||
verbose_name="Официальная цена на сайте",
|
||
max_digits=6,
|
||
decimal_places=2,
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
# # Внешний вид и физические параметры
|
||
# colors = models.ManyToManyField(
|
||
# CaseColors,
|
||
# related_name='headphones',
|
||
# blank=True,
|
||
# verbose_name="Доступные цвета"
|
||
# )
|
||
weight = models.FloatField(
|
||
verbose_name="Вес (г)",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
# Метаданные
|
||
release_year = models.PositiveSmallIntegerField(
|
||
verbose_name="Год выхода",
|
||
null=True,
|
||
blank=True,
|
||
help_text="Год выпуска модели"
|
||
)
|
||
date_added = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата добавления"
|
||
)
|
||
published = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Опубликовано"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Наушники"
|
||
verbose_name_plural = "Наушники"
|
||
ordering = ['brand', 'model']
|
||
|
||
def __str__(self):
|
||
return f"{self.brand.name} {self.model}"
|
||
|
||
def save(self, *args, **kwargs):
|
||
if not self.slug:
|
||
self.slug = slugify(f"{self.brand.name} {self.model}")
|
||
super().save(*args, **kwargs)
|
||
|
||
# Обновляем slug для связанных цветов, если изменилась модель
|
||
if self.pk and hasattr(self, 'case_colors'):
|
||
for color in self.case_colors.all():
|
||
new_slug = slugify(f"{self.model}_{color.name_en}")
|
||
if color.slug != new_slug:
|
||
color.slug = new_slug
|
||
color.save()
|
||
|
||
|
||
class HeadphonesConnector(models.Model):
|
||
headphones = models.ForeignKey(
|
||
'Headphones',
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Наушники"
|
||
)
|
||
wired_headphones = models.ForeignKey(
|
||
'WiredHeadphones',
|
||
on_delete=models.CASCADE,
|
||
null=True,
|
||
blank=True,
|
||
verbose_name="Проводные наушники"
|
||
)
|
||
connector = models.ForeignKey(
|
||
DeviceConnectorType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Разъем"
|
||
)
|
||
is_primary = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Основной разъем"
|
||
)
|
||
notes = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
verbose_name="Примечания"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Разъем наушников"
|
||
verbose_name_plural = "Разъемы наушников"
|
||
ordering = ['-is_primary', 'connector__name']
|
||
|
||
def __str__(self):
|
||
return f"{self.connector.name} ({'основной' if self.is_primary else 'доп.'})"
|
||
|
||
# ======================
|
||
# Специфичные модели для типов наушников
|
||
# ======================
|
||
class WiredHeadphones(models.Model):
|
||
headphones = models.OneToOneField(
|
||
Headphones,
|
||
on_delete=models.CASCADE,
|
||
primary_key=True,
|
||
related_name='wired_details',
|
||
verbose_name="Наушники"
|
||
)
|
||
cable_connection_type = models.ForeignKey(
|
||
CableConnectionType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Тип разъема кабеля"
|
||
)
|
||
cable_length = models.DecimalField(
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
verbose_name="Длина кабеля (м)",
|
||
help_text="Длина кабеля в метрах"
|
||
)
|
||
# Удаляем старое поле connector_to_device
|
||
custom_connector_name = models.CharField(
|
||
max_length=50,
|
||
blank=True,
|
||
verbose_name="Проприетарный разъем"
|
||
)
|
||
|
||
# Добавляем свойство для удобного доступа к разъемам
|
||
@property
|
||
def connectors(self):
|
||
return self.headphones.headphonesconnector_set.all()
|
||
|
||
def save(self, *args, **kwargs):
|
||
super().save(*args, **kwargs)
|
||
# Обновляем связь в разъемах
|
||
HeadphonesConnector.objects.filter(headphones=self.headphones).update(wired_headphones=self)
|
||
|
||
class Meta:
|
||
verbose_name = "Характеристики проводных наушников"
|
||
verbose_name_plural = "Характеристики проводных наушников"
|
||
|
||
def __str__(self):
|
||
return f"Проводные характеристики для {self.headphones.model}"
|
||
|
||
|
||
class WirelessHeadphonesChip(models.Model):
|
||
name = models.CharField(max_length=50, verbose_name="Название")
|
||
description = models.TextField(blank=True, verbose_name="Описание")
|
||
|
||
class Meta:
|
||
verbose_name = "Используемый чип"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
|
||
class WirelessHeadphones(models.Model):
|
||
headphones = models.OneToOneField(
|
||
Headphones,
|
||
on_delete=models.CASCADE,
|
||
primary_key=True,
|
||
related_name='wireless_details',
|
||
verbose_name="Наушники"
|
||
)
|
||
wireless_technology = models.ForeignKey(
|
||
WirelessTechnology,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Беспроводная технология"
|
||
)
|
||
bluetooth_version = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
verbose_name="Версия Bluetooth"
|
||
)
|
||
battery_life = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Время работы (ч)",
|
||
help_text="Время работы от батареи в часах"
|
||
)
|
||
charging_time = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Время зарядки наушников (мин)",
|
||
help_text="Время зарядки наушников в минутах"
|
||
)
|
||
has_charging_case = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Зарядный кейс"
|
||
)
|
||
charging_case_battery_life = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Время работы с кейсом (ч)",
|
||
help_text="Общее время работы с кейсом в часах"
|
||
)
|
||
charging_case_time = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Время зарядки кейса (мин)",
|
||
help_text="Время зарядки кейса в минутах"
|
||
)
|
||
|
||
charging_interface = models.ForeignKey(
|
||
ChargingInterface,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Интерфейс зарядки"
|
||
)
|
||
chip = models.ForeignKey(
|
||
WirelessHeadphonesChip,
|
||
blank=True,
|
||
null=True,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Чип, используемый в наушниках"
|
||
)
|
||
supported_codecs = models.ManyToManyField(
|
||
AudioCodec,
|
||
blank=True,
|
||
verbose_name="Поддерживаемые кодеки"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Характеристики беспроводных наушников"
|
||
verbose_name_plural = "Характеристики беспроводных наушников"
|
||
|
||
def __str__(self):
|
||
return f"Беспроводные характеристики для {self.headphones.model}"
|
||
|
||
|
||
class HybridHeadphones(models.Model):
|
||
headphones = models.OneToOneField(
|
||
Headphones,
|
||
on_delete=models.CASCADE,
|
||
primary_key=True,
|
||
related_name='hybrid_details',
|
||
verbose_name="Наушники"
|
||
)
|
||
|
||
# Беспроводная часть
|
||
wireless_technology = models.ForeignKey(
|
||
WirelessTechnology,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Беспроводная технология"
|
||
)
|
||
bluetooth_version = models.CharField(
|
||
max_length=20,
|
||
blank=True,
|
||
verbose_name="Версия Bluetooth"
|
||
)
|
||
battery_life = models.IntegerField(
|
||
verbose_name="Время работы (ч)",
|
||
help_text="В беспроводном режиме"
|
||
)
|
||
charging_time = models.IntegerField(
|
||
verbose_name="Время зарядки (мин)"
|
||
)
|
||
supported_codecs = models.ManyToManyField(
|
||
AudioCodec,
|
||
blank=True,
|
||
verbose_name="Поддерживаемые кодеки"
|
||
)
|
||
|
||
# Проводная часть
|
||
cable_connection_type = models.ForeignKey(
|
||
CableConnectionType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Тип разъема кабеля"
|
||
)
|
||
cable_length = models.DecimalField(
|
||
max_digits=5,
|
||
decimal_places=2,
|
||
verbose_name="Длина кабеля (м)",
|
||
null = True,
|
||
blank = True
|
||
)
|
||
cable_material = models.ForeignKey(
|
||
CableMaterial,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Материал кабеля",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
connector_to_device = models.ForeignKey(
|
||
DeviceConnectorType,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Разъем для устройства"
|
||
)
|
||
|
||
# Общие характеристики
|
||
auto_switch_mode = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Автопереключение режимов"
|
||
)
|
||
simultaneous_connection = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Одновременное подключение"
|
||
)
|
||
|
||
# Зарядка
|
||
charging_interface = models.ForeignKey(
|
||
ChargingInterface,
|
||
on_delete=models.PROTECT,
|
||
verbose_name="Интерфейс зарядки"
|
||
)
|
||
has_charging_case = models.BooleanField(
|
||
default=False,
|
||
verbose_name="Зарядный кейс"
|
||
)
|
||
charging_case_battery_life = models.IntegerField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Время работы с кейсом (ч)"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Характеристики гибридных наушников"
|
||
verbose_name_plural = "Характеристики гибридных наушников"
|
||
|
||
def __str__(self):
|
||
return f"Гибридные характеристики для {self.headphones.model}"
|
||
|
||
|
||
# ======================
|
||
# Вспомогательные модели
|
||
# ======================
|
||
class HeadphonesDriver(models.Model):
|
||
headphones = models.ForeignKey(
|
||
Headphones,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Наушники"
|
||
)
|
||
driver = models.ForeignKey(
|
||
Driver,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Драйвер"
|
||
)
|
||
count = models.PositiveSmallIntegerField(
|
||
default=1,
|
||
verbose_name="Количество"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Драйвер в наушниках"
|
||
verbose_name_plural = "Драйверы в наушниках"
|
||
unique_together = [('headphones', 'driver')]
|
||
|
||
def __str__(self):
|
||
return f"{self.driver.driver_type.name} x{self.count}"
|
||
|
||
|
||
class Comment(models.Model):
|
||
id = models.CharField(
|
||
max_length=100,
|
||
blank=True,
|
||
unique=True,
|
||
default=uuid.uuid4,
|
||
primary_key=True,
|
||
verbose_name="Идентификатор"
|
||
)
|
||
date = models.DateTimeField(
|
||
auto_now_add=True,
|
||
verbose_name="Дата создания"
|
||
)
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Пользователь"
|
||
)
|
||
body = models.TextField(
|
||
blank=True,
|
||
null=True,
|
||
verbose_name="Текст комментария"
|
||
)
|
||
post = models.ForeignKey(
|
||
Headphones,
|
||
on_delete=models.CASCADE,
|
||
blank=True,
|
||
related_name='comments',
|
||
related_query_name='comment',
|
||
verbose_name="Наушники"
|
||
)
|
||
parent = models.ForeignKey(
|
||
'self',
|
||
related_name='reply',
|
||
null=True,
|
||
blank=True,
|
||
on_delete=models.CASCADE,
|
||
verbose_name="Родительский комментарий"
|
||
)
|
||
|
||
class Meta:
|
||
verbose_name = "Комментарий"
|
||
verbose_name_plural = "Комментарии"
|
||
ordering = ['-date']
|
||
|
||
def __str__(self):
|
||
return f'Комментарий к "{self.post.model}" от {self.user}'
|