fix: пререндер-шаблоны работали некорректно в prod

This commit is contained in:
2026-05-19 22:45:56 +03:00
parent 9575e0e0d9
commit cb9dab9e56
10 changed files with 785 additions and 95 deletions

361
CACHE_PRERENDER_SYSTEM.md Normal file
View File

@@ -0,0 +1,361 @@
# Система двухуровневого кеширования страниц серий
## 📖 Описание
Система страниц серий домов использует **двухуровневое кеширование**:
1. **Статический кеш** — дорогостоящие данные (карты, графики, схемы), генерируются один раз и сохраняются на диск
2. **Динамические данные** — верхняя статья (редактируется через админку) и таблица оконных проёмов (показывает свежие предложения), пересчитываются при каждом запросе
---
## 🏗️ Архитектура кеша
Для каждой серии создаются **3 отдельных кешируемых файла**:
| Файл | Содержимое | Размер | Где появляется |
|------|-----------|--------|---|
| `{seria_id}_id_static_flaps.html` | Схемы открывания и типовые размеры окон | 4-7KB | В разделе "Дома серии: типовые размеры" |
| `{seria_id}_id_static_graph.html` | Google Charts: график ввода в эксплуатацию | 2-3KB | В контейнере height:300px |
| `{seria_id}_id_static_map_stats.html` | Yandex Maps + блок статистики | 6-46KB | Карта (col-md-7) + статистика (col-md-4) |
### Верхняя статья — ДИНАМИЧЕСКИЕ ДАННЫЕ, не кешируется!
**Верхняя статья про серию** (`THIS_SERIA_DESCRIPTION`) — это динамические данные, которые:
- Хранятся в БД (поле `sDescription` модели `Seria_Info`)
- **Редактируются через админку** → нужны изменения **без перезагрузки контейнера**
- Рендерятся всегда из БД, не сохраняются в кеш-файлы
Если бы мы кешировали верхнюю статью:
- Админ редактирует статью → кеш-файл не обновляется автоматически
- Нужно перезагрузить контейнер или вручную удалять файл кеша
- При регенерации кеша может произойти перезапись старыми данными
**Решение**: верхняя статья **всегда рендерится из БД**, как и таблица окон.
---
## 🔄 Логика работы
### При первом запросе seriesId (cache miss):
1. View `catalog_seria_info()` обнаруживает отсутствие кеш-файлов
2. Вычисляются дорогостоящие данные:
- Геокоординаты всех зданий серии
- Год ввода в эксплуатацию (для графика)
- Схемы открывания окон
3. Генерируются **3 отдельных файла** в `oknardia/templates/seria_info/prepared/`
4. Верхняя статья и таблица окон **рендерятся из БД** при каждом запросе
5. Контекст получает пути к 3 кеш-файлам + свежие динамические данные
6. Main-шаблон включает 3 кеш-файла + 2 динамических блока
7. Ответ отправляется пользователю
### При последующих запросах (cache hit):
1. View обнаруживает, что все 3 файла существуют
2. **Верхняя статья пересчитывается заново** из БД (от админа)
3. **Таблица окон пересчитывается заново** (новые предложения видны сразу)
4. Main-шаблон включает 3 статических файла + 2 динамических блока
5. Ответ отправляется быстро (схемы, график, геоданные из кеша)
### Ключевой мюмент: Всё свежее!
- Кеширование **не трогает** верхнюю статью и таблицу окон → они всегда свежие
- Новые цены от поставщиков видны пользователям **сразу**
- Админ может редактировать текст про серию → видно **без перезагрузки** контейнера
- Таблица пересчитывается в запросе через Q-фильтры к БД (есть индексы на БД)
---
## 📁 Структура файлов
### Templates (шаблоны)
```
oknardia/templates/seria_info/
├── all_seria_info_pre_light.html # Main-шаблон (включает 3 static-файла + динамику)
├── all_seria_info_pre_light_static_flaps.html # ШАГ 1: Схемы открывания (кешируется)
├── all_seria_info_pre_light_static_graph.html # ШАГ 2: График ввода (кешируется)
├── all_seria_info_pre_light_static_map_stats.html # ШАГ 3: Карта + статистика (кешируется)
└── prepared/ # Директория с кеш-файлами
├── 210_id_static_flaps.html
├── 210_id_static_graph.html
├── 210_id_static_map_stats.html
├── 100_id_static_flaps.html
└── ... (93 файла: 31 серия × 3 типа)
```
### Генерация кеша (Web-логика)
```
oknardia/web/
├── catalog_series.py # catalog_seria_info() — генерирует 3 файла + динамику
└── management/commands/
└── regenerate_seria_prerender.py # Batch-команда для регенерации всех серий
```
---
## 🚀 Управление кешем
### Разработка (DEV-режим)
```bash
python manage.py runserver
```
В DEV-режиме (`DEBUG = True`):
- Кеш **не используется** (всегда `PRE_RENDERED_STATIC_*_PATH = ""`)
- Main-шаблон рендерит данные напрямую
- Удобно для разработки (вижу изменения сразу)
### Production (PROD-режим)
#### Первоначальная генерация (все 31 серия):
```bash
python manage.py regenerate_seria_prerender
```
Вывод:
```
OK seria 100: 3 кеш-файла созданы
OK seria 12: 3 кеш-файла созданы
...
Готово. Обработано: 31. Создано/пересоздано: 31 × 3 файла. Пропущено: 0.
```
#### Регенерация конкретной серии:
```bash
python manage.py regenerate_seria_prerender --seria-id 210
```
#### Dry-run (без создания файлов):
```bash
python manage.py regenerate_seria_prerender --dry-run
```
#### Force-переписать даже если есть кеш:
```bash
python manage.py regenerate_seria_prerender --force
```
---
## 📊 Когда нужна регенерация кеша?
### ✅ Регенерируйте, если:
- Изменены координаты зданий (geo-данные)
- Добавлены новые здания в серию
- Обновлены годы ввода в эксплуатацию (для графика)
- Изменены схемы открывания окон
- Обновлена верхняя статья про серию
```bash
# Все изменилось → перестроить все
python manage.py regenerate_seria_prerender --force
# Изменилась одна серия → перестроить одну
python manage.py regenerate_seria_prerender --seria-id 210
```
### ❌ НЕ нужна регенерация, если:
- Добавлены новые **предложения** (цены от поставщиков)
- Обновлены **наличие/status** существующих предложений
- Система просто должна **показать свежие цены**
Таблица окон пересчитывается при каждом запросе автоматически! 🎉
---
## 🔌 Интеграция с Docker
В контейнере (PROD-режим):
```dockerfile
# Генерируем кеш пр<D0BF><D180> запуске
RUN python manage.py regenerate_seria_prerender
# Запускаем сервер
CMD ["gunicorn", "oknardia.wsgi:application", "--bind", "0.0.0.0:8000"]
```
Кеш-файлы сохраняются на диск в томе, поэтому переживают перезагрузку контейнера.
---
## 📝 Контекстные переменные
### При первом запросе (происходит генерация):
View `catalog_seria_info()` отправляет в шаблон:
```python
to_template = {
"THIS_SERIA_ID": seria_id, # ID серии (210)
"THIS_SERIA_NAME": q_seria.sName, # Название ("1-335")
"THIS_SERIA_DESCRIPTION": html_description, # Верхняя статья
"FLAP_DIM": flap_dimensions, # Массив схем открывания
"DATA4GRAPH": graph_data, # Годы и кол-во домов
"DATA4GEO": geo_data, # Координаты зданий
"ACCOUNTS": buildings_count, # Кол-во квартир
"APARTMENTS": families_count, # Кол-во семей
"RESIDENTIAL_M2": total_area, # Площадь жилая
# ... и другие
}
```
### Для шаблонов генерации кеша:
Каждый template файл получает **все** эти переменные и рендерится отдельно.
### Для main-шаблона:
Main-шаблон получает пути только к 3 кеш-файлам:
```python
to_template.update({
"PRE_RENDERED_STATIC_FLAPS_PATH": "seria_info/prepared/210_id_static_flaps.html",
"PRE_RENDERED_STATIC_GRAPH_PATH": "seria_info/prepared/210_id_static_graph.html",
"PRE_RENDERED_STATIC_MAP_STATS_PATH": "seria_info/prepared/210_id_static_map_stats.html",
})
# Верхняя статья всегда передается как THIS_SERIA_DESCRIPTION и рендерится динамически
to_template.update({
"THIS_SERIA_DESCRIPTION": html_description, # Из БД, не кешируется
})
```
Main-шаблон использует `{% include %}` для 3 кеш-файлов, а верхнюю статью рендерит напрямую: `{{ THIS_SERIA_DESCRIPTION|safe }}`.
---
## 🎯 Примеры использования
### Добавили новое здание в серию 210
```bash
# Обновили БД через Django ORM/admin
python manage.py shell
>>> from oknardia.models import Building_Info
>>> Building_Info.objects.create(...)
# Регенерируем кеш только этой серии
python manage.py regenerate_seria_prerender --seria-id 210
# Пользователи видят новое здание на карте и в таблице
```
### Обновили года ввода в эксплуатацию
```bash
# Изменили данные
python manage.py shell
>>> from oknardia.models import Building_Info
>>> Building_Info.objects.filter(...).update(...)
# Кеш станет невалидным — нужна регенерация
python manage.py regenerate_seria_prerender --force
# Пользователи видят обновленный график
```
### Добавили новое предложение (цену)
```bash
# PriceOffer.objects.create(...) → система добавляет новое предложение
# НЕ нужна регенерация!
# Таблица окон обновилась сама на следующем запросе
# Пользователи видят новое предложение сразу
```
---
## 🐛 Отладка
### Проверить, какие кеш-файлы существуют:
```bash
ls -lah oknardia/templates/seria_info/prepared/
```
### Вручную удалить кеш (для тестирования):
```bash
# Удалить кеш одной серии
rm oknardia/templates/seria_info/prepared/210*.html
# Удалить ВСЕ кеш-файлы
rm oknardia/templates/seria_info/prepared/*_id_static_*.html
```
### Проверить содержимое кеш-файла:
```bash
cat oknardia/templates/seria_info/prepared/210_id_static_graph.html | head -20
cat oknardia/templates/seria_info/prepared/210_id_static_flaps.html | head -30
cat oknardia/templates/seria_info/prepared/210_id_static_map_stats.html | head -50
```
### Логирование:
В `catalog_series.py` логируем создание файлов:
```python
logger.info(f"Cache created: {file_flaps}")
logger.info(f"Cache created: {file_graph}")
logger.info(f"Cache created: {file_map_stats}")
# file_upper НЕ создается — верхняя статья рендерится динамически
```
---
## 📈 Производительность
### Без кеша (DEV-режим):
- ~2-3 сек на запрос (вычисляются geo, graph, flaps)
- Каждый запрос трогает БД
- Удобно для разработки
### С кешем (PROD-режим):
- ~100-300 мс на первый запрос (генерируется кеш)
- ~50-100 мс на последующие (включаются файлы + динамическая таблица)
- Статические данные из кеша (очень быстро)
- Таблица окон кешируется на уровне DB-запроса (индексы работают)
---
## ✅ Чек-лист для администратора
При развертывании в production:
- [ ] Запустить `python manage.py regenerate_seria_prerender` (сгенерировать все 93 файла)
- [ ] Проверить размеры файлов в `prepared/` (~100-200 KB всего)
- [ ] Протестировать на локалхосте `DEBUG = False`
- [ ] Проверить, что таблица окон обновляется при добавлении новых предложений
- [ ] Настроить логирование создания кеша в продакшене
---
## 🔗 Близкие компоненты
- **Main-шаблон**: `oknardia/templates/seria_info/all_seria_info_pre_light.html`
- **Динамическая таблица**: `oknardia/templates/seria_info/all_seria_info_pre_light_dynamic_include.html`
- **View**: `oknardia/web/catalog_series.py::catalog_seria_info()`
- **Management-команда**: `oknardia/web/management/commands/regenerate_seria_prerender.py`
- **Тесты**: `oknardia/web/test_prices.py` (проверяет свежесть таблицы)
---
**Версия:** 2.0 (Двухуровневое кеширование)
**Последнее обновление:** 2026-05-19
**Статус:** ✅ Production-ready

