# -*- coding: utf-8 -*- from django.core.exceptions import ObjectDoesNotExist from django.db.models import Count, F, IntegerField, Value from django.shortcuts import render, redirect from django.http import HttpRequest, HttpResponse from django.template.loader import render_to_string from oknardia.settings import * from oknardia.models import ( Apartment_Type, MountDim2Apartment, PriceOffer, Seria_Info, Win_MountDim, Building_Info, ) from web.report1 import get_last_all_user_visit_list, get_last_user_visit_list from web.add_func import get_flaps_for_big_pictures import time import os import math import pytils def _make_slug(value: str) -> str: """Транслитерирует строку в slug (pytils).""" return pytils.translit.slugify(value) def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None: """Дописывает в контекст стандартный хвост: визиты и время выполнения.""" to_template.update({ 'LOG_VISIT': get_last_all_user_visit_list(), 'ticks': float(time.perf_counter() - time_start), }) # Каталог типовых серий зданий. def catalog_seria(request: HttpRequest) -> HttpResponse: """ КАТАЛОГ ТИПОВЫХ СЕРИЙ: выводит список корневых серий из каталога. :param request: HttpRequest -- входящий http-запрос :return response: HttpResponse -- исходящий http-ответ """ time_start = time.perf_counter() # Только корневые серии (id == kRoot_id), сортировка как в старом SQL. q_seria = ( Seria_Info.objects.filter(id=F('kRoot_id')) .values('id', 'sURL2IMG', 'sName') .order_by('sName') ) to_template: dict[str, object] = { 'SERIAS': [ { 'ID': row['id'], 'URL': row['sURL2IMG'], 'NAME': row['sName'], 'NAME_T': _make_slug(row['sName']), } for row in q_seria ] } _append_visit_context(to_template, request, time_start) return render(request, "catalog/catalog_seria.html", to_template) def catalog_seria_info( request: HttpRequest, seria_name_translit: str | None, seria_id: int = DEFAULT_SERIA_ID_FOR_CATALOG, ) -> HttpResponse: """ КАТАЛОГ ТИПОВОЙ СЕРИИ: детальная страница по серии домов. Что делает вьюха: - канонизирует URL (root-id серии + корректный slug), - собирает таблицу окон по типам квартир, - для "тяжелого" режима дополнительно готовит навигацию/график/гео-данные и сохраняет pre-render include-шаблон для последующих быстрых ответов. :param request: HttpRequest -- входящий http-запрос :param seria_name_translit: str -- имя серии здания (транслитерированное через pytils) :param seria_id: int -- id серии :return response: HttpResponse -- исходящий http-ответ """ time_start = time.perf_counter() # Канонизируем URL: страница серии должна открываться только по корневой серии и правильному slug. try: seria_id = int(seria_id) q_seria = Seria_Info.objects.only("id", "kRoot_id", "sName").get(id=seria_id) if q_seria.id != q_seria.kRoot_id or seria_name_translit != pytils.translit.slugify(q_seria.sName): return redirect(f"/catalog/seria/{pytils.translit.slugify(q_seria.sName)}/all{seria_id}") except (ObjectDoesNotExist, ValueError): return redirect("/catalog/") # В DEV отключаем pre-render cache: всегда рендерим «тяжелый» шаблон напрямую, # чтобы тестировать актуальную серверную логику, а не сохраненный html-файл. if DEBUG: light_template = "seria_info/all_seria_info_pre_light.html" light_template_w_path = "" is_hard_template = True else: # В PROD используем существующий pre-render include при наличии на диске. light_template = f"seria_info/prepared/{seria_id}_id.html" light_template_w_path = f"{TEMPLATES[0]['DIRS'][0]}/{light_template}" is_hard_template = not os.path.isfile(light_template_w_path) to_template: dict[str, object] = {} # Получаем все уникальные проемы серии и сразу добавляем iQuantity=1 # для совместимости с get_flaps_for_big_pictures(). list_win_in_seria = list( Win_MountDim.objects.filter(kApartment__kSeria_id=seria_id) .annotate(iQuantity=Value(1, output_field=IntegerField())) .only( "id", "iWinWidth", "iWinHight", "sDescripion", "bIsDoor", "bIsNearDoor", "sFlapConfig", "iWinDepth", ) .order_by("-bIsNearDoor", "-bIsDoor", "iWinWidth", "-iWinHight", "id") .distinct() ) window_ids = [win.id for win in list_win_in_seria] apartments_in_seria = list( Apartment_Type.objects.filter(kSeria_id=seria_id) .values("id", "sNameApartment") .order_by("iSort", "id") ) apartment_ids = [apartment["id"] for apartment in apartments_in_seria] # Кэшируем количество проемов по паре (квартира, проем), чтобы не делать N*M обращений к БД. quantities_by_pair = { (row["kApartment_id"], row["kMountDim_id"]): row["iQuantity"] for row in MountDim2Apartment.objects.filter( kApartment_id__in=apartment_ids, kMountDim_id__in=window_ids, ).values("kApartment_id", "kMountDim_id", "iQuantity") } # Число офферов считаем один раз по каждому проему и переиспользуем при сборке таблицы. offers_by_window = { row["kOffer2MountDim_id"]: row["num_offers"] for row in PriceOffer.objects.filter(kOffer2MountDim_id__in=window_ids) .values("kOffer2MountDim_id") .annotate(num_offers=Count("id")) } total_column = len(list_win_in_seria) - 1 table_of_win_in_seria_by_apartmment = [] offer_and_merchant_per_win = [ { "WIN_OFFER": offers_by_window.get(list_win_in_seria[i].id, 0), "WIN_MERCHANT": 0, "WIN_W": list_win_in_seria[i].iWinWidth, "WIN_H": list_win_in_seria[i].iWinHight, "WIN_ID": list_win_in_seria[i].id, } for i in range(total_column + 1) ] for apartment in apartments_in_seria: row_for_table = [] # None = в строке квартиры еще не встретилось ни одного окна. min_offer_in_row = None for count_column, window in enumerate(list_win_in_seria): quantity = quantities_by_pair.get((apartment["id"], window.id), 0) if quantity != 0: num_offers = offers_by_window.get(window.id, 0) row_for_table.append( { "WIN_NUM": [chr(65 + count_column)], "WIN_Q": quantity, "WIN_ID": window.id, "WIN_WIDTH": window.iWinWidth, "WIN_HEIGHT": window.iWinHight, "WIN_DESCRIPTION": window.sDescripion, "WIN_FLAPCFG": window.sFlapConfig, } ) if min_offer_in_row is None or min_offer_in_row > num_offers: min_offer_in_row = num_offers else: row_for_table.append({"WIN_NUM": "—"}) table_of_win_in_seria_by_apartmment.append( { "WIN_IN_APART": row_for_table, "APART_NAME": apartment["sNameApartment"], "APART_ID": apartment["id"], # Если у серии нет ни одного окна, показываем 0 вместо служебного sentinel. "NUM_OFFERS": 0 if min_offer_in_row is None else min_offer_in_row, } ) to_template.update( { "WIN_OFFER_AND_MERCHANT": offer_and_merchant_per_win, "TABLE_OF_WINDOWS": table_of_win_in_seria_by_apartmment, } ) # Для "тяжелого" шаблона получаем навигацию, карту и график, затем кэшируем pre-render. if is_hard_template: to_template.update(get_flaps_for_big_pictures(list_win_in_seria)) seria_id, for_seria_nav = seria_nav(seria_id) to_template.update(for_seria_nav) to_template.update(seria_info_year(seria_id)) to_template.update(seria_info_geo_code(seria_id)) if not DEBUG: # Пре-рендер происходит только для "включаемого" шаблона, # чтобы избежать дублирования базовой разметки. string_prerender = render_to_string("seria_info/all_seria_info_pre_light_include.html", to_template) with open(light_template_w_path, "w", encoding="utf-8") as file: file.write(string_prerender) # Основной шаблон будет просто включать в себя уже готовый HTML light_template = "seria_info/all_seria_info_pre_light.html" else: to_template.update({"THIS_SERIA_NAME": q_seria.sName}) # Указываем путь к кешированному файлу для include to_template.update({"PRE_RENDERED_INCLUDE_PATH": light_template}) # Основной шаблон должен быть один и тот же light_template = "seria_info/all_seria_info_pre_light.html" _append_visit_context(to_template, request, time_start) return render(request, light_template, to_template) def seria_nav(seria_id: int = DEFAULT_SERIA_ID_FOR_CATALOG) -> tuple[int, dict]: """ Возвращает корректный seria_id и данные навигации по корневым сериям. Если переданный seria_id невалиден, подбирает ближайший допустимый root-id. :param seria_id: id серии :return: tuple[int, dict] -- (seria_id, {"SERIA_NAV_DIM": ..., "THIS_SERIA_*": ...}) """ q_seria = list( Seria_Info.objects.filter(id=F("kRoot_id")) # sURL2IMG нужен для OG-image в шаблоне seria_info .only("id", "sName", "sSeriaDescription", "kRoot_id", "kParent_id", "sURL2IMG") .order_by("sName") ) if not q_seria: return seria_id, {"SERIA_NAV_DIM": []} error_seria = True for count_seria in q_seria: if count_seria.id == int(seria_id): error_seria = False break if error_seria: # Ошибочный seria_id. Такой базовой серии нет и надо ее найти. try: query = Seria_Info.objects.get(id=int(seria_id)) if query.kRoot_id is not None: # базовая серия прописана в kRoot_id seria_id = query.kRoot_id else: # Корневой серии нет. # Ищем методом наименьших расстояний min_min = 100_000_000 min_id = seria_id for count_seria in q_seria: if math.fabs(int(seria_id) - count_seria.id) < min_min: min_min = math.fabs(int(seria_id) - count_seria.id) min_id = count_seria.id seria_id = min_id except ObjectDoesNotExist: seria_id = q_seria[0].id return all_seria_nav(seria_id, q_seria) def all_seria_nav(seria_id: int, q_seria) -> tuple[int, dict]: """ Формирует структуру навигации по сериям для шаблонов. :param seria_id: активный id серии :param q_seria: коллекция серий (ORM-объекты или dict из values()) :return: tuple[int, dict] -- (seria_id, словарь с SERIA_NAV_DIM и данными активной серии) """ seria_nav_dim = [] this_return = {} # Поддерживаем оба формата входных элементов: ORM-объекты и dict из values(). for count_seria in q_seria: seria_name = count_seria["sName"] if isinstance(count_seria, dict) else count_seria.sName seria_id_value = count_seria["id"] if isinstance(count_seria, dict) else count_seria.id seria_description = ( count_seria.get("sSeriaDescription") if isinstance(count_seria, dict) else count_seria.sSeriaDescription ) one_seria = { "SERIA_R": seria_name, "ID2URL": seria_id_value, "SERIA_L": pytils.translit.slugify(seria_name), } if seria_id_value == seria_id: # Изображение серии: используется в OG-image в шаблоне seria_info seria_image = ( count_seria.get("sURL2IMG") if isinstance(count_seria, dict) else count_seria.sURL2IMG ) this_return.update({ "THIS_SERIA_NAME": seria_name, "THIS_SERIA_DESCRIPTION": seria_description, # ID и slug серии нужны для canonical URL и JSON-LD в шаблоне "THIS_SERIA_ID": seria_id_value, "THIS_SERIA_NAME_T": pytils.translit.slugify(seria_name), # URL изображения серии для OG-тегов (путь относительно /media/) "THIS_SERIA_IMAGE_URL": str(seria_image) if seria_image else "", }) seria_nav_dim.append(one_seria) this_return.update({"SERIA_NAV_DIM": seria_nav_dim}) return seria_id, this_return def seria_info_year(seria_id: int = DEFAULT_SERIA_ID_FOR_CATALOG) -> dict: """Возвращает данные для графика ввода домов серии в эксплуатацию. :param seria_id: int -- id корневой серии :return: dict -- данные для графика по годам вида: {"DATA4GRAPH": [{'YEAR': 1997, 'NUMS': 1, 'CLRS': '99'}, {'YEAR': 1998, 'NUMS': 15, 'CLRS': 'сс'}, {'YEAR': 1998, 'NUMS': 10, 'CLRS': 'a9'} ] } """ seria_in_years = [] query = list( Building_Info.objects.filter(kSeria_Link__kRoot_id=seria_id) .values("iCommissioning_year") .annotate(NumInYear=Count("iCommissioning_year")) .order_by("iCommissioning_year") ) max_per_year = 0 graph_color_light = 0xCC # самый светлый цвет на графике (максимальное значение) graph_color_dark = 0x99 # самый темный цвет на графике (минимальное значение) for year_count in query: if int(year_count["NumInYear"]) > max_per_year: max_per_year = int(year_count["NumInYear"]) for year_count in query: data_of_year = {} try: data_of_year.update({ "YEAR": int(year_count["iCommissioning_year"]), "NUMS": year_count["NumInYear"], "CLRS": str(hex(int(graph_color_dark + year_count["NumInYear"] * ( graph_color_light - graph_color_dark) / max_per_year)))[2:] }) except ValueError: continue seria_in_years.append(data_of_year) return {"DATA4GRAPH": seria_in_years} def seria_info_geo_code(seria_id: int | str = DEFAULT_SERIA_ID_FOR_CATALOG) -> dict: """Возвращает гео-точки и агрегированную статистику по серии. Кроме массива координат, функция считает суммарные показатели серии: жилые/муниципальные/государственные площади, число жителей, квартир, лицевых счетов и диапазон показателя состояния домов. :param seria_id: int | str -- id серии, для которой нужно получить данные. :return: dict -- { "DATA4GEO": [...], "MUNICIPAL_M2": ..., "RESIDENTIAL_M2": ..., "GOVERNMENT_M2": ..., "RESIDENTS": ..., "APARTMENTS": ..., "ACCOUNTS": ..., "CONDITION_MAX": ..., "CONDITION_MIN": ..., } """ data_return = {} seria_to_geo = [] municipal_m2 = 0 # муниципальный фонд (кв.м) residential_m2 = 0 # жилой фонд (кв.м) government_m2 = 0 # государственные учреждения занимают (кв.м.) residents = 0 # количество жильцов apartments = 0 # число квартир accounts = 0 # количество лицевых счетов condition_max = 0 # максимальное значение показателя состояния здания condition_min = 1_000_000 # минимальное значение показателя состояния здания query = Building_Info.objects.filter(kSeria_Link__kRoot_id=int(seria_id)).values( "id", "kSeria_Link__kRoot_id", "sAddress", "fResidential_Area", "fMunicipal_Area", "fGovernment_Area", "iNum_Residents", "iNum_Apartments", "iNum_Accounts", "fCondition_House", "fGeoCode_Latitude", "fGeoCode_Longitude", ) # iterator() уменьшает пиковое потребление памяти на больших сериях домов. for count in query.iterator(chunk_size=500): latitude = count["fGeoCode_Latitude"] or 0 longitude = count["fGeoCode_Longitude"] or 0 municipal_area = count["fMunicipal_Area"] or 0 residential_area = count["fResidential_Area"] or 0 government_area = count["fGovernment_Area"] or 0 num_residents = count["iNum_Residents"] or 0 num_apartments = count["iNum_Apartments"] or 0 num_accounts = count["iNum_Accounts"] or 0 house_condition = count["fCondition_House"] or 0 if int(latitude) != 0 and int(longitude) != 0: seria_to_geo.append({"LATITUDE": latitude, "LONGITUDE": longitude, "ADDR_ID": count["id"], "ADDR_LAT": pytils.translit.slugify(count["sAddress"]), "ADDR_RUS": count["sAddress"], "SER_ID": count["kSeria_Link__kRoot_id"] }) if municipal_area > 0: municipal_m2 += municipal_area if residential_area > 0: residential_m2 += residential_area if government_area > 0: government_m2 += government_area if num_residents > 0: residents += num_residents if num_apartments > 0: apartments += num_apartments if num_accounts > 0: accounts += num_accounts if house_condition > 0: if house_condition > condition_max: condition_max = house_condition if house_condition < condition_min: condition_min = house_condition data_return.update({"DATA4GEO": seria_to_geo, "MUNICIPAL_M2": municipal_m2, "RESIDENTIAL_M2": residential_m2, "GOVERNMENT_M2": government_m2, "RESIDENTS": residents, "APARTMENTS": apartments, "ACCOUNTS": accounts, "CONDITION_MAX": condition_max, "CONDITION_MIN": condition_min}) return data_return