Files
2022_oknardia/oknardia/web/catalog_series.py

451 lines
20 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 -*-
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