# -*- 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)