Добавлена система проектов с автоматическим ресайзом изображений и адаптивным дизайном
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:
2025-11-26 09:44:14 +09:00
parent 5bcf3e8198
commit e7d6d5262d
26 changed files with 3029 additions and 447 deletions

View File

@@ -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