Compare commits
22 Commits
main
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| 60e0eead1c | |||
| e15017f3a6 | |||
| 958f11f398 | |||
| 3ab38b4885 | |||
| 4b038302c3 | |||
| 3c10b490b3 | |||
| 2d7e0813d6 | |||
| 5678624608 | |||
| cc739c5e07 | |||
| 54ed78185c | |||
| b376fdcaf9 | |||
| a3bc67613f | |||
| cb9dab9e56 | |||
| 9575e0e0d9 | |||
| 2396387883 | |||
| 8fe641c8f4 | |||
| 58e17d7d3b | |||
| 2dee8b77cb | |||
| 03ed9d24f9 | |||
| 71059bdae6 | |||
| 50b5ee4bdf | |||
| 98912808a1 |
128
.dockerignore
Normal 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/ -> НЕ ИСКЛЮЧАЕМ
|
||||
|
||||
30
.env.sample
@@ -25,6 +25,9 @@ ADMINS=Admin:admin@example.com
|
||||
# URL для доступа к админке Django (можно сменить для безопасности, чтобы боты не могли её найти)
|
||||
ADMIN_URL=admin/
|
||||
|
||||
# CSRF Trusted Origins (для корректной работы CSRF при доступе к админке и другим формам с разных доменов/портов)
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8000,http://localhost:8000,https://yourdomain.com
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE
|
||||
# ============================================================================
|
||||
@@ -35,7 +38,7 @@ DATABASE_ENGINE=django.db.backends.sqlite3
|
||||
# Имя/путь базы данных:
|
||||
# - для SQLite: только имя файла (полный путь соберется в settings.py через PROJECT_ROOT/database)
|
||||
# - для MySQL/MariaDB: имя базы
|
||||
DATABASE_NAME=oknadria.sqlite3
|
||||
DATABASE_NAME=oknardia.sqlite3
|
||||
|
||||
# Для MySQL/MariaDB (используются, если DATABASE_ENGINE=django.db.backends.mysql)
|
||||
# DATABASE_HOST=localhost
|
||||
@@ -114,6 +117,16 @@ LOG_LEVEL=INFO
|
||||
# CELERY_BROKER_URL=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
|
||||
# - 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]
|
||||
|
||||
72
.gitea/workflows/docker-publish.yaml
Normal 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
@@ -164,3 +164,7 @@ db.json.zip
|
||||
.log/
|
||||
.logs/
|
||||
sitemap*.xml
|
||||
|
||||
# Django static files (собранная статика, пересоздается при деплое)
|
||||
public/static_collected/
|
||||
|
||||
|
||||
361
CACHE_PRERENDER_SYSTEM.md
Normal 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
@@ -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"]
|
||||
@@ -394,40 +394,72 @@ location = /sitemap.xml {
|
||||
## 4) Команда `regenerate_seria_prerender`
|
||||
|
||||
Назначение:
|
||||
- пересобрать pre-render шаблоны для страниц серий (`catalog_seria_info`) в каталоге `seria_info/prepared/`.
|
||||
- пересобрать pre-render шаблоны для **статических данных** страниц серий (`catalog_seria_info`) в каталоге `seria_info/prepared/`.
|
||||
- генерирует **3 типа файлов** для каждой серии (верхняя статья НЕ кешируется, она рендерится динамически из БД):
|
||||
- `{seria_id}_id_static_flaps.html` — схемы открывания
|
||||
- `{seria_id}_id_static_graph.html` — график ввода в эксплуатацию
|
||||
- `{seria_id}_id_static_map_stats.html` — карта + статистика
|
||||
|
||||
Проверка без записи файлов:
|
||||
### Контекст: двухуровневое кеширование
|
||||
|
||||
Система использует двухуровневое кеширование:
|
||||
- **Статический кеш** — дорогостоящие операции (геокоординаты, графики, схемы). Генерируются один раз и сохраняются на диск.
|
||||
- **Динамические данные** — верхняя статья про серию (может редактироваться через админку, видно БЕЗ перезагрузки контейнера) и таблица оконных проёмов (показывает актуальные предложения).
|
||||
|
||||
Подробнее: см. [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md)
|
||||
|
||||
### Проверка без записи файлов (dry-run):
|
||||
|
||||
```bash
|
||||
cd /Users/e-serg/PRJ/2022-oknardia
|
||||
poetry run python oknardia/manage.py regenerate_seria_prerender --dry-run
|
||||
```
|
||||
|
||||
Пересборка только отсутствующих файлов:
|
||||
Пересборка только отсутствующих файлов (стандартный запуск):
|
||||
|
||||
```bash
|
||||
cd /Users/e-serg/PRJ/2022-oknardia
|
||||
poetry run python oknardia/manage.py regenerate_seria_prerender
|
||||
```
|
||||
|
||||
Принудительная пересборка всех root-серий:
|
||||
Вывод:
|
||||
```
|
||||
OK seria 210: 3 кеш-файла созданы
|
||||
OK seria 100: 3 кеш-файла созданы
|
||||
...
|
||||
Готово. Обработано: 31. Создано/пересоздано: 31 × 3 файла. Пропущено: 0.
|
||||
```
|
||||
|
||||
Принудительная пересборка всех root-серий (даже если кеш существует):
|
||||
|
||||
```bash
|
||||
cd /Users/e-serg/PRJ/2022-oknardia
|
||||
poetry run python oknardia/manage.py regenerate_seria_prerender --force
|
||||
```
|
||||
|
||||
Выборочная пересборка:
|
||||
Выборочная пересборка (конкретные серии):
|
||||
|
||||
```bash
|
||||
cd /Users/e-serg/PRJ/2022-oknardia
|
||||
poetry run python oknardia/manage.py regenerate_seria_prerender --seria-id 843 --seria-id 2100 --force
|
||||
```
|
||||
|
||||
Когда запускать:
|
||||
- после обновления логики `catalog_seria_info`;
|
||||
- после массового обновления данных серий/окон/квартир;
|
||||
- после очистки `seria_info/prepared/`.
|
||||
### Когда запускать
|
||||
|
||||
- **После первого развертывания** — сгенерировать кеш всех 31 серии один раз.
|
||||
- **После обновления логики `catalog_seria_info`** — изменились параметры графика или карты.
|
||||
- **После изменения координат зданий** — geo-данные обновлены.
|
||||
- **После добавления новых зданий в серию** — карта и список зда<D0B4><D0B0>ий изменились.
|
||||
- **По расписанию** (опционально, если данные по геокоординатам обновляются):
|
||||
```bash
|
||||
0 3 * * 0 cd /Users/e-serg/PRJ/2022-oknardia && poetry run python oknardia/manage.py regenerate_seria_prerender >> /var/log/oknardia-prerender.log 2>&1
|
||||
```
|
||||
|
||||
### Когда НЕ нужна регенерация
|
||||
|
||||
- **При добавлении новых предложений/цен** — таблица окон обновляется при каждом запросе автоматически.
|
||||
- **При редактировании верхней статьи через админку** — она рендерится динамически, кешируется НЕ нужно.
|
||||
- **При изменении наличия/статуса профилей** — рейтинги пересчитываются запросом через `make_rating`.
|
||||
|
||||
## 5) Команда `populate_seo_fields`
|
||||
|
||||
@@ -532,14 +564,14 @@ print(f'Пусто sMetaKeywords: {posts.filter(sMetaKeywords=\"\").count()}')
|
||||
✓ Записей обновлено в БД: 28
|
||||
✗ Ошибок при обработке: 0
|
||||
|
||||
✅ Обновлено 28 записей успешно!
|
||||
Обновлено 28 записей успешно!
|
||||
```
|
||||
|
||||
### Откат и безопасность
|
||||
|
||||
- ✅ **Безопасна для повторного запуска** — пустые поля не изменяются при повторной работе.
|
||||
- ✅ **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
|
||||
- ✅ **Всегда используй `--dry-run`** перед первым запуском для проверки.
|
||||
- **Безопасна для повторного запуска** — пустые поля не изменяются при повторной работе.
|
||||
- **Откат через SQL** — если нужно очистить, используй: `UPDATE oknardia_blogposts SET sSlug='', sMetaDescription='', sMetaKeywords='';`
|
||||
- **Всегда используй `--dry-run`** перед первым запуском для проверки.
|
||||
|
||||
## 6) Команда `make_rating`
|
||||
|
||||
|
||||
242
PRODUCTION_DEPLOY.md
Normal 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 дней
|
||||
```
|
||||
|
||||
30
README.md
@@ -41,7 +41,8 @@
|
||||
|
||||
# См. также:
|
||||
|
||||
* [`MANAGEMENT_RUNBOOK.md`](MANAGEMENT_RUNBOOK.md) – единый runbook по management-командам и batch-операциям, сниппеты.
|
||||
* [`MANAGEMENT_RUNBOOK.md`](MANAGEMENT_RUNBOOK.md) – единый runbook по management-командам и кастом-операциям (регенерация кеша, рейтингов, sitemap и т.д.).
|
||||
* [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md) – двухуровневая система кеширования страниц серий, структура статик-шаблонов, управление кешем.
|
||||
* [`AGENTS.md`](AGENTS.md) – контекст проекта для AI-ассистентов (архитектура, конвенции, рабочие сценарии).
|
||||
* [`SETUP.md`](SETUP.md) – пошаговая настройка окружения, запуск проекта и базовые команды разработки.
|
||||
|
||||
@@ -65,14 +66,25 @@
|
||||
|
||||
В папке `oknardia/templates/seria_info/prepared/` создаются пре-рендер HTML-шаблоны с информацией о сериях домов.
|
||||
|
||||
Эти шаблоны создаются при первом обращении к странице серии и хранятся для ускорения последующих запросов.
|
||||
**Важно**: их надо периодически удалять, особенно если меняются:
|
||||
* данные по сериям и размерам окон
|
||||
* коммерческие предложения и цены
|
||||
* рейтинги компонентов
|
||||
**Архитектура (май 2026)**: Для каждой серии создаются **3 отдельных кешируемых файла** (верхняя статья НЕ кешируется):
|
||||
* `{seria_id}_id_static_flaps.html` — схемы открывания окон
|
||||
* `{seria_id}_id_static_graph.html` — график ввода в эксплуатацию
|
||||
* `{seria_id}_id_static_map_stats.html` — карта Яндекса и статистика
|
||||
|
||||
**Рекомендация**: настроить cronjob на ежедневное или еженедельное удаление этих файлов. При обращении к соответствующим
|
||||
страницам эти шаблоны будут пересозданы автоматически. На быстрых серверах можно вообще отключить кеширование, если оно
|
||||
не критично для производительности.
|
||||
**Верхняя статья рендерится динамически** из БД, поэтому изменения через админку видны без перезагрузки контейнера.
|
||||
|
||||
Таблица оконных проёмов **не кешируется** — пересчитывается при каждом запросе, поэтому новые предложения видны пользователям сразу.
|
||||
|
||||
**Регенерация кеша**:
|
||||
```bash
|
||||
python manage.py regenerate_seria_prerender # все сер<D0B5><D180>и
|
||||
python manage.py regenerate_seria_prerender --seria-id 210 # конкретная серия
|
||||
```
|
||||
|
||||
⏱️ **Когда регенерировать**: Изменены координаты зданий, добавлены новые здания, обновлены годы ввода в эксплуатацию.
|
||||
|
||||
❌ **Когда НЕ нужна регенерация**: Добавлены новые предложения/цены (таблица обновляется автоматически), изменены статьи через админку (рендерятся динамически).
|
||||
|
||||
**Подробности**: см. [`CACHE_PRERENDER_SYSTEM.md`](CACHE_PRERENDER_SYSTEM.md)
|
||||
|
||||
|
||||
|
||||
407
SETUP.md
@@ -1,330 +1,187 @@
|
||||
# 🚀 SETUP.md — Первичная настройка Окнардии
|
||||
|
||||
**Версия**: 0.2.0 | **Дата**: 16.04.2026
|
||||
**Версия**: 0.2.0 | **Дата**: 18.05.2026 | **Docker**: ✅ Поддерживается
|
||||
|
||||
Этот документ описывает пошаговую настройку проекта для разработки и деплоя.
|
||||
|
||||
## 📋 Предварительные требования
|
||||
---
|
||||
|
||||
- **Python**: 3.12+
|
||||
- **Django**: 6.0+
|
||||
- **MariaDB/MySQL**: 5.7+ или 8.0+
|
||||
- **Redis** (опционально, для кеширования): 6.0+
|
||||
- **Poetry** (для управления зависимостями)
|
||||
## 🐳 Быстрый старт: Docker Dev Environment
|
||||
|
||||
### На macOS:
|
||||
### 1️⃣ Запуск контейнера
|
||||
|
||||
```bash
|
||||
# Установка зависимостей (если не установлены)
|
||||
brew install mariadb-connector-c
|
||||
brew install redis # опционально
|
||||
cd /Users/e-serg/PRJ/2022-oknardia
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
## 🔑 Шаг 1: Конфигурация секретов
|
||||
Сайт будет доступен на **http://localhost:8060**
|
||||
|
||||
### 1.1 Создайте файл `my_secret.py`
|
||||
### 2️⃣ Основные команды
|
||||
|
||||
```bash
|
||||
cd oknardia/oknardia
|
||||
cp my_secret.py.template my_secret.py
|
||||
nano my_secret.py # отредактируйте значения
|
||||
# Просмотр логов в реальном времени
|
||||
docker compose -f docker-compose.local.yml logs web -f
|
||||
|
||||
# Зайти в контейнер (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)
|
||||
- Пароль БД (MY_DATABASE_PASSWORD_DEV)
|
||||
- Email credentials (MY_EMAIL_HOST_USER_DEV, MY_EMAIL_HOST_PASSWORD_DEV)
|
||||
- Пути к файлам (MY_MEDIA_ROOT_DEV2, MY_STATIC_ROOT_DEV2)
|
||||
- SECRET_KEY (сгенерируйте новый!)
|
||||
**✨ Особенности:**
|
||||
- ✅ **Live reload** — при изменении кода автоматически перезагружается
|
||||
- ✅ **Синхронизция файлов** — база, медиа, статика синхронизированы с хостом
|
||||
- ✅ **Миграции автоматические** — применяются при каждом старте
|
||||
- ✅ **DEBUG режим** — подробные ошибки и админка
|
||||
|
||||
### 1.2 (Опционально) Создайте файл `.env.local`
|
||||
👁️ **Подробнее про Docker разработку** → см. раздел **"🐳 Docker Development"** ниже.
|
||||
|
||||
```bash
|
||||
cd /path/to/project
|
||||
cp .env.example .env.local
|
||||
nano .env.local # отредактируйте значения
|
||||
---
|
||||
|
||||
## 🐳 Docker Development
|
||||
|
||||
### Структура контейнера
|
||||
|
||||
```
|
||||
/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: Настройка БД
|
||||
|
||||
### 2.1 Создайте БД и пользователя
|
||||
|
||||
```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;
|
||||
```yaml
|
||||
volumes:
|
||||
- .:/home/app
|
||||
```
|
||||
|
||||
### 2.2 Выполните миграции
|
||||
Это монтирует весь проект (`/Users/e-serg/PRJ/2022-oknardia`) в `/home/app` контейнера.
|
||||
|
||||
**Синхронизация:**
|
||||
- Изменения на хосте сразу видны в контейнере
|
||||
- Разные между хостом и контейнером сохраняются на диск
|
||||
- БД и медиа-файлы персистент (не теряются при рестарте контейнера)
|
||||
|
||||
### Миграции в Docker
|
||||
|
||||
```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
|
||||
|
||||
# Или непосредственно внутри контейнера:
|
||||
docker compose -f docker-compose.local.yml exec web python manage.py migrate
|
||||
```
|
||||
|
||||
### 2.3 Создайте суперпользователя
|
||||
### Установка новых пакетов
|
||||
|
||||
```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
|
||||
# Установите poetry (если не установлен)
|
||||
curl -sSL https://install.python-poetry.org | python3 -
|
||||
|
||||
# Установите зависимости
|
||||
poetry install
|
||||
|
||||
# Активируйте виртуальное окружение
|
||||
poetry shell
|
||||
docker compose -f docker-compose.local.yml exec web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
### Вариант 2: pip (классический способ)
|
||||
### Очистка кеша и статики
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # На Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
# Пересоберите статику:
|
||||
docker compose -f docker-compose.local.yml exec web python manage.py collectstatic --clear --noinput
|
||||
|
||||
# Или удалите и пересоздайте:
|
||||
rm -rf ./public/static_collected/*
|
||||
docker compose -f docker-compose.local.yml restart web
|
||||
```
|
||||
|
||||
## 🏃 Шаг 4: Запуск разработки
|
||||
|
||||
### 4.1 Запустите локальный сервер
|
||||
### DEBUG и логирование
|
||||
|
||||
```bash
|
||||
cd oknardia
|
||||
python manage.py runserver
|
||||
# DEBUG=True включен по умолчанию
|
||||
# Смотрите подробные логи:
|
||||
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
|
||||
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/)
|
||||
- [AGENTS.md](./AGENTS.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
|
||||
|
||||
|
||||
147
config/nginx/oknardia-app--external-nginx.conf
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
##}
|
||||
|
||||
@@ -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 ----------------
|
||||
90
docker-compose.local-prod.yml
Normal 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
@@ -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
@@ -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/app/oknardia/templates/error/$${code}.html "$$ERROR_DIR/$${code}.html";
|
||||
done &&
|
||||
cp /home/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"
|
||||
|
||||
@@ -34,6 +34,8 @@ STATIC_SOURCE_ROOT = PUBLIC_ROOT / 'static'
|
||||
env = environ.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
|
||||
# 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/'))
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
# ПРЕДУПРЕЖДЕНИЕ БЕЗОПАСНОСТИ: не работайте в режиме DEBUG в продашене!
|
||||
# PREDУПРЕЖДЕНИЕ БЕЗОПАСНОСТИ: не работайте в режиме DEBUG в продашене!
|
||||
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=[]))
|
||||
@@ -139,7 +142,6 @@ DATETIME_FORMAT = 'Y-m-d H:i:s'
|
||||
STATIC_URL = '/static/'
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
|
||||
MEDIA_ROOT = str(PUBLIC_ROOT / 'media')
|
||||
# STATIC_ROOT отделен от исходной статики, чтобы избежать staticfiles.E002.
|
||||
STATIC_ROOT = str(PUBLIC_ROOT / 'static_collected')
|
||||
@@ -157,21 +159,42 @@ STATICFILES_DIRS = [
|
||||
str(STATIC_SOURCE_ROOT)
|
||||
] 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_BASE_PATH = str(STATIC_SOURCE_ROOT)
|
||||
|
||||
# Определяем движок БД из переменной окружения (по умолчанию SQLite)
|
||||
database_engine = env('DATABASE_ENGINE', default='django.db.backends.sqlite3')
|
||||
|
||||
if database_engine == 'django.db.backends.sqlite3':
|
||||
# Для 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
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': str(sqlite_db_path),
|
||||
}
|
||||
'OPTIONS': {
|
||||
'timeout': 20,
|
||||
},
|
||||
},
|
||||
}
|
||||
else:
|
||||
# База не SQLite (mariaDB, например): читаем все параметры подключения из env.
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': database_engine,
|
||||
@@ -314,3 +337,62 @@ CATALOG_SORTER_MAGIC_NUMBER_TIZER = 1
|
||||
MAX_LEN_RING_LOG_BUFFER = 250 # МАКСИМАЛЬНЫЙ РАЗМЕР КОЛЬЦЕВОГО БУФЕРА
|
||||
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -1,26 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""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'))
|
||||
"""
|
||||
"""oknardia Конфигурация URL"""
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path, re_path
|
||||
from django.conf.urls.static import static
|
||||
from django.urls import include, re_path, path
|
||||
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 web import views, autocomplete_addr, user_manager, blog, diagrams, report1, report2, catalog, prices, service, \
|
||||
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 = [
|
||||
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:
|
||||
# Медиа-файлы
|
||||
urlpatterns += static(MEDIA_URL, document_root=MEDIA_ROOT)
|
||||
# --- страничка для тестирования верстки текста в блоге
|
||||
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),
|
||||
]
|
||||
# ___ ____ _ _____ _ _ _____ _
|
||||
# | | | | \ ___| |_ _ _ ___ |_ _|___ ___| | |_ ___ ___ | _ |___ ___ ___| |
|
||||
# |_ | | | | -_| . | | | . | | | | . | . | | . | .'| _| | __| .'| | -_| |
|
||||
|
||||
@@ -16,10 +16,12 @@
|
||||
<meta name="document-state" content="{{ META_DOCUMENT_STATE|default:"Dynamic" }}" />
|
||||
<meta name="generator" content="OKNARDIA 0.3β by Python/Django" />
|
||||
<title>{% block Title %}{% endblock %} : ОКНАРДИЯ</title>
|
||||
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.svg" type="image/svg+xml ">
|
||||
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.png" type="image/png">
|
||||
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.gif" type="image/gif">
|
||||
<link rel="icon" href="{{ request.scheme }}://{{ request.get_host }}/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" href="{{ request.scheme }}://{{ request.get_host }}/favicon.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="{{ request.scheme }}://{{ request.get_host }}/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<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-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 %}
|
||||
|
||||
31
oknardia/templates/error/400.html
Normal 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>
|
||||
29
oknardia/templates/error/401.html
Normal 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>
|
||||
|
||||
32
oknardia/templates/error/403.html
Normal 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>
|
||||
|
||||
31
oknardia/templates/error/404.html
Normal 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>
|
||||
29
oknardia/templates/error/413.html
Normal 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>
|
||||
|
||||
29
oknardia/templates/error/429.html
Normal 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>
|
||||
|
||||
31
oknardia/templates/error/500.html
Normal 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>
|
||||
29
oknardia/templates/error/502.html
Normal 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>
|
||||
|
||||
29
oknardia/templates/error/503.html
Normal 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>
|
||||
|
||||
29
oknardia/templates/error/504.html
Normal 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>
|
||||
|
||||
29
oknardia/templates/error/under_reconstruction.html
Normal 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>
|
||||
|
||||
@@ -132,7 +132,10 @@ TechArticle: описывает страницу как технический
|
||||
</div>
|
||||
</div>{# <!--- Хлебные крошки: КОНЕЦ ---> #}
|
||||
<div class="row">
|
||||
<div class="col-md-12">{{ THIS_SERIA_DESCRIPTION|safe }}</div>
|
||||
<div class="col-md-12">
|
||||
{# ВЕРХНЯЯ СТАТЬЯ: рендерится динамически (из БД), можно редактировать через админку #}
|
||||
<div>{{ THIS_SERIA_DESCRIPTION|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
@@ -140,7 +143,12 @@ TechArticle: описывает страницу как технический
|
||||
<h2 class="header">Дома серии {{ THIS_SERIA_NAME }}: типовые размеры и схемы открывания</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>
|
||||
|
||||
@@ -149,14 +157,8 @@ TechArticle: описывает страницу как технический
|
||||
<h3 class="header">Оконные проёмы в типовых квартирах <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
|
||||
</div>
|
||||
<div class="col-lg-8 col-xs-12 col-md-offset-1">
|
||||
{# --- ОСНОВНОЙ БЛОК С ТАБЛИЦЕЙ --- #}
|
||||
{# Если есть кешированный файл, включаем его. Иначе - рендерим блок на лету. #}
|
||||
{% if PRE_RENDERED_INCLUDE_PATH %}
|
||||
{% include PRE_RENDERED_INCLUDE_PATH %}
|
||||
{% else %}
|
||||
{% include "seria_info/all_seria_info_pre_light_include.html" %}
|
||||
{% endif %}
|
||||
{# --- КОНЕЦ ОСНОВНОГО БЛОКА --- #}
|
||||
{# ТАБЛИЦА ОКОН: часть, которая считается при каждом запросе #}
|
||||
{% include "seria_info/all_seria_info_pre_light_dynamic_include.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,8 +166,13 @@ TechArticle: описывает страницу как технический
|
||||
<div class="col-md-9"><a name="s_graph"></a>
|
||||
<h2 class="header">Здания серия {{ THIS_SERIA_NAME }}: ввод в эксплуатацию по годам</h2>
|
||||
</div>
|
||||
<div class="col-md-9 col-md-offset-1" style="height:300px;font-size:large;">
|
||||
{% include 'seria_info/yaer_graph.html' %}
|
||||
<div class="col-md-9 col-md-offset-1" style="height:300px;font-size:large;" id="graph">
|
||||
{# ГРАФИК: статическая часть, если используется кеш #}
|
||||
{% if PRE_RENDERED_STATIC_GRAPH_PATH %}
|
||||
{% include PRE_RENDERED_STATIC_GRAPH_PATH %}
|
||||
{% else %}
|
||||
{% include 'seria_info/all_seria_info_pre_light_static_graph.html' %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-9 col-md-offset-1">
|
||||
<div style="font-size: xx-small;float: right">© 2015-{% now "Y" %}, данные: oknardia.ru</div>
|
||||
@@ -179,23 +186,28 @@ TechArticle: описывает страницу как технический
|
||||
<div class="col-md-7 col-lg-offset-1">
|
||||
<p><small>Чтобы посмотреть цены на установку и замену окон от партнёров «Окнардия» в своей квартире: найдите дом на карте; кликните на него; перейдите по ссылке «Смотреть коммерческие предложения». При необходимости смените типовую планировку квартиры (на странице ценовой выдачи, справа от изображения типовых проёмов и схем открывания).</small></p>
|
||||
<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 style="font-size: xx-small;float: right">© 2015-{% now "Y" %}, данные: oknardia.ru</div>
|
||||
</div>
|
||||
|
||||
{# КАРТА И СТАТИСТИКА: статическая часть, если используется кеш #}
|
||||
{% if PRE_RENDERED_STATIC_MAP_STATS_PATH %}
|
||||
{% include PRE_RENDERED_STATIC_MAP_STATS_PATH %}
|
||||
{% else %}
|
||||
<diV class="col-md-4">
|
||||
<h3 class="header">Статистика <nobr>серии {{ THIS_SERIA_NAME }}</nobr></h3>
|
||||
<p>Совокупно во всех зданиях типового проекта:</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> — муниципальное жильё.</li>
|
||||
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и городские службы, учреждения бытового обслуживания, магазины, офисы и тому подобное.</li>
|
||||
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }} — <strong>{{ CONDITION_MAX|stringformat:".2f" }} %</strong>. Минимальный — <strong>{{ CONDITION_MIN|stringformat:".2f" }} %</strong>. </li>
|
||||
<li><strong>{{ RESIDENTIAL_M2|stringformat:".1f"|price_format }} м²</strong> жилых помещений.</li>
|
||||
<li><strong>{{ MUNICIPAL_M2|stringformat:".1f"|price_format }} м²</strong> — муниципальное жильё.</li>
|
||||
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и городские службы, учреждения бытового обслуживания, магазины, офисы и тому подобное.</li>
|
||||
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }} — <strong>{{ CONDITION_MAX|stringformat:".2f" }} %</strong>. Минимальный — <strong>{{ CONDITION_MIN|stringformat:".2f" }} %</strong>. </li>
|
||||
</ul>
|
||||
</diV>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{# ============================================================================ #}
|
||||
{# ДИНАМИЧЕСКИЕ ДАННЫЕ ДЛЯ СЕРИИ (НЕ кешируемая часть) #}
|
||||
{# Содержит: Таблица раскладки окон по квартирам + статистика предложений #}
|
||||
{# ЗАМЕЧАНИЕ: этот блок ЧАСТО меняется (при добавлении новых предложений) #}
|
||||
{# ============================================================================ #}
|
||||
|
||||
<div class="col-md-9 col-xs-12" style="padding:0;">
|
||||
<!--- прешаблон начало --->
|
||||
<table style="padding:2px;">
|
||||
{% for row in TABLE_OF_WINDOWS %}
|
||||
<tr class="tr2">
|
||||
@@ -34,5 +39,5 @@
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
<!--- прешаблон конец --->
|
||||
</div>
|
||||
|
||||
@@ -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 мм.</nobr><br />{% if not I_WIN_DIM.iQuantity == 0 %}
|
||||
<nobr><b>{{ I_WIN_DIM.iQuantity }} шт.</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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>Совокупно во всех зданиях типового проекта:</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> — муниципальное жильё.</li>
|
||||
<li><strong>{{ GOVERNMENT_M2|stringformat:".1f"|price_format }} м²</strong> занимают государственные и городские службы, учреждения бытового обслуживания, магазины, офисы и тому подобное.</li>
|
||||
<li>Максимальный износ жилого фонда серии {{ THIS_SERIA_NAME }} — <strong>{{ CONDITION_MAX|stringformat:".2f" }} %</strong>. Минимальный — <strong>{{ CONDITION_MIN|stringformat:".2f" }} %</strong>. </li>
|
||||
</ul>
|
||||
</diV>
|
||||
|
||||
13
oknardia/templates/seria_info/prepared/.gitignore
vendored
Normal 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
|
||||
@@ -1,4 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from pathlib import Path
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Count, F, IntegerField, Value
|
||||
from django.shortcuts import render, redirect
|
||||
@@ -17,6 +18,9 @@ from web.report1 import get_last_all_user_visit_list
|
||||
from web.add_func import get_flaps_for_big_pictures, sanitize_slug
|
||||
import time
|
||||
import os
|
||||
import math
|
||||
import base64
|
||||
import json
|
||||
|
||||
|
||||
def _append_visit_context(to_template: dict, request: HttpRequest, time_start: float) -> None:
|
||||
@@ -89,13 +93,15 @@ def catalog_seria_info(
|
||||
# чтобы тестировать актуальную серверную логику, а не сохраненный html-файл.
|
||||
if DEBUG:
|
||||
light_template = "seria_info/all_seria_info_pre_light.html"
|
||||
light_template_w_path = ""
|
||||
static_include_path = "" # в DEV не используем кеш
|
||||
is_hard_template = True
|
||||
else:
|
||||
# В PROD используем существующий pre-render include при наличии на диске.
|
||||
light_template = f"seria_info/prepared/{seria_id}_id.html"
|
||||
light_template_w_path = f"{TEMPLATES[0]['DIRS'][0]}/{light_template}"
|
||||
is_hard_template = not os.path.isfile(light_template_w_path)
|
||||
# В PROD используем существующий pre-render include для статических данных (если есть).
|
||||
light_template = "seria_info/all_seria_info_pre_light.html"
|
||||
static_template_filename = f"seria_info/prepared/{seria_id}_id_static.html"
|
||||
static_template_w_path = f"{TEMPLATES[0]['DIRS'][0]}/{static_template_filename}"
|
||||
is_hard_template = not os.path.isfile(static_template_w_path)
|
||||
static_include_path = static_template_filename if not is_hard_template else ""
|
||||
|
||||
to_template: dict[str, object] = {}
|
||||
# Получаем все уникальные проемы серии и сразу добавляем iQuantity=1
|
||||
@@ -192,30 +198,80 @@ def catalog_seria_info(
|
||||
{
|
||||
"WIN_OFFER_AND_MERCHANT": offer_and_merchant_per_win,
|
||||
"TABLE_OF_WINDOWS": table_of_win_in_seria_by_apartmment,
|
||||
# Первая квартира из таблицы (нужна для картоки в пре-рендер шаблоне)
|
||||
"first_apart_id": table_of_win_in_seria_by_apartmment[0]["APART_ID"] if table_of_win_in_seria_by_apartmment else 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Для "тяжелого" шаблона получаем навигацию, карту и график, затем кэшируем pre-render.
|
||||
# Для "тяжелого" шаблона получаем навигацию, карту и график.
|
||||
# ВАЖНО: таблица окон (TABLE_OF_WINDOWS) считается ВСЕГДА — она не кешируется!
|
||||
if is_hard_template:
|
||||
to_template.update(get_flaps_for_big_pictures(list_win_in_seria))
|
||||
seria_id, for_seria_nav = seria_nav(seria_id)
|
||||
to_template.update(for_seria_nav)
|
||||
to_template.update(seria_info_year(seria_id))
|
||||
to_template.update(seria_info_geo_code(seria_id))
|
||||
|
||||
if not DEBUG:
|
||||
# Пре-рендер происходит только для "включаемого" шаблона,
|
||||
# чтобы избежать дублирования базовой разметки.
|
||||
string_prerender = render_to_string("seria_info/all_seria_info_pre_light_include.html", to_template)
|
||||
with open(light_template_w_path, "w", encoding="utf-8") as file:
|
||||
file.write(string_prerender)
|
||||
# Основной шаблон будет просто включать в себя уже готовый HTML
|
||||
light_template = "seria_info/all_seria_info_pre_light.html"
|
||||
else:
|
||||
to_template.update({"THIS_SERIA_NAME": q_seria.sName})
|
||||
# Указываем путь к кешированному файлу для include
|
||||
to_template.update({"PRE_RENDERED_INCLUDE_PATH": light_template})
|
||||
# Основной шаблон должен быть один и тот же
|
||||
light_template = "seria_info/all_seria_info_pre_light.html"
|
||||
# Пре-рендер ТРЁХ отдельных файлов для статических данных.
|
||||
# Верхняя статья НЕ кешируется — она рендерится динамически, чтобы изменения
|
||||
# через админку были видны сразу без перезагрузки контейнера.
|
||||
prepared_dir = Path(TEMPLATES[0]["DIRS"][0]) / PATH_FOR_SERIA_INFO_HTML_INCLUDE
|
||||
prepared_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 1. Схемы открывания и размеры
|
||||
string_flaps = render_to_string(
|
||||
"seria_info/all_seria_info_pre_light_static_flaps.html",
|
||||
to_template
|
||||
)
|
||||
file_flaps = prepared_dir / f"{seria_id}_id_static_flaps.html"
|
||||
with open(file_flaps, "w", encoding="utf-8") as f:
|
||||
f.write(string_flaps)
|
||||
|
||||
# 2. График ввода в эксплуатацию
|
||||
string_graph = render_to_string(
|
||||
"seria_info/all_seria_info_pre_light_static_graph.html",
|
||||
to_template
|
||||
)
|
||||
file_graph = prepared_dir / f"{seria_id}_id_static_graph.html"
|
||||
with open(file_graph, "w", encoding="utf-8") as f:
|
||||
f.write(string_graph)
|
||||
|
||||
# 3. Карта и статистика
|
||||
string_map_stats = render_to_string(
|
||||
"seria_info/all_seria_info_pre_light_static_map_stats.html",
|
||||
to_template
|
||||
)
|
||||
file_map_stats = prepared_dir / f"{seria_id}_id_static_map_stats.html"
|
||||
with open(file_map_stats, "w", encoding="utf-8") as f:
|
||||
f.write(string_map_stats)
|
||||
|
||||
# Добавляем в контекст пути к кешируемым файлам (верхняя статья всегда динамична)
|
||||
pre_rendered_flaps_path = ""
|
||||
pre_rendered_graph_path = ""
|
||||
pre_rendered_map_stats_path = ""
|
||||
|
||||
if not DEBUG:
|
||||
# В production используем кеширующие файлы, если они существую<D183><D18E>
|
||||
prepared_dir = Path(TEMPLATES[0]["DIRS"][0]) / PATH_FOR_SERIA_INFO_HTML_INCLUDE
|
||||
|
||||
file_flaps = prepared_dir / f"{seria_id}_id_static_flaps.html"
|
||||
file_graph = prepared_dir / f"{seria_id}_id_static_graph.html"
|
||||
file_map_stats = prepared_dir / f"{seria_id}_id_static_map_stats.html"
|
||||
|
||||
if file_flaps.exists():
|
||||
pre_rendered_flaps_path = f"seria_info/prepared/{seria_id}_id_static_flaps.html"
|
||||
if file_graph.exists():
|
||||
pre_rendered_graph_path = f"seria_info/prepared/{seria_id}_id_static_graph.html"
|
||||
if file_map_stats.exists():
|
||||
pre_rendered_map_stats_path = f"seria_info/prepared/{seria_id}_id_static_map_stats.html"
|
||||
|
||||
to_template.update({
|
||||
"THIS_SERIA_NAME": q_seria.sName,
|
||||
"PRE_RENDERED_STATIC_FLAPS_PATH": pre_rendered_flaps_path,
|
||||
"PRE_RENDERED_STATIC_GRAPH_PATH": pre_rendered_graph_path,
|
||||
"PRE_RENDERED_STATIC_MAP_STATS_PATH": pre_rendered_map_stats_path,
|
||||
})
|
||||
|
||||
|
||||
_append_visit_context(to_template, request, time_start)
|
||||
@@ -440,4 +496,20 @@ def seria_info_geo_code(seria_id: int | str = DEFAULT_SERIA_ID_FOR_CATALOG) -> d
|
||||
"ACCOUNTS": accounts,
|
||||
"CONDITION_MAX": condition_max,
|
||||
"CONDITION_MIN": condition_min})
|
||||
|
||||
# Кодируем геоданные в Base64 для защиты (используется в статик-шаблонах)
|
||||
# Формат: [latitude, longitude, addr_id, seria_id] для каждого здания
|
||||
geo_for_encoding = []
|
||||
for geo_point in seria_to_geo:
|
||||
geo_for_encoding.append([
|
||||
float(geo_point["LATITUDE"]),
|
||||
float(geo_point["LONGITUDE"]),
|
||||
geo_point["ADDR_ID"],
|
||||
geo_point["SER_ID"]
|
||||
])
|
||||
|
||||
geo_json = json.dumps(geo_for_encoding, separators=(',', ':'))
|
||||
geo_b64 = base64.b64encode(geo_json.encode('utf-8')).decode('utf-8')
|
||||
data_return["DATA4GEO_B64"] = geo_b64
|
||||
|
||||
return data_return
|
||||
|
||||
@@ -14,9 +14,20 @@ from web.add_func import sanitize_slug
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Пересоздает pre-render шаблоны для страниц серий (/catalog/seria/.../all<ID>)."""
|
||||
"""Пересоздает pre-render шаблоны <EFBFBD><EFBFBD>ля статических данных страниц серий (/catalog/seria/.../all<ID>).
|
||||
|
||||
help = "Пересоздает pre-render шаблоны catalog_seria_info для выбранных или всех корневых серий."
|
||||
ВАЖНО: Кешируются ТОЛЬКО статические данные (схемы, график, карта, статистика).
|
||||
Верхняя статья рендерится ДИНАМИЧЕСКИ из БД, чтобы изменения через админку
|
||||
были видны без перезагрузки контейнера.
|
||||
Таблица оконных проёмов также пересчитывается при каждом запросе.
|
||||
|
||||
Создаёт 3 файла для каждой серии:
|
||||
- _id_static_flaps.html (схемы открывания)
|
||||
- _id_static_graph.html (график ввода в эксплуатацию)
|
||||
- _id_static_map_stats.html (карта и статистика)
|
||||
"""
|
||||
|
||||
help = "Пересоздает pre-render шаблоны (3 файла) для статических данных выбранных или всех корневых серий."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -29,7 +40,7 @@ class Command(BaseCommand):
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Пересоздать даже если pre-render файл уже существует.",
|
||||
help="Пересоздать даже если pre-render файлы уже существуют.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
@@ -61,26 +72,35 @@ class Command(BaseCommand):
|
||||
skipped = 0
|
||||
|
||||
for seria in targets:
|
||||
target_file = prepared_dir / f"{seria.id}_id.html"
|
||||
if target_file.exists() and not force:
|
||||
# Проверяем существование вс<D0B2><D181>х 3 файлов (верхняя статья НЕ кешируется)
|
||||
target_files = [
|
||||
prepared_dir / f"{seria.id}_id_static_flaps.html",
|
||||
prepared_dir / f"{seria.id}_id_static_graph.html",
|
||||
prepared_dir / f"{seria.id}_id_static_map_stats.html",
|
||||
]
|
||||
all_exist = all(f.exists() for f in target_files)
|
||||
|
||||
if all_exist and not force:
|
||||
skipped += 1
|
||||
self.stdout.write(f"SKIP {seria.id}: {target_file}")
|
||||
self.stdout.write(f"SKIP {seria.id}: все кеш-файлы уже существуют")
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
action = "REGEN" if target_file.exists() else "CREATE"
|
||||
self.stdout.write(f"{action} {seria.id}: {target_file}")
|
||||
action = "REGEN" if all_exist else "CREATE"
|
||||
self.stdout.write(f"{action} {seria.id}: 3 файла (flaps, graph, map_stats)")
|
||||
planned += 1
|
||||
continue
|
||||
|
||||
if target_file.exists():
|
||||
target_file.unlink()
|
||||
# Удаляем старые файлы перед пересоздаванием
|
||||
for f in target_files:
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
|
||||
slug = sanitize_slug(seria.sName)
|
||||
request = request_factory.get(f"/catalog/seria/{slug}/all{seria.id}")
|
||||
|
||||
# В команде принудительно включаем «production-mode» для вьюхи,
|
||||
# чтобы она прошла тяжелую ветку и пересоздала pre-render файл.
|
||||
# чтобы она прошла тяжелую ветку и пересоздала pre-render файлы.
|
||||
old_debug = catalog_series.DEBUG
|
||||
try:
|
||||
catalog_series.DEBUG = False
|
||||
@@ -92,22 +112,29 @@ class Command(BaseCommand):
|
||||
raise CommandError(
|
||||
f"Серия {seria.id}: ожидался status=200, получен {response.status_code}."
|
||||
)
|
||||
if not target_file.exists():
|
||||
raise CommandError(f"Серия {seria.id}: pre-render файл не создан: {target_file}")
|
||||
|
||||
# Проверяем, что все 3 файла были созданы
|
||||
missing_files = [f for f in target_files if not f.exists()]
|
||||
if missing_files:
|
||||
raise CommandError(
|
||||
f"Серия {seria.id}: не созданы файлы: {[f.name for f in missing_files]}"
|
||||
)
|
||||
|
||||
created += 1
|
||||
self.stdout.write(self.style.SUCCESS(f"OK {seria.id}: {target_file}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"OK {seria.id}: 3 кеш-файла созданы")
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"DRY-RUN. Обработано: {len(targets)}. Будет создано/пересоздано: {planned}. Пропущено: {skipped}."
|
||||
f"DRY-RUN. Обработано: {len(targets)}. Б<EFBFBD><EFBFBD>дет создано/пересоздано: {planned}. Пропущено: {skipped}."
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Готово. Обработано: {len(targets)}. Создано/пересоздано: {created}. Пропущено: {skipped}."
|
||||
f"Готово. Обработано: {len(targets)}. Создано/пересоздано: {created} × 3 файла. Пропущено: {skipped}."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -242,3 +242,66 @@ def get_address(request: HttpRequest) -> HttpResponse:
|
||||
'ticks': float(time.perf_counter() - time_start),
|
||||
})
|
||||
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
@@ -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"]
|
||||
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]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -228,6 +249,17 @@ files = [
|
||||
[package.extras]
|
||||
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]]
|
||||
name = "pillow"
|
||||
version = "11.3.0"
|
||||
@@ -507,7 +539,21 @@ h2 = ["h2 (>=4,<5)"]
|
||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.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]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.12,<3.13"
|
||||
content-hash = "5fdba0321d441277f8b911d178c048c672533761e93443473897572f4ed16ebf"
|
||||
content-hash = "151e463bb47b6a3e0307c77b6032da97f414908c2775be9ea3c6ccfdee19e1a5"
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 387 B |
BIN
public/favicon.ico
Executable file → Normal file
|
Before Width: | Height: | Size: 529 B After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.png
Executable file → Normal file
|
Before Width: | Height: | Size: 635 B After Width: | Height: | Size: 3.1 KiB |
2
public/favicon.svg
Executable file → Normal 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 |
@@ -1,5 +1,5 @@
|
||||
# Маркетплейс-агрегатор цен на установку пластиковых и деревянных окон — ОКНАРДИЯ
|
||||
# robots.txt последняя версия: 2026-05-15
|
||||
# robots.txt последняя версия: 2026-05-20
|
||||
|
||||
User-Agent: *
|
||||
Allow: /
|
||||
@@ -10,6 +10,9 @@ Disallow: /*?*token*
|
||||
Disallow: /*.json$
|
||||
Disallow: /.*\.(js|css)$ # CSS и JS обслуживаются отдельно через CDN
|
||||
|
||||
# Параметры, которые не влияют на смысловое содержимое
|
||||
Clean-param: page-back /
|
||||
|
||||
# Быстрые краулеры
|
||||
User-agent: YandexBot
|
||||
Crawl-delay: 15
|
||||
|
||||
21
public/site.webmanifest
Normal 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"
|
||||
}
|
||||
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -15,6 +15,8 @@ Pillow = "^11.2.1"
|
||||
requests = "^2.32.3"
|
||||
pytils = "^0.4.4"
|
||||
rjsmin = "^1.2.0"
|
||||
gunicorn = "^23.0.0"
|
||||
whitenoise = "^6.8.2"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
django-debug-toolbar = "^6.3"
|
||||
|
||||