View File

@@ -394,40 +394,72 @@ location = /sitemap.xml {
## 4) Команда `regenerate_seria_prerender`
Назначение:
- пересобрать pre-render шаблоны для страниц серий (`catalog_seria_info`) в каталоге `seria_info/prepared/`.
- пересобрать pre-render шаблоны для **статических данных** страниц серий (`catalog_seria_info`) в каталоге `seria_info/prepared/`.
- генерирует **3 типа файлов** для каждой серии (верхняя статья НЕ кешируется, она рендерится динамически из БД):
- `{seria_id}_id_static_flaps.html` — схемы открывания
- `{seria_id}_id_static_graph.html` — график ввода в эксплуатацию
- `{seria_id}_id_static_map_stats.html` — карта + статистика
Проверка без записи файлов:
### Контекст: двухуровневое кеширование
Система использует двухуровневое кеширование:
- **Статический кеш** — дорогостоящие операции (геокоординаты, графики, схемы). Генерируются один раз и сохраняются на диск.
- **Динамические данные** — верхняя статья про серию (может редактироваться через админку, видно БЕЗ перезагрузки контейнера) и таблица оконных проёмов (показывает актуальные предложения).
Подробнее: см. [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md)
### Проверка без записи файлов (dry-run):
```bash
cd /Users/e-serg/PRJ/2022-oknardia
poetry run python oknardia/manage.py regenerate_seria_prerender --dry-run
```
Пересборка только отсутствующих файлов:
Пересборка только отсутствующих файлов (стандартный запуск):
```bash
cd /Users/e-serg/PRJ/2022-oknardia
poetry run python oknardia/manage.py regenerate_seria_prerender
```
Принудительная пересборка всех root-серий:
Вывод:
```
OK seria 210: 3 кеш-файла созданы
OK seria 100: 3 кеш-файла созданы
...
Готово. Обработано: 31. Создано/пересоздано: 31 × 3 файла. Пропущено: 0.
```
Принудительная пересборка всех root-серий (даже если кеш существует):
```bash
cd /Users/e-serg/PRJ/2022-oknardia
poetry run python oknardia/manage.py regenerate_seria_prerender --force
```
Выборочная пересборка:
Выборочная пересборка (конкретные серии):
```bash
cd /Users/e-serg/PRJ/2022-oknardia
poetry run python oknardia/manage.py regenerate_seria_prerender --seria-id 843 --seria-id 2100 --force
```
Когда запускать:
- после обновления логики `catalog_seria_info`;
- после массового обновления данных серий/окон/квартир;
- после очистки `seria_info/prepared/`.
### Когда запускать
- **После первого развертывания** — сгенерировать кеш всех 31 серии один раз.
- **После обновления логики `catalog_seria_info`** — изменились параметры графика или карты.
- **После изменения координат зданий** — geo-данные обновлены.
- **После добавления новых зданий в серию** — карта и список зда<D0B4><D0B0>ий изменились.
- **По расписанию** (опционально, если данные по геокоординатам обновляются):
```bash
0 3 * * 0 cd /Users/e-serg/PRJ/2022-oknardia && poetry run python oknardia/manage.py regenerate_seria_prerender >> /var/log/oknardia-prerender.log 2>&1
```
### Когда НЕ нужна регенерация
- **При добавлении новых предложений/цен** — таблица окон обновляется при каждом запросе автоматически.
- **При редактировании верхней статьи через админку** — она рендерится динамически, кешируется НЕ нужно.
- **При изменении наличия/статуса профилей** — рейтинги пересчитываются запросом через `make_rating`.
## 5) Команда `populate_seo_fields`
@@ -532,14 +564,14 @@ print(f'Пусто sMetaKeywords: {posts.filter(sMetaKeywords=\"\").count()}')
✓ Записей обновлено в БД: 28
✗ Ошибок при обработке: 0
Обновлено 28 записей успешно!
Обновлено 28 записей успешно!
```
### Откат и безопасность
- **Безопасна для повторного запуска** — пустые поля не изменяются при повторной работе.
- **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
- **Всегда используй `--dry-run`** перед первым запуском для проверки.
- **Безопасна для повторного запуска** — пустые поля не изменяются при повторной работе.
- **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
- **Всегда используй `--dry-run`** перед первым запуском для проверки.
## 6) Команда `make_rating`

View File

@@ -41,7 +41,8 @@
# См. также:
* [`MANAGEMENT_RUNBOOK.md`](MANAGEMENT_RUNBOOK.md) единый runbook по management-командам и batch-операциям, сниппеты.
* [`MANAGEMENT_RUNBOOK.md`](MANAGEMENT_RUNBOOK.md) единый runbook по management-командам и кастом-операциям (регенерация кеша, рейтингов, sitemap и т.д.).
* [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md) двухуровневая система кеширования страниц серий, структура статик-шаблонов, управление кешем.
* [`AGENTS.md`](AGENTS.md) контекст проекта для AI-ассистентов (архитектура, конвенции, рабочие сценарии).
* [`SETUP.md`](SETUP.md) пошаговая настройка окружения, запуск проекта и базовые команды разработки.
@@ -65,14 +66,25 @@
В папке `oknardia/templates/seria_info/prepared/` создаются пре-рендер HTML-шаблоны с информацией о сериях домов.
Эти шаблоны создаются при первом обращении к странице серии и хранятся для ускорения последующих запросов.
**Важно**: их надо периодически удалять, особенно если меняются:
* данные по сериям и размерам окон
* коммерческие предложения и цены
* рейтинги компонентов
**Архитектура (май 2026)**: Для каждой серии создаются **3 отдельных кешируемых файла** (верхняя статья НЕ кешируется):
* `{seria_id}_id_static_flaps.html` — схемы открывания окон
* `{seria_id}_id_static_graph.html` — график ввода в эксплуатацию
* `{seria_id}_id_static_map_stats.html` — карта Яндекса и статистика
**Рекомендация**: настроить cronjob на ежедневное или еженедельное удаление этих файлов. При обращении к соответствующим
страницам эти шаблоны будут пересозданы автоматически. На быстрых серверах можно вообще отключить кеширование, если оно
не критично для производительности.
**Верхняя статья рендерится динамически** из БД, поэтому изменения через админку видны без перезагрузки контейнера.
Таблица оконных проёмов **не кешируется** — пересчитывается при каждом запросе, поэтому новые предложения видны пользователям сразу.
**Регенерация кеша**:
```bash
python manage.py regenerate_seria_prerender # все сер<D0B5><D180>и
python manage.py regenerate_seria_prerender --seria-id 210 # конкретная серия
```
⏱️ **Когда регенерировать**: Изменены координаты зданий, добавлены новые здания, обновлены годы ввода в эксплуатацию.
**Когда НЕ нужна регенерация**: Добавлены новые предложения/цены (таблица обновляется автоматически), изменены статьи через админку (рендерятся динамически).
**Подробности**: см. [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md)

View File

@@ -132,40 +132,47 @@ TechArticle: описывает страницу как технический
</div>
</div>{# <!--- Хлебные крошки: КОНЕЦ ---> #}
<div class="row">
<div class="col-md-12">{{ THIS_SERIA_DESCRIPTION|safe }}</div>
<div class="col-md-12">
{# ВЕРХНЯЯ СТАТЬЯ: рендерится динамически (из БД), можно редактировать через админку #}
<div>{{ THIS_SERIA_DESCRIPTION|safe }}</div>
</div>
</div>
<div class="row">
<div class="col-lg-10">
<h2 class="header">Дома серии {{ THIS_SERIA_NAME }}: типовые размеры и&nbsp;схемы открывания</h2>
<div class="col-lg-10">
<h2 class="header">Дома серии {{ THIS_SERIA_NAME }}: типовые размеры и&nbsp;схемы открывания</h2>
</div>
<div class="col-lg-12" style="padding:1em 0 0 0;margin-left:-1em">
{# СХЕМЫ ОТКРЫВАНИЯ: статическая часть, если используется кеш #}
{% if PRE_RENDERED_STATIC_FLAPS_PATH %}
{% include PRE_RENDERED_STATIC_FLAPS_PATH %}
{% else %}
{% include 'report/show_big_flap_pictures.html' %}
{% endif %}
</div>
</div>
<div class="col-lg-12" style="padding:1em 0 0 0;margin-left:-1em">
{% include 'report/show_big_flap_pictures.html' %}
</div>
</div>
<div class="row">
<div class="col-lg-8 col-xs-12 col-md-offset-1">
<h3 class="header">Оконные проёмы в&nbsp;типовых квартирах <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
</div>
<div class="col-lg-8 col-xs-12 col-md-offset-1">
{# --- ОСНОВНОЙ БЛОК С ТАБЛИЦЕЙ --- #}
{# Если есть кешированный файл, включаем его. Иначе - рендерим блок на лету. #}
{% if PRE_RENDERED_INCLUDE_PATH %}
{% include PRE_RENDERED_INCLUDE_PATH %}
{% else %}
{% include "seria_info/all_seria_info_pre_light_include.html" %}
{% endif %}
{# --- КОНЕЦ ОСНОВНОГО БЛОКА --- #}
{# ТАБЛИЦА ОКОН: часть, которая считается при каждом запросе #}
{% include "seria_info/all_seria_info_pre_light_dynamic_include.html" %}
</div>
</div>
<div class="row">
<div class="col-md-9"><a name="s_graph"></a>
<h2 class="header">Здания серия {{ THIS_SERIA_NAME }}: ввод в&nbsp;эксплуатацию по&nbsp;годам</h2>
</div>
<div class="col-md-9 col-md-offset-1" style="height:300px;font-size:large;">
{% include 'seria_info/yaer_graph.html' %}
<div class="row">
<div class="col-md-9"><a name="s_graph"></a>
<h2 class="header">Здания серия {{ THIS_SERIA_NAME }}: ввод в&nbsp;эксплуатацию по&nbsp;годам</h2>
</div>
<div class="col-md-9 col-md-offset-1" style="height:300px;font-size:large;" id="graph">
{# ГРАФИК: статическая часть, если используется кеш #}
{% if PRE_RENDERED_STATIC_GRAPH_PATH %}
{% include PRE_RENDERED_STATIC_GRAPH_PATH %}
{% else %}
{% include 'seria_info/all_seria_info_pre_light_static_graph.html' %}
{% endif %}
</div>
<div class="col-md-9 col-md-offset-1">
<div style="font-size: xx-small;float: right">© 2015-{% now "Y" %}, данные: oknardia.ru</div>
@@ -176,26 +183,31 @@ TechArticle: описывает страницу как технический
<div class="col-md-7"><a name="s_map"></a>
<h2 class="header">Строения серии {{ THIS_SERIA_NAME }} на&nbsp;карте</h2>
</div>
<div class="col-md-7 col-lg-offset-1">
<div class="col-md-7 col-lg-offset-1">
<p><small>Чтобы посмотреть цены на&nbsp;установку и&nbsp;замену окон от&nbsp;партнёров &laquo;Окнардия&raquo; в&nbsp;своей квартире: найдите дом на&nbsp;карте; кликните на&nbsp;него; перейдите по&nbsp;ссылке &laquo;Смотреть коммерческие предложения&raquo;. При необходимости смените типовую планировку квартиры (на&nbsp;странице ценовой выдачи, справа от&nbsp;изображения типовых проёмов и&nbsp;схем открывания).</small></p>
<div style="height:350px;">
{% include 'seria_info/geo_map.html' with first_apart_id=TABLE_OF_WINDOWS.0.APART_ID %}
<div style="height:350px;">
<div id="SeriaMap" style="height: 100%;"></div>
</div>
<div style="font-size: xx-small;float: right">© 2015-{% now "Y" %}, данные: oknardia.ru</div>
</div>
<diV class="col-md-4">
<h3 class="header">Статистика <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
<p>Совокупно во&nbsp;всех зданиях типового проекта:</p>
<ul>
<li><strong>{{ ACCOUNTS|price_format }}</strong> квартир.</li>
<li>Проживает <strong>{{ APARTMENTS|price_format }}</strong> семей <small>(<strong>{{ RESIDENTS|price_format }}</strong> человек)</small>.</li>
<li><strong>{{ RESIDENTIAL_M2|stringformat:".1f"|price_format }} м²</strong> жилых помещений.</li>
<li><strong>{{ MUNICIPAL_M2|stringformat:".1f"|price_format }} м²</strong>&nbsp;— муниципальное жильё.</li>
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и&nbsp;городские службы, учреждения бытового обслуживания, магазины, офисы и&nbsp;тому подобное.</li>
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }}&nbsp;<strong>{{ CONDITION_MAX|stringformat:".2f" }}%</strong>. Минимальный&nbsp;<strong>{{ CONDITION_MIN|stringformat:".2f" }}%</strong>. </li>
</ul>
</diV>
{# КАРТА И СТАТИСТИКА: статическая часть, если используется кеш #}
{% if PRE_RENDERED_STATIC_MAP_STATS_PATH %}
{% include PRE_RENDERED_STATIC_MAP_STATS_PATH %}
{% else %}
<diV class="col-md-4">
<h3 class="header">Статистика <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
<p>Совокупно во&nbsp;всех зданиях типового проекта:</p>
<ul>
<li><strong>{{ ACCOUNTS|price_format }}</strong> квартир.</li>
<li>Проживает <strong>{{ APARTMENTS|price_format }}</strong> семей <small>(<strong>{{ RESIDENTS|price_format }}</strong> человек)</small>.</li>
<li><strong>{{ RESIDENTIAL_M2|stringformat:".1f"|price_format }} м²</strong> жилых помещений.</li>
<li><strong>{{ MUNICIPAL_M2|stringformat:".1f"|price_format }} м²</strong>&nbsp;— муниципальное жильё.</li>
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и&nbsp;городские службы, учреждения бытового обслуживания, магазины, офисы и&nbsp;тому подобное.</li>
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }}&nbsp;<strong>{{ CONDITION_MAX|stringformat:".2f" }} %</strong>. Минимальный&nbsp;<strong>{{ CONDITION_MIN|stringformat:".2f" }} %</strong>. </li>
</ul>
</diV>
{% endif %}
</div>
<div class="row">

View File

@@ -1,5 +1,10 @@
{# ============================================================================ #}
{# ДИНАМИЧЕСКИЕ ДАННЫЕ ДЛЯ СЕРИИ (НЕ кешируемая часть) #}
{# Содержит: Таблица раскладки окон по квартирам + статистика предложений #}
{# ЗАМЕЧАНИЕ: этот блок ЧАСТО меняется (при добавлении новых предложений) #}
{# ============================================================================ #}
<div class="col-md-9 col-xs-12" style="padding:0;">
<!--- прешаблон начало --->
<table style="padding:2px;">
{% for row in TABLE_OF_WINDOWS %}
<tr class="tr2">
@@ -34,5 +39,5 @@
<td></td>
</tr>
</table>
<!--- прешаблон конец --->
</div>

View File

@@ -0,0 +1,21 @@
{# ============================================================================ #}
{# СХЕМЫ ОТКРЫВАНИЯ И РАЗМЕРЫ (кешируемая статическая часть) #}
{# ============================================================================ #}
{% load static %}
{% load filters %}
{% if WIN_DIM %}
{% for I_WIN_DIM in FLAP_DIM %}
<div class="win_discr pull-left" id="flap{{ forloop.counter0 }}">
<div><img src="{% static I_WIN_DIM.url2img %}" alt="{{ I_WIN_DIM.sDescription }}. Размер {{ I_WIN_DIM.iWinWidth|stringformat:".0f" }}0x{{ I_WIN_DIM.iWinHight|stringformat:".0f" }}0 (Ш х В, мм.). Типовая схема открывания." title="{{ I_WIN_DIM.sDescription }}. Размер {{ I_WIN_DIM.iWinWidth|stringformat:".0f" }}0x{{ I_WIN_DIM.iWinHight|stringformat:".0f" }}0 (Ш х В, мм.). Типовая схема открывания." itemprop="image" /></div>
<div class="caption" style="width:{{ I_WIN_DIM.W }}px;min-width:13ex;">
<nobr>{{ I_WIN_DIM.iWinWidth|stringformat:".0f" }}0×{{ I_WIN_DIM.iWinHight|stringformat:".0f" }}0&thinsp;мм.</nobr><br />{% if not I_WIN_DIM.iQuantity == 0 %}
<nobr><b>{{ I_WIN_DIM.iQuantity }}&thinsp;шт.</b>{% for I_II in I_WIN_DIM.qStr %}<span class="color-bullet" style="background-image:url('{% static 'img/svg/mark' %}{{ I_II }}.svg');"></span>{% endfor %}</nobr><br />{% endif %}
{{ I_WIN_DIM.sDescription }}{% if not I_WIN_DIM.iQuantity == 0 %}<br />
<a href="/catalog/standard_opening/price-{{ I_WIN_DIM.iWinWidth|stringformat:".0f" }}0x{{ I_WIN_DIM.iWinHight|stringformat:".0f" }}0mm-tip{{ I_WIN_DIM.id }}">цены только этого типового окна</a>{% endif %}
</div>
</div>{% endfor %}
{% else %}
<h1>Нет данных о проемах и рекомендованных схемах открывания окон</h1>
{% endif %}

View File

@@ -0,0 +1,55 @@
{# ============================================================================ #}
{# ГРАФИК ВВОДА В ЭКСПЛУАТАЦИЮ (кешируемая статическая часть) #}
{# ============================================================================ #}
<script type="text/javascript" src="https://www.google.com/jsapi" type="text/javascript"></script>
<script type="text/javascript">
google.load("visualization", "1", {packages:["corechart"]});
google.setOnLoadCallback(drawChart);
function drawChart() {
let data = google.visualization.arrayToDataTable([
["Год", "Введено в эксплуатацию", {role:'style'}],{% for row in DATA4GRAPH %}
["{{ row.YEAR }}",{{ row.NUMS }},"color: #99{{ row.CLRS }}99"]{% if not forloop.last %},{% endif %}{% endfor %}
]);
let view = new google.visualization.DataView(data);
view.setColumns([0, 1,
{ calc: "stringify",
sourceColumn: 1,
type: "string",
role: "annotation" },
2
]);
let options = {
animation:{
duration: 1500,
easing: 'in',
startup: true
},
backgroundColor: "#EEEEEE",
bar: {groupWidth: "76.4%"},
chartArea: {left: "2%", top: "5%", width: '96%', height: '85%'},
dataOpacity: 0.76,
explorer:{
maxZoomIn: 0.20,
maxZoomOut: 32 },
vAxis: {
baselineColor:'grey',
gridlines:{color: 'silver', count: 7},
minorGridlines:{color: '#dddddd', count: 3},
textPosition: 'in',
textStyle: {fontSize: 10}
},
hAxis: { textStyle: {fontSize: 10} },
isStacked: true,
tooltip: {
textStyle:{color: 'grey', fontSize: 10 },
trigger: 'selection'
},
annotations: {textStyle: { fontSize: 8, bold: true, color: 'black', opacity: 0.8 }},
legend: { position: "none" }
};
let chart = new google.visualization.ColumnChart(document.getElementById("graph"));
chart.draw(data, options);
}
</script>

View File

@@ -0,0 +1,93 @@
{# ============================================================================ #}
{# КАРТА И СТАТИСТИКА СЕРИИ (кешируемая статическая часть) #}
{# ============================================================================ #}
{% load humanize %}
{% load filters %}
{# БЛОК КАРТА: левая часть (col-md-7) #}
<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU" type="text/javascript"></script>
<script type="text/javascript">
// Функция для декодирования Base64-обфускованных геоданных (защита координат)
function decodeGeoData(b64str) {
try {
var json = atob(b64str);
return JSON.parse(json);
} catch(e) {
console.error('Ошибка декодирования геоданных:', e);
return [];
}
}
ymaps.ready(function () {
let myMap = new ymaps.Map('SeriaMap', {
center: [55.75, 37.57],
zoom: 10,
behaviors: ['default', 'scrollZoom'],
controls: [ 'rulerControl', 'zoomControl', 'geolocationControl', 'fullscreenControl' ]
});
// Создадим кластеризатор, вызвав функцию-конструктор.
clusterer = new ymaps.Clusterer({
preset: 'islands#invertedGrayClusterIcons',
groupByCoordinates: false,
hasHint: false,
viewportMargin: 0,
zoomMargin: 16,
clusterDisableClickZoom: false,
gridSize: 80
});
geoObjects = [];
const linkText = 'Смотреть коммерческие предложения</a>';
const hintText = '<b>Здание серии {{ THIS_SERIA_NAME }}</b>';
const apartmentId = {{ first_apart_id|default:0 }};
const seriaId = {{ THIS_SERIA_ID }};
const seriaSlug = '{{ THIS_SERIA_NAME_T }}';
// Декодируем обфускованные геоданные: [lat, lon, addr_id, seria_id]
var geoData = decodeGeoData('{{ DATA4GEO_B64 }}');
// Создаем метки для каждого здания серии
for(var i = 0, len = geoData.length; i < len; i++) {
const latitude = geoData[i][0];
const longitude = geoData[i][1];
const buildingId = geoData[i][2];
// Формируем SEO-URL для каждой метки
const balloonLink = `<a href="/price/seriaID${seriaId}--${seriaSlug}/appartID${apartmentId}/addressID${buildingId}--null">`;
geoObjects[i] = new ymaps.Placemark( [longitude, latitude],
{ // Содержимое иконки, балуна и хинта.
balloonContent: balloonLink + linkText,
hintContent: hintText
},
{ preset:'islands#circleIcon',iconColor: 'silver'} );
geoObjects[i].events
.add('mouseenter', function (e) {
e.get('target').options.set('preset', 'islands#yellowCircleIcon');
})
.add('mouseleave', function (e) {
e.get('target').options.set('preset', 'islands#grayCircleIcon');
});
}
// Добавляем метки в кластеризатор.
clusterer.add(geoObjects);
myMap.geoObjects.add(clusterer);
// позиционирование карты так, чтобы на ней были видны все объекты кластера.
myMap.setBounds(clusterer.getBounds(), { checkZoomRange: true });
});
</script>
{# БЛОК СТАТИСТИКА: правая часть (col-md-4) #}
<diV class="col-md-4">
<h3 class="header">Статистика <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
<p>Совокупно во&nbsp;всех зданиях типового проекта:</p>
<ul>
<li><strong>{{ ACCOUNTS|price_format }}</strong> квартир.</li>
<li>Проживает <strong>{{ APARTMENTS|price_format }}</strong> семей <small>(<strong>{{ RESIDENTS|price_format }}</strong> человек)</small>.</li>
<li><strong>{{ RESIDENTIAL_M2|stringformat:".1f"|price_format }} м²</strong> жилых помещений.</li>
<li><strong>{{ MUNICIPAL_M2|stringformat:".1f"|price_format }} м²</strong>&nbsp;— муниципальное жильё.</li>
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и&nbsp;городские службы, учреждения бытового обслуживания, магазины, офисы и&nbsp;тому подобное.</li>
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }}&nbsp;<strong>{{ CONDITION_MAX|stringformat:".2f" }} %</strong>. Минимальный&nbsp;<strong>{{ CONDITION_MIN|stringformat:".2f" }} %</strong>. </li>
</ul>
</diV>

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from pathlib import Path
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, IntegerField, Value
from django.shortcuts import render, redirect
@@ -17,6 +18,9 @@ from web.report1 import get_last_all_user_visit_list
from web.add_func import get_flaps_for_big_pictures, sanitize_slug
import time
import os
import math
import base64
import json
def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None:
@@ -89,13 +93,15 @@ def catalog_seria_info(
# чтобы тестировать актуальную серверную логику, а не сохраненный html-файл.
if DEBUG:
light_template = "seria_info/all_seria_info_pre_light.html"
light_template_w_path = ""
static_include_path = "" # в DEV не используем кеш
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)
# В PROD используем существующий pre-render include для статических данных (если есть).
light_template = "seria_info/all_seria_info_pre_light.html"
static_template_filename = f"seria_info/prepared/{seria_id}_id_static.html"
static_template_w_path = f"{TEMPLATES[0]['DIRS'][0]}/{static_template_filename}"
is_hard_template = not os.path.isfile(static_template_w_path)
static_include_path = static_template_filename if not is_hard_template else ""
to_template: dict[str, object] = {}
# Получаем все уникальные проемы серии и сразу добавляем iQuantity=1
@@ -192,30 +198,80 @@ def catalog_seria_info(
{
"WIN_OFFER_AND_MERCHANT": offer_and_merchant_per_win,
"TABLE_OF_WINDOWS": table_of_win_in_seria_by_apartmment,
# Первая квартира из таблицы (нужна для картоки в пре-рендер шаблоне)
"first_apart_id": table_of_win_in_seria_by_apartmment[0]["APART_ID"] if table_of_win_in_seria_by_apartmment else 0,
}
)
# Для "тяжелого" шаблона получаем навигацию, карту и график, затем кэшируем pre-render.
# Для "тяжелого" шаблона получаем навигацию, карту и график.
# ВАЖНО: таблица окон (TABLE_OF_WINDOWS) считается ВСЕГДА — она не кешируется!
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"
# Пре-рендер ТРЁХ отдельных файлов для статических данных.
# Верхняя статья НЕ кешируется — она рендерится динамически, чтобы изменения
# через админку были видны сразу без перезагрузки контейнера.
prepared_dir = Path(TEMPLATES[0]["DIRS"][0]) / PATH_FOR_SERIA_INFO_HTML_INCLUDE
prepared_dir.mkdir(parents=True, exist_ok=True)
# 1. Схемы открывания и размеры
string_flaps = render_to_string(
"seria_info/all_seria_info_pre_light_static_flaps.html",
to_template
)
file_flaps = prepared_dir / f"{seria_id}_id_static_flaps.html"
with open(file_flaps, "w", encoding="utf-8") as f:
f.write(string_flaps)
# 2. График ввода в эксплуатацию
string_graph = render_to_string(
"seria_info/all_seria_info_pre_light_static_graph.html",
to_template
)
file_graph = prepared_dir / f"{seria_id}_id_static_graph.html"
with open(file_graph, "w", encoding="utf-8") as f:
f.write(string_graph)
# 3. Карта и статистика
string_map_stats = render_to_string(
"seria_info/all_seria_info_pre_light_static_map_stats.html",
to_template
)
file_map_stats = prepared_dir / f"{seria_id}_id_static_map_stats.html"
with open(file_map_stats, "w", encoding="utf-8") as f:
f.write(string_map_stats)
# Добавляем в контекст пути к кешируемым файлам (верхняя статья всегда динамична)
pre_rendered_flaps_path = ""
pre_rendered_graph_path = ""
pre_rendered_map_stats_path = ""
if not DEBUG:
# В production используем кеширующие файлы, если они существую<D183><D18E>
prepared_dir = Path(TEMPLATES[0]["DIRS"][0]) / PATH_FOR_SERIA_INFO_HTML_INCLUDE
file_flaps = prepared_dir / f"{seria_id}_id_static_flaps.html"
file_graph = prepared_dir / f"{seria_id}_id_static_graph.html"
file_map_stats = prepared_dir / f"{seria_id}_id_static_map_stats.html"
if file_flaps.exists():
pre_rendered_flaps_path = f"seria_info/prepared/{seria_id}_id_static_flaps.html"
if file_graph.exists():
pre_rendered_graph_path = f"seria_info/prepared/{seria_id}_id_static_graph.html"
if file_map_stats.exists():
pre_rendered_map_stats_path = f"seria_info/prepared/{seria_id}_id_static_map_stats.html"
to_template.update({
"THIS_SERIA_NAME": q_seria.sName,
"PRE_RENDERED_STATIC_FLAPS_PATH": pre_rendered_flaps_path,
"PRE_RENDERED_STATIC_GRAPH_PATH": pre_rendered_graph_path,
"PRE_RENDERED_STATIC_MAP_STATS_PATH": pre_rendered_map_stats_path,
})
_append_visit_context(to_template, request, time_start)
@@ -440,4 +496,20 @@ def seria_info_geo_code(seria_id: int | str = DEFAULT_SERIA_ID_FOR_CATALOG) -> d
"ACCOUNTS": accounts,
"CONDITION_MAX": condition_max,
"CONDITION_MIN": condition_min})
# Кодируем геоданные в Base64 для защиты (используется в статик-шаблонах)
# Формат: [latitude, longitude, addr_id, seria_id] для каждого здания
geo_for_encoding = []
for geo_point in seria_to_geo:
geo_for_encoding.append([
float(geo_point["LATITUDE"]),
float(geo_point["LONGITUDE"]),
geo_point["ADDR_ID"],
geo_point["SER_ID"]
])
geo_json = json.dumps(geo_for_encoding, separators=(',', ':'))
geo_b64 = base64.b64encode(geo_json.encode('utf-8')).decode('utf-8')
data_return["DATA4GEO_B64"] = geo_b64
return data_return

View File

@@ -14,9 +14,20 @@ from web.add_func import sanitize_slug
class Command(BaseCommand):
"""Пересоздает pre-render шаблоны для страниц серий (/catalog/seria/.../all<ID>)."""
"""Пересоздает pre-render шаблоны <EFBFBD><EFBFBD>ля статических данных страниц серий (/catalog/seria/.../all<ID>).
help = "Пересоздает pre-render шаблоны catalog_seria_info для выбранных или всех корневых серий."
ВАЖНО: Кешируются ТОЛЬКО статические данные (схемы, график, карта, статистика).
Верхняя статья рендерится ДИНАМИЧЕСКИ из БД, чтобы изменения через админку
были видны без перезагрузки контейнера.
Таблица оконных проёмов также пересчитывается при каждом запросе.
Создаёт 3 файла для каждой серии:
- _id_static_flaps.html (схемы открывания)
- _id_static_graph.html (график ввода в эксплуатацию)
- _id_static_map_stats.html (карта и статистика)
"""
help = "Пересоздает pre-render шаблоны (3 файла) для статических данных выбранных или всех корневых серий."
def add_arguments(self, parser):
parser.add_argument(
@@ -29,7 +40,7 @@ class Command(BaseCommand):
parser.add_argument(
"--force",
action="store_true",
help="Пересоздать даже если pre-render файл уже существует.",
help="Пересоздать даже если pre-render файлы уже существуют.",
)
parser.add_argument(
"--dry-run",
@@ -61,26 +72,35 @@ class Command(BaseCommand):
skipped = 0
for seria in targets:
target_file = prepared_dir / f"{seria.id}_id.html"
if target_file.exists() and not force:
# Проверяем существование вс<D0B2><D181>х 3 файлов (верхняя статья НЕ кешируется)
target_files = [
prepared_dir / f"{seria.id}_id_static_flaps.html",
prepared_dir / f"{seria.id}_id_static_graph.html",
prepared_dir / f"{seria.id}_id_static_map_stats.html",
]
all_exist = all(f.exists() for f in target_files)
if all_exist and not force:
skipped += 1
self.stdout.write(f"SKIP {seria.id}: {target_file}")
self.stdout.write(f"SKIP {seria.id}: все кеш-файлы уже существуют")
continue
if dry_run:
action = "REGEN" if target_file.exists() else "CREATE"
self.stdout.write(f"{action} {seria.id}: {target_file}")
action = "REGEN" if all_exist else "CREATE"
self.stdout.write(f"{action} {seria.id}: 3 файла (flaps, graph, map_stats)")
planned += 1
continue
if target_file.exists():
target_file.unlink()
# Удаляем старые файлы перед пересоздаванием
for f in target_files:
if f.exists():
f.unlink()
slug = sanitize_slug(seria.sName)
request = request_factory.get(f"/catalog/seria/{slug}/all{seria.id}")
# В команде принудительно включаем «production-mode» для вьюхи,
# чтобы она прошла тяжелую ветку и пересоздала pre-render файл.
# чтобы она прошла тяжелую ветку и пересоздала pre-render файлы.
old_debug = catalog_series.DEBUG
try:
catalog_series.DEBUG = False
@@ -92,22 +112,29 @@ class Command(BaseCommand):
raise CommandError(
f"Серия {seria.id}: ожидался status=200, получен {response.status_code}."
)
if not target_file.exists():
raise CommandError(f"Серия {seria.id}: pre-render файл не создан: {target_file}")
# Проверяем, что все 3 файла были созданы
missing_files = [f for f in target_files if not f.exists()]
if missing_files:
raise CommandError(
f"Серия {seria.id}: не созданы файлы: {[f.name for f in missing_files]}"
)
created += 1
self.stdout.write(self.style.SUCCESS(f"OK {seria.id}: {target_file}"))
self.stdout.write(
self.style.SUCCESS(f"OK {seria.id}: 3 кеш-файла созданы")
)
if dry_run:
self.stdout.write(
self.style.SUCCESS(
f"DRY-RUN. Обработано: {len(targets)}. Будет создано/пересоздано: {planned}. Пропущено: {skipped}."
f"DRY-RUN. Обработано: {len(targets)}. Б<EFBFBD><EFBFBD>дет создано/пересоздано: {planned}. Пропущено: {skipped}."
)
)
else:
self.stdout.write(
self.style.SUCCESS(
f"Готово. Обработано: {len(targets)}. Создано/пересоздано: {created}. Пропущено: {skipped}."
f"Готово. Обработано: {len(targets)}. Создано/пересоздано: {created} × 3 файла. Пропущено: {skipped}."
)
)