519 lines
21 KiB
Python
519 lines
21 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Каталог производителей и компаний.
|
||
|
||
Модуль предоставляет views для отображения:
|
||
1. Списка всех производителей с их ключевыми показателями (рейтинг, количество
|
||
предложений, среднюю цену и т.п.)
|
||
2. Детальную информацию о конкретном производителе со всеми его оконными наборами
|
||
|
||
Все запросы переведены на Django ORM для лучшей производительности и чистоты кода.
|
||
"""
|
||
from django.shortcuts import render, redirect
|
||
from django.http import HttpRequest, HttpResponse, Http404
|
||
from django.db.models import Count, Avg, Max, Min, DecimalField
|
||
from oknardia.models import (
|
||
MerchantBrand,
|
||
SetKit,
|
||
PriceOffer,
|
||
)
|
||
from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list
|
||
from web.add_func import get_rating_set_for_stars
|
||
import django.utils.dateformat
|
||
import time
|
||
import random
|
||
import re
|
||
import pytils
|
||
|
||
|
||
def _get_company_statistics() -> list:
|
||
"""
|
||
Получает список компаний (MerchantBrand) с агрегированной статистикой.
|
||
|
||
Статистика включает:
|
||
- Количество оконных наборов от компании
|
||
- Средний рейтинг наборов
|
||
- Количество ценовых предложений
|
||
- Среднюю цену предложений
|
||
- Дату последнего обновления цены
|
||
|
||
Оптимизировано для минимизации запросов к БД.
|
||
|
||
Returns:
|
||
list: Список словарей с данными компаний
|
||
"""
|
||
# 1. Статистика по наборам (SetKit) для каждой компании
|
||
set_stats = (
|
||
SetKit.objects
|
||
.filter(kSet2User__kMerchantOffice__kMerchantName__isnull=False)
|
||
.values('kSet2User__kMerchantOffice__kMerchantName_id')
|
||
.annotate(
|
||
num_sets=Count('id', distinct=True),
|
||
avg_rating=Avg('fSetRating')
|
||
)
|
||
)
|
||
set_stats_dict = {
|
||
stat['kSet2User__kMerchantOffice__kMerchantName_id']: {
|
||
'num_sets': stat['num_sets'],
|
||
'avg_rating': stat['avg_rating'] or 0
|
||
}
|
||
for stat in set_stats
|
||
}
|
||
|
||
# 2. Статистика по ценовым предложениям (PriceOffer)
|
||
companies_data = (
|
||
PriceOffer.objects
|
||
.filter(
|
||
sOfferActive=True,
|
||
kOfferFromUser__kMerchantOffice__kMerchantName__isnull=False
|
||
)
|
||
.values('kOfferFromUser__kMerchantOffice__kMerchantName_id')
|
||
.annotate(
|
||
num_offers=Count('id', distinct=True),
|
||
price_avg=Avg('fOfferPrice', output_field=DecimalField()),
|
||
last_update=Max('dOfferModify')
|
||
)
|
||
.order_by('-last_update')
|
||
)
|
||
|
||
# 3. Получаем все объекты MerchantBrand одним запросом (решение проблемы N+1)
|
||
company_ids = [
|
||
offer['kOfferFromUser__kMerchantOffice__kMerchantName_id']
|
||
for offer in companies_data
|
||
]
|
||
merchants = MerchantBrand.objects.in_bulk(company_ids)
|
||
|
||
# 4. Собираем финальный результат
|
||
result = []
|
||
for offer in companies_data:
|
||
company_id = offer['kOfferFromUser__kMerchantOffice__kMerchantName_id']
|
||
merchant = merchants.get(company_id)
|
||
|
||
if not merchant:
|
||
continue
|
||
|
||
set_stat = set_stats_dict.get(company_id, {
|
||
'num_sets': 0,
|
||
'avg_rating': 0
|
||
})
|
||
|
||
result.append({
|
||
'id': merchant.id,
|
||
'sMerchantName': merchant.sMerchantName,
|
||
'pMerchantLogo': merchant.pMerchantLogo,
|
||
'NumSets': set_stat['num_sets'],
|
||
'RatingAVG': set_stat['avg_rating'],
|
||
'NumOffers': offer['num_offers'],
|
||
'PriceAVG': offer['price_avg'],
|
||
'lastUpdate': offer['last_update']
|
||
})
|
||
|
||
# Сортируем по среднему рейтингу (убывание)
|
||
result.sort(key=lambda x: x['RatingAVG'], reverse=True)
|
||
|
||
return result
|
||
|
||
|
||
def _format_company_for_template(company_data: dict) -> dict:
|
||
"""
|
||
Форматирует данные компании для вывода в шаблон.
|
||
|
||
Применяет:
|
||
- Конвертацию времени в читаемый формат (e.g., "3 дня назад")
|
||
- Склонение существительных (plural forms)
|
||
- Вычисление звёзд рейтинга
|
||
- Скатывание имени в slug для URL
|
||
|
||
Args:
|
||
company_data (dict): Словарь с данными компании
|
||
|
||
Returns:
|
||
dict: Отформатированные данные компании
|
||
"""
|
||
formatted = company_data.copy()
|
||
|
||
# Вычисляем звёзды на основе рейтинга
|
||
formatted['STARS'] = get_rating_set_for_stars(
|
||
formatted['RatingAVG']
|
||
)
|
||
|
||
# Применяем правильные формы множественного числа
|
||
formatted['NumSets'] = pytils.numeral.get_plural(
|
||
formatted['NumSets'],
|
||
"оконный набор, оконных набора, оконных наборов"
|
||
)
|
||
formatted['NumOffers'] = pytils.numeral.get_plural(
|
||
formatted['NumOffers'],
|
||
"вариант, варианта, вариантов"
|
||
)
|
||
|
||
# Конвертируем время последнего обновления в читаемый формат
|
||
if formatted['lastUpdate']:
|
||
timestamp = int(
|
||
django.utils.dateformat.format(
|
||
formatted['lastUpdate'],
|
||
'U'
|
||
)
|
||
)
|
||
formatted['lastUpdate'] = pytils.dt.distance_of_time_in_words(
|
||
timestamp
|
||
)
|
||
|
||
# Генерируем slug из имени компании для URL
|
||
formatted['sMerchantMainURL'] = pytils.translit.slugify(
|
||
formatted['sMerchantName']
|
||
)
|
||
|
||
return formatted
|
||
|
||
|
||
def catalog_company(request: HttpRequest) -> HttpResponse:
|
||
"""
|
||
Показывает список всех производителей с ключевыми показателями.
|
||
|
||
GET параметры: опционально могут использоваться для фильтрации
|
||
|
||
Контекст шаблона:
|
||
- COMPANIES (list): Список компаний с статистикой
|
||
- LOG_VISIT (list): Последние визиты всех пользователей
|
||
|
||
Args:
|
||
request (HttpRequest): HTTP запрос от клиента
|
||
|
||
Returns:
|
||
HttpResponse: Отрендеренная HTML страница со списком компаний
|
||
"""
|
||
# Получаем статистику по компаниям с использованием ORM
|
||
companies_list = _get_company_statistics()
|
||
|
||
# Форматируем каждую компанию для вывода в шаблон
|
||
formatted_companies = [
|
||
_format_company_for_template(company)
|
||
for company in companies_list
|
||
]
|
||
|
||
# Получаем информацию о посещениях для персонализации
|
||
to_template: dict[str, object] = {
|
||
'COMPANIES': formatted_companies,
|
||
'LOG_VISIT': get_last_all_user_visit_list(),
|
||
}
|
||
|
||
return render(request, "catalog/catalog_company.html", to_template)
|
||
|
||
|
||
def _lowercase_first_char(text: str) -> str:
|
||
"""
|
||
Преобразует первый символ строки в нижний регистр.
|
||
|
||
Args:
|
||
text (str): Исходная строка
|
||
|
||
Returns:
|
||
str: Строка с строчным первым символом (если длина > 0)
|
||
"""
|
||
return text[0].lower() + text[1:] if len(text) > 0 else text
|
||
|
||
|
||
def _clean_text_field(text: str, empty_values: list) -> str:
|
||
"""
|
||
Очищает текстовое поле, удаляя типичные маркеры "пусто" и преобразуя
|
||
первый символ в нижний регистр.
|
||
|
||
Args:
|
||
text (str): Исходный текст
|
||
empty_values (list): Список значений, которые считаются "пустыми"
|
||
|
||
Returns:
|
||
str: Очищенный текст или пустая строка если значение в empty_values
|
||
"""
|
||
if text.lower() in empty_values:
|
||
return ""
|
||
return _lowercase_first_char(text)
|
||
|
||
|
||
def _get_company_sets_detail(company_id: int) -> list:
|
||
"""
|
||
Получает все оконные наборы для компании с полной статистикой по ценам.
|
||
|
||
Использует оптимизированные select_related и prefetch_related для минимизации
|
||
запросов к БД. Группирует данные по наборам (SetKit) с уникальностью.
|
||
|
||
Args:
|
||
company_id (int): ID компании (MerchantBrand)
|
||
|
||
Returns:
|
||
list: Список словарей с данными наборов, отсортированные по рейтингу
|
||
"""
|
||
# Получаем активные ценовые предложения для компаний с агрегацией по наборам
|
||
price_stats = (
|
||
PriceOffer.objects
|
||
.filter(
|
||
sOfferActive=True,
|
||
kOfferFromUser__kMerchantOffice__kMerchantName_id=company_id
|
||
)
|
||
.values('kOffer2SetKit_id')
|
||
.annotate(
|
||
num_offers=Count('id'),
|
||
price_avg=Avg('fOfferPrice', output_field=DecimalField()),
|
||
last_update=Max('dOfferModify'),
|
||
early_creation=Min('dOfferCreate')
|
||
)
|
||
)
|
||
|
||
# Преобразуем в словарь для быстрого доступа по ID набора
|
||
price_stats_dict = {
|
||
stat['kOffer2SetKit_id']: {
|
||
'num_offers': stat['num_offers'],
|
||
'price_avg': stat['price_avg'],
|
||
'last_update': stat['last_update'],
|
||
'early_creation': stat['early_creation']
|
||
}
|
||
for stat in price_stats
|
||
}
|
||
|
||
# Получаем все наборы компании с их зависимостями
|
||
# select_related оптимизирует ForeignKey запросы (профиль, стеклопакет)
|
||
sets_queryset = (
|
||
SetKit.objects
|
||
.filter(
|
||
kSet2User__kMerchantOffice__kMerchantName_id=company_id
|
||
)
|
||
.select_related(
|
||
'kSet2User',
|
||
'kSet2User__kMerchantOffice',
|
||
'kSet2User__kMerchantOffice__kMerchantName',
|
||
'kSet2PVCprofiles',
|
||
'kSet2Glazing'
|
||
)
|
||
.order_by('-fSetRating')
|
||
)
|
||
|
||
# Собираем результат, комбинируя данные SetKit с агрегированной статистикой
|
||
result = []
|
||
seen_set_ids = set()
|
||
|
||
for setkit in sets_queryset:
|
||
# Пропускаем дубликаты наборов (может быть несколько ценовых предложений
|
||
# для одного набора)
|
||
if setkit.id in seen_set_ids:
|
||
continue
|
||
seen_set_ids.add(setkit.id)
|
||
|
||
# Получаем статистику по ценам для этого набора
|
||
price_stat = price_stats_dict.get(setkit.id, {
|
||
'num_offers': 0,
|
||
'price_avg': None,
|
||
'last_update': None,
|
||
'early_creation': None
|
||
})
|
||
|
||
# Собираем все данные в один объект
|
||
result.append({
|
||
'setkit': setkit,
|
||
'num_offers': price_stat['num_offers'],
|
||
'price_avg': price_stat['price_avg'],
|
||
'last_update': price_stat['last_update'],
|
||
'early_creation': price_stat['early_creation'],
|
||
'merchant_office': setkit.kSet2User.kMerchantOffice,
|
||
'merchant_brand': setkit.kSet2User.kMerchantOffice.kMerchantName,
|
||
'profile': setkit.kSet2PVCprofiles,
|
||
'glazing': setkit.kSet2Glazing
|
||
})
|
||
|
||
return result
|
||
|
||
|
||
def _format_set_for_template(set_data: dict, empty_values: list) -> dict:
|
||
"""
|
||
Форматирует данные оконного набора для вывода в шаблон.
|
||
|
||
Применяет:
|
||
- Преобразование URL в удобный для отображения формат
|
||
- Разделение email адресов на части (для обфускации)
|
||
- Вычисление звёзд рейтинга
|
||
- Конвертация времени в читаемый формат
|
||
- Создание slugs для названий и производителей
|
||
- Склонение числительных(контуры, швы и т.п.)
|
||
- Очистку пустых полей от стандартных маркеров ("нет", "—" и т.п.)
|
||
|
||
Args:
|
||
set_data (dict): Данные набора с объектами моделей
|
||
empty_values (list): Список значений, считаемых "пустыми"
|
||
|
||
Returns:
|
||
dict: Отформатированные данные для шаблона
|
||
"""
|
||
set_kit = set_data['setkit']
|
||
merchant_office = set_data['merchant_office']
|
||
merchant_brand = set_data['merchant_brand']
|
||
profile = set_data['profile']
|
||
glazing = set_data['glazing']
|
||
|
||
formatted = {
|
||
# Ключи ниже оставлены в legacy-формате, т.к. шаблон использует именно их имена.
|
||
'idSetKit': set_kit.id,
|
||
'sSetName': set_kit.sSetName,
|
||
'sMerchantName': merchant_brand.sMerchantName,
|
||
'sMerchantDescription': merchant_brand.sMerchantDescription,
|
||
'fSetRating': {
|
||
'RATING': set_kit.fSetRating,
|
||
'STARS': get_rating_set_for_stars(set_kit.fSetRating)
|
||
},
|
||
'num_offers': set_data['num_offers'],
|
||
'price_avg': set_data['price_avg'],
|
||
'bSetDelivery': set_kit.bSetDelivery,
|
||
'bSetUninstallInstall': set_kit.bSetUninstallInstall,
|
||
'sSetImplementAll': set_kit.sSetImplementAll,
|
||
'sSetImplementHandles': set_kit.sSetImplementHandles,
|
||
'sMerchantMainURL': {
|
||
'URL': merchant_office.kMerchantName.sMerchantMainURL,
|
||
'URL_VIEW': re.sub(
|
||
r"^https?://|/$|www\.",
|
||
"",
|
||
merchant_office.kMerchantName.sMerchantMainURL
|
||
)
|
||
},
|
||
'sOfficePhones': merchant_office.sOfficePhones,
|
||
'sOfficeDescription': merchant_office.sOfficeDescription,
|
||
'sOfficeEmails': merchant_office.sOfficeEmails,
|
||
'sOfficeName': merchant_office.sOfficeName,
|
||
'sOfficeAddress': merchant_office.sOfficeAddress,
|
||
'fOfficeGeoCode_Latitude': merchant_office.fOfficeGeoCode_Latitude,
|
||
'fOfficeGeoCode_Longitude': merchant_office.fOfficeGeoCode_Longitude,
|
||
'sOfficeDiscountMetaFormula': merchant_office.sOfficeDiscountMetaFormula,
|
||
'pMerchantLogo': merchant_office.kMerchantName.pMerchantLogo,
|
||
'idPVC': profile.id,
|
||
'sProfileBriefDescription': profile.sProfileBriefDescription,
|
||
'iProfileCameras': profile.iProfileCameras,
|
||
'sProfileName': {
|
||
'NAME': profile.sProfileName,
|
||
'NAME_T': pytils.translit.slugify(profile.sProfileName)
|
||
},
|
||
'sProfileManufacturer': {
|
||
'NAME': profile.sProfileManufacturer,
|
||
'NAME_T': pytils.translit.slugify(profile.sProfileManufacturer)
|
||
},
|
||
'sProfileColor': profile.sProfileColor,
|
||
'sProfileSealDescription': profile.sProfileSealDescription,
|
||
'fProfileSeals': pytils.numeral.sum_string(
|
||
profile.fProfileSeals,
|
||
pytils.numeral.MALE,
|
||
"контур, контура, контуров"
|
||
),
|
||
'sGlazingBriefDescription': glazing.sGlazingBriefDescription,
|
||
'sGlazingManufacturer': glazing.sGlazingManufacturer,
|
||
'sGlazingMark': glazing.sGlazingMark,
|
||
'sGlazingToning': glazing.sGlazingToning,
|
||
'sSetImplementCatch': _clean_text_field(set_kit.sSetImplementCatch, empty_values),
|
||
'sSetClimateControl': _clean_text_field(set_kit.sSetClimateControl, empty_values),
|
||
'sProfileReinforcement': _lowercase_first_char(profile.sProfileReinforcement),
|
||
'sSetSill': _lowercase_first_char(set_kit.sSetSill),
|
||
'sSetPanes': _lowercase_first_char(set_kit.sSetPanes),
|
||
'sSetSlope': _lowercase_first_char(set_kit.sSetSlope),
|
||
'sSetUninstallInstall': _lowercase_first_char(set_kit.sSetUninstallInstall),
|
||
'sSetDelivery': _lowercase_first_char(set_kit.sSetDelivery),
|
||
'sSetOtherConditions': _lowercase_first_char(set_kit.sSetOtherConditions),
|
||
}
|
||
|
||
# Конвертируем даты в читаемый формат
|
||
if set_data['last_update']:
|
||
timestamp = int(django.utils.dateformat.format(set_data['last_update'], 'U'))
|
||
formatted['lastUpdate'] = pytils.dt.distance_of_time_in_words(timestamp)
|
||
|
||
if set_data['early_creation']:
|
||
timestamp = int(django.utils.dateformat.format(set_data['early_creation'],'U'))
|
||
formatted['earlyCreation'] = pytils.dt.distance_of_time_in_words(timestamp)
|
||
|
||
# Разделяем email на части для обфускации (показываем середину отдельно)
|
||
# На фронтенде JS собирает все обратно в валидный e-mail
|
||
if formatted['sOfficeEmails']:
|
||
try:
|
||
email_len = len(formatted['sOfficeEmails'])
|
||
k = random.randint(1, max(1, int(email_len / 2) - 1))
|
||
formatted['sOfficeEmails'] = [
|
||
formatted['sOfficeEmails'][0:k],
|
||
formatted['sOfficeEmails'][k:-k],
|
||
formatted['sOfficeEmails'][-k:]
|
||
]
|
||
except (ValueError, ZeroDivisionError):
|
||
# Если ошибка при случайном разделении, оставляем как есть
|
||
pass
|
||
|
||
return formatted
|
||
|
||
|
||
def catalog_company_detail(
|
||
request: HttpRequest,
|
||
company_id: str,
|
||
company_name_slug: str
|
||
) -> HttpResponse:
|
||
"""
|
||
Показывает детальную информацию о компании и все её оконные наборы.
|
||
|
||
Производит редирект если slug в URL не совпадает с актуальным.
|
||
|
||
GET параметры: опционально могут использоваться для фильтрации
|
||
|
||
Контекст шаблона:
|
||
- COMPANY (str): Название компании
|
||
- COMPANY_ID (int): ID компании
|
||
- COMPANY_T (str): Slug компании
|
||
- SETS (list): Список оконных наборов с их полной информацией
|
||
- IMG_FOR_BLOG (str): Логотип компании
|
||
- LIST_NOT (list): Стандартные маркеры "пусто"
|
||
- LOG_VISIT (list): Последние визиты всех пользователей
|
||
- ticks (float): Время выполнения представления (в секундах)
|
||
|
||
Args:
|
||
request (HttpRequest): HTTP запрос от клиента
|
||
company_id (str): ID компании в виде строки
|
||
company_name_slug (str): Slug названия компании из URL
|
||
|
||
Returns:
|
||
HttpResponse: Отрендеренная HTML страница с деталью компании или редирект
|
||
"""
|
||
time_start = time.perf_counter()
|
||
company_id_int = int(company_id)
|
||
|
||
# Получаем компанию или возвращаем 404
|
||
try:
|
||
company = MerchantBrand.objects.get(id=company_id_int)
|
||
except MerchantBrand.DoesNotExist:
|
||
raise Http404("Компания не найдена")
|
||
|
||
# Проверяем что slug совпадает (для SEO и красивых URL)
|
||
actual_slug = pytils.translit.slugify(company.sMerchantName)
|
||
if actual_slug != company_name_slug:
|
||
return redirect(
|
||
f'/catalog/company/{company_id_int}-{actual_slug}'
|
||
)
|
||
|
||
# Типичные маркеры, которые означают что поле пусто
|
||
empty_values = ["нет", "—", ""]
|
||
|
||
# Получаем все наборы компании с ценовой статистикой
|
||
sets_list = _get_company_sets_detail(company_id_int)
|
||
|
||
# Форматируем каждый набор для вывода в шаблон
|
||
formatted_sets = [
|
||
_format_set_for_template(set_data, empty_values)
|
||
for set_data in sets_list
|
||
]
|
||
|
||
to_template: dict[str, object] = {
|
||
'COMPANY': company.sMerchantName,
|
||
'COMPANY_ID': company_id_int,
|
||
'COMPANY_T': company_name_slug,
|
||
'SETS': formatted_sets,
|
||
'HEADER': f'Изготовитель окон «{company.sMerchantName}»',
|
||
'META_KEYWORDS': company.sMerchantName,
|
||
'IMG_FOR_BLOG': company.pMerchantLogo,
|
||
'LIST_NOT': empty_values,
|
||
'LOG_VISIT': get_last_all_user_visit_list(),
|
||
}
|
||
|
||
# Добавляем метрику выполнения представления
|
||
to_template['ticks'] = float(time.perf_counter() - time_start)
|
||
|
||
return render(request, "catalog/catalog_company_detail.html", to_template)
|