21 Commits
main ... v0.3.1

Author SHA1 Message Date
e15017f3a6 add: Страницы обработки ошибок
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 42s
2026-05-21 00:42:54 +03:00
958f11f398 mod: Clean-param 2026-05-20 19:54:42 +03:00
3ab38b4885 mod: алиас sitemap sitemap.xml 2026-05-20 19:25:10 +03:00
4b038302c3 add: Добавлен алиас /sitemap.xml -> /media/_serv_sitemap/sitemap.xml в nginx конфиг
- Поисковики ищут sitemap.xml в корне сайта
- Nginx теперь проксирует /sitemap.xml на /media/_serv_sitemap/sitemap.xml
- Установлен кеш на 1 день (чаще обновляется, чем картинки)
2026-05-20 18:38:02 +03:00
3c10b490b3 minor: Улучшены комментарии nginx конфига и исправлен sed паттерн для корректной замены путей
- Уточнены комментарии про архитектуру (медиа, ошибки, sitemap через Django)
- Исправлен sed паттерн: было '/home/user/app/oknardia-site' -> '/home/user/path-to-oknardia-app'
- Улучшен www редирект для правильной подстановки домена
- Убраны дублирующиеся комментарии про требуемую замену
2026-05-20 18:35:07 +03:00
2d7e0813d6 minor: Финализация production конфигурации
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 3m44s
2026-05-20 14:29:39 +03:00
5678624608 add: Production docker-compose.yml с nginx и watchtower + инструкция деплоя
Some checks failed
Build and Push Docker Image / build-and-push (push) Has been cancelled
2026-05-20 13:36:33 +03:00
cc739c5e07 mod: таймауты SQLite 2026-05-20 12:59:58 +03:00
54ed78185c fix: корректная отдача статики из корня каталога public 2026-05-20 00:13:14 +03:00
b376fdcaf9 mod: пересчет рейтингов при запуске контейнера 2026-05-19 23:03:05 +03:00
a3bc67613f fix: удалены чувствительные данные (кеш-файлы) из истории git + восстановлен .gitignore 2026-05-19 22:52:45 +03:00
cb9dab9e56 fix: пререндер-шаблоны работали некорректно в prod 2026-05-19 22:45:56 +03:00
9575e0e0d9 add: пред-прод контейнер (рабочий) 2026-05-19 21:07:43 +03:00
2396387883 add: пред-прод контейнер (рабочий) 2026-05-19 13:31:15 +03:00
8fe641c8f4 add: пред-прод контейнер 2026-05-18 19:18:03 +03:00
58e17d7d3b add: ключи развертывания 2026-05-18 16:35:30 +03:00
2dee8b77cb mod: новый набор favicon 2026-05-18 16:34:43 +03:00
03ed9d24f9 minor: комментарии 2026-05-18 15:23:03 +03:00
71059bdae6 mod: Все для контейнера в dev-режиме. 2026-05-18 15:17:35 +03:00
50b5ee4bdf add: .dockerignore (исключить из контейнера лишние файлы). 2026-05-17 01:23:42 +03:00
98912808a1 del: конфиг uWSGI в контейнере не нужен. 2026-05-17 01:16:02 +03:00
50 changed files with 2601 additions and 575 deletions

128
.dockerignore Normal file
View File

@@ -0,0 +1,128 @@
# Git и версионирование
.git
.gitignore
.gitattributes
# Документация и инструкции
*.md
README.md
SETUP.md
AGENTS.md
MANAGEMENT_RUNBOOK.md
# Python и виртуальное окружение
__pycache__
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
pip-log.txt
pip-delete-this-directory.txt
# IDE и редакторы
.vscode
.idea
*.swp
*.swo
*~
.DS_Store
.project
.pydevproject
.settings/
*.sublime-workspace
*.sublime-project
# Тестирование и линтинг
.pytest_cache/
.coverage
htmlcov/
.tox/
.mypy_cache/
.hypothesis/
# Локальные данные и конфиги
.env
.env.local
local_settings.py
database/*.sqlite3
database/*.db
logs/
*.log
# Статика для разработки и bootstrap-стили
public/static/bootstrap-3.3.7-dist.zip
public/static/css/*.map
public/static/js/*.map
public/static/**/*.map
# Временные файлы
*.tmp
*.temp
*.bak
# Шрифты
*.ttf
*.otf
# Системные файлы macOS
.AppleDouble
.LSOverride
.Spotlight-V100
.Trashes
.VolumeIcon.icns
# Системные файлы Linux
.Xauthority
.Xdefaults
# Docker и CI/CD
docker-compose.override.yml
.dockerignore
Dockerfile*
# Node modules (если понадобятся в будущем)
node_modules/
npm-debug.log
yarn-error.log
# Конфиги оставляем как пример!
# config/ -> НЕ ИСКЛЮЧАЕМ (нужен как образец)
# *.ini, *.conf -> НЕ ИСКЛЮЧАЕМ (нужны как примеры)
# Django автогенерируемые файлы (создаются при старте контейнера, не нужны в образе)
# public/static_collected/ # собранная статика (создаётся при collectstatic)
# public/media/ # медиа-файлы (могут быть большие, создаются динамически)
# oknardia/templates/seria_info/prepared/ # пре-рендер шаблоны серий (создаются управ.командой)
# public/media/_serv_sitemap/ # sitemap'ы (создаются управ.командой)
# Примечание: Мы не исключаем эти директории потому что:
# 1. public/media/ может содержать важные медиа для разработки
# 2. пре-рендер шаблоны кэшируются для production оптимизации
# При сборке production образа они создаются автоматически в docker-compose через manage.py команды.
# Базы данных и кеши
*.sqlite3
*.db
database/oknadria_backup*.sqlite3
# Миграции можем оставить, они нужны для контейнера
# oknardia/oknardia/migrations/ -> НЕ ИСКЛЮЧАЕМ

View File

@@ -25,6 +25,9 @@ ADMINS=Admin:admin@example.com
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти) # URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
ADMIN_URL=admin/ ADMIN_URL=admin/
# CSRF Trusted Origins (для корректной работы CSRF при доступе к админке и другим формам с разных доменов/портов)
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://yourdomain.com
# ============================================================================ # ============================================================================
# DATABASE # DATABASE
# ============================================================================ # ============================================================================
@@ -35,7 +38,7 @@ DATABASE_ENGINE=django.db.backends.sqlite3
# Имя/путь базы данных: # Имя/путь базы данных:
# - для SQLite: только имя файла (полный путь соберется в settings.py через PROJECT_ROOT/database) # - для SQLite: только имя файла (полный путь соберется в settings.py через PROJECT_ROOT/database)
# - для MySQL/MariaDB: имя базы # - для MySQL/MariaDB: имя базы
DATABASE_NAME=oknadria.sqlite3 DATABASE_NAME=oknardia.sqlite3
# Для MySQL/MariaDB (используются, если DATABASE_ENGINE=django.db.backends.mysql) # Для MySQL/MariaDB (используются, если DATABASE_ENGINE=django.db.backends.mysql)
# DATABASE_HOST=localhost # DATABASE_HOST=localhost
@@ -114,6 +117,16 @@ LOG_LEVEL=INFO
# CELERY_BROKER_URL=redis://localhost:6379/0 # CELERY_BROKER_URL=redis://localhost:6379/0
# CELERY_RESULT_BACKEND=redis://localhost:6379/0 # CELERY_RESULT_BACKEND=redis://localhost:6379/0
# ============================================================================
# DOCKER: LOCAL PRODUCTION TESTING
# ============================================================================
# Разрешить Django обслуживать медиа-файлы через Python (только для локального тестирования)
# ВАЖНО: В настоящем production медиа и статику обслуживает Nginx, а не Django!
# Используется ТОЛЬКО в docker-compose.local-prod.yml для локального тестирования production конфигурации.
# На production сервере НЕ устанавливайте это значение в True!
ALLOW_MEDIA_SERVE=False
# ============================================================================ # ============================================================================
# ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ # ИНСТРУКЦИЯ ПО ИСПОЛЬЗОВАНИЮ
# ============================================================================ # ============================================================================
@@ -139,3 +152,18 @@ LOG_LEVEL=INFO
# - AWS Systems Manager Parameter Store # - AWS Systems Manager Parameter Store
# - HashiCorp Vault # - HashiCorp Vault
# ****************************************************************************************************************
# Системные пути на хосте (ТОЛЬКО ДЛЯ ПРОДАКШЕНА)
# Используется скриптом для генерации корректного Nginx конфига (alias к медиа-файлам).
# На локальной машине разработчика (Dev) эта переменная игнорируется.
# В ПРОДАКШЕНЕ: Укажите полный путь к папке проекта на сервере
HOST_PROJECT_PATH=/home/username/projects
# ****************************************************************************************************************
# Настройки доступа к пакетам в репозитории, чтобы wathtower мог проверять их свежесть и скачивать для обновления.
# Получить эти данные можно в настройках вашего репозитория, например:
# для GitHub: в разделе "Developer settings" -> "Personal access tokens";
# для Gitea: в разделе "Settings / Настройки" -> "Actions / Действия" -> "Secrets / Секреты".
REPO_USER=[login]
REPO_PASS=[token]

View File

@@ -0,0 +1,72 @@
name: Build and Push Docker Image
run-name: Build and Push Docker Image ${{ github.ref_name }}
on:
push:
# Запускать сборку только при создании тега, начинающегося с 'v' (например, v1.0.0, v2.3.1)
tags:
- 'v*'
env:
REGISTRY: git.cube2.ru
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest # Или метка вашего раннера, если он специфичный (например, macos или self-hosted)
container:
image: catthehacker/ubuntu:act-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Настройка QEMU для мультиплатформенной сборки (если нужно собирать под разные архитектуры)
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# Настройка Docker Buildx (обязательно для build-push-action)
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Логин в реестр Gitea
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD }}
# Извлечение метаданных (тегов и лейблов) для Docker
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=raw,value=latest,enable=${{ github.ref_type == 'tag' }}
# Сборка и отправка образа
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: Dockerfile
push: true
# Собираем под текущую архитектуру (linux/amd64).
# Если сервер и MacMini на разных архитектурах (x86 vs ARM), добавьте нужные, например: linux/amd64,linux/arm64
# platforms: linux/amd64,linux/arm64
# ---
# Собираем только под linux/amd64 (для скорости + все равно сервер на x86).
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# ДОБАВЛЕНО для медленного интернета и оптимизации сборки:
cache-from: type=gha
cache-to: type=gha,mode=max
timeout: 1800 # Увеличено до 30 минут на всю сборку

4
.gitignore vendored
View File

@@ -164,3 +164,7 @@ db.json.zip
.log/ .log/
.logs/ .logs/
sitemap*.xml sitemap*.xml
# Django static files (собранная статика, пересоздается при деплое)
public/static_collected/

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

88
Dockerfile Normal file
View File

@@ -0,0 +1,88 @@
# =================================================
# STAGE 1: Builder - Установка зависимостей
# =================================================
FROM python:3.12-slim AS builder
# Устанавливаем переменные окружения
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PIP_DEFAULT_TIMEOUT=100
# Устанавливаем Poetry
RUN pip install --no-cache-dir --default-timeout=100 --retries 10 poetry poetry-plugin-export
# Создаем рабочую директорию
WORKDIR /app
# Копируем только файлы зависимостей для кэширования этого слоя
COPY pyproject.toml poetry.lock /app/
# Экспортируем lock-файл в requirements.txt и ставим зависимости через pip.
# Это обычно быстрее и проще для Docker, чем полноценная установка через Poetry.
RUN poetry export --format requirements.txt --without-hashes --with dev --output /tmp/requirements.txt \
&& pip install --no-cache-dir --default-timeout=100 --retries 10 -r /tmp/requirements.txt
# =================================================
# STAGE 2: Final - Создание чистого и безопасного образа
# =================================================
FROM python:3.12-slim AS stage-final
# Устанавливаем переменные окружения
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=oknardia.settings
# Удалить: Для DEV окружения отключены создание непривилегированного пользователя
# и все связанные с ним операции chown — контейнер запускается от root,
# что избегает проблем с доступом к смонтированным томам (база, статика).
# RUN addgroup --system app && adduser --system --ingroup app app
# Создаем рабочую директорию (где находится manage.py)
WORKDIR /home/app/oknardia
# Копируем установленные Python-пакеты из builder-стадии
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
# Удалить для dev: Копируем исходный код проекта. В DEV хозяин files не критичен (root).
# COPY --chown=1000:1000 . .
# Копируем весь проект в /home/app (корень проекта)
COPY . /home/app/
# Удалить для dev: Создание директорий и установка прав
# RUN mkdir -p /nginx_configs_host/nginx && chown -R 1000:1000 /nginx_configs_host
# RUN mkdir -p /home/app/web/public/staticfiles && chown -R 1000:1000 /home/app/web/public
# RUN mkdir -p /home/app/web/public/media/_error && chown -R 1000:1000 /home/app/web/public/media
# RUN mkdir -p /home/app/web/database && chown -R 1000:1000 /home/app/web/database
# Удалить для dev: USER 1000 — для DEV запускаем от root
# USER 1000
# Собираем статику
# Используем dummy ключ, так как .env файла нет на этапе сборки
# ВАЖНО: В DEV режиме collectstatic запускается в docker-compose command, а не при сборке,
# чтобы избежать ошибок с недоступными файлами.
# RUN SECRET_KEY=dummy python oknardia/manage.py collectstatic --noinput --clear
# Открываем порт
EXPOSE 8000
# Проверка здоровья контейнера
# Docker будет периодически проверять, жив ли контейнер, отправляя GET запрос к главной странице.
# Параметры:
# --interval=30s - проверка каждые 30 секунд
# --timeout=3s - ожидаем ответ максимум 3 секунды
# --start-period=10s - даем контейнеру 10 секунд на запуск перед первой проверкой
# --retries=3 - объявляем контейнер unhealthy после 3 неудачных попыток
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/').read()" || exit 1
# Переходим в директорию с manage.py для корректного запуска Django
WORKDIR /home/app/oknardia
# Команда запуска по умолчанию (для продакшена).
# В DEV режиме используется runserver через docker-compose.local.yml,
# который автоматически отдаёт статику и имеет auto-reload.
CMD ["python", "-m", "gunicorn", "--workers", "2", "--bind", "0.0.0.0:8000", "oknardia.wsgi:application"]

