Files
smartsoltech_site/smartsoltech/web/models.py
Andrew K. Choi e7d6d5262d
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 с отображением всех медиа
2025-11-26 09:44:14 +09:00

569 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
class ContactInfo(models.Model):
"""Модель для контактной информации компании"""
company_name = models.CharField(max_length=200, default="SmartSolTech", verbose_name="Название компании")
email = models.EmailField(default="info@smartsoltech.kr", verbose_name="Email")
phone = models.CharField(max_length=20, default="+82-10-5693-6103", verbose_name="Телефон")
telegram = models.CharField(max_length=100, default="@smartsoltech", verbose_name="Telegram")
address = models.TextField(default="Чолланамдо, Кванджу", verbose_name="Адрес")
working_hours = models.CharField(max_length=100, default="Пн-Пт 9:00-18:00", verbose_name="Часы работы")
description = models.TextField(default="Мы - команда профессионалов в сфере IT-решений", verbose_name="Описание")
call_to_action = models.CharField(max_length=200, default="Начнем сотрудничество?", verbose_name="Призыв к действию")
subtitle = models.CharField(max_length=200, default="Свяжитесь с нами для обсуждения вашего проекта", verbose_name="Подзаголовок")
is_active = models.BooleanField(default=True, verbose_name="Активно")
class Meta:
verbose_name = 'Контактная информация'
verbose_name_plural = 'Контактная информация'
def __str__(self):
return f"Контакты - {self.company_name}"
@classmethod
def get_active(cls):
"""Получить активную контактную информацию"""
return cls.objects.filter(is_active=True).first() or cls.objects.create()
class HeroBanner(models.Model):
"""Модель для главного баннера на сайте"""
title = models.CharField(max_length=200, verbose_name="Заголовок")
subtitle = models.TextField(blank=True, verbose_name="Подзаголовок")
description = models.TextField(blank=True, verbose_name="Описание")
image = models.ImageField(upload_to='static/img/hero/', blank=True, null=True, verbose_name="Фоновое изображение")
video = models.FileField(upload_to='static/video/hero/', blank=True, null=True,
help_text='Фоновое видео для баннера (MP4, WebM)', verbose_name="Фоновое видео")
video_poster = models.ImageField(upload_to='static/img/hero/posters/', blank=True, null=True,
help_text='Превью изображение для видео', verbose_name="Превью видео")
button_text = models.CharField(max_length=100, blank=True, verbose_name="Текст кнопки")
button_link = models.URLField(blank=True, verbose_name="Ссылка кнопки")
is_active = models.BooleanField(default=True, verbose_name="Активен")
order = models.PositiveIntegerField(default=0, verbose_name="Порядок отображения")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = 'Hero Баннер'
verbose_name_plural = 'Hero Баннеры'
ordering = ['order', '-created_at']
def __str__(self):
return self.title
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 = ['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)
description = models.TextField(default='Описание услуги')
price = models.DecimalField(max_digits=10, decimal_places=2)
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, related_name='services')
image = models.ImageField(upload_to='static/img/services/', blank=True, null=True)
video = models.FileField(upload_to='static/video/services/', blank=True, null=True, help_text='Видео файл для услуги (MP4, WebM, AVI)')
video_poster = models.ImageField(upload_to='static/img/services/posters/', blank=True, null=True, help_text='Превью изображение для видео')
video = models.FileField(upload_to='static/video/services/', blank=True, null=True, help_text='Видео файл для услуги (MP4, WebM, AVI)')
video_poster = models.ImageField(upload_to='static/img/services/posters/', blank=True, null=True, help_text='Превью изображение для видео')
class Meta:
verbose_name = 'Услуга'
verbose_name_plural = 'Услуги'
ordering = ['name']
def __str__(self):
return self.name
def average_rating(self):
reviews = self.reviews.all()
if reviews:
return sum(review.rating for review in reviews) / reviews.count()
return 0
def review_count(self):
return self.reviews.count()
class Client(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='client_profile', null=True, blank=True)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField(unique=True)
phone_number = models.CharField(max_length=15, unique=True)
image = models.ImageField(upload_to='static/img/customer/', blank=True, null=True)
chat_id = models.CharField(max_length=100, blank=True, null=True) # Telegram chat ID
class Meta:
verbose_name = 'Клиент'
verbose_name_plural = 'Клиенты'
ordering = ['last_name', 'first_name']
def __str__(self):
return f"{self.first_name} {self.last_name} {self.chat_id}"
class BlogPost(models.Model):
title = models.CharField(max_length=200)
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='Видео файл для блог поста')
video_poster = models.ImageField(upload_to='static/img/blog/posters/', blank=True, null=True, help_text='Превью изображение для видео')
class Meta:
verbose_name = 'Блог'
verbose_name_plural = 'Блоги'
ordering = ['-published_date']
def __str__(self):
return self.title
class ServiceRequest(models.Model):
service = models.ForeignKey(Service, on_delete=models.CASCADE)
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='related_service_requests', null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
token = models.UUIDField(default=uuid.uuid4, unique=True) # Генерация уникального токена
chat_id = models.CharField(max_length=100, blank=True, null=True) # Telegram chat ID
is_verified = models.BooleanField(default=False)
class Meta:
verbose_name = 'Заявка на услугу'
verbose_name_plural = 'Заявки на услуги'
ordering = ['-is_verified', '-created_at']
def __str__(self):
return f"Request for {self.service.name} by {self.client.first_name}"
class Order(models.Model):
service_request = models.OneToOneField(ServiceRequest, on_delete=models.CASCADE, related_name='related_order')
client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='related_orders')
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='related_orders')
message = models.TextField(blank=True, null=True)
order_date = models.DateTimeField(auto_now_add=True)
status = models.CharField(
max_length=50,
choices=[
('pending', 'Ожидание'),
('in_progress', 'В процессе'),
('completed', 'Завершен'),
('cancelled', 'Отменён')
],
default='pending'
)
class Meta:
ordering = ['-order_date']
verbose_name = 'Заказ'
verbose_name_plural = 'Заказы'
def __str__(self):
return f"Order #{self.id} by {self.client.first_name}"
def is_completed(self):
return self.status == 'completed'
def get_absolute_url(self):
return reverse('order_detail', kwargs={'pk': self.pk})
# ПРОЕКТЫ
# ============================================
class Project(models.Model):
"""Расширенная модель проекта с множественными категориями и медиа"""
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='Превью изображение для видео проекта')
# Дополнительная информация о проекте
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 = ['-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')
service = models.ForeignKey(Service, on_delete=models.CASCADE, related_name='reviews')
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='reviews', blank=True, null=True)
rating = models.IntegerField()
comment = models.TextField()
review_date = models.DateTimeField(auto_now_add=True)
image = models.ImageField(upload_to='static/img/review/', blank=True, null=True)
video = models.FileField(upload_to='static/video/review/', blank=True, null=True, help_text='Видео отзыв о работе')
video_poster = models.ImageField(upload_to='static/img/review/posters/', blank=True, null=True, help_text='Превью для видео отзыва')
class Meta:
verbose_name = 'Отзыв'
verbose_name_plural = 'Отзывы'
ordering = ['-review_date']
def __str__(self):
return f"Отзыв от {self.client.first_name} {self.client.last_name} for {self.service.name}"
class Team(models.Model):
"""Модель для управления персоналом компании"""
first_name = models.CharField(max_length=100, verbose_name="Имя")
last_name = models.CharField(max_length=100, verbose_name="Фамилия")
position = models.CharField(max_length=200, verbose_name="Должность")
department = models.CharField(max_length=100, verbose_name="Отдел", blank=True)
bio = models.TextField(verbose_name="Биография/Описание", blank=True)
photo = models.ImageField(upload_to='static/img/team/', blank=True, null=True, verbose_name="Фотография")
email = models.EmailField(blank=True, verbose_name="Email")
phone = models.CharField(max_length=20, blank=True, verbose_name="Телефон")
# Социальные сети
linkedin = models.URLField(blank=True, verbose_name="LinkedIn")
github = models.URLField(blank=True, verbose_name="GitHub")
telegram = models.CharField(max_length=100, blank=True, verbose_name="Telegram")
# Навыки и технологии
skills = models.TextField(blank=True, verbose_name="Навыки", help_text="Разделите навыки запятыми")
experience_years = models.PositiveIntegerField(default=0, verbose_name="Лет опыта")
# Настройки отображения
is_active = models.BooleanField(default=True, verbose_name="Активен")
display_order = models.PositiveIntegerField(default=0, verbose_name="Порядок отображения")
show_on_about = models.BooleanField(default=True, verbose_name="Показывать на странице О нас")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = 'Сотрудник'
verbose_name_plural = 'Команда'
ordering = ['display_order', 'last_name', 'first_name']
def __str__(self):
return f"{self.first_name} {self.last_name} - {self.position}"
@property
def full_name(self):
return f"{self.first_name} {self.last_name}"
@property
def skills_list(self):
"""Возвращает список навыков"""
if self.skills:
return [skill.strip() for skill in self.skills.split(',') if skill.strip()]
return []
class Career(models.Model):
"""Модель для управления вакансиями и карьерными возможностями"""
EMPLOYMENT_TYPE_CHOICES = [
('full_time', 'Полная занятость'),
('part_time', 'Частичная занятость'),
('contract', 'Контракт'),
('internship', 'Стажировка'),
('remote', 'Удаленная работа'),
('freelance', 'Фриланс'),
]
EXPERIENCE_LEVEL_CHOICES = [
('junior', 'Junior (0-1 год)'),
('middle', 'Middle (2-4 года)'),
('senior', 'Senior (5+ лет)'),
('lead', 'Team Lead'),
('intern', 'Стажер'),
]
STATUS_CHOICES = [
('active', 'Активная'),
('paused', 'Приостановлена'),
('closed', 'Закрыта'),
('draft', 'Черновик'),
]
title = models.CharField(max_length=200, verbose_name="Название вакансии")
department = models.CharField(max_length=100, verbose_name="Отдел")
location = models.CharField(max_length=200, default="Кванджу, Южная Корея", verbose_name="Местоположение")
employment_type = models.CharField(
max_length=20,
choices=EMPLOYMENT_TYPE_CHOICES,
default='full_time',
verbose_name="Тип занятости"
)
experience_level = models.CharField(
max_length=20,
choices=EXPERIENCE_LEVEL_CHOICES,
default='middle',
verbose_name="Уровень опыта"
)
# Описание вакансии
description = models.TextField(verbose_name="Описание вакансии")
responsibilities = models.TextField(verbose_name="Обязанности")
requirements = models.TextField(verbose_name="Требования")
benefits = models.TextField(blank=True, verbose_name="Преимущества и условия")
# Зарплатная вилка
salary_min = models.PositiveIntegerField(blank=True, null=True, verbose_name="Зарплата от (₩)")
salary_max = models.PositiveIntegerField(blank=True, null=True, verbose_name="Зарплата до (₩)")
salary_currency = models.CharField(max_length=10, default="KRW", verbose_name="Валюта")
# Необходимые навыки
required_skills = models.TextField(verbose_name="Обязательные навыки", help_text="Разделите навыки запятыми")
preferred_skills = models.TextField(blank=True, verbose_name="Желательные навыки", help_text="Разделите навыки запятыми")
# Статус и метаданные
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='active',
verbose_name="Статус"
)
is_featured = models.BooleanField(default=False, verbose_name="Рекомендуемая вакансия")
application_deadline = models.DateField(blank=True, null=True, verbose_name="Дедлайн подачи заявок")
# Контактная информация
contact_email = models.EmailField(default="hr@smartsoltech.kr", verbose_name="Email для связи")
contact_person = models.CharField(max_length=200, blank=True, verbose_name="Контактное лицо")
# Timestamps
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
published_at = models.DateTimeField(blank=True, null=True, verbose_name="Дата публикации")
class Meta:
verbose_name = 'Вакансия'
verbose_name_plural = 'Карьера'
ordering = ['-is_featured', '-published_at', '-created_at']
def __str__(self):
return f"{self.title} ({self.get_experience_level_display()})"
@property
def required_skills_list(self):
"""Возвращает список обязательных навыков"""
if self.required_skills:
return [skill.strip() for skill in self.required_skills.split(',') if skill.strip()]
return []
@property
def preferred_skills_list(self):
"""Возвращает список желательных навыков"""
if self.preferred_skills:
return [skill.strip() for skill in self.preferred_skills.split(',') if skill.strip()]
return []
@property
def salary_range(self):
"""Возвращает строку с зарплатной вилкой"""
if self.salary_min and self.salary_max:
return f"{self.salary_min:,} - ₩{self.salary_max:,}"
elif self.salary_min:
return f"от ₩{self.salary_min:,}"
elif self.salary_max:
return f"до ₩{self.salary_max:,}"
return "По договоренности"
def is_active_position(self):
"""Проверяет, активна ли вакансия"""
from django.utils import timezone
if self.status != 'active':
return False
if self.application_deadline and self.application_deadline < timezone.now().date():
return False
return True