Добавлена система проектов с автоматическим ресайзом изображений и адаптивным дизайном
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
- Удалена старая система портфолио (PortfolioCategory, PortfolioItem) - Расширена модель Project: slug, categories (M2M), thumbnail, media files, meta fields - Объединены категории: ProjectCategory удалена, используется общая Category - Автоматический ресайз thumbnail до 600x400px с умным кропом по центру - Создан /projects/ - страница списка проектов с фильтрацией по категориям - Создан /project/<pk>/ - детальная страница проекта с галереей Swiper - Адаптивный дизайн: 3 карточки в ряд (десктоп), 2 (планшет), 1 (мобильный) - Параллакс-эффект на изображениях при наведении - Lazy loading для оптимизации загрузки - Фильтры категорий в виде пилюль как на странице услуг - Компактные карточки с фиксированной шириной - Кликабельные проекты в service_detail с отображением всех медиа
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, User
|
||||
from tinymce.models import HTMLField
|
||||
import uuid
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -54,15 +55,25 @@ class HeroBanner(models.Model):
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
slug = models.SlugField(max_length=100, unique=True, blank=True, null=True, verbose_name="URL")
|
||||
description = models.TextField(default='Описание категории')
|
||||
icon = models.CharField(max_length=50, blank=True, verbose_name="Иконка", help_text="Класс FontAwesome (например: fa-code)")
|
||||
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активна")
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Категория'
|
||||
verbose_name_plural = 'Категории'
|
||||
ordering = ['name']
|
||||
ordering = ['order', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
from django.utils.text import slugify
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Service(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
@@ -111,7 +122,7 @@ class Client(models.Model):
|
||||
|
||||
class BlogPost(models.Model):
|
||||
title = models.CharField(max_length=200)
|
||||
content = models.TextField()
|
||||
content = HTMLField(verbose_name="Содержание")
|
||||
published_date = models.DateTimeField(auto_now_add=True)
|
||||
image = models.ImageField(upload_to='static/img/blog/', blank=True, null=True)
|
||||
video = models.FileField(upload_to='static/video/blog/', blank=True, null=True, help_text='Видео файл для блог поста')
|
||||
@@ -172,26 +183,199 @@ class Order(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse('order_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
# ПРОЕКТЫ
|
||||
# ============================================
|
||||
|
||||
class Project(models.Model):
|
||||
name = models.CharField(max_length=200)
|
||||
description = models.TextField(default='Описание проекта')
|
||||
completion_date = models.DateField(blank=True, null=True)
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='projects')
|
||||
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='projects')
|
||||
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='project', null=True, blank=True)
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
image = models.ImageField(upload_to='static/img/project/', blank=True, null=True)
|
||||
"""Расширенная модель проекта с множественными категориями и медиа"""
|
||||
|
||||
STATUS_CHOICES = [
|
||||
('in_progress', 'В процессе'),
|
||||
('completed', 'Завершен'),
|
||||
('archived', 'В архиве'),
|
||||
]
|
||||
|
||||
# Основная информация
|
||||
name = models.CharField(max_length=200, verbose_name="Название проекта")
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL", blank=True)
|
||||
|
||||
# Краткое описание для списка
|
||||
short_description = models.TextField(max_length=300, verbose_name="Краткое описание", default='Описание проекта')
|
||||
|
||||
# Полное описание с WYSIWYG редактором
|
||||
description = HTMLField(verbose_name="Полное описание")
|
||||
|
||||
# Связи с существующими моделями
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='projects', verbose_name="Клиент")
|
||||
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='projects', verbose_name="Услуга")
|
||||
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name='project', null=True, blank=True, verbose_name="Заказ")
|
||||
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Категория (старая)")
|
||||
|
||||
# Множественные категории проектов = категории услуг
|
||||
categories = models.ManyToManyField(Category, related_name='projects', verbose_name="Категории", blank=True)
|
||||
|
||||
# Главное изображение (для обратной совместимости и превью)
|
||||
image = models.ImageField(upload_to='static/img/project/', blank=True, null=True, verbose_name="Главное изображение")
|
||||
thumbnail = models.ImageField(upload_to='static/img/project/thumbnails/', blank=True, null=True, verbose_name="Миниатюра")
|
||||
|
||||
# Видео (для обратной совместимости)
|
||||
video = models.FileField(upload_to='static/video/project/', blank=True, null=True, help_text='Видео презентация проекта')
|
||||
video_poster = models.ImageField(upload_to='static/img/project/posters/', blank=True, null=True, help_text='Превью изображение для видео проекта')
|
||||
status = models.CharField(max_length=50, choices=[('in_progress', 'В процессе'), ('completed', 'Завершен')], default='in_progress')
|
||||
|
||||
# Дополнительная информация о проекте
|
||||
project_url = models.URLField(blank=True, verbose_name="Ссылка на проект")
|
||||
github_url = models.URLField(blank=True, verbose_name="GitHub репозиторий")
|
||||
|
||||
# Технологии и инструменты
|
||||
technologies = models.TextField(blank=True, verbose_name="Технологии", help_text="Разделите запятыми")
|
||||
|
||||
# Метрики проекта
|
||||
duration = models.CharField(max_length=100, blank=True, verbose_name="Длительность")
|
||||
team_size = models.PositiveIntegerField(blank=True, null=True, verbose_name="Размер команды")
|
||||
|
||||
# Даты и статус
|
||||
completion_date = models.DateField(blank=True, null=True, verbose_name="Дата завершения")
|
||||
status = models.CharField(max_length=50, choices=STATUS_CHOICES, default='in_progress', verbose_name="Статус")
|
||||
|
||||
# Счетчики и метрики
|
||||
views_count = models.PositiveIntegerField(default=0, verbose_name='Количество просмотров')
|
||||
likes_count = models.PositiveIntegerField(default=0, verbose_name='Количество лайков')
|
||||
|
||||
# Настройки отображения
|
||||
is_featured = models.BooleanField(default=False, verbose_name="Избранный проект")
|
||||
display_order = models.PositiveIntegerField(default=0, verbose_name="Порядок отображения")
|
||||
|
||||
# SEO
|
||||
meta_title = models.CharField(max_length=200, blank=True, verbose_name="SEO заголовок")
|
||||
meta_description = models.TextField(max_length=300, blank=True, verbose_name="SEO описание")
|
||||
meta_keywords = models.CharField(max_length=200, blank=True, verbose_name="Ключевые слова")
|
||||
|
||||
# Timestamps
|
||||
created_at = models.DateTimeField(auto_now_add=True, null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Проект'
|
||||
verbose_name_plural = 'Проекты'
|
||||
ordering = ['-completion_date']
|
||||
ordering = ['-is_featured', '-display_order', '-completion_date']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('project_detail', kwargs={'pk': self.pk})
|
||||
|
||||
@property
|
||||
def technologies_list(self):
|
||||
"""Возвращает список технологий"""
|
||||
if self.technologies:
|
||||
return [tech.strip() for tech in self.technologies.split(',') if tech.strip()]
|
||||
return []
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
from django.utils.text import slugify
|
||||
self.slug = slugify(self.name)
|
||||
|
||||
# Автоматически создаем thumbnail из главного изображения
|
||||
if self.image and not self.thumbnail:
|
||||
self.thumbnail = self.image
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Ресайз thumbnail после сохранения
|
||||
if self.thumbnail:
|
||||
self._resize_thumbnail()
|
||||
|
||||
def _resize_thumbnail(self):
|
||||
"""Автоматический ресайз thumbnail до 600x400px"""
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
from django.core.files.base import ContentFile
|
||||
import os
|
||||
|
||||
if not self.thumbnail:
|
||||
return
|
||||
|
||||
try:
|
||||
# Открываем изображение
|
||||
img = Image.open(self.thumbnail.path)
|
||||
|
||||
# Конвертируем в RGB если нужно
|
||||
if img.mode not in ('RGB', 'RGBA'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Целевой размер
|
||||
target_width = 600
|
||||
target_height = 400
|
||||
|
||||
# Вычисляем соотношение сторон
|
||||
img_ratio = img.width / img.height
|
||||
target_ratio = target_width / target_height
|
||||
|
||||
# Обрезаем изображение по центру
|
||||
if img_ratio > target_ratio:
|
||||
# Изображение шире, обрезаем по ширине
|
||||
new_width = int(img.height * target_ratio)
|
||||
left = (img.width - new_width) // 2
|
||||
img = img.crop((left, 0, left + new_width, img.height))
|
||||
else:
|
||||
# Изображение выше, обрезаем по высоте
|
||||
new_height = int(img.width / target_ratio)
|
||||
top = (img.height - new_height) // 2
|
||||
img = img.crop((0, top, img.width, top + new_height))
|
||||
|
||||
# Ресайз до целевого размера
|
||||
img = img.resize((target_width, target_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# Сохраняем оптимизированное изображение
|
||||
img.save(self.thumbnail.path, quality=85, optimize=True)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при ресайзе thumbnail для проекта {self.name}: {e}")
|
||||
|
||||
|
||||
class ProjectMedia(models.Model):
|
||||
"""Медиа-файлы для проектов (множественные фото и видео)"""
|
||||
|
||||
MEDIA_TYPE_CHOICES = [
|
||||
('image', 'Изображение'),
|
||||
('video', 'Видео'),
|
||||
('embed_video', 'Встроенное видео (YouTube, Vimeo)'),
|
||||
]
|
||||
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='media_files', verbose_name="Проект")
|
||||
|
||||
media_type = models.CharField(max_length=20, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="Тип медиа")
|
||||
|
||||
# Для изображений
|
||||
image = models.ImageField(upload_to='static/img/project/gallery/', blank=True, null=True, verbose_name="Изображение")
|
||||
|
||||
# Для видео
|
||||
video = models.FileField(upload_to='static/video/project/gallery/', blank=True, null=True, verbose_name="Видео файл")
|
||||
video_poster = models.ImageField(upload_to='static/img/project/gallery/posters/', blank=True, null=True, verbose_name="Превью видео")
|
||||
|
||||
# Для встроенных видео
|
||||
embed_code = models.TextField(blank=True, verbose_name="Код встраивания (iframe)", help_text="Вставьте iframe код от YouTube или Vimeo")
|
||||
|
||||
# Описание и метаданные
|
||||
caption = models.CharField(max_length=200, blank=True, verbose_name="Подпись")
|
||||
alt_text = models.CharField(max_length=200, blank=True, verbose_name="Alt текст")
|
||||
|
||||
# Порядок отображения
|
||||
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
|
||||
|
||||
# Timestamps
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Медиа файл проекта'
|
||||
verbose_name_plural = 'Медиа файлы проектов'
|
||||
ordering = ['order', 'uploaded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_media_type_display()} для {self.project.name}"
|
||||
|
||||
class Review(models.Model):
|
||||
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='reviews')
|
||||
@@ -382,4 +566,3 @@ class Career(models.Model):
|
||||
if self.application_deadline and self.application_deadline < timezone.now().date():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
Reference in New Issue
Block a user