View File

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

242
PRODUCTION_DEPLOY.md Normal file
View File

@@ -0,0 +1,242 @@
# Production Deployment Guide для Oknardia
## Структура на хосте
Структура директорий на production сервере должна быть:
```
~/docker-app/oknardia-site/
├── .env # Переменные окружения Django
├── config/ # Конфиги
│ └── nginx/
│ └── oknardia-app--external-nginx.conf # Конфиг Nginx (шаблон)
├── database/ # БД SQLite (создается автоматически)
│ └── oknardia.sqlite3
├── media/ # Медиа файлы, uploads, кеши
│ ├── img_avatar/
│ ├── img_seria/
│ ├── _serv_sitemap/
│ └── _error/ # Error pages (генерируются контейнером)
└── docker-compose.prod.yml # Конфиг Docker
```
## Установка на Production сервере
### 1. Создать директории и скопировать файлы
```bash
# На хосте
mkdir -p ~/docker-app/oknardia-site/{config/nginx,database,media}
# Скопировать docker-compose.prod.yml и .env из проекта
cp docker-compose.prod.yml ~/docker-app/oknardia-site/
cp .env ~/docker-app/oknardia-site/
# Скопировать конфиг nginx
cp config/nginx/oknardia-app--external-nginx.conf ~/docker-app/oknardia-site/config/nginx/
```
### 2. Настроить .env на сервере
Отредактировать `~/docker-app/oknardia-site/.env`:
```bash
# ОБЯЗАТЕЛЬНО заполнить эти переменные!
# Путь к проекту на хосте (используется для sed-замены в конфиге nginx)
HOST_PROJECT_PATH=/home/default_user/projects/oknardia-site
# Credentials для скачивания образа из реестра Gitea
REPO_USER=имя_пользователя_gitea
REPO_PASS=токен_из_личногоабинета_gitea
# Остальное скопировать из локального .env
DEBUG=False
DJANGO_SECRET_KEY=...
```
**Важно:** `HOST_PROJECT_PATH` должен совпадать с корневой папкой проекта на хосте, куда ты скопировал docker-compose.prod.yml!
### 3. Запустить контейнеры
```bash
cd ~/docker-app/oknardia-site/
# Первый запуск (холодный старт)
docker compose -f docker-compose.prod.yml up -d
# Проверить логи
docker compose -f docker-compose.prod.yml logs -f web
# Проверить статус контейнеров
docker compose -f docker-compose.prod.yml ps
```
### 4. Проверить что nginx конфиг был сгенерирован
После первого запуска контейнер должен:
- Создать `/config/nginx/oknardia-app--external-nginx.conf` (боевой конфиг)
- Создать `/config/nginx/oknardia-app--external-nginx.conf.example` (exemplo)
```bash
ls -la ~/docker-app/oknardia-site/config/nginx/
```
### 5. Настроить Nginx на хосте
На хосте установить Nginx и скопировать/ссылаться на конфиг из контейнера:
```bash
# На хосте (например, Ubuntu 20.04)
sudo systemctl install nginx
# Включить сгенерированный конфиг в основной конфиг Nginx
# /etc/nginx/sites-available/oknardia или /etc/nginx/conf.d/oknardia.conf
# Пример:
sudo ln -s /home/default_user/projects/oknardia-site/config/nginx/oknardia-app--external-nginx.conf \
/etc/nginx/sites-enabled/oknardia
# Проверить и перезагрузить Nginx
sudo nginx -t
sudo systemctl reload nginx
```
## Команды для управления
```bash
cd ~/docker-app/oknardia-site/
# Запустить контейнеры (если они были остановлены)
docker compose -f docker-compose.prod.yml up -d
# Остановить контейнеры
docker compose -f docker-compose.prod.yml down
# Перезагрузить контейнеры (удалить и создать заново)
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
# Посмотреть логи
docker compose -f docker-compose.prod.yml logs -f web
docker compose -f docker-compose.prod.yml logs -f watchtower
# Выполнить команду внутри контейнера Django
docker compose -f docker-compose.prod.yml exec web python manage.py shell
# Пересоздать пре-рендер кеши вручную
docker compose -f docker-compose.prod.yml exec web python manage.py regenerate_seria_prerender
```
## Что делает docker-compose.prod.yml
### Сервис `web` (Django + Gunicorn)
При старте контейнера:
1. ✅ Применяет миграции БД (`migrate --noinput`)
2. ✅ Собирает статику (`collectstatic --noinput`)
3. ✅ Генерирует sitemap'ы (`generate_sitemaps`)
4. ✅ Пересоздает пре-рендер кеши для серий (`regenerate_seria_prerender`)
5. ✅ Пересчитывает рейтинги (`make_rating`)
6. ✅ Копирует конфиг nginx с авто-заменой путей (через `sed`)
7. ✅ Запускает Gunicorn на `127.0.0.1:8000`
### Сервис `watchtower` (авто-обновление)
- 👁️ Следит за реестром Gitea (проверка каждые 30 минут)
- 🔄 Автоматически скачивает новый образ, если он есть
- ♻️ По-graceful перезагружает контейнер web
- 🗑️ Удаляет старые образы (WATCHTOWER_CLEANUP=true)
Watchtower использует метку на контейнере `web`:
```yaml
labels:
- "com.centurylinklabs.watchtower.scope=oknardia-scope"
```
## Файлы, которые монтируются с хоста
| На хосте | В контейнере | Назначение |
|----------|----------|-----------|
| `./database/` | `/home/app/database/` | БД SQLite (КРИТИЧНО!) |
| `./media/` | `/home/app/public/media/` | Медиа файлы, uploads |
| `./config/` | `/nginx_configs_host/` | Конифиги (читаются и пишущутся контейнером) |
## Переменные окружения в docker-compose.prod.yml
```yaml
environment:
- DEBUG=False # Production: без debug
- DJANGO_LOG_LEVEL=INFO # Минимум логов
- ALLOW_MEDIA_SERVE=False # Nginx обслуживает медиа
- HOST_PROJECT_PATH=... # Путь для sed-замены в конфиге nginx
- REPO_USER=${REPO_USER} # Из .env (для Watchtower)
- REPO_PASS=${REPO_PASS} # Из .env (для Watchtower)
```
## Health Check
Контейнер имеет healthcheck, который проверяет доступность Django:
- Проверка каждые 3 минуты
- Таймаут: 12 секунд
- Unhealthy после 3 неудачных попыток
- Это критично для Watchtower (он ждет что контейнер станет healthy перед finalization)
## Troubleshooting
### ContainerError при запуске
Проверить логи:
```bash
docker compose -f docker-compose.prod.yml logs web
```
### БД не синхронизируется между перезагрузками
Убедиться что `./database/` правильно монтирует:
```bash
docker compose -f docker-compose.prod.yml exec web ls -la /home/app/database/
```
### Watchtower не обновляет контейнер
Проверить:
- Есть ли интернет на сервере
- Правильны ли REPO_USER и REPO_PASS в .env
- Существует ли образ `oknardia:latest` в реестре
```bash
docker compose -f docker-compose.prod.yml logs watchtower
```
### Nginx конфиг не обновился
Конфиг копируется при старте контейнера. Если нужно пересоздать:
```bash
docker compose -f docker-compose.prod.yml down
docker compose -f docker-compose.prod.yml up -d
```
Потом проверить:
```bash
cat ~/docker-app/oknardia-site/config/nginx/oknardia-app--external-nginx.conf
```
## Резервные копии
Важно регулярно бэкапить:
- `./database/oknardia.sqlite3` - БД
- `./media/` - Загруженные файлы
Пример cronjob для ежедневного бэкапа:
```bash
# /etc/cron.daily/backup-oknardia
#!/bin/bash
BACKUP_DIR="/backup/oknardia"
APP_DIR="/home/default_user/projects/oknardia-site"
mkdir -p $BACKUP_DIR
tar -czf $BACKUP_DIR/oknardia-$(date +%Y%m%d).tar.gz $APP_DIR
find $BACKUP_DIR -name "oknardia-*.tar.gz" -mtime +30 -delete # Удалять старше 30 дней
```

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-ассистентов (архитектура, конвенции, рабочие сценарии). * [`AGENTS.md`](AGENTS.md) контекст проекта для AI-ассистентов (архитектура, конвенции, рабочие сценарии).
* [`SETUP.md`](SETUP.md) пошаговая настройка окружения, запуск проекта и базовые команды разработки. * [`SETUP.md`](SETUP.md) пошаговая настройка окружения, запуск проекта и базовые команды разработки.
@@ -65,14 +66,25 @@
В папке `oknardia/templates/seria_info/prepared/` создаются пре-рендер HTML-шаблоны с информацией о сериях домов. В папке `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)

407
SETUP.md
View File

@@ -1,330 +1,187 @@
# 🚀 SETUP.md — Первичная настройка Окнардии # 🚀 SETUP.md — Первичная настройка Окнардии
**Версия**: 0.2.0 | **Дата**: 16.04.2026 **Версия**: 0.2.0 | **Дата**: 18.05.2026 | **Docker**: ✅ Поддерживается
Этот документ описывает пошаговую настройку проекта для разработки и деплоя. Этот документ описывает пошаговую настройку проекта для разработки и деплоя.
## 📋 Предварительные требования ---
- **Python**: 3.12+ ## 🐳 Быстрый старт: Docker Dev Environment
- **Django**: 6.0+
- **MariaDB/MySQL**: 5.7+ или 8.0+
- **Redis** (опционально, для кеширования): 6.0+
- **Poetry** (для управления зависимостями)
### На macOS: ### 1⃣ Запуск контейнера
```bash ```bash
# Установка зависимостей (если не установлены) cd /Users/e-serg/PRJ/2022-oknardia
brew install mariadb-connector-c docker compose -f docker-compose.local.yml up --build
brew install redis # опционально
``` ```
## 🔑 Шаг 1: Конфигурация секретов Сайт будет доступен на **http://localhost:8060**
### 1.1 Создайте файл `my_secret.py` ### 2⃣ Основные команды
```bash ```bash
cd oknardia/oknardia # Просмотр логов в реальном времени
cp my_secret.py.template my_secret.py docker compose -f docker-compose.local.yml logs web -f
nano my_secret.py # отредактируйте значения
# Зайти в контейнер (bash)
docker compose -f docker-compose.local.yml exec web bash
# Перезагрузить контейнер
docker compose -f docker-compose.local.yml restart web
# Остановить контейнер
docker compose -f docker-compose.local.yml down
``` ```
**Что нужно заполнить:** **✨ Особенности:**
- IP адреса и хосты (MY_HOST_HOME2, MY_DATABASE_HOST_DEV2) - **Live reload** — при изменении кода автоматически перезагружается
- Пароль БД (MY_DATABASE_PASSWORD_DEV) - **Синхронизция файлов** — база, медиа, статика синхронизированы с хостом
- Email credentials (MY_EMAIL_HOST_USER_DEV, MY_EMAIL_HOST_PASSWORD_DEV) - **Миграции автоматические** — применяются при каждом старте
- Пути к файлам (MY_MEDIA_ROOT_DEV2, MY_STATIC_ROOT_DEV2) - **DEBUG режим** — подробные ошибки и админка
- SECRET_KEY (сгенерируйте новый!)
### 1.2 (Опционально) Создайте файл `.env.local` 👁️ **Подробнее про Docker разработку** → см. раздел **"🐳 Docker Development"** ниже.
```bash ---
cd /path/to/project
cp .env.example .env.local ## 🐳 Docker Development
nano .env.local # отредактируйте значения
### Структура контейнера
```
/home/app/ # PROJECT_ROOT
├── oknardia/ # основная папка Django
│ ├── manage.py # точка входа
│ ├── oknardia/ # конфиг Django
│ ├── web/ # приложение
│ └── templates/ # шаблоны
├── database/ # SQLite БД (синхронизирована)
├── public/
│ ├── static/ # исходная статика
│ ├── static_collected/ # собранная статика
│ └── media/ # загруженные файлы
└── ...
``` ```
**Примечание**: либо используйте `my_secret.py`, либо `.env.local`, выбирайте удобный способ. ### Volume mounts (синхронизация)
## 🗄️ Шаг 2: Настройка БД ```yaml
volumes:
### 2.1 Создайте БД и пользователя - .:/home/app
```bash
# Подключитесь к MySQL
mysql -u root -p
# В MySQL консоли:
CREATE DATABASE django_oknardia_dev;
CREATE USER 'web'@'localhost' IDENTIFIED BY 'your-password';
GRANT ALL PRIVILEGES ON django_oknardia_dev.* TO 'web'@'localhost';
FLUSH PRIVILEGES;
EXIT;
``` ```
### 2.2 Выполните миграции Это монтирует весь проект (`/Users/e-serg/PRJ/2022-oknardia`) в `/home/app` контейнера.
**Синхронизация:**
- Изменения на хосте сразу видны в контейнере
- Разные между хостом и контейнером сохраняются на диск
- БД и медиа-файлы персистент (не теряются при рестарте контейнера)
### Миграции в Docker
```bash ```bash
cd /path/to/project/oknardia # Автоматические при запуске (через docker-compose command):
python manage.py migrate --noinput
# Или вручную внутри контейнера:
docker compose -f docker-compose.local.yml exec web bash
python manage.py makemigrations
python manage.py migrate python manage.py migrate
# Или непосредственно внутри контейнера:
docker compose -f docker-compose.local.yml exec web python manage.py migrate
``` ```
### 2.3 Создайте суперпользователя ### Установка новых пакетов
```bash ```bash
python manage.py createsuperuser # 1. На хосте добавьте в pyproject.toml:
poetry add some_package
# 2. Пересоберите контейнер:
docker compose -f docker-compose.local.yml up --build
# 3. Зависимости переустановят при старте контейнера
``` ```
## 📦 Шаг 3: Установка зависимостей ### Создание суперюзера (админ)
### Вариант 1: Poetry (рекомендуется)
```bash ```bash
# Установите poetry (если не установлен) docker compose -f docker-compose.local.yml exec web python manage.py createsuperuser
curl -sSL https://install.python-poetry.org | python3 -
# Установите зависимости
poetry install
# Активируйте виртуальное окружение
poetry shell
``` ```
### Вариант 2: pip (классический способ) ### Очистка кеша и статики
```bash ```bash
python -m venv venv # Пересоберите статику:
source venv/bin/activate # На Windows: venv\Scripts\activate docker compose -f docker-compose.local.yml exec web python manage.py collectstatic --clear --noinput
pip install -r requirements.txt
# Или удалите и пересоздайте:
rm -rf ./public/static_collected/*
docker compose -f docker-compose.local.yml restart web
``` ```
## 🏃 Шаг 4: Запуск разработки ### DEBUG и логирование
### 4.1 Запустите локальный сервер
```bash ```bash
cd oknardia # DEBUG=True включен по умолчанию
python manage.py runserver # Смотрите подробные логи:
docker compose -f docker-compose.local.yml logs web -f --tail=50
# Или с фильтром (только ошибки):
docker compose -f docker-compose.local.yml logs web --tail=100 | grep -i error
``` ```
Откройте браузер: **http://127.0.0.1:8000** ### Типичные проблемы Docker
### 4.2 Запустите задачи Celery (опционально) **"unable to open database file"**
```bash
# Проверьте что БД существует:
ls -la ./database/oknardia.sqlite3
# Если нет:
cp ./database/oknadria_backup-2026-05-12.sqlite3 ./database/oknardia.sqlite3
docker compose -f docker-compose.local.yml restart web
```
**"404 Not Found" для статики**
```bash
# Пересоберите статику:
docker compose -f docker-compose.local.yml exec web python manage.py collectstatic --noinput
docker compose -f docker-compose.local.yml restart web
```
**Контейнер падает**
```bash
# Смотрите полные логи:
docker compose -f docker-compose.local.yml logs web --tail=200
# Часто ошибка в settings.py или import'ах
```
**"Connection refused" при обращении в БД**
```bash
# Проверьте что контейнер запущен:
docker compose -f docker-compose.local.yml ps
# Перезагрузитесь:
docker compose -f docker-compose.local.yml down && docker compose -f docker-compose.local.yml up
```
### Очистка Docker
```bash ```bash
celery -A oknardia worker -l info # Удалить контейнер и образ:
docker compose -f docker-compose.local.yml down -v
# Удалить весь Docker мусор:
docker system prune -a --volumes
``` ```
## 📁 Шаг 5: Создание необходимых директорий ---
## Дополнительные ресурсы
```bash
# Статика и медиа файлы
mkdir -p public/media
mkdir -p public/static
mkdir -p public/static/img/_flap.cfg
mkdir -p public/static/img/_miniflap.cfg
# Логи
mkdir -p logs
# Сгенерируйте статику
python manage.py collectstatic --noinput
```
## 🧪 Шаг 6: Тестирование
```bash
# Запустите тесты
python manage.py test
# С покрытием (если установлен coverage)
coverage run --source='.' manage.py test
coverage report
```
## 🔐 Шаг 7: Проверка безопасности
### 7.1 Django встроенная проверка
```bash
python manage.py check --deploy
```
### 7.2 Проверка на утечки секретов
```bash
# Установите инструмент
pip install truffleHog
# Проверьте репозиторий
truffleHog filesystem . --json
```
## ✅ Проверка готовности
Убедитесь, что все работает:
```bash
# 1. Статус БД
python manage.py dbshell < /dev/null && echo "✓ Database OK"
# 2. Статус приложений Django
python manage.py check && echo "✓ Django OK"
# 3. Статус файлов
test -d public/media && test -d public/static && echo "✓ Directories OK"
# 4. Тесты
python manage.py test 2>&1 | tail -5
```
## 🚀 Развертывание на продакшене
### Для разных хостов
**Masterhost VDS:**
```bash
# Установка окружения
export DJANGO_SECRET_KEY="your-production-key"
export DATABASE_PASSWORD="production-db-password"
export DATABASE_HOST="localhost"
export DEBUG="False"
# Запуск через uWSGI + Nginx
uwsgi --ini config/oknardia.ini
```
**Docker (рекомендуется):**
```bash
docker build -t oknardia:latest .
docker run -d \
-e DJANGO_SECRET_KEY="..." \
-e DATABASE_PASSWORD="..." \
-p 8000:8000 \
oknardia:latest
```
## 🛠️ Полезные команды
```bash
# Управление миграциями
python manage.py makemigrations # Создать миграцию
python manage.py migrate # Применить миграции
python manage.py migrate --fake-initial # Подделать первую миграцию
# Управление данными
python manage.py shell # Интерпретатор Python с контекстом Django
python manage.py dumpdata > backup.json # Резервная копия данных
python manage.py loaddata backup.json # Восстановление данных
# Статика и медиа
python manage.py collectstatic # Собрать статику для продакшена
python manage.py findstatic # Найти файлы статики
# Администрирование
python manage.py createsuperuser # Создать администратора
python manage.py changepassword username # Изменить пароль
# Очистка
python manage.py clearsessions # Удалить старые сессии
python manage.py remove_stale_contenttypes # Удалить устаревшие типы контента
# Служебные
python manage.py check # Проверить конфигурацию
python manage.py check --deploy # Проверка для продакшена
python manage.py generate_sitemaps # Оффлайн генерация sitemap XML
python manage.py regenerate_seria_prerender --dry-run # Проверка пересборки pre-render шаблонов серий
python manage.py regenerate_seria_prerender --force # Принудительная пересборка pre-render шаблонов серий
```
### Пересборка pre-render шаблонов серий (рекомендуемый сценарий)
Шаблоны для `catalog_seria_info` пересобираются оффлайн management-командой, без reload из кода Django.
```bash
cd /path/to/project
poetry run python oknardia/manage.py regenerate_seria_prerender --force
# затем (опционально) один внешний reload процесса приложения, если это требуется вашей конфигурацией
# sudo systemctl reload gunicorn
```
Для выборочной пересборки используйте `--seria-id` несколько раз:
```bash
poetry run python oknardia/manage.py regenerate_seria_prerender --seria-id 843 --seria-id 2100 --force
```
## 📚 Дополнительные ресурсы
- [Django документация](https://docs.djangoproject.com/en/stable/) - [Django документация](https://docs.djangoproject.com/en/stable/)
- [AGENTS.md](./AGENTS.md) — архитектура и конвенции проекта - [AGENTS.md](./AGENTS.md) — архитектура и конвенции проекта
- [README.md](./README.md) — основная информация о проекте - [README.md](./README.md) — основная информация о проекте
## ❓ Решение проблем
### Проблема: `mysqlclient` не устанавливается на macOS
**Решение:**
```bash
brew install mariadb-connector-c
pip install mysqlclient
# или
brew unlink mariadb-connector-c # после установки
```
### Проблема: `ModuleNotFoundError: No module named 'oknardia'`
**Решение:**
```bash
# Убедитесь, что находитесь в правильной директории
cd /path/to/project/oknardia
python manage.py runserver
```
### Проблема: `OperationalError: (2002, "Can't connect to local MySQL server")`
**Решение:**
```bash
# Проверьте, что MySQL запущен
# macOS:
brew services start mariadb
# Linux:
sudo systemctl start mysql
# Проверьте credentials в my_secret.py или .env
```
### Проблема: миграции не применяются
**Решение:**
```bash
# Проверьте статус миграций
python manage.py showmigrations
# Примените все миграции
python manage.py migrate --run-syncdb
# Если проблема в конкретной миграции
python manage.py migrate app_name 0001 --fake
python manage.py migrate app_name
```
## 🤝 Общие вопросы
**Q: Где хранятся секреты?**
A: В `my_secret.py` (в .gitignore) или переменных окружения (.env)
**Q: Как запустить проект без интернета?**
A: Установите все зависимости заранее, используйте локальное хранилище медиа
**Q: Как работает система рейтинга?**
A: Смотрите [AGENTS.md](./AGENTS.md), раздел "Система рейтинга и ранжирования"
---
**Версия документа**: 1.0
**Последнее обновление**: 16.04.2026
**Автор**: GitHub Copilot

View File

@@ -0,0 +1,147 @@
# config/nginx/oknardia-app--external-nginx.conf
# ==============================================================================
# КОНФИГУРАЦИОННЫЙ ФАЙЛ NGINX (Reverse Proxy для Docker + Production)
# ==============================================================================
#
# ИНФОРМАЦИЯ:
# 1. Этот файл используется как шаблон при деплое
# 2. При первом деплое пути `/home/user/path-to-oknardia-app` заменяются на реальный путь через sed
# 3. Сгенерированный конфиг скопируется в `/etc/nginx/sites-available/oknardia`
# 4. Последующие деплои ОБНОВЛЯЮТ этот файл автоматически (sed + копирование)
#
# АРХИТЕКТУРА:
# - Nginx (порты 80/443) <-> Gunicorn контейнер (localhost:8000)
# - Медиа файлы отдаются из `/home/user/path-to-oknardia-app/media/` напрямую
# - Ошибки 5xx берутся из `media/_error` (копируются контейнером при старте)
# - Sitemap.xml отдается через Django/WhiteNoise (в media/_serv_sitemap/)
# - Static файлы (/static/) тоже отдаются через Django/WhiteNoise
# 1. Описываем, где живет наш Django в Docker
upstream oknardia-django {
# Мы пробрасываем порт 8050 из контейнера наружу (в docker-compose.yml имя сервиса 'web', контейнер 'oknardia-backend')
server 127.0.0.1:8060;
keepalive_requests 200;
}
# 2. Конфигурируем сервер
server {
server_name tmp.oknardia.ru; # Основное доменное имя
# Слушаем 80 порт (Certbot потом добавит сюда редирект на 443 и настройки SSL)
listen 80;
listen [::]:80;
charset utf-8;
client_max_body_size 10M; # Разрешаем загрузку не слишком больших картинок
# Логи (пути могут отличаться в зависимости от настроек сервера, здесь стандартные для Ubuntu)
access_log /var/log/nginx/oknardia.access.log;
error_log /var/log/nginx/oknardia.error.log;
# --- GZIP (Сжатие) ---
# Очень важно для динамического HTML от Django, который Gunicorn отдает несжатым.
gzip on;
gzip_vary on; # Добавляет заголовок Vary: Accept-Encoding
gzip_proxied any; # Сжимать ответы, даже если мы за прокси
gzip_comp_level 6; # Оптимальный баланс скорость/сжатие
gzip_min_length 1000; # Не сжимать совсем мелочь
# Типы файлов для сжатия (HTML сжимается автоматически, его писать не нужно)
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/xml+rss
image/svg+xml
image/x-icon
application/vnd.ms-fontobject
font/woff
font/woff2;
# --- МЕДИА ФАЙЛЫ (Загруженный контент) ---
# Nginx отдает их напрямую с диска хоста, не дергая Docker.
# Сюда входит: загруженные картинки, документы, свитмапы, ошибки 5xx
# Путь должен совпадать с тем, где лежит volume на хост-машине.
location /media/ {
alias /home/user/path-to-oknardia-app/media/;
expires 30d; # Кешируем картинки на месяц
add_header Cache-Control "public, no-transform";
}
# --- SITEMAP.XML ---
# Sitemap хранится в media/_serv_sitemap/ но должен быть доступен из корня
# для поисковиков (они ищут http://example.com/sitemap.xml)
location = /sitemap.xml {
alias /home/user/path-to-oknardia-app/media/_serv_sitemap/sitemap.xml;
expires 7d; # Кешируем на неделю (редко меняется)
add_header Cache-Control "public";
}
# --- СТРАНИЦЫ ОШИБОК (Custom Error Pages) ---
# Если Django упал (502) или сработал тайм-аут (504), Nginx должен отдать статический HTML.
# Эти файлы копируются в `media/_error` при старте контейнера Docker.
#
# ВАЖНО: error_page директива перехватывает ошибки от апстрима (Gunicorn).
error_page 500 /500.html;
error_page 502 /502.html;
error_page 503 /503.html;
error_page 504 /504.html;
location = /500.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /502.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /503.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /504.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
error_page 400 /400.html;
error_page 401 /401.html;
error_page 403 /403.html;
error_page 404 /404.html;
error_page 413 /413.html;
error_page 429 /429.html;
location = /400.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /401.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /403.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /404.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /413.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
location = /429.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
error_page 405 406 407 408 409 410 411 412 414 415 416 417 418 421 422 423 424 425 426 428 431 451 /under_reconstruction.html;
location = /under_reconstruction.html { root /home/user/path-to-oknardia-app/media/_error; internal; }
# --- ВСЁ ОСТАЛЬНОЕ (Django + WhiteNoise) ---
# Статика (/static/), robots.txt, favicon.ico и сам сайт обрабатываются внутри контейнера.
# Nginx просто прокидывает запрос внутрь.
location / {
proxy_pass http://oknardia-django;
# Передаем правильные заголовки, чтобы Django знал реальный IP и протокол
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Если нужно чтобы Django обрабатывал и HTTP, и HTTPS, то можно раскомментировать эту строку
# и передавать реальный протокол от клиента
# proxy_set_header X-Forwarded-Proto $scheme;
# Явно указываем https, потому что клиент всегда приходит по HTTPS к Nginx
# Даже если внутри контейнера это HTTP на 127.0.0.1:8050, для Django это должно быть HTTPS
proxy_set_header X-Forwarded-Proto https;
# Тайм-ауты (важно для долгих операций, если они есть)
proxy_read_timeout 180s;
proxy_connect_timeout 180s;
}
}
# 3. Редирект с www на основной домен (SEO best practice)
server {
server_name www.tmp.oknardia.ru;
listen 80;
listen [::]:80;
return 301 $scheme://tmp.oknardia.ru$request_uri;
}

View File

@@ -1,110 +0,0 @@
# Разработка сайта OKNARDIA.RU
# == Конфикурационный файл nginx oknardia.conf
# Описываем апстрим-потоки которые должен подключить Nginx
# Для каждого сайта надо настроить свйо поток, со своим уникальным именем.
# Если будете настраивать несколько python (django) сайтов - измените название upstream
upstream oknardia-django {
# расположение файла Unix-сокет для взаимодействие с uwsgi
server unix:/home/web/oknardia-ru/socket/oknardia.sock;
# server ///home/web/oknardia-ru/socket/oknardia.sock;
# также можно использовать веб-сокет (порт) для взаимодействие с uwsgi. Но это медленнее
# server 127.0.0.1:8001; # для взаимодействия с uwsgi через веб-порт
keepalive_requests 200;
}
# конфигурируем сервер
server {
server_name tmp.cube2.ru; # доменное имя сайта
listen 80; # managed by Certbot
## listen 443 ssl http2; # managed by Certbot
## ssl_certificate /etc/letsencrypt/live/cadpoint.ru/fullchain.pem; # managed by Certbot
## ssl_certificate_key /etc/letsencrypt/live/cadpoint.ru/privkey.pem; # managed by Certbot
## include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
## ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
# server_name 90.156.203.25; # доменное имя сайта
charset utf-8; # кодировка по умолчанию
access_log /home/web/oknardia-ru/logs/oknardia-access.log; # логи с доступом
error_log /home/web/oknardia-ru/logs/oknardia-error.log; # логи с ошибками
client_max_body_size 100M; # максимальный объем файла для загрузки на сайт (max upload size)
error_page 404 /404.html;
error_page 500 /500.html;
location /media { alias /home/web/oknardia-ru/public/media; } # Расположение media-файлов Django
location /static { alias /home/web/oknardia-ru/public/static; } # Расположение static-файлов Django
location /robots.txt { root /home/web/oknardia-ru/public; } # Расположение robots.txt
location /favicon.ico { root /home/web/oknardia-ru/public; } # Расположение favicon.ico
location /favicon.gif { root /home/web/oknardia-ru/public; } # Расположение favicon
location /favicon.png { root /home/web/oknardia-ru/public; } # Расположение favicon
location /favicon.svg { root /home/web/oknardia-ru/public; } # Расположение favicon
location /author.txt { root /home/web/oknardia-ru/public; } # Расположение author.txt
location = /404.html {
root /home/web/oknardia-ru/oknardia/templates/404.html;
internal;
}
location = /500.html {
root /home/web/oknardia-ru/oknardia/templates/500.html;
internal;
}
# location ~ \.(html|htm|ico|svg|png|gif|jpg|jpeg)$ {
location ~ \.(xml|html|htm|ico|svg|png|gif|jpg|jpeg)$ {
root /home/web/oknardia-ru/public; # Расположение статичных *.xml, *.html и *.txt
}
location / {
uwsgi_pass oknardia-django; # upstream обрабатывающий обращений
include uwsgi_params; # конфигурационный файл uwsgi;
proxy_set_header Host $host;
# ограничение количества запросов c одного IP-адреса с помощью модуля Limit_Req_Module
# limit_req zone=one burst=20 nodelay;
# one — имя зоны настроеной в /etc/nginx/nginx.conf (для всех сайтов сервера) в блоке http {…}
# burst — максимальный всплеск активности, можно регулировать до какого значения запросов
# в секунду может быть всплеск запросов;
# nodelay — незамедлительно, при достижении лимита подключений, выдавать код 503
# (Service Unavailable) для этого IP
fastcgi_keep_conn on;
uwsgi_read_timeout 1800; # некоторые запросы на Raspbery pi очень долго обрабатываются. Например, переиндексация.
uwsgi_send_timeout 200; # на всякий случай время записи в сокет
}
}
# переадресация с www на "без" www
server {
server_name www.tmp.cube2.ru;
listen 80;
return 301 http://tmp.cube2.ru$request_uri;
## listen 443 ssl; # managed by Certbot
## ssl_certificate /etc/letsencrypt/live/cadpoint.ru/fullchain.pem; # managed by Certbot
## ssl_certificate_key /etc/letsencrypt/live/cadpoint.ru/privkey.pem; # managed by Certbot
## include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
## ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
}
# переадресация с http на https
##server {
## if ($host = oknardia.ru) {
## return 301 https://$host$request_uri;
## } # managed by Certbot
##
## listen 80;
## server_name cadpoint.ru;
## return 404; # managed by Certbot
##}
# переадресация с http на https для www
##server {
## if ($host = www.cadpoint.ru) {
## return 301 https://cadpoint.ru$request_uri;
## } # managed by Certbot
##
## listen 80;
## server_name www.cadpoint.ru;
## return 404; # managed by Certbot
##}

View File

@@ -1,63 +0,0 @@
# === Конфигурационный файл uwsgi cadpoint.ini
[uwsgi]
# НАСТРОЙКИ ДЛЯ DJANGO
# Корневая папка проекта (полный путь)
chdir = /home/web/oknardia-ru/oknardia
# Django wsgi файл rsvo_new/wsgi.py записываем так:
module = oknardia.wsgi
# полный путь к виртуальному окружению
home = /home/web/oknardia-ru/env
# полный путь к файлу сокета
socket = /home/web/oknardia-ru/socket/oknardia.sock
# Исходящие сообщения в лог
daemonize = /home/web/oknardia-ru/logs/oknardia_uwsgi.log
# ЗАГАДОЧНЫЕ НАСТРОЙКИ, ПО ИДЕЕ ОНИ НУЖНЫ, НО И БЕЗ НИХ ВСЁ РАБОТАЕТ
# расположение wsgi.py
wsgi-file = /home/web/oknardia-ru/oknardia/oknardia/wsgi.py
# расположение виртуального окружения (как оно работает если этот параметр не указан, не ясно)
virtualenv = /home/web/oknardia-ru/env
# имя файла при изменении которого происходит авторестарт приложения
# (когда этого параметра нет, то ничего не авторестартится, но с ним все рестартится.
# Cтоит изменить любой Python-исходник проекта, как изменения сразу вступают в силу.
touch-reload = /home/web/oknardia-ru/logs/touch-reload.txt
py-autoreload = 5
# НАСТРОЙКИ ОБЩИЕ
# быть master-процессом
master = true
# максимальное количество процессов
processes = 1
# если uWSGI установлен как сервис через apt-get то нужно установить еще плагин:
# sudo apt-get install uwsgi-plugin-python
# и добавить в этот конфиг: plugin = python
plugin = python3
# права доступа к файлу сокета. По умолчанию должно хватать 664. Но каких-то прав не хватает, поэтому 666.
chmod-socket = 666
# очищать окружение от служебных файлов uwsgi по завершению
vacuum = true
# количество секунд после которых подкисший процесс будет перезапущен
# Так как некоторе скрипты требуют изрядно времени (особенно полная переиндексация) то ставим значение побольше
harakiri = 2600
# В общем случае, при некотых значениях harakiri логах uWSGI может вываливаться предупреждение:
# WARNING: you have enabled harakiri without post buffering. Slow upload could be rejected on post-unbuffered webservers
# можно оставить harakiri закомментированным, но нам нужно 900 и на него не ругается. Ругается на 30.
# разрешаем многопоточность
enable-threads = true
vacuum = true
thunder-lock = true
max-requests = 500
# пользователь и группа пользователей от имени которых запускать uWSGI
# указываем www-data: к этой группе относится nginx, и ранее мы включили в эту группу нашего [user]
# uid = nginx
# gid = nginx
# uid = root
# gid = root
uid = web
gid = web
print = ---------------- Запущен uWSGI для oknardia.ru ----------------

View File

@@ -0,0 +1,90 @@
# ==============================================================================
# Docker Compose для PRODUCTION тестирования (Local Production Simulation)
# Этот файл содержит настройки для локального тестирования production конфигурации.
#
# ВАЖНО: Используется для отладки production сборки ЛОКАЛЬНО перед деплоем!
#
# Запуск: docker compose -f docker-compose.local-prod.yml up --build
# Остановка: docker compose -f docker-compose.local-prod.yml down
# ==============================================================================
services:
web:
# Имя контейнера для удобства
container_name: oknardia-backend-prod-test
# Сборка из текущей директории
build: .
# Пробрасывание портов (тестируем на другом порту, чтобы не конфликтовать с dev)
# Dev на 8060, Prod на 8061
ports:
- "8061:8000"
# 1. КОМАНДА ЗАПУСКА (Prod режим)
# Используем Gunicorn как в production.
# При старте контейнера:
# 1. Создаём медиа директорию
# 2. Применяем миграции
# 3. Собираем статику
# 4. Генерируем sitemap'ы
# 5. Пересоздаём пре-рендер шаблоны серий
# 6. Пересчитываем рейтинги профилей, стеклопакетов и наборов
# (метод Манна-Уитни: может занять 30-60 сек на БД с 94+ профилями)
# 7. Запускаем Gunicorn (нет live reload — это production режим!)
# (timeout=120 сек для запуска, достаточно для всех процедур инициализации)
command: >
sh -c "mkdir -p /home/app/public/media &&
python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
python manage.py generate_sitemaps &&
python manage.py regenerate_seria_prerender &&
python manage.py make_rating &&
python -m gunicorn --workers 2 --bind 0.0.0.0:8000 --timeout 120 oknardia.wsgi:application"
# 2. МОНТИРОВАНИЕ КОДА И ДАННЫХ
# Подключаем весь проект целиком, чтобы Django правильно вычислил пути.
# В production файлы будут в контейнере, но для локального тестирования
# монтируем для удобства остановки/пересоздания.
volumes:
# Весь проект в /home/app
- .:/home/app
# 3. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ (Production)
env_file:
# Основной конфиг из .env (DATABASE, EMAIL, API ключи)
- .env
environment:
# ПЕРЕОПРЕДЕЛЯЕМ для production режима:
- DEBUG=False # ВАЖНО: выключен debug для production
- DJANGO_LOG_LEVEL=INFO # Вместо DEBUG (меньше логов)
# Для локального тестирования: разрешить отдачу медиа через Django (только test!)
# В production это обслуживает Nginx!
- ALLOW_MEDIA_SERVE=True
# 4. РЕСУРСЫ (Для локального тестирования можно без лимитов, но в реальном production нужны)
# deploy:
# resources:
# limits:
# cpus: '1'
# memory: 512M
# reservations:
# cpus: '0.5'
# memory: 256M
# 5. ЛОГИРОВАНИЕ
# Для отладки production проблем полезно видеть логи
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 6. ЗДОРОВЬЕ КОНТЕЙНЕРА
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s

67
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,67 @@
# ==============================================================================
# Docker Compose для РАЗРАБОТКИ (Local Development)
# Этот файл содержит настройки для локальной работы (live reload, debug).
# Запуск: docker compose -f docker-compose.local up --build
# ==============================================================================
services:
web:
# Имя контейнера для удобства
container_name: oknardia-backend-dev
# Сборка из текущей директории
build: .
# Пробрасывание портов (чтобы сайт был доступен на localhost:8060)
# Занятые порты можно посмотреть через `sudo ss -tulpn`
ports:
- "8060:8000"
# 1. КОМАНДА ЗАПУСКА (Dev режим)
# Используем Django runserver для разработки:
# - Автоматически отдаёт статику без WhiteNoise
# - Имеет встроенный auto-reload при изменении кода
# - Безопаснее и проще для dev, чем Gunicorn
# - Миграции применяются автоматически при каждом старте
# - Пересоздаём пре-рендер шаблоны и sitemap'ы для актуальности
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
python manage.py generate_sitemaps &&
python manage.py regenerate_seria_prerender &&
python manage.py runserver 0.0.0.0:8000"
# 2. МОНТИРОВАНИЕ КОДА (Live Reload)
# Подключаем весь проект целиком в /home/app для правильного вычисления путей PROJECT_ROOT.
# При изменении кода runserver автоматически перезагружается (auto-reload).
#
# Структура монтирования:
# Host: /Users/e-serg/PRJ/2022-oknardia (.)
# Container: /home/app
# ├── oknardia/ <- исходный код (manage.py находится здесь)
# ├── database/ <- SQLite БД для синхронизации между хостом и контейнером
# ├── public/ <- статика и медиа-файлы
# └── templates/ <- Django шаблоны
volumes:
- .:/home/app
# 3. ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ
env_file:
# файл с переменными окружения для разработки
- .env
environment:
# На всякий случай принудительно включаем DEBUG и DEBUG-уровень логов (вдруг в .env что-то не так)
# Эти настройки более приоритетные, чем в .env (если дублируются).
- DEBUG=True
- DJANGO_LOG_LEVEL=DEBUG
# В dev нам не нужно ограничивать буферизацию так строго, но не помешает.
# 4. РЕСУРСЫ (Без лимитов для разработки)
# Удаляем секцию ограничений, чтобы локально использовать все доступные ресурсы хоста.
# deploy:
# resources:
# limits:
# cpus: ...
# memory: ...
# mem_limit: ...

130
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,130 @@
# ==============================================================================
# Docker Compose для PRODUCTION
# Этот файл запускается на боевом сервере.
# Вариант 1 (если переименовали в docker-compose.yml): docker compose up -d
# Вариант 2 (если оставили имя): docker compose -f docker-compose.prod.yml up -d
# ==============================================================================
services:
# --- ОСНОВНОЙ СЕРВИС: DJANGO + GUNICORN + WHITENOISE ---
web:
# Имя контейнера для удобства
container_name: oknardia-backend
# В production используем готовый, собранный образ из реестра (Gitea)
image: git.cube2.ru/erjemin/oknardia:latest
# ПЕРЕЗАПУСК при сбое
restart: always
# Метки для Watchtower (авто-обновление)
labels:
- "com.centurylinklabs.watchtower.scope=oknardia-scope"
# КОМАНДА ЗАПУСКА (Production режим)
# При старте контейнера:
# 1. Применяем миграции
# 2. Собираем статику
# 3. Генерируем sitemap'ы
# 4. Пересоздаём пре-рендер шаблоны серий
# 5. Пересчитываем рейтинги
# 6. Создаём папку конфигов nginx (если нет)
# 7. Копируем конфиг nginx с авто-заменой путей через sed
# (переменная HOST_PROJECT_PATH берется из .env и подставляется в контейнер)
# 8. Инициализируем боевой конфиг (если нет, копируем из примера)
# 9. Запускаем Gunicorn
command: >
sh -c "python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
python manage.py generate_sitemaps &&
python manage.py regenerate_seria_prerender &&
python manage.py make_rating &&
mkdir -p /nginx_configs_host/nginx &&
sed \"s|/home/user/path-to-oknardia-app|$${HOST_PROJECT_PATH}|g\" /home/app/config/nginx/oknardia-app--external-nginx.conf > /nginx_configs_host/nginx/oknardia-app--external-nginx.conf.example &&
if [ ! -f /nginx_configs_host/nginx/oknardia-app--external-nginx.conf ]; then
cp /nginx_configs_host/nginx/oknardia-app--external-nginx.conf.example /nginx_configs_host/nginx/oknardia-app--external-nginx.conf;
echo 'INIT: Created new nginx config with correct paths';
fi &&
ERROR_DIR=/home/app/web/public/media/_error &&
mkdir -p "$$ERROR_DIR" &&
for code in 400 401 403 404 413 429 500 502 503 504; do
cp /home/user/path-to-oknardia-app/oknardia/templates/error/$${code}.html "$$ERROR_DIR/$${code}.html";
done &&
cp /home/user/path-to-oknardia-app/oknardia/templates/error/under_reconstruction.html "$$ERROR_DIR/under_reconstruction.html"
python -m gunicorn --workers 2 --bind 0.0.0.0:8000 --timeout 120 oknardia.wsgi:application"
# Пробрасывание портов
# Слушаем только на localhost хоста для безопасности
ports:
- "127.0.0.1:8060:8000"
# МОНТИРОВАНИЕ ТОМОВ (Volumes)
volumes:
# БД SQLite
- ./database:/home/app/database
# Медиа файлы
- ./media:/home/app/public/media
# Конфиги nginx
- ./config:/nginx_configs_host
# Пользователь и права
user: "0:0"
# ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ (Production)
# .env файл содержит все sensitive данные (DB, Email, API keys, REPO_USER/PASS)
env_file:
- .env
environment:
- DJANGO_SETTINGS_MODULE=oknardia.settings
- PYTHONUNBUFFERED=1
- DEBUG=False
- DJANGO_LOG_LEVEL=INFO
- ALLOW_MEDIA_SERVE=False
# ЗДОРОВЬЕ КОНТЕЙНЕРА (Healthcheck)
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/').read()"]
interval: 3m
timeout: 12s
start_period: 60s
retries: 3
# ЛОГИРОВАНИЕ (Ротация)
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
# РЕСУРСЫ
deploy:
resources:
limits:
cpus: '0.75'
memory: 768M
mem_limit: 768M
# --- WATCHTOWER: АВТО-ОБНОВЛЕНИЕ ОБРАЗОВ ---
watchtower:
image: containrrr/watchtower
container_name: oknardia_watchtower
restart: always
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- REPO_USER=${REPO_USER}
- REPO_PASS=${REPO_PASS}
- WATCHTOWER_SCOPE=oknardia-scope
- WATCHTOWER_CLEANUP=true
- DOCKER_API_VERSION=1.44
- WATCHTOWER_WAIT_ON_TIMEOUT=60
- WATCHTOWER_LIFECYCLE_HOOKS=true
command: --interval 1800 --cleanup
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

View File

@@ -34,6 +34,8 @@ STATIC_SOURCE_ROOT = PUBLIC_ROOT / 'static'
env = environ.Env() env = environ.Env()
environ.Env.read_env(str(PROJECT_ROOT / '.env')) environ.Env.read_env(str(PROJECT_ROOT / '.env'))
CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=[])
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
@@ -45,10 +47,11 @@ SECRET_KEY = env(
ADMIN_URL = _normalize_admin_url(env(var='ADMIN_URL', default='admin/')) ADMIN_URL = _normalize_admin_url(env(var='ADMIN_URL', default='admin/'))
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
# ПРЕДУПРЕЖДЕНИЕ БЕЗОПАСНОСТИ: не работайте в режиме DEBUG в продашене! # PREDУПРЕЖДЕНИЕ БЕЗОПАСНОСТИ: не работайте в режиме DEBUG в продашене!
DEBUG = TEMPLATE_DEBUG = env.bool('DEBUG', default=False) DEBUG = TEMPLATE_DEBUG = env.bool('DEBUG', default=False)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['127.0.0.1', 'localhost']) # Допустимые хосты (+ 'testserver' для management команд типа regenerate_seria_prerender)
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['127.0.0.1', 'localhost', 'testserver'])
# Настройки сообщений об ошибках когда все упало и т.п. # Настройки сообщений об ошибках когда все упало и т.п.
ADMINS = _env_admins(env.list('ADMINS', default=[])) ADMINS = _env_admins(env.list('ADMINS', default=[]))
@@ -139,7 +142,6 @@ DATETIME_FORMAT = 'Y-m-d H:i:s'
STATIC_URL = '/static/' STATIC_URL = '/static/'
MEDIA_URL = '/media/' MEDIA_URL = '/media/'
MEDIA_ROOT = str(PUBLIC_ROOT / 'media') MEDIA_ROOT = str(PUBLIC_ROOT / 'media')
# STATIC_ROOT отделен от исходной статики, чтобы избежать staticfiles.E002. # STATIC_ROOT отделен от исходной статики, чтобы избежать staticfiles.E002.
STATIC_ROOT = str(PUBLIC_ROOT / 'static_collected') STATIC_ROOT = str(PUBLIC_ROOT / 'static_collected')
@@ -157,21 +159,42 @@ STATICFILES_DIRS = [
str(STATIC_SOURCE_ROOT) str(STATIC_SOURCE_ROOT)
] if STATIC_SOURCE_ROOT.is_dir() else [] ] if STATIC_SOURCE_ROOT.is_dir() else []
# Django 5 требует явное описание хранилищ.
# `default` нужен для загружаемых файлов (FileField, ImageField, filer и подобное) и смотрит в `MEDIA_ROOT`.
# `staticfiles` остаётся отдельно: в dev используется обычная статика Django, в prod — WhiteNoise.
STORAGES = {
'default': {
'BACKEND': 'django.core.files.storage.FileSystemStorage',
'OPTIONS': {
'location': MEDIA_ROOT,
},
},
'staticfiles': {
'BACKEND': 'django.contrib.staticfiles.storage.StaticFilesStorage',
},
}
# Путь к каталогу static для генерации кэш-файлов и служебных JS. # Путь к каталогу static для генерации кэш-файлов и служебных JS.
STATIC_BASE_PATH = str(STATIC_SOURCE_ROOT) STATIC_BASE_PATH = str(STATIC_SOURCE_ROOT)
# Определяем движок БД из переменной окружения (по умолчанию SQLite)
database_engine = env('DATABASE_ENGINE', default='django.db.backends.sqlite3') database_engine = env('DATABASE_ENGINE', default='django.db.backends.sqlite3')
if database_engine == 'django.db.backends.sqlite3': if database_engine == 'django.db.backends.sqlite3':
# Для SQLite принимаем только имя файла из env и кладем БД в PROJECT_ROOT/database. # Для SQLite принимаем только имя файла из env и кладем БД в PROJECT_ROOT/database.
sqlite_db_filename = Path(env('DATABASE_NAME', default='oknadria.sqlite3')).name sqlite_db_filename = Path(env('DATABASE_NAME', default='oknardia.sqlite3')).name
sqlite_db_path = PROJECT_ROOT / 'database' / sqlite_db_filename sqlite_db_path = PROJECT_ROOT / 'database' / sqlite_db_filename
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': str(sqlite_db_path), 'NAME': str(sqlite_db_path),
} 'OPTIONS': {
'timeout': 20,
},
},
} }
else: else:
# База не SQLite (mariaDB, например): читаем все параметры подключения из env.
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': database_engine, 'ENGINE': database_engine,
@@ -314,3 +337,62 @@ CATALOG_SORTER_MAGIC_NUMBER_TIZER = 1
MAX_LEN_RING_LOG_BUFFER = 250 # МАКСИМАЛЬНЫЙ РАЗМЕР КОЛЬЦЕВОГО БУФЕРА MAX_LEN_RING_LOG_BUFFER = 250 # МАКСИМАЛЬНЫЙ РАЗМЕР КОЛЬЦЕВОГО БУФЕРА
YANDEX_MAPS_API_KEY = env('YANDEX_MAPS_API_KEY', default='') YANDEX_MAPS_API_KEY = env('YANDEX_MAPS_API_KEY', default='')
# ============================================================================
# Конфигурация в зависимости от режима разработки (DEBUG) vs. production
# ============================================================================
if DEBUG:
# В dev: стандартная отдача статики Django (без WhiteNoise/кэширования).
# Медиа-файлы отдаются через Django.
pass
else:
# В prod: WhiteNoise + CompressedStaticFilesStorage для оптимизации.
# Статика собирается с хешем в имени и кэшируется.
#
# ВАЖНО: Для production нужна полная настройка Nginx:
# - Nginx обслуживает статику (/static/) прямо из /home/app/public/static_collected/
# - Nginx обслуживает медиа (/media/) прямо из /home/app/public/media/
# - Nginx проксирует остальное на Gunicorn (:8000)
#
# Для локального тестирования production конфига этот файл симулирует Nginx.
# 1. Добавляем WhiteNoise в начало MIDDLEWARE (после SecurityMiddleware) для отдачи статики
MIDDLEWARE.insert(1, 'whitenoise.middleware.WhiteNoiseMiddleware')
# 2. Переводим staticfiles на WhiteNoise со сжатием
# ВАЖНО: используем CompressedStaticFilesStorage вместо CompressedManifestStaticFilesStorage,
# потому что Manifest не справляется с relative paths в CSS (например, jQuery UI).
# CompressedStaticFilesStorage сжимает файлы без manifest, что работает надежнее.
STORAGES['staticfiles'] = {
'BACKEND': 'whitenoise.storage.CompressedStaticFilesStorage', # noqa: F821
}
# 3. WhiteNoise конфиг: обслуживание корневых файлов из public/ (robots.txt, favicon.*, sitemap.xml и т.д.)
# Параметр WHITENOISE_LOCATION указывает WhiteNoise, где искать файлы помимо STATIC_ROOT
WHITENOISE_LOCATION = str(PUBLIC_ROOT)
# 4. MIME-типы для шрифтов (иначе браузер может не загрузить)
WHITENOISE_MIMETYPES = {
'.woff': 'font/woff',
'.woff2': 'font/woff2',
}
# 5. Конфигурация WhiteNoise для обслуживания статических файлов и файлов из /public (например,
# robots.txt, favicon.ico и т.п.)
# WHITENOISE_ROOT = PUBLIC_ROOT
# 6. Кэширование неизменяемых файлов (с хешем в имени) на 1 год в браузере
# ВАЖНО: лямбда должна принимает ДВА аргумента: path и url (как требует WhiteNoise)
WHITENOISE_IMMUTABLE_FILE_TEST = lambda path, url: 'CACHE' in path
# 7. ЛОКАЛЬНЫЙ ТЕСТ: для отдачи медиа в docker-compose.local-prod.yml
# В реальном production это обслуживает Nginx! Никогда не используй в production!
# Добавляем StaticFilesHandler который обслуживает и медиа и статику
if env.bool('ALLOW_MEDIA_SERVE', default=False):
# Для локального тестирования добавляем обслуживание медиа через Django
# ВАЖНО: это очень медленно и небезопасно для production!
from django.conf.urls.static import static
# Будет добавлено в urls.py при импорте: urlpatterns += static(MEDIA_URL, document_root=MEDIA_ROOT)

View File

@@ -1,26 +1,82 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""oknardia Конфигурация URL """oknardia Конфигурация URL"""
Список `urlpatterns` направляет URL-адреса в представления. Дополнительную информацию см.:
https://docs.djangoproject.com/en/4.1/topics/http/urls/
Примеры:
Представления функций
1. Добавьте import: из представлений импорта my_app
2. Добавьте URL-адрес в urlpatterns: path('', views.home, name='home')
Представления на основе классов
1. Добавьте импорт: from other_app.views import Home
2. Добавьте URL-адрес в шаблоны URL-адресов: path('', Home.as_view(), name='home')
Включение другой конфигурации URL
1. Импортируйте функцию include(): из django.urls import include, path
2. Добавьте URL-адрес в urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin from django.contrib import admin
from django.urls import include, path, re_path from django.urls import include, re_path, path
from django.conf.urls.static import static from django.http import FileResponse
from pathlib import Path
import environ
import mimetypes
import os
# Инициализируем env
env = environ.Env()
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
environ.Env.read_env(str(PROJECT_ROOT / '.env'))
from oknardia.settings import * from oknardia.settings import *
from web import views, autocomplete_addr, user_manager, blog, diagrams, report1, report2, catalog, prices, service, \ from web import views, autocomplete_addr, user_manager, blog, diagrams, report1, report2, catalog, prices, service, \
catalog_profiles, catalog_series, catalog_openings, catalog_companies catalog_profiles, catalog_series, catalog_openings, catalog_companies
def _serve_public_root_file(request, path):
"""Отдает статик-файл из public/ с правильным Content-Type и UTF-8 для текстовых файлов."""
file_path = PUBLIC_ROOT / path
# Безопасность: проверяем, что файл гарантированно внутри PUBLIC_ROOT
try:
file_path = file_path.resolve()
file_path.relative_to(PUBLIC_ROOT.resolve())
except ValueError:
from django.http import Http404
raise Http404("Файл не найден")
if not file_path.is_file():
from django.http import Http404
raise Http404("Файл не найден")
# Определяем MIME type
mime_type, _ = mimetypes.guess_type(str(file_path))
# Для текстовых файлов добавляем charset=utf-8
if mime_type and mime_type.startswith('text'):
mime_type = f"{mime_type}; charset=utf-8"
response = FileResponse(open(file_path, 'rb'), content_type=mime_type)
# Определяем Content-Disposition в зависимости от типа файла:
# inline — браузер отображает/обрабатывает (иконки, картинки, CSS, JS, robots.txt, manifest)
# attachment — браузер скачивает (архивы, документы и т.д.)
inline_types = {
'image/', # все картинки (PNG, SVG, JPG и т.д.)
'text/', # все текстовые файлы
'application/javascript',
'application/json',
'application/xml',
'application/rss+xml',
'application/x-web-app-manifest+json', # manifest
}
if mime_type and any(mime_type.lower().startswith(t) for t in inline_types):
response['Content-Disposition'] = 'inline'
else:
# Для остального (неизвестные типы, архивы и т.д.) — скачивание
response['Content-Disposition'] = f'attachment; filename="{file_path.name}"'
return response
def _iter_public_root_files():
"""Находит все обычные файлы в корне public/, кроме служебных артефактов (начинающихся с точки)."""
if not PUBLIC_ROOT.exists():
return
for file_path in sorted(PUBLIC_ROOT.iterdir()):
if not file_path.is_file():
continue
# Пропускаем служебные файлы (начинающиеся с точки)
if file_path.name.startswith('.'):
continue
yield file_path.name
urlpatterns = [ urlpatterns = [
path(ADMIN_URL, admin.site.urls), path(ADMIN_URL, admin.site.urls),
@@ -105,11 +161,51 @@ urlpatterns = [
] ]
# Динамическая генерация URL patterns для всех файлов в корне public/
# (кроме служебных файлов, начинающихся с точки)
# В DEV режиме и при ALLOW_MEDIA_SERVE=True отдаются через Django,
# в PRODUCTION это должно обслуживать Nginx/WhiteNoise
PUBLIC_ROOT_URLPATTERNS = [
path(filename, _serve_public_root_file, {'path': filename})
for filename in _iter_public_root_files()
]
# Добавляем static файлы для корня ДО основных URL patterns
# (чтобы отдавать файлы быстро и не проверять остальные рулы)
urlpatterns = [*PUBLIC_ROOT_URLPATTERNS, *urlpatterns]
handler404 = 'web.views.handler404'
handler400 = 'web.views.handler400'
handler403 = 'web.views.handler403'
handler500 = 'web.views.handler500'
# Для локального тестирования production конфига: отдача медиа через Django
# В реальном production медиа обслуживает Nginx!
if DEBUG or env.bool('ALLOW_MEDIA_SERVE', default=False):
from django.views.static import serve as serve_static
# Проверяем что директория медиа существует
if os.path.isdir(MEDIA_ROOT):
# Добавляем URL pattern для отдачи медиа файлов
urlpatterns += [
re_path(
r'^media/(?P<path>.*)$',
serve_static,
{'document_root': MEDIA_ROOT},
name='media'
),
]
if DEBUG: if DEBUG:
# Медиа-файлы
urlpatterns += static(MEDIA_URL, document_root=MEDIA_ROOT)
# --- страничка для тестирования верстки текста в блоге # --- страничка для тестирования верстки текста в блоге
urlpatterns += [re_path(r'^blog/tmp[/*]$', service.tmp),] urlpatterns += [re_path(r'^blog/tmp[/*]$', service.tmp),]
# --- странички для тестирования страниц с кодами ошибок
urlpatterns += [
re_path(r'^400$', views.handler400),
re_path(r'^403$', views.handler403),
re_path(r'^404$', views.handler404),
re_path(r'^500$', views.handler500),
]
# ___ ____ _ _____ _ _ _____ _ # ___ ____ _ _____ _ _ _____ _
# | | | | \ ___| |_ _ _ ___ |_ _|___ ___| | |_ ___ ___ | _ |___ ___ ___| | # | | | | \ ___| |_ _ _ ___ |_ _|___ ___| | |_ ___ ___ | _ |___ ___ ___| |
# |_ | | | | -_| . | | | . | | | | . | . | | . | .'| _| | __| .'| | -_| | # |_ | | | | -_| . | | | . | | | | . | . | | . | .'| _| | __| .'| | -_| |

View File

@@ -16,10 +16,12 @@
<meta name="document-state" content="{{ META_DOCUMENT_STATE|default:"Dynamic" }}" /> <meta name="document-state" content="{{ META_DOCUMENT_STATE|default:"Dynamic" }}" />
<meta name="generator" content="OKNARDIA 0.3β by Python/Django" /> <meta name="generator" content="OKNARDIA 0.3β by Python/Django" />
<title>{% block Title %}{% endblock %} : ОКНАРДИЯ</title> <title>{% block Title %}{% endblock %} : ОКНАРДИЯ</title>
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.svg" type="image/svg+xml "> <link rel="icon" type="image/png" href="{{ request.scheme }}://{{ request.get_host }}/favicon.png" sizes="96x96" />
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.png" type="image/png"> <link rel="icon" type="image/svg+xml" href="{{ request.scheme }}://{{ request.get_host }}/favicon.svg" />
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.gif" type="image/gif"> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.ico" type="image/x-icon"> <link rel="apple-touch-icon" sizes="180x180" href="{{ request.scheme }}://{{ request.get_host }}/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="oknardia.ru" />
<link rel="manifest" href="{{ request.scheme }}://{{ request.get_host }}/site.webmanifest" />
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet" type="text/css" />{# <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" type="text/css" />#} <link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet" type="text/css" />{# <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" type="text/css" />#}
<link href="{% static 'css/bootstrap-theme.min.css' %}" rel="stylesheet" type="text/css" />{# <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css" rel="stylesheet" type="text/css" />#} <link href="{% static 'css/bootstrap-theme.min.css' %}" rel="stylesheet" type="text/css" />{# <link href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap-theme.min.css" rel="stylesheet" type="text/css" />#}
<link href="{% static 'css/oknardia1.css' %}" rel="stylesheet" type="text/css" />{% block Top_CSS1 %}{% endblock %}{% block Top_CSS2 %}{% endblock %}{% block Top_CSS3 %}{% endblock %} <link href="{% static 'css/oknardia1.css' %}" rel="stylesheet" type="text/css" />{% block Top_CSS1 %}{% endblock %}{% block Top_CSS2 %}{% endblock %}{% block Top_CSS3 %}{% endblock %}

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>400 — Некорректный запрос</title>
<!-- Аналитика: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru -->
<script src="/static/js/analytics.js" type="text/javascript"></script>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6; /* Полупрозрачность только для картинки */
z-index: -1; /* Уводит картинку под текст */
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/static/img/oknardia_logo.gif'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">400</i></h1>
<h2 style="color: #333; font-size: 40px;">Некорректный запрос</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Сервер не может обработать ваш запрос. Пожалуйста, проверьте корректность данных.</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>401 — Требуется авторизация</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">401</i></h1>
<h2 style="color: #333; font-size: 40px;">Требуется авторизация</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Для доступа к этому ресурсу требуется авторизация.</h4>
<p style="margin-top: 55px;"><a href="/login-logout">Войти в систему</a> или <a href="/">вернуться на главную</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>403 — Доступ запрещён</title>
<!-- Аналитика: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru -->
<script src="/static/js/analytics.js" type="text/javascript"></script>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6; /* Полупрозрачность только для картинки */
z-index: -1; /* Уводит картинку под текст */
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/static/img/oknardia_logo.gif'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">403</i></h1>
<h2 style="color: #333; font-size: 40px;">Доступ запрещён</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">У вас нет прав для доступа к этому ресурсу. Если вы считаете это ошибкой, свяжитесь с администратором.</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>404 — Страница не найдена</title>
<!-- Аналитика: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru -->
<script src="/static/js/analytics.js" type="text/javascript"></script>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6; /* Полупрозрачность только для картинки */
z-index: -1; /* Уводит картинку под текст */
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/static/img/oknardia_logo.gif'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">404</i></h1>
<h2 style="color: #333; font-size: 40px;">Страница не найдена</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">К сожалению, запрашиваемая страница не существует или была удалена.</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>413 — Файл слишком большой</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">413</i></h1>
<h2 style="color: #333; font-size: 40px;">Файл слишком большой</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Размер загружаемого файла превышает допустимый лимит (максимум 10 МБ).</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>429 — Слишком много запросов</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">429</i></h1>
<h2 style="color: #333; font-size: 40px;">Слишком много запросов</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Вы отправляете слишком много запросов. Пожалуйста, немного подождите и попробуйте снова.</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>500 — Внутренняя ошибка сервера</title>
<!-- Аналитика: Google Analytics 4, Yandex.Metrika, Top.Mail.Ru -->
<script src="/static/js/analytics.js" type="text/javascript"></script>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6; /* Полупрозрачность только для картинки */
z-index: -1; /* Уводит картинку под текст */
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">500</i></h1>
<h2 style="color: #333; font-size: 40px;">Внутренняя ошибка сервера</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">На нашем сервере произошла непредвиденная ошибка. Команда разработчиков уже работает над решением проблемы.</h4>
<p style="color: #999; margin: 20px 0 0 30px; font-size: 14px;">Если проблема сохраняется, пожалуйста, свяжитесь с администратором: <a href="mailto:info@oknardia.ru">info@oknardia.ru</a></p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>502 — Плохой шлюз</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">502</i></h1>
<h2 style="color: #333; font-size: 40px;">Плохой шлюз</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Сервер приложения временно недоступен. Мы работаем над восстановлением работоспособности.</h4>
<p style="color: #999; margin: 20px 0 0 30px; font-size: 14px;">Пожалуйста, попробуйте перезагрузить страницу через несколько минут. Если проблема сохраняется, свяжитесь с нами: <a href="mailto:info@oknardia.ru">info@oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>503 — Сервис недоступен</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">503</i></h1>
<h2 style="color: #333; font-size: 40px;">Сервис недоступен</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Сайт временно недоступен для плановых работ. Мы скоро вернёмся!</h4>
<p style="color: #999; margin: 20px 0 0 30px; font-size: 14px;">Информация об обслуживании обновляется автоматически. Спасибо за терпение!</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>504 — Тайм-аут шлюза</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 120px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px">504</i></h1>
<h2 style="color: #333; font-size: 40px;">Тайм-аут шлюза</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Сервер приложения слишком долго отвечает на запрос. Это может быть перегрузка или технический сбой.</h4>
<p style="color: #999; margin: 20px 0 0 30px; font-size: 14px;">Попробуйте перезагрузить страницу через несколько минут. Если проблема повторится, свяжитесь с поддержкой: <a href="mailto:info@oknardia.ru">info@oknardia.ru</a></p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Технические работы</title>
<style>
.container::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background-image: url('/media/img_seria/1LG-504D12_2.jpg');
opacity: 0.6;
z-index: -1;
background-position: 18% 15%;
background-repeat: no-repeat;
background-size: 200px auto;
}
</style>
</head>
<body>
<div class="container" style="margin: 10% 0 0 20%;">
<h1 style="font-size: 80px; margin: 0; color: #ccc; background-image: url('/media/img_seria/1LG-504D12_2.jpg'); background-position: left 65%; background-repeat: no-repeat; background-size: 400px auto;"><i style="padding-left: 420px"></i></h1>
<h2 style="color: #333; font-size: 40px;">Технические работы</h2>
<h4 style="color: #666; margin: 0 0 0 30px; font-size: 20px;">Приносим извинения. В данный момент сайт проходит техническое обслуживание. Мы скоро вернёмся!</h4>
<p style="margin-top: 55px;"><a href="/">Вернуться на главную oknardia.ru</a></p>
</div>
</body>
</html>

View File

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

View File

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

@@ -0,0 +1,13 @@
# Игнорируем все файлы в этой папке (генерируются динамически)
# Содержит кеш-файлы со статик-шаблонами:
# - *_id_static_flaps.html
# - *_id_static_graph.html
# - *_id_static_map_stats.html
#
# Эти файлы генерируются при запуске management-команды:
# python manage.py regenerate_seria_prerender
#
# В продакшене они сохраняются на диск и включаются в шаблоны
# за счет системы двухуровневого кеширования.
*
!.gitignore

View File

@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from pathlib import Path
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, IntegerField, Value from django.db.models import Count, F, IntegerField, Value
from django.shortcuts import render, redirect 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 from web.add_func import get_flaps_for_big_pictures, sanitize_slug
import time import time
import os import os
import math
import base64
import json
def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None: def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None:
@@ -89,13 +93,15 @@ def catalog_seria_info(
# чтобы тестировать актуальную серверную логику, а не сохраненный html-файл. # чтобы тестировать актуальную серверную логику, а не сохраненный html-файл.
if DEBUG: if DEBUG:
light_template = "seria_info/all_seria_info_pre_light.html" light_template = "seria_info/all_seria_info_pre_light.html"
light_template_w_path = "" static_include_path = "" # в DEV не используем кеш
is_hard_template = True is_hard_template = True
else: else:
# В PROD используем существующий pre-render include при наличии на диске. # В PROD используем существующий pre-render include для статических данных (если есть).
light_template = f"seria_info/prepared/{seria_id}_id.html" light_template = "seria_info/all_seria_info_pre_light.html"
light_template_w_path = f"{TEMPLATES[0]['DIRS'][0]}/{light_template}" static_template_filename = f"seria_info/prepared/{seria_id}_id_static.html"
is_hard_template = not os.path.isfile(light_template_w_path) 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] = {} to_template: dict[str, object] = {}
# Получаем все уникальные проемы серии и сразу добавляем iQuantity=1 # Получаем все уникальные проемы серии и сразу добавляем iQuantity=1
@@ -192,30 +198,80 @@ def catalog_seria_info(
{ {
"WIN_OFFER_AND_MERCHANT": offer_and_merchant_per_win, "WIN_OFFER_AND_MERCHANT": offer_and_merchant_per_win,
"TABLE_OF_WINDOWS": table_of_win_in_seria_by_apartmment, "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: if is_hard_template:
to_template.update(get_flaps_for_big_pictures(list_win_in_seria)) to_template.update(get_flaps_for_big_pictures(list_win_in_seria))
seria_id, for_seria_nav = seria_nav(seria_id) seria_id, for_seria_nav = seria_nav(seria_id)
to_template.update(for_seria_nav) to_template.update(for_seria_nav)
to_template.update(seria_info_year(seria_id)) to_template.update(seria_info_year(seria_id))
to_template.update(seria_info_geo_code(seria_id)) to_template.update(seria_info_geo_code(seria_id))
if not DEBUG: 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: prepared_dir = Path(TEMPLATES[0]["DIRS"][0]) / PATH_FOR_SERIA_INFO_HTML_INCLUDE
file.write(string_prerender) prepared_dir.mkdir(parents=True, exist_ok=True)
# Основной шаблон будет просто включать в себя уже готовый HTML
light_template = "seria_info/all_seria_info_pre_light.html" # 1. Схемы открывания и размеры
else: string_flaps = render_to_string(
to_template.update({"THIS_SERIA_NAME": q_seria.sName}) "seria_info/all_seria_info_pre_light_static_flaps.html",
# Указываем путь к кешированному файлу для include to_template
to_template.update({"PRE_RENDERED_INCLUDE_PATH": light_template}) )
# Основной шаблон должен быть один и тот же file_flaps = prepared_dir / f"{seria_id}_id_static_flaps.html"
light_template = "seria_info/all_seria_info_pre_light.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) _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, "ACCOUNTS": accounts,
"CONDITION_MAX": condition_max, "CONDITION_MAX": condition_max,
"CONDITION_MIN": condition_min}) "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 return data_return

View File

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

View File

@@ -242,3 +242,66 @@ def get_address(request: HttpRequest) -> HttpResponse:
'ticks': float(time.perf_counter() - time_start), 'ticks': float(time.perf_counter() - time_start),
}) })
return render(request, "popup/popup_show_apartment_variants.html", to_template) return render(request, "popup/popup_show_apartment_variants.html", to_template)
# ============================================================================
# ОБРАБОТЧИКИ ОШИБОК ДЛЯ ТЕСТИРОВАНИЯ В DEBUG РЕЖИМЕ
# ============================================================================
# Используется в urls.py при DEBUG=True для тестирования верстки страниц ошибок.
# Позволяет визуально проверить, как выглядят страницы 400, 403, 404 и 500
# без необходимости искусственно вызывать реальные ошибки.
def handler400(request: HttpRequest, exception=None) -> HttpResponse:
"""Отображает страницу ошибки 400 (Bad Request).
Используется только в DEBUG режиме для тестирования верстки.
:param request: входящий http-запрос
:param exception: исключение (если есть)
:return response: исходящий http-ответ с кодом 400
"""
response = render(request, "error/400.html", {})
response.status_code = 400
return response
def handler403(request: HttpRequest, exception=None) -> HttpResponse:
"""Отображает страницу ошибки 403 (Forbidden).
Используется только в DEBUG режиме для тестирования верстки.
:param request: входящий http-запрос
:param exception: исключение (если есть)
:return response: исходящий http-ответ с кодом 403
"""
response = render(request, "error/403.html", {})
response.status_code = 403
return response
def handler404(request: HttpRequest, exception=None) -> HttpResponse:
"""Отображает страницу ошибки 404 (Not Found).
Используется только в DEBUG режиме для тестирования верстки.
:param request: входящий http-запрос
:param exception: исключение (если есть)
:return response: исходящий http-ответ с кодом 404
"""
response = render(request, "error/404.html", {})
response.status_code = 404
return response
def handler500(request: HttpRequest) -> HttpResponse:
"""Отображает страницу ошибки 500 (Internal Server Error).
Используется только в DEBUG режиме для тестирования верстки.
:param request: входящий http-запрос
:return response: исходящий http-ответ с кодом 500
"""
response = render(request, "error/500.html", {})
response.status_code = 500
return response

48
poetry.lock generated
View File

@@ -214,6 +214,27 @@ develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.dev0)", "py
docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"] docs = ["furo (>=2021.8.17b43,<2021.9.dev0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"] testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"]
[[package]]
name = "gunicorn"
version = "23.0.0"
description = "WSGI HTTP Server for UNIX"
optional = false
python-versions = ">=3.7"
files = [
{file = "gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d"},
{file = "gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec"},
]
[package.dependencies]
packaging = "*"
[package.extras]
eventlet = ["eventlet (>=0.24.1,!=0.36.0)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"]
tornado = ["tornado (>=0.2)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.11" version = "3.11"
@@ -228,6 +249,17 @@ files = [
[package.extras] [package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "11.3.0" version = "11.3.0"
@@ -507,7 +539,21 @@ h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["backports-zstd (>=1.0.0)"] zstd = ["backports-zstd (>=1.0.0)"]
[[package]]
name = "whitenoise"
version = "6.12.0"
description = "Radically simplified static file serving for WSGI applications"
optional = false
python-versions = ">=3.10"
files = [
{file = "whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2"},
{file = "whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad"},
]
[package.extras]
brotli = ["brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.12,<3.13" python-versions = ">=3.12,<3.13"
content-hash = "5fdba0321d441277f8b911d178c048c672533761e93443473897572f4ed16ebf" content-hash = "151e463bb47b6a3e0307c77b6032da97f414908c2775be9ea3c6ccfdee19e1a5"

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 B

BIN
public/favicon.ico Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 B

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 B

After

Width:  |  Height:  |  Size: 3.1 KiB

2
public/favicon.svg Executable file → Normal file
View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 5.81 5.81" shape-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd"><path d="M2.25 4.2h-.02H0V1.69h2.25v.98h3.54V4.2z" fill="#393185"/><g fill="#fefefe" class="E"><path d="M1.45 3.55h.16l-.08-.3-.08.3zm.27.43l-.05-.21h-.28l-.06.21h-.26l.32-1.03h.33l.33 1.03h-.33z"/><path d="M1.07 2.95v1.03H.76v-.4H.52v.4H.2V2.95h.32v.38h.24v-.38zm.98 0H1.7l-.18-.39-.1.15v.24h-.3V1.92h.3v.43l.28-.43h.28l-.26.38z"/><path d="M.63 2.74c.06 0 .09-.02.12-.07.02-.04.03-.13.03-.25 0-.18-.05-.27-.14-.27-.11 0-.16.1-.16.31 0 .19.05.28.15.28zm0 .23c-.14 0-.26-.04-.35-.14-.09-.09-.14-.22-.14-.38a.58.58 0 0 1 .13-.38c.09-.1.21-.15.37-.15.14 0 .26.04.35.14.09.09.13.22.13.38s-.04.29-.14.39c-.09.1-.2.14-.35.14z"/></g><g fill="#d2cde7" class="E"><path d="M5.22 3.17H5.1c-.06 0-.1.01-.12.03-.01.02-.02.05-.02.07 0 .04.01.06.03.08.03.01.07.02.12.02h.11v-.2zm0 .42h-.14l-.17.39h-.29l.22-.43c-.14-.05-.21-.15-.21-.28 0-.1.03-.18.1-.24.07-.05.16-.08.27-.08h.54v1.03h-.32v-.39z"/><path d="M4.04 3.87v.11h-.3V2.95h.3v.42.19c.03-.08.07-.15.11-.22l.16-.28v-.11h.31v1.03h-.31v-.41c0-.05 0-.12.01-.19-.03.07-.07.14-.11.22l-.17.27z"/><path d="M3.04 3.76h.28L3.2 3.2h-.02l-.14.56zm-.25 0l.23-.8h.38l.23.8h.11v.44h-.2l-.02-.21H2.9l-.02.21h-.2v-.44h.11z"/><path d="M2.36 3.38h.11c.09 0 .13-.03.13-.1s-.04-.1-.12-.1h-.12v.2zm.01.22v.38h-.32V2.95h.43c.11 0 .2.01.25.04.06.02.1.06.14.11.03.05.05.11.05.18 0 .09-.04.17-.1.23a.41.41 0 0 1-.28.09h-.17z"/></g><path d="M3.09 1.9c0 .13-.05.2-.14.2a.11.11 0 0 1-.1-.05c-.03-.04-.04-.09-.04-.15 0-.13.05-.2.14-.2a.11.11 0 0 1 .1.05c.03.03.04.08.04.15zM3 1.9c0-.05 0-.09-.01-.11-.01-.01-.02-.02-.04-.02s-.03.01-.04.03c-.01.01-.01.05-.01.1s0 .08.01.1.02.03.04.03.03-.01.04-.03C3 1.99 3 1.95 3 1.9zm.25-.19h.09v.16l.09-.16h.09l-.1.14.1.24h-.08l-.07-.18-.03.05v.13h-.09v-.38zm.66.19c0 .13-.04.2-.14.2-.04 0-.08-.02-.1-.05-.02-.04-.04-.09-.04-.15 0-.13.05-.2.14-.2.05 0 .08.02.11.05.02.03.03.08.03.15zm-.09 0c0-.05 0-.09-.01-.11-.01-.01-.02-.02-.04-.02-.01 0-.03.01-.03.03-.01.01-.02.05-.02.1s.01.08.01.1c.01.02.03.03.04.03.02 0 .03-.01.04-.03.01-.01.01-.05.01-.1zm.26-.19h.08v.15h.09v-.15h.09v.38h-.09v-.16h-.09v.16h-.08v-.38zm.43 0h.09v.15h.09v-.15h.08v.38h-.08v-.16H4.6v.16h-.09v-.38zm.44.38v-.38h.08v.14h.03c.06 0 .09.01.11.03.03.02.04.05.04.09 0 .03-.01.06-.03.08-.01.02-.02.03-.04.03-.02.01-.05.01-.08.01h-.11zm.08-.06h.03c.01 0 .02 0 .03-.01.01 0 .01 0 .02-.01 0-.01.01-.02.01-.04s-.01-.03-.01-.04c-.01-.01-.03-.02-.05-.02h-.03v.12zm.22-.32h.08v.38h-.08v-.38zm.34.34v.04h-.08v-.38h.08v.21l.01-.02.09-.15v-.04h.08v.38h-.08v-.2-.01l-.01.02-.09.15zm-.04-.45h.05c0 .02.02.03.04.03.03 0 .04-.01.05-.03h.05c-.02.05-.05.07-.1.07-.04 0-.08-.02-.09-.07zm-3 .58h.09l.11.39h-.09l-.02-.08h-.11l-.02.08h-.07l.11-.39zm.07.24l-.03-.16-.04.16h.07zm.5-.24v.07h-.15v.32h-.09v-.39h.24zm.14 0h.11c.03 0 .06 0 .07.01.02.01.04.02.05.04s.02.04.02.07-.01.05-.02.07-.02.03-.04.04-.04.01-.08.01h-.03v.15h-.08v-.39zm.08.06v.12h.03c.02 0 .03-.01.04-.02s.01-.02.01-.04c0-.01 0-.03-.01-.03 0-.01-.01-.02-.01-.02-.01 0-.02-.01-.03-.01h-.03zm.31-.06h.24v.07h-.15v.09h.11v.06h-.11v.1h.15v.07h-.24v-.39zm.62 0v.07h-.15v.32h-.09v-.39h.24zm.2 0h.09l.11.39h-.09l-.02-.08h-.11l-.01.08h-.08l.11-.39zm.08.24l-.04-.16-.04.16h.08zm.2-.24h.24v.07h-.08v.32h-.09v-.32h-.07v-.07zm.64.19c0 .14-.05.2-.14.2-.05 0-.08-.01-.1-.05-.03-.03-.04-.08-.04-.15 0-.13.05-.19.14-.19.04 0 .08.01.1.05.03.03.04.08.04.14zm-.09 0c0-.05 0-.08-.01-.1s-.02-.03-.04-.03-.03.01-.04.03-.01.05-.01.1 0 .09.01.11c.01.01.02.02.04.02s.03 0 .04-.02.01-.05.01-.11zm.25-.19h.11c.04 0 .06 0 .08.01s.03.02.05.04c.01.02.02.04.02.07s-.01.05-.02.07-.02.03-.04.04-.05.01-.08.01h-.03v.15h-.09v-.39zm.09.06v.12h.02c.03 0 .04-.01.05-.02 0-.01.01-.02.01-.04 0-.01-.01-.03-.01-.03-.01-.01-.01-.02-.02-.02s-.02-.01-.03-.01h-.02z" fill="#7e71b1" class="E"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="1418" height="1417" viewBox="0 0 809.89 809.74" shape-rendering="geometricPrecision" image-rendering="optimizeQuality" fill-rule="evenodd"><defs><clipPath id="A" class="O"><path d="M809.89 404.87c0-11.12-.46-22.12-1.34-33.02L602.2 165.55 206.95 643.52l164.93 164.87c10.91.88 21.93 1.35 33.07 1.35 223.64 0 404.94-181.27 404.94-404.87z"/></clipPath><clipPath id="B" class="O"><path d="M590.6 161.13h-70.51L334.74 648.61H590.6c9.67 0 17.5-7.84 17.5-17.5V178.62c0-9.66-7.83-17.49-17.5-17.49z"/></clipPath><clipPath id="C" class="O"><path d="M447.08 353.14L334.74 648.61H590.6c9.67 0 17.5-7.84 17.5-17.5V178.62a17.4 17.4 0 0 0-4.23-11.4l-1.67-1.67-155.12 187.59z"/></clipPath><path id="D" d="M809.89 404.87C809.89 181.87 627.98 0 404.95 0 181.91 0 0 181.87 0 404.87c0 222.99 181.91 404.87 404.95 404.87 223.03 0 404.94-181.88 404.94-404.87z"/><path id="E" d="M590.6 648.61H219.29c-9.67 0-17.5-7.84-17.5-17.5V178.62c0-9.66 7.84-17.49 17.5-17.49H590.6c9.67 0 17.5 7.83 17.5 17.49v452.49c0 9.66-7.83 17.5-17.5 17.5z"/><path id="F" d="M262.74 587.67h170.81V222.06H262.74z"/><path id="G" d="M468.19 587.67h78.96V222.06h-78.96z"/><path id="H" d="M345.27 396.69c-2.05 0-4.1-.78-5.66-2.35-3.12-3.12-3.12-8.19 0-11.31l50.01-50c3.13-3.12 8.2-3.12 11.32 0 3.12 3.13 3.12 8.19 0 11.31l-50.01 50c-1.56 1.57-3.61 2.35-5.66 2.35z"/></defs><ellipse cx="404.95" cy="404.87" rx="404.95" ry="404.87" fill="#f96" class="N"/><g clip-path="url(#A)" class="N"><path d="M35.56-5.84h945.73v986.97H35.56z" fill="#f7b78f"/><use href="#D" fill="#ff8033"/></g><g class="N"><use href="#E" fill="#366695"/><path d="M262.74 222.06h170.81v365.61H262.74zm205.45 0h78.97v365.61h-78.97z" fill="#71e2ef"/></g><g clip-path="url(#B)" class="N"><path d="M163.35-10.26H779.5V820H163.35z" fill="#d1d1e5"/><use href="#D" fill="#d4866a"/><use href="#E" fill="#335d90"/><g fill="#62c1d8"><use href="#F"/><use href="#G"/></g></g><g clip-path="url(#C)" class="N"><path d="M163.35-5.84H779.5V820H163.35z" fill="#ce9b91"/><use href="#D" fill="#d47241"/><use href="#E" fill="#335d90"/><g fill="#62c1d8"><use href="#F"/><use href="#G"/></g></g><g fill="#fff" class="N"><path d="M304.26 389.44a7.98 7.98 0 0 1-5.66-2.34c-3.12-3.13-3.12-8.19 0-11.32l82.52-82.5c3.12-3.12 8.19-3.12 11.32 0 3.12 3.13 3.12 8.19 0 11.31l-82.52 82.51a7.98 7.98 0 0 1-5.66 2.34z"/><use href="#H"/><use href="#H" x="-49.51" y="-47"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,5 +1,5 @@
# Маркетплейс-агрегатор цен на установку пластиковых и деревянных окон — ОКНАРДИЯ # Маркетплейс-агрегатор цен на установку пластиковых и деревянных окон — ОКНАРДИЯ
# robots.txt последняя версия: 2026-05-15 # robots.txt последняя версия: 2026-05-20
User-Agent: * User-Agent: *
Allow: / Allow: /
@@ -10,6 +10,9 @@ Disallow: /*?*token*
Disallow: /*.json$ Disallow: /*.json$
Disallow: /.*\.(js|css)$ # CSS и JS обслуживаются отдельно через CDN Disallow: /.*\.(js|css)$ # CSS и JS обслуживаются отдельно через CDN
# Параметры, которые не влияют на смысловое содержимое
Clean-param: page-back /
# Быстрые краулеры # Быстрые краулеры
User-agent: YandexBot User-agent: YandexBot
Crawl-delay: 15 Crawl-delay: 15

21
public/site.webmanifest Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "Окнардия",
"short_name": "Окнардия",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -15,6 +15,8 @@ Pillow = "^11.2.1"
requests = "^2.32.3" requests = "^2.32.3"
pytils = "^0.4.4" pytils = "^0.4.4"
rjsmin = "^1.2.0" rjsmin = "^1.2.0"
gunicorn = "^23.0.0"
whitenoise = "^6.8.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
django-debug-toolbar = "^6.3" django-debug-toolbar = "^6.3"