Files
2022_oknardia/oknardia/web/catalog_companies.py
T

531 lines
22 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_cookies,
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): Список компаний с статистикой
- LAST_VISIT (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,
'LAST_VISIT': get_last_user_visit_list(
get_last_user_visit_cookies(request)[:3]
),
'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): Стандартные маркеры "пусто"
- LAST_VISIT (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,
'LAST_VISIT': get_last_user_visit_list(
get_last_user_visit_cookies(request)[:3]
),
'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)