Files
2022_oknardia/oknardia/web/catalog_companies.py

531 lines
22 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.
# -*- 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)