diff --git a/README.md b/README.md index 5807fbb..9463d08 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,7 @@ * Рефакторинг `catalog_seria_info` и связанных функций в `catalog_series.py`: raw SQL ⟶ ORM (`catalog_seria_info`, `seria_nav`, `seria_info_year`, `seria_info_geo_code`), снижена нагрузка на БД за счёт предвыборки и переиспользования агрегатов (`quantities_by_pair`, `offers_by_window`), добавлены безопасные fallback-значения для пустых выборок, включена потоковая обработка `iterator(chunk_size=500)` для гео-данных, обновлены комментарии и docstring под фактическую логику (таблица окон, pre-render light/heavy шаблонов, гео+статистика серии). * Добавлена management-команда `regenerate_seria_prerender` для оффлайн-пересборки pre-render шаблонов `catalog_seria_info` (все или выбранные root-серии), с режимами `--dry-run` и `--force`; серверный reload (Gunicon? uWSGI или что там еще будет) должен быть вынесен из кода приложения в оркестрацию (cron/systemd/deploy step). * Рефакторинг `standard_opening`: raw SQL -> ORM, упрощена дедублекация, убраны лишние запросы и переменные контекста, добавлены комментарии, SEO-описание и keywords, стандартизирован хвост контекста с визитами и `ticks` через общий helper внутри `catalog_openings.py`. -* -* +* Рефакторинг `catalog_company` и `catalog_company_detail` (`/catalog/company`): raw SQL → ORM для получения списка компаний и их наборов, вынесены вспомогательные функции (`_get_company_statistics`, `_get_company_sets_detail`, `_format_company_for_template`, `_format_set_for_template`, `_clean_text_field`, `_lowercase_first_char`), упрощена логика форматирования данных, добавлены подробные комментарии и docstring для каждой функции, использованы `select_related` и `annotate` для оптимизации запросов, добавлена защита от `Http404` при неправильных slugs. Улучшены SEO-атрибуты, и добавлена разметка shema.org. * * diff --git a/oknardia/templates/catalog/catalog_company.html b/oknardia/templates/catalog/catalog_company.html index 42caf0d..6f9f10a 100755 --- a/oknardia/templates/catalog/catalog_company.html +++ b/oknardia/templates/catalog/catalog_company.html @@ -1,58 +1,81 @@ {% extends "base.html" %} {% load static %}{% load filters %} -{% block Title %}Каталог изготовителей и поставщиков окон{% endblock %} +{% block Title %}Каталог оконных компаний: производители и поставщики окон, рейтинг и цены{% endblock %} {% block Add_Body_Attribute %} style="padding-top:70px;"{% endblock %} -{% block Description %}Каталог изготовителей окон, партнёры «Окнардия», рейтинг, {% for i in COMPANIES %}{{ i.sMerchantName }}, {% endfor %} средняя цена окна{% endblock %} +{% block Description %}Актуальный каталог оконных компаний России. Сравните производителей и поставщиков пластиковых окон по рейтингу, ассортименту, средней цене и дате последнего обновления.{% endblock %} -{% block Keywords %}Оконные компании, {% for i in COMPANIES %}{{ i.sMerchantName }}, {% endfor %} изготовители окон, производители окон, постащики окон, партнёры, каталог компаний, каталог оконных компаний, oknardia, окнардия {{ META_KEYWORDS|default:"" }} {% endblock %} +{% block Keywords %}оконные компании, каталог компаний, производители окон, поставщики окон, рейтинг оконных компаний, сравнить цены на окна, oknardia, окнардия{% endblock %} -{% block Date4Meta %}{% if PUB_DAT %}{{ PUB_DAT|date:"c" }}{% else %}{% now "c" %}{% endif %}{% endblock %} +{% block Author4Meta %}: Каталог «Окнардия»{% endblock %} -{% block Last4Meta %}{% if PUB_DAT %}{{ PUB_DAT|date:"c" }}{% else %}{% now "c" %}{% endif %}{% endblock %} - -{% block Author4Meta %}: Каталог изготовителей окон{% endblock %} - -{% block CopyrightAuthor4Meta %}: Каталог изготовителей окон{% endblock %} +{% block CopyrightAuthor4Meta %}: Каталог «Окнардия»{% endblock %} {% block Top_Meta1 %}{# #} - {% if IMG_FOR_BLOG %} - {% else %} - {% endif %} - - - - - - - - - - - - + - - - - + + + + - - + + - - - + {# Удалить: — устаревший тег #} + + {# #}{% endblock %} +{% block ADD_TO_HEAD %}{% comment %} +JSON-LD для страницы-списка компаний: CollectionPage + ItemList с элементами Organization. +Это понятнее для поисковиков, чем legacy microdata на метатегах. +{% endcomment %} + +{% endblock %} + + {% block Main_Content %}
{# #}
@@ -90,7 +113,3 @@ {% include "report/report_log_user_visit.html" %}
{% endblock %} - - - - diff --git a/oknardia/templates/catalog/catalog_company_detail.html b/oknardia/templates/catalog/catalog_company_detail.html index 03b251b..f7d4308 100755 --- a/oknardia/templates/catalog/catalog_company_detail.html +++ b/oknardia/templates/catalog/catalog_company_detail.html @@ -5,30 +5,20 @@ {% block Add_Body_Attribute %} style="padding-top:70px;"{% endblock %} -{% block Description %}«{{ COMPANY }}», описание компании «{{ COMPANY }}», оконные наборы от «{{ COMPANY }}» и их состав, характеристики «{{ COMPANY }}», рейтинг «{{ COMPANY }}», средние цены и отклонение цен «{{ COMPANY }}».{% endblock %} +{% block Description %}Производитель окон «{{ COMPANY }}» в каталоге Окнардии: оконные наборы, их состав и характеристики, независимый рейтинг качества, средние цены на замену оконных конструкций в типовых домах.{% endblock %} -{% block Keywords %}{{ COMPANY }}, компания {{ COMPANY }}, окна {{ COMPANY }}, изготовитель окон {{ COMPANY }}, производитель окон {{ COMPANY }}, поставщик окон {{ COMPANY }}, партнёр, каталог компаний, каталог оконных компаний, oknardia, окнардия {{ META_KEYWORDS|default:"" }} {% endblock %} +{% block Keywords %}{{ COMPANY }}, компания {{ COMPANY }}, окна {{ COMPANY }}, изготовитель окон {{ COMPANY }}, производитель окон {{ COMPANY }}, поставщик окон {{ COMPANY }}, партнёр, каталог компаний, каталог оконных компаний, oknardia, окнардия{% endblock %} -{% block Date4Meta %}{% if PUB_DAT %}{{ PUB_DAT|date:"c" }}{% else %}{% now "c" %}{% endif %}{% endblock %} -{% block Last4Meta %}{% if PUB_DAT %}{{ PUB_DAT|date:"c" }}{% else %}{% now "c" %}{% endif %}{% endblock %} +{% block Author4Meta %}Каталог изготовителей окон{% endblock %} -{% block Author4Meta %}: Каталог изготовителей окон{% endblock %} - -{% block CopyrightAuthor4Meta %}: Каталог изготовителей окон{% endblock %} +{% block CopyrightAuthor4Meta %}Каталог изготовителей окон{% endblock %} {% block Top_Meta1 %}{# #} - {% if IMG_FOR_BLOG %} - {% else %} - {% endif %} - - - - - + {# Microdata (itemprop) убрана — заменена на JSON-LD в блоке ADD_TO_HEAD ниже (чище, надёжнее) #} - + {# Удалить: — тег Google News 2011 г., отменён в 2014, поисковики игнорируют #} @@ -36,24 +26,59 @@ - + - + - - + + - + {# Удалить: — устарело с 2015, Twitter его не использует #} {# #}{% endblock %} +{% block Top_JS5 %} + {% endblock %} -{% block Top_JS5 %}{% endblock %} +{% block ADD_TO_HEAD %}{% comment %} + JSON-LD разметка Schema.org для страницы производителя окон. + Тип LocalBusiness описывает компанию-поставщика окон: название, контакты, адрес, геокоординаты, + логотип и ссылку на официальный сайт производителя. + Данные берутся из первого набора в SETS (все наборы принадлежат одному офису/бренду), + поэтому достаточно SETS.0 для контактной информации. + Документация: https://schema.org/LocalBusiness #}{% endcomment %} + +{% endblock %} {% block Main_Content %} diff --git a/oknardia/web/catalog_companies.py b/oknardia/web/catalog_companies.py index 7cc9d62..882ba12 100644 --- a/oknardia/web/catalog_companies.py +++ b/oknardia/web/catalog_companies.py @@ -1,11 +1,27 @@ # -*- coding: utf-8 -*- +""" +Каталог производителей и компаний. + +Модуль предоставляет views для отображения: +1. Списка всех производителей с их ключевыми показателями (рейтинг, количество + предложений, среднюю цену и т.п.) +2. Детальную информацию о конкретном производителе со всеми его оконными наборами + +Все запросы переведены на Django ORM для лучшей производительности и чистоты кода. +""" from django.shortcuts import render, redirect -from django.http import HttpRequest, HttpResponse -from django.utils import timezone +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.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 @@ -14,148 +30,500 @@ 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: - time_start = time.perf_counter() - to_template: dict[str, object] = {} # словарь, для передачи шаблону - q_company = MerchantBrand.objects.raw('SELECT' - ' oknardia_merchantbrand.id,' - ' oknardia_merchantbrand.sMerchantName,' - ' oknardia_merchantbrand.pMerchantLogo,' - ' oknardia_merchantbrand.sMerchantMainURL,' - ' COUNT(oknardia_priceoffer.id) AS NumOffers,' - ' AVG(oknardia_priceoffer.fOfferPrice) AS PriceAVG,' - ' MAX(oknardia_priceoffer.dOfferModify) AS lastUpdate,' - ' Q.NumSets,' - ' Q.RatingAVG,' - ' 1 AS STARS ' - 'FROM (SELECT' - ' COUNT(oknardia_setkit.sSetName) AS NumSets,' - ' oknardia_merchantoffice.kMerchantName_id AS Q_ID,' - ' AVG(oknardia_setkit.fSetRating) AS RatingAVG' - ' FROM oknardia_merchantoffice' - ' INNER JOIN oknardia_ouruser' - ' ON oknardia_ouruser.kMerchantOffice_id = oknardia_merchantoffice.id' - ' INNER JOIN oknardia_setkit' - ' ON oknardia_setkit.kSet2User_id = oknardia_ouruser.id' - ' GROUP BY oknardia_merchantoffice.id,' - ' oknardia_merchantoffice.kMerchantName_id) AS Q,' - ' oknardia_ouruser' - ' INNER JOIN oknardia_merchantoffice' - ' ON oknardia_ouruser.kMerchantOffice_id = oknardia_merchantoffice.id' - ' INNER JOIN oknardia_priceoffer' - ' ON oknardia_priceoffer.kOfferFromUser_id = oknardia_ouruser.id' - ' INNER JOIN oknardia_merchantbrand' - ' ON oknardia_merchantoffice.kMerchantName_id = oknardia_merchantbrand.id' - ' WHERE Q_ID = oknardia_merchantoffice.kMerchantName_id ' - 'GROUP BY oknardia_merchantoffice.kMerchantName_id ' - 'ORDER BY Q.RatingAVG DESC;') - list_company = list(q_company) - for i in list_company: - i.STARS = get_rating_set_for_stars(i.RatingAVG) - i.NumSets = pytils.numeral.get_plural(i.NumSets, u"оконный набор, оконных набора, оконных наборов") - i.NumOffers = pytils.numeral.get_plural(i.NumOffers, u"вариант, варианта, вариантов") - i.lastUpdate = pytils.dt.distance_of_time_in_words(int(django.utils.dateformat.format(i.lastUpdate, 'U'))) - i.sMerchantMainURL = pytils.translit.slugify(i.sMerchantName) - # print("NAME:", i.sMerchantName, "\tNumSets:", i.NumSets, "\tNumOffers:", i.NumOffers, - # "\t:AverageRating:", i.RatingAVG, "\tAveragePrice:", i.PriceAVG, "\tSTARS:", i.STARS) - to_template.update({ - 'COMPANIES': list_company, - # получаем последние визиты клиента через куки - 'LAST_VISIT': get_last_user_visit_list(get_last_user_visit_cookies(request)[:3]), - # получаем последние визиты всех посетителей из базы - # id2log, log_visit = get_last_all_user_visit_list() + """ + Показывает список всех производителей с ключевыми показателями. + + 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(), - 'ticks': float(time.perf_counter() - time_start) - }) + } + return render(request, "catalog/catalog_company.html", to_template) -def catalog_company_detail(request: HttpRequest, company_id: str, company_name_slug: str) -> HttpResponse: +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-формате, т.к. шаблон использует именно их имена. + '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() - to_template: dict[str, object] = {} # словарь, для передачи шаблону - company_id = int(company_id) - q_by_id = MerchantBrand.objects.get(id=company_id) - if pytils.translit.slugify(q_by_id.sMerchantName) != company_name_slug: - return redirect('/catalog/company/%d-%s' % (company_id, pytils.translit.slugify(q_by_id.sMerchantName))) - to_template.update({'COMPANY': q_by_id.sMerchantName}) - to_template.update({'COMPANY_ID': company_id}) - to_template.update({'COMPANY_T': company_name_slug}) - list_not = [u"нет", u"—", ""] - to_template.update({'LIST_NOT': list_not}) - q_sets = MerchantBrand.objects.raw(f"SELECT" - f" COUNT(oknardia_priceoffer.id) AS NumOffers," - f" AVG(oknardia_priceoffer.fOfferPrice) AS priceAVG," - f" MAX(oknardia_priceoffer.dOfferModify) AS lastUpdate," - f" MIN(oknardia_priceoffer.dOfferCreate) AS earlyCreation," - f" oknardia_merchantbrand.*," - f" oknardia_merchantoffice.*," - f" oknardia_merchantoffice.id AS idMERCH," - f" oknardia_setkit.*," - f" oknardia_setkit.id AS idSET," - f" oknardia_pvcprofiles.*," - f" oknardia_pvcprofiles.id AS idPVC," - f" oknardia_glazing.*, " - f" oknardia_glazing.id AS idGLAZ " - f"FROM oknardia_ouruser" - f" INNER JOIN oknardia_merchantoffice" - f" ON oknardia_ouruser.kMerchantOffice_id = oknardia_merchantoffice.id" - f" INNER JOIN oknardia_merchantbrand" - f" ON oknardia_merchantoffice.kMerchantName_id = oknardia_merchantbrand.id" - f" INNER JOIN oknardia_setkit" - f" ON oknardia_setkit.kSet2User_id = oknardia_ouruser.id" - f" INNER JOIN oknardia_priceoffer" - f" ON oknardia_priceoffer.kOffer2SetKit_id = oknardia_setkit.id" - f" INNER JOIN oknardia_pvcprofiles" - f" ON oknardia_setkit.kSet2PVCprofiles_id = oknardia_pvcprofiles.id" - f" INNER JOIN oknardia_glazing" - f" ON oknardia_setkit.kSet2Glazing_id = oknardia_glazing.id " - f"WHERE oknardia_merchantbrand.id = {company_id} " - f"AND oknardia_priceoffer.sOfferActive = TRUE " - f"GROUP BY oknardia_merchantoffice.id," - f" oknardia_setkit.id," - f" oknardia_setkit.fSetRating " - f"ORDER BY oknardia_setkit.fSetRating DESC;") - list_sets = list(q_sets) - for i in list_sets: - i.sMerchantMainURL = {"URL": i.sMerchantMainURL, - "URL_VIEW": re.sub(r"(?:^http://|^https://|/$|www\.)", "", i.sMerchantMainURL)} - k = random.randint(1, int(len(i.sOfficeEmails)/2) - 1) - i.sOfficeEmails = [i.sOfficeEmails[0:k], i.sOfficeEmails[k:-k], i.sOfficeEmails[-k:]] - to_template.update({'IMG_FOR_BLOG': i.pMerchantLogo}) - i.fSetRating = {"RATING": i.fSetRating, - "STARS": get_rating_set_for_stars(i.fSetRating)} - i.lastUpdate = pytils.dt.distance_of_time_in_words(int(django.utils.dateformat.format(i.lastUpdate, 'U'))) - i.earlyCreation = pytils.dt.distance_of_time_in_words(int(django.utils.dateformat.format(i.earlyCreation, 'U'))) - i.sProfileName = {"NAME": i.sProfileName, - "NAME_T": pytils.translit.slugify(i.sProfileName)} - i.sProfileManufacturer = {"NAME": i.sProfileManufacturer, - "NAME_T": pytils.translit.slugify(i.sProfileManufacturer)} - i.fProfileSeals = pytils.numeral.sum_string(i.fProfileSeals, pytils.numeral.MALE, u"контур, контура, контуров") - if i.sSetImplementCatch.lower() in list_not: - i.sSetImplementCatch = "" - if i.sSetClimateControl.lower() in list_not: - i.sSetClimateControl = "" - if len(i.sProfileReinforcement) > 0: - i.sProfileReinforcement = i.sProfileReinforcement[0].lower()+i.sProfileReinforcement[1:] - if len(i.sSetSill) > 0: - i.sSetSill = i.sSetSill[0].lower()+i.sSetSill[1:] - if len(i.sSetPanes) > 0: - i.sSetPanes = i.sSetPanes[0].lower()+i.sSetPanes[1:] - if len(i.sSetSlope) > 0: - i.sSetSlope = i.sSetSlope[0].lower()+i.sSetSlope[1:] - if len(i.sSetUninstallInstall) > 0: - i.sSetUninstallInstall = i.sSetUninstallInstall[0].lower()+i.sSetUninstallInstall[1:] - if len(i.sSetDelivery) > 0: - i.sSetDelivery = i.sSetDelivery[0].lower()+i.sSetDelivery[1:] - if len(i.sSetOtherConditions) > 0: - i.sSetOtherConditions = i.sSetOtherConditions[0].lower()+i.sSetOtherConditions[1:] - to_template.update({ - 'SETS': list_sets, - # получаем последние визиты клиента через куки - 'LAST_VISIT': get_last_user_visit_list(get_last_user_visit_cookies(request)[:3]), - # получаем последние визиты всех посетителей из базы - # id2log, log_visit = get_last_all_user_visit_list() + 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(), - 'ticks': float(time.perf_counter() - time_start) - }) + } + + # Добавляем метрику выполнения представления + to_template['ticks'] = float(time.perf_counter() - time_start) + return render(request, "catalog/catalog_company_detail.html", to_template)