Добавлен установщик
This commit is contained in:
9
Makefile
9
Makefile
@@ -60,8 +60,9 @@ deploy: ## Собрать и запустить (docker)
|
|||||||
|
|
||||||
# --- nginx (на сервере) ------------------------------------------------------
|
# --- nginx (на сервере) ------------------------------------------------------
|
||||||
|
|
||||||
nginx-print: ## Показать конфиг nginx для этого стенда, ничего не меняя
|
# DOMAIN=sched.example.ru make nginx-print / nginx-install
|
||||||
deploy/nginx-site.sh --print
|
nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя
|
||||||
|
deploy/nginx-site.sh --print --domain $(DOMAIN)
|
||||||
|
|
||||||
nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (запускать с sudo)
|
nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo)
|
||||||
sudo deploy/nginx-site.sh
|
sudo deploy/nginx-site.sh --domain $(DOMAIN)
|
||||||
|
|||||||
545
README.md
545
README.md
@@ -1,22 +1,19 @@
|
|||||||
# Расписание
|
# Расписание
|
||||||
|
|
||||||
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
|
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на
|
||||||
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних
|
||||||
API и отображается на фронте.
|
библиотек и отдаётся как JSON API и как сайт, который можно «установить» на
|
||||||
|
главный экран телефона (PWA).
|
||||||
|
|
||||||
## Быстрый старт
|
Умеет:
|
||||||
|
|
||||||
Нужны Python 3.11+, bash (Linux, macOS или WSL на Windows) и публичная ссылка
|
- расписание по группам, преподавателям и аудиториям с разделением
|
||||||
на таблицу.
|
числитель/знаменатель и бейджем текущей недели;
|
||||||
|
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
|
||||||
```bash
|
- совпадающие недели показываются одним списком, различающиеся — двумя
|
||||||
python3 --version # должен быть 3.11 или новее
|
колонками, пустая — «Окно»;
|
||||||
cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска
|
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
|
||||||
make run # создаст .venv, поставит зависимости и запустит оба сервиса
|
- PWA: манифест, service worker, иконки — «на главный экран».
|
||||||
```
|
|
||||||
|
|
||||||
Открыть <http://127.0.0.1:8080>. Остановить — `Ctrl+C`.
|
|
||||||
Подробнее — разделы «Зависимости» и «Запуск» ниже.
|
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
@@ -24,20 +21,324 @@ make run # создаст .venv, поставит зависим
|
|||||||
Яндекс.Диск (xlsx)
|
Яндекс.Диск (xlsx)
|
||||||
│ скачивание по публичной ссылке
|
│ скачивание по публичной ссылке
|
||||||
▼
|
▼
|
||||||
api (FastAPI), порт 8000, в docker публикуется на 127.0.0.1
|
api — FastAPI, порт 8000
|
||||||
• скачивает и разбирает xlsx без сторонних библиотек
|
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
|
||||||
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
|
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
|
||||||
• один воркер, поэтому кэш общий
|
• один воркер, поэтому кэш общий
|
||||||
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
|
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
|
||||||
│ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка)
|
│
|
||||||
frontend (FastAPI: статика + прокси), прод :8090, dev :8080
|
frontend — FastAPI, порт из .env (по умолчанию 8080)
|
||||||
• отдаёт index.html / app.js / style.css / картинки
|
• отдаёт index.html / app.js / style.css / иконки
|
||||||
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
|
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
|
||||||
▲
|
▲
|
||||||
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Как устроено расписание в таблице
|
Роли:
|
||||||
|
|
||||||
|
- **docker** изолирует оба сервиса, перезапускает их при падении
|
||||||
|
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
|
||||||
|
`/healthz`); frontend стартует, только когда api стал healthy.
|
||||||
|
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
|
||||||
|
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
|
||||||
|
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
|
||||||
|
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
|
||||||
|
|
||||||
|
## Установка на сервер
|
||||||
|
|
||||||
|
Скачайте zip с проектом, распакуйте и запустите одну команду:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unzip schedule.zip && cd schedule
|
||||||
|
bash install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Установка требует root — скрипт сам перезапустится через sudo. До запуска DNS
|
||||||
|
домена уже должен указывать на сервер, а порты 80/443 — быть открытыми:
|
||||||
|
сертификат Let's Encrypt выпускается прямо в процессе установки.
|
||||||
|
|
||||||
|
Скрипт спросит:
|
||||||
|
|
||||||
|
| Вопрос | Что ответить |
|
||||||
|
|---|---|
|
||||||
|
| Как ставить: Docker или систему | Docker — рекомендуется (разница ниже) |
|
||||||
|
| Публичная ссылка на таблицу | ссылка Я.Диска, единственная обязательная настройка |
|
||||||
|
| Домен сайта | например `sched.example.ru`; пусто = без nginx, сайт на 127.0.0.1 |
|
||||||
|
| Порт api и порт сайта | по умолчанию 8000 и 8080 |
|
||||||
|
| Пользователь служб (только для «в систему») | от кого работают сервисы |
|
||||||
|
| Почта (только если задан домен) | для уведомлений Let's Encrypt |
|
||||||
|
|
||||||
|
Ответы можно передать и флагами: `--mode`, `--link`, `--domain`, `--api-port`,
|
||||||
|
`--front-port`, `--user`, `--email`. `install.sh --dry-run` показывает план
|
||||||
|
установки, ничего не меняя.
|
||||||
|
|
||||||
|
### Docker или система — что выбрать
|
||||||
|
|
||||||
|
**Docker** — рекомендуется: сервисы изолированы, автоматически перезапускаются,
|
||||||
|
обновляются одной командой, не зависят от Python в системе. Установщик поставит
|
||||||
|
Docker (если нет), запишет `.env`, соберёт образы, запустит контейнеры и
|
||||||
|
дождётся их готовности.
|
||||||
|
|
||||||
|
**Система (без Docker)** — если Docker не нужен: установщик проверит Python
|
||||||
|
3.11+, создаст `.venv`, поставит зависимости и зарегистрирует две службы
|
||||||
|
systemd — `schedule-api` и `schedule-frontend` (автозапуск после ребута,
|
||||||
|
перезапуск при падении).
|
||||||
|
|
||||||
|
В обоих случаях, если домен задан, установщик дополнительно поставит nginx и
|
||||||
|
certbot, выпустит сертификат Let's Encrypt (webroot — продление автоматическое,
|
||||||
|
сайт не останавливается) и настроит конфиг: http → https, прокси на порт
|
||||||
|
фронта. Снаружи виден только nginx.
|
||||||
|
|
||||||
|
### Ручная установка (без скрипта)
|
||||||
|
|
||||||
|
Установщик делает ровно следующее — при желании те же шаги выполняются руками.
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env && nano .env # SCHEDULE_LINK (+ порты при желании)
|
||||||
|
sudo ./deploy.sh # собрать и запустить
|
||||||
|
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
|
||||||
|
```
|
||||||
|
|
||||||
|
**Система:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3 python3-venv python3-pip # Python 3.11+
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
cp .env.example .env && nano .env # SCHEDULE_LINK
|
||||||
|
```
|
||||||
|
|
||||||
|
Две службы systemd (`/etc/systemd/system/`), затем
|
||||||
|
`sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# schedule-api.service
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule API (FastAPI)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/путь/к/проекту/src/backend
|
||||||
|
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
# schedule-frontend.service
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule frontend (FastAPI)
|
||||||
|
After=network-online.target schedule-api.service
|
||||||
|
Wants=network-online.target schedule-api.service
|
||||||
|
[Service]
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/путь/к/проекту/src/frontend
|
||||||
|
Environment=SCHEDULE_API=http://127.0.0.1:8000
|
||||||
|
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y nginx certbot
|
||||||
|
# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html
|
||||||
|
sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru
|
||||||
|
sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080
|
||||||
|
curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на
|
||||||
|
`FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет
|
||||||
|
`nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя:
|
||||||
|
`deploy/nginx-site.sh --print --domain sched.example.ru`.
|
||||||
|
|
||||||
|
### Обслуживание
|
||||||
|
|
||||||
|
- **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`;
|
||||||
|
система — заменить файлы проекта и
|
||||||
|
`sudo systemctl restart schedule-api schedule-frontend`.
|
||||||
|
- **Диагностика**: Docker — `sudo docker compose ps` и
|
||||||
|
`sudo docker logs api --tail 50`; система — `systemctl status schedule-api`
|
||||||
|
и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша —
|
||||||
|
`/api/status` (смотреть поле `error`).
|
||||||
|
- **Ротация логов контейнеров** — настроить сразу: без лимита логи растут
|
||||||
|
вечно и однажды забивают диск. `/etc/docker/daemon.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
затем `sudo systemctl restart docker`.
|
||||||
|
- **Диск**: следить `df -h /`; раз в месяц
|
||||||
|
`sudo docker system prune -af && sudo journalctl --vacuum-size=200M`.
|
||||||
|
|
||||||
|
## Локальная разработка
|
||||||
|
|
||||||
|
Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version # должен быть 3.11 или новее
|
||||||
|
cp .env.example .env # вписать SCHEDULE_LINK
|
||||||
|
make run # создаст .venv, поставит зависимости, поднимет оба сервиса
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть <http://127.0.0.1:8080> (API — <http://127.0.0.1:8000>). Остановить —
|
||||||
|
`Ctrl+C`; отвязать от терминала — `nohup make run &`.
|
||||||
|
|
||||||
|
Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по
|
||||||
|
интервалу.
|
||||||
|
|
||||||
|
Полезные команды (`make help` — весь список):
|
||||||
|
|
||||||
|
| Команда | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `make venv` | создать `.venv` и установить зависимости |
|
||||||
|
| `make run` (или `make dev`) | запустить API и сайт |
|
||||||
|
| `make run-api` / `make run-front` | запустить только один из сервисов |
|
||||||
|
| `make test` | прогнать тесты |
|
||||||
|
| `make nginx-print` / `make nginx-install` | показать / установить конфиг nginx |
|
||||||
|
|
||||||
|
Без make — то же самое руками:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip install -r requirements-dev.txt
|
||||||
|
.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend
|
||||||
|
.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test # 121 тест, сеть не нужна
|
||||||
|
```
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
**Системные требования**
|
||||||
|
|
||||||
|
| Что | Зачем |
|
||||||
|
|---|---|
|
||||||
|
| Python 3.11+ | локальная разработка и режим «в систему» |
|
||||||
|
| Docker + docker compose v2 | режим Docker (опционально) |
|
||||||
|
|
||||||
|
**Python-пакеты** — закреплены в `requirements.txt` (рабочие) и
|
||||||
|
`requirements-dev.txt` (тесты):
|
||||||
|
|
||||||
|
| Пакет | Версия | Назначение |
|
||||||
|
|---|---|---|
|
||||||
|
| fastapi | 0.141.1 | веб-фреймворк обоих сервисов |
|
||||||
|
| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы |
|
||||||
|
| python-dotenv | 1.2.3 | чтение `.env` |
|
||||||
|
| requests | 2.34.2 | скачивание файла с Я.Диска и прокси к API |
|
||||||
|
| pytest | 9.1.1 | тесты — только dev |
|
||||||
|
| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` — только dev |
|
||||||
|
|
||||||
|
Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных
|
||||||
|
нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен
|
||||||
|
только тестам; модуль импортируется как `httpx2` — httpx перешёл под опеку
|
||||||
|
Pydantic и был переименован в августе 2026, `import httpx` больше не работает.
|
||||||
|
|
||||||
|
### Если установка не идёт
|
||||||
|
|
||||||
|
- Проверьте `python3 --version`: нужен 3.11 или новее. Если под `python3`
|
||||||
|
скрывается старая версия, создайте окружение нужной явно:
|
||||||
|
`python3.12 -m venv .venv`.
|
||||||
|
- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте
|
||||||
|
пакет venv: `sudo apt install python3-venv` (для нестандартной версии —
|
||||||
|
например, `python3.12-venv`).
|
||||||
|
|
||||||
|
### Если pip не находит пакеты: зеркала
|
||||||
|
|
||||||
|
Если pip настроен на корпоративное/национальное зеркало, оно может отставать
|
||||||
|
от PyPI — свежих пакетов там ещё нет. Диагностика и обход:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip index versions httpx2 # есть ли пакет на текущем индексе
|
||||||
|
pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало
|
||||||
|
pip config set global.index-url <зеркало> # закрепить зеркало навсегда
|
||||||
|
```
|
||||||
|
|
||||||
|
Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте;
|
||||||
|
URL подставлять как есть в `-i` / `index-url`):
|
||||||
|
|
||||||
|
| Зеркало | URL |
|
||||||
|
|---|---|
|
||||||
|
| PyPI, официальный | `https://pypi.org/simple` |
|
||||||
|
| TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` |
|
||||||
|
| Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` |
|
||||||
|
| BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` |
|
||||||
|
| SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` |
|
||||||
|
| USTC | `https://mirrors.ustc.edu.cn/pypi/simple` |
|
||||||
|
| SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` |
|
||||||
|
|
||||||
|
Примечания: Alibaba Cloud (`https://mirrors.aliyun.com/pypi/simple`) обычно
|
||||||
|
тоже свежий, но из части сетей недоступен; Яндекс-зеркало (`mirror.yandex.ru`)
|
||||||
|
PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов
|
||||||
|
надёжнее разово ставить с официального индекса.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | По умолчанию | Где | Описание |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
||||||
|
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут |
|
||||||
|
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
|
||||||
|
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
|
||||||
|
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
||||||
|
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
||||||
|
| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) |
|
||||||
|
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) |
|
||||||
|
|
||||||
|
`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий
|
||||||
|
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
|
||||||
|
переменные окружения.
|
||||||
|
|
||||||
|
## Структура кода
|
||||||
|
|
||||||
|
```
|
||||||
|
install.sh интерактивная установка на сервер: Docker или systemd + nginx
|
||||||
|
src/
|
||||||
|
backend/
|
||||||
|
main.py FastAPI: маршруты, create_app(cache), /healthz
|
||||||
|
schedule/
|
||||||
|
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
|
||||||
|
скрытые столбцы, красный текст, серийные даты
|
||||||
|
source.py скачивание файла по публичной ссылке Я.Диска
|
||||||
|
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
||||||
|
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
||||||
|
cache.py ScheduleCache: блокировка, интервал, stale-while-error
|
||||||
|
errors.py ScheduleError
|
||||||
|
frontend/
|
||||||
|
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||||||
|
static/
|
||||||
|
index.html, app.js, style.css
|
||||||
|
manifest.json PWA: имя, иконки, standalone-режим
|
||||||
|
sw.js минимальный service worker (без кэша) для установки приложения
|
||||||
|
img/ логотипы и иконки приложения
|
||||||
|
tools/
|
||||||
|
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
||||||
|
deploy/
|
||||||
|
nginx-site.sh генерация и включение конфига nginx (используется install.sh)
|
||||||
|
tests/
|
||||||
|
conftest.py общие фикстуры
|
||||||
|
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
||||||
|
fixtures.py общая тестовая таблица
|
||||||
|
test_lessons.py все форматы ячеек
|
||||||
|
test_xlsx.py чтение книги
|
||||||
|
test_parsing.py разбор листов, недели, индексы
|
||||||
|
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
|
||||||
|
test_deploy.py проверка, что образы копируют все нужные файлы
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как устроена таблица-источник
|
||||||
|
|
||||||
Лист «Бакалавры и магистры» — это сетка:
|
Лист «Бакалавры и магистры» — это сетка:
|
||||||
|
|
||||||
@@ -103,45 +404,6 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
|
|||||||
- Приложение можно «установить» на главный экран (манифест + service
|
- Приложение можно «установить» на главный экран (манифест + service
|
||||||
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
|
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
|
||||||
|
|
||||||
## Структура кода
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
backend/
|
|
||||||
main.py FastAPI: маршруты, create_app(cache), /healthz
|
|
||||||
schedule/
|
|
||||||
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
|
|
||||||
скрытые столбцы, красный текст, серийные даты
|
|
||||||
source.py скачивание файла по публичной ссылке Я.Диска
|
|
||||||
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
|
||||||
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
|
||||||
cache.py ScheduleCache: блокировка, интервал, stale-while-error
|
|
||||||
errors.py ScheduleError
|
|
||||||
frontend/
|
|
||||||
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
|
||||||
static/
|
|
||||||
index.html, app.js, style.css
|
|
||||||
manifest.json PWA: имя, иконки, standalone-режим
|
|
||||||
sw.js минимальный service worker (без кэша) для установки приложения
|
|
||||||
img/ логотипы (owl.svg, owl-white.svg), иконки приложения
|
|
||||||
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
|
|
||||||
tools/
|
|
||||||
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
|
||||||
deploy/
|
|
||||||
nginx-site.sh генерация и включение конфига nginx (SSL — сертификат родителя)
|
|
||||||
nginx-dev.conf конфиг-заготовка для стенда без wildcard у родителя
|
|
||||||
README-dev.md как поднять второй стенд рядом с основным
|
|
||||||
tests/
|
|
||||||
conftest.py общие фикстуры
|
|
||||||
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
|
||||||
fixtures.py общая тестовая таблица
|
|
||||||
test_lessons.py все форматы ячеек
|
|
||||||
test_xlsx.py чтение книги
|
|
||||||
test_parsing.py разбор листов, недели, индексы
|
|
||||||
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
|
|
||||||
test_deploy.py проверка, что образы копируют все нужные файлы
|
|
||||||
```
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Полный список методов отдаёт сам бэкенд: `GET /`.
|
Полный список методов отдаёт сам бэкенд: `GET /`.
|
||||||
@@ -159,167 +421,10 @@ tests/
|
|||||||
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день |
|
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день |
|
||||||
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории |
|
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории |
|
||||||
| GET | `/week` | недели семестра и текущая чётность |
|
| GET | `/week` | недели семестра и текущая чётность |
|
||||||
| GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
|
| GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) |
|
||||||
| GET | `/healthz` | живость процесса (не обращается к таблице) |
|
| GET | `/healthz` | живость процесса (не обращается к таблице) |
|
||||||
|
|
||||||
Фронт проксирует только то, что использует, под префиксом `/api/...`:
|
Фронт проксирует только то, что использует, под префиксом `/api/...`:
|
||||||
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
|
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
|
||||||
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
|
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
|
||||||
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
|
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
|
||||||
|
|
||||||
## Переменные окружения
|
|
||||||
|
|
||||||
| Переменная | По умолчанию | Где | Описание |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
|
||||||
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут |
|
|
||||||
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
|
|
||||||
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
|
|
||||||
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
|
||||||
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
|
||||||
| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) |
|
|
||||||
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) |
|
|
||||||
|
|
||||||
`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий
|
|
||||||
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
|
|
||||||
переменные окружения.
|
|
||||||
|
|
||||||
## Зависимости
|
|
||||||
|
|
||||||
Проект состоит из двух сервисов на Python (FastAPI): `api` и `frontend`.
|
|
||||||
Всё ставится одной командой `make venv` (или вручную, см. ниже).
|
|
||||||
|
|
||||||
**Системные требования**
|
|
||||||
|
|
||||||
| Что | Зачем |
|
|
||||||
|---|---|
|
|
||||||
| Python 3.11+ | оба сервиса |
|
|
||||||
| Docker + docker compose v2 | только для запуска в контейнерах (опционально) |
|
|
||||||
|
|
||||||
**Python-зависимости** — закреплены в `requirements.txt` (рабочие) и
|
|
||||||
`requirements-dev.txt` (тесты):
|
|
||||||
|
|
||||||
| Пакет | Версия | Назначение |
|
|
||||||
|---|---|---|
|
|
||||||
| fastapi | 0.141.1 | веб-фреймворк обоих сервисов |
|
|
||||||
| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы |
|
|
||||||
| python-dotenv | 1.2.3 | чтение `.env` |
|
|
||||||
| requests | 2.34.2 | скачивание файла с Яндекс.Диска и прокси к API |
|
|
||||||
| pytest | 9.1.1 | тесты (`make test`) — только dev |
|
|
||||||
| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` в тестах — только dev |
|
|
||||||
|
|
||||||
Ничего, кроме этого, не нужно: xlsx разбирается стандартной библиотекой
|
|
||||||
(`zipfile` + `xml.etree`), базы данных нет, кэш — в памяти процесса.
|
|
||||||
|
|
||||||
**Если установка не идёт**
|
|
||||||
|
|
||||||
- Проверьте `python3 --version`: нужен 3.11 или новее. Если в системе под
|
|
||||||
`python3` скрывается старая версия (например, 3.6 на старых CentOS), создайте
|
|
||||||
окружение нужной версией явно: `python3.12 -m venv .venv`.
|
|
||||||
- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте
|
|
||||||
пакет venv: `sudo apt install python3-venv` (для нестандартной версии —
|
|
||||||
например, `python3.12-venv`).
|
|
||||||
- `httpx2` и `httpcore2` — новые пакеты (август 2026, httpx перешёл под
|
|
||||||
опеку Pydantic и был переименован). В коде и тестах модуль импортируется
|
|
||||||
как `httpx2`; `import httpx` больше не работает. В рабочем коде httpx2
|
|
||||||
не используется — нужен только тестам.
|
|
||||||
|
|
||||||
**Если pip не находит пакеты: зеркала**
|
|
||||||
|
|
||||||
Если pip настроен на корпоративное/национальное зеркало, оно может отставать
|
|
||||||
от PyPI — свежие пакеты на нём ещё не появились. Диагностика и обход:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip index versions httpx2 # есть ли пакет на текущем индексе
|
|
||||||
pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало
|
|
||||||
pip config set global.index-url <зеркало> # закрепить зеркало навсегда
|
|
||||||
```
|
|
||||||
|
|
||||||
Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте;
|
|
||||||
URL подставлять как есть в `-i` / `index-url`):
|
|
||||||
|
|
||||||
| Зеркало | URL |
|
|
||||||
|---|---|
|
|
||||||
| PyPI, официальный | `https://pypi.org/simple` |
|
|
||||||
| TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` |
|
|
||||||
| Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` |
|
|
||||||
| BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` |
|
|
||||||
| SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` |
|
|
||||||
| USTC | `https://mirrors.ustc.edu.cn/pypi/simple` |
|
|
||||||
| SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` |
|
|
||||||
|
|
||||||
Makefile написан под bash: Linux, macOS или WSL. На Windows без WSL
|
|
||||||
используйте Docker-вариант из раздела «В Docker».
|
|
||||||
|
|
||||||
## Запуск
|
|
||||||
|
|
||||||
### Без Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <адрес репозитория> && cd Schedule
|
|
||||||
cp .env.example .env # открыть .env и вписать SCHEDULE_LINK
|
|
||||||
make run # = make venv + запуск api и сайта
|
|
||||||
```
|
|
||||||
|
|
||||||
`make run` создаёт виртуальное окружение `.venv`, ставит зависимости и
|
|
||||||
поднимает два сервиса с авто-перезагрузкой:
|
|
||||||
|
|
||||||
- API — <http://127.0.0.1:8000> (например, `/status` и `/groups`);
|
|
||||||
- сайт — <http://127.0.0.1:8080> (его и открывать в браузере).
|
|
||||||
|
|
||||||
Чтобы отвязать процесс от терминала, стоит запускать через `nohup make run &`
|
|
||||||
|
|
||||||
Остановить — `Ctrl+C`. Данные скачиваются сразу на старте приложения
|
|
||||||
(скачивание занимает несколько секунд) и дальше обновляются по интервалу.
|
|
||||||
|
|
||||||
Полезные команды (`make help` — весь список):
|
|
||||||
|
|
||||||
| Команда | Что делает |
|
|
||||||
|---|---|
|
|
||||||
| `make venv` | создать `.venv` и установить зависимости |
|
|
||||||
| `make run` (или `make dev`) | запустить API и сайт |
|
|
||||||
| `make run-api` / `make run-front` | запустить только один из сервисов |
|
|
||||||
| `make test` | прогнать тесты |
|
|
||||||
|
|
||||||
Если хочется без make — то же самое руками:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install -r requirements-dev.txt
|
|
||||||
.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend
|
|
||||||
.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
### В Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
|
|
||||||
./deploy.sh # = docker compose build && up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
|
|
||||||
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
|
|
||||||
|
|
||||||
### Настройка nginx (прод)
|
|
||||||
|
|
||||||
Скрипт генерирует и включает конфиг сайта: http → https, прокси на
|
|
||||||
`FRONTEND_PORT` из `.env`, после проверки `nginx -t` перечитывает nginx.
|
|
||||||
SSL обеспечивается сертификатом родительского домена (обычно wildcard
|
|
||||||
`*.our-place.ru`) — отдельный certbot для поддомена не нужен.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo ls /etc/letsencrypt/live/ # имя сертификата родительского домена
|
|
||||||
sudo make nginx-install # или: sudo deploy/nginx-site.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Параметры: `--domain sched.example.ru`, `--cert example.ru` (по умолчанию —
|
|
||||||
родительский домен от `--domain`), `--port 8090` (по умолчанию —
|
|
||||||
`FRONTEND_PORT` из `.env`). Посмотреть конфиг, ничего не меняя:
|
|
||||||
`make nginx-print`. Если wildcard у родителя нет — выпустите сертификат
|
|
||||||
скриптом certbot, как для dev-стенда (см. `deploy/README-dev.md`).
|
|
||||||
|
|
||||||
### Тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
make test # 121 тест, сеть не нужна
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
# Второй стенд рядом с основным
|
|
||||||
|
|
||||||
Как поднять ещё один экземпляр проекта (например, для проверки правок в таблице
|
|
||||||
на отдельном домене): свой каталог, свои порты, своя ссылка на таблицу.
|
|
||||||
Основной стенд при этом не трогаем.
|
|
||||||
|
|
||||||
## 1. Код
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev
|
|
||||||
git clone <адрес репозитория> /opt/schedule-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. .env
|
|
||||||
|
|
||||||
Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
SCHEDULE_LINK=https://disk.yandex.ru/i/...
|
|
||||||
SCHEDULE_INTERVAL_MINUTES=15
|
|
||||||
SCHEDULE_RETRY_AFTER=60
|
|
||||||
API_PORT=8001
|
|
||||||
FRONTEND_PORT=8091
|
|
||||||
```
|
|
||||||
|
|
||||||
Порты 8001/8091 выбраны, чтобы не пересечься с основным стендом (8000/8090).
|
|
||||||
В docker-compose.yml оба порта публикуются на `127.0.0.1` — API и фронт не
|
|
||||||
смотрят в интернет напрямую, наружу их отдаёт nginx.
|
|
||||||
|
|
||||||
## 3. Запуск
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/schedule-dev
|
|
||||||
sudo docker compose up -d --build
|
|
||||||
sudo docker compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. nginx и сертификат
|
|
||||||
|
|
||||||
Конфиг генерирует `deploy/nginx-site.sh`: порт фронта возьмёт из `.env`,
|
|
||||||
сертификат — родительского домена (wildcard), затем включит сайт, проверит
|
|
||||||
`nginx -t` и перечитает nginx:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo deploy/nginx-site.sh --domain dev.schedule.our-place.ru --cert our-place.ru
|
|
||||||
```
|
|
||||||
|
|
||||||
Если у родительского домена нет wildcard-сертификата — выпустите свой
|
|
||||||
(конфиг-заготовка для ручного пути: `deploy/nginx-dev.conf`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo cp deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
|
||||||
sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
|
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
sudo certbot --nginx -d dev.schedule.our-place.ru
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Проверка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -s http://127.0.0.1:8091/healthz # {"ok":true}
|
|
||||||
curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200
|
|
||||||
```
|
|
||||||
|
|
||||||
## Обновление
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/schedule-dev && git pull && sudo docker compose up -d --build
|
|
||||||
```
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Тестовый стенд dev.schedule.our-place.ru
|
|
||||||
#
|
|
||||||
# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем):
|
|
||||||
# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек.
|
|
||||||
#
|
|
||||||
# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
|
|
||||||
# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
|
|
||||||
# sudo certbot --nginx -d dev.schedule.our-place.ru
|
|
||||||
# sudo nginx -t && sudo systemctl reload nginx
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name dev.schedule.our-place.ru;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name dev.schedule.our-place.ru;
|
|
||||||
|
|
||||||
ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem;
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
|
|
||||||
client_max_body_size 8m;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
# порт должен совпадать с FRONTEND_PORT из .env стенда
|
|
||||||
proxy_pass http://127.0.0.1:8091;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
|
||||||
# принудительное обновление расписания из админки — до ~2 минут
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
# Список имён: sudo ls /etc/letsencrypt/live/
|
# Список имён: sudo ls /etc/letsencrypt/live/
|
||||||
#
|
#
|
||||||
# Имя сертификата берётся из --cert, иначе — родительский домен от --domain
|
# Имя сертификата берётся из --cert, иначе — родительский домен от --domain
|
||||||
# (schedule.example.ru -> example.ru). Порт фронта берётся из --port, иначе из
|
# (sched.example.ru -> example.ru). Порт фронта берётся из --port, иначе из
|
||||||
# FRONTEND_PORT файла .env в корне проекта, иначе 8090.
|
# FRONTEND_PORT файла .env в корне проекта, иначе 8080.
|
||||||
#
|
#
|
||||||
# Примеры:
|
# Примеры:
|
||||||
# deploy/nginx-site.sh --print # показать конфиг, ничего не менять
|
# deploy/nginx-site.sh --print --domain sched.example.ru # показать конфиг
|
||||||
# sudo deploy/nginx-site.sh # поставить и перечитать nginx
|
# sudo deploy/nginx-site.sh --domain sched.example.ru # поставить и перечитать nginx
|
||||||
# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru
|
# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -19,7 +19,9 @@ usage() {
|
|||||||
grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2
|
grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2
|
||||||
}
|
}
|
||||||
|
|
||||||
DOMAIN="schedule.our-place.ru"
|
die() { echo "Ошибка: $*" >&2; exit 2; }
|
||||||
|
|
||||||
|
DOMAIN=""
|
||||||
CERT_NAME=""
|
CERT_NAME=""
|
||||||
FRONTEND_PORT=""
|
FRONTEND_PORT=""
|
||||||
PRINT=0
|
PRINT=0
|
||||||
@@ -35,9 +37,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя домена: $DOMAIN" >&2; exit 2; }
|
[[ -n $DOMAIN ]] || die "Укажите домен: --domain sched.example.ru (см. --help)"
|
||||||
|
[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || die "Кривое имя домена: $DOMAIN"
|
||||||
if [[ -z $CERT_NAME ]]; then
|
if [[ -z $CERT_NAME ]]; then
|
||||||
CERT_NAME="${DOMAIN#*.}" # schedule.our-place.ru -> our-place.ru (wildcard родителя)
|
CERT_NAME="${DOMAIN#*.}" # sched.example.ru -> example.ru (wildcard родителя)
|
||||||
fi
|
fi
|
||||||
[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; }
|
[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; }
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ if [[ -z $FRONTEND_PORT ]]; then
|
|||||||
| tr -d ' "' | awk -F: '{print $NF}'
|
| tr -d ' "' | awk -F: '{print $NF}'
|
||||||
) || FRONTEND_PORT=""
|
) || FRONTEND_PORT=""
|
||||||
fi
|
fi
|
||||||
FRONTEND_PORT=${FRONTEND_PORT:-8090}
|
FRONTEND_PORT=${FRONTEND_PORT:-8080}
|
||||||
[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; }
|
[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; }
|
||||||
|
|
||||||
read -r -d '' CONFIG <<'TEMPLATE' || true
|
read -r -d '' CONFIG <<'TEMPLATE' || true
|
||||||
|
|||||||
316
install.sh
Executable file
316
install.sh
Executable file
@@ -0,0 +1,316 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Интерактивная установка «Расписания» на чистый сервер.
|
||||||
|
#
|
||||||
|
# Запуск из распакованного каталога проекта (нужен root — скрипт сам
|
||||||
|
# перезапустится через sudo):
|
||||||
|
#
|
||||||
|
# bash install.sh
|
||||||
|
#
|
||||||
|
# Скрипт спросит: как ставить (Docker или прямо в систему), публичную ссылку
|
||||||
|
# на таблицу и домен сайта. Затем поставит всё нужное, запустит сервисы и
|
||||||
|
# настроит nginx с сертификатом Let's Encrypt.
|
||||||
|
#
|
||||||
|
# Для автоматизации ответы можно передать аргументами:
|
||||||
|
# --mode docker|system способ установки
|
||||||
|
# --link URL публичная ссылка Яндекс.Диска с таблицей
|
||||||
|
# --domain DOMAIN домен сайта (пусто = без nginx, сайт на 127.0.0.1)
|
||||||
|
# --api-port N порт api (по умолчанию 8000)
|
||||||
|
# --front-port N порт сайта (по умолчанию 8080)
|
||||||
|
# --user NAME пользователь служб (режим «в систему»)
|
||||||
|
# --email ADDR почта для уведомлений Let's Encrypt
|
||||||
|
# --dry-run показать план установки, ничего не меняя
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||||
|
err() { printf '\033[31m==>\033[0m %s\n' "$*" >&2; }
|
||||||
|
die() { err "$*"; exit 1; }
|
||||||
|
|
||||||
|
usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2; }
|
||||||
|
|
||||||
|
MODE="" LINK="" DOMAIN="" DOMAIN_SET=0 API_PORT="" FRONT_PORT="" RUN_USER="" EMAIL="" DRY_RUN=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--mode) MODE="$2"; shift 2 ;;
|
||||||
|
--link) LINK="$2"; shift 2 ;;
|
||||||
|
--domain) DOMAIN="$2"; DOMAIN_SET=1; shift 2 ;;
|
||||||
|
--api-port) API_PORT="$2"; shift 2 ;;
|
||||||
|
--front-port) FRONT_PORT="$2"; shift 2 ;;
|
||||||
|
--user) RUN_USER="$2"; shift 2 ;;
|
||||||
|
--email) EMAIL="$2"; shift 2 ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "Неизвестный аргумент: $1 (см. --help)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ask "Вопрос" [по_умолчанию] — ответ в $REPLY
|
||||||
|
ask() {
|
||||||
|
local def="${2:-}" input
|
||||||
|
if ! read -rp "$1${def:+ [$def]}: " input; then
|
||||||
|
die "Нужен интерактивный ввод — запустите в терминале или передайте все параметры флагами (см. --help)"
|
||||||
|
fi
|
||||||
|
REPLY="${input:-$def}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- вопросы -------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ -z $MODE ]]; then
|
||||||
|
log "Как ставить?"
|
||||||
|
log " 1) Docker — изолированные контейнеры, автоперезапуск, обновление одной командой (рекомендуется)"
|
||||||
|
log " 2) Система — без Docker: venv + службы systemd"
|
||||||
|
while true; do
|
||||||
|
ask "Способ установки (docker/system)" "docker"
|
||||||
|
REPLY=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs)
|
||||||
|
if [[ $REPLY == 1 ]]; then
|
||||||
|
REPLY=docker
|
||||||
|
elif [[ $REPLY == 2 ]]; then
|
||||||
|
REPLY=system
|
||||||
|
fi
|
||||||
|
[[ $REPLY =~ ^(docker|system)$ ]] && break || err "Ответьте docker или system"
|
||||||
|
done
|
||||||
|
MODE=$REPLY
|
||||||
|
fi
|
||||||
|
[[ $MODE =~ ^(docker|system)$ ]] || die "--mode: только docker или system"
|
||||||
|
|
||||||
|
if [[ -z $LINK ]]; then
|
||||||
|
while true; do
|
||||||
|
ask "Публичная ссылка на таблицу (Яндекс.Диск)"
|
||||||
|
REPLY=$(echo "$REPLY" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr -d '"')
|
||||||
|
[[ -n $REPLY ]] && break || err "Без ссылки приложение не сможет скачать расписание"
|
||||||
|
done
|
||||||
|
LINK=$REPLY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DOMAIN_SET == 0 && -z $DOMAIN ]]; then
|
||||||
|
ask "Домен сайта, например sched.example.ru (пусто = без nginx, сайт только на 127.0.0.1)"
|
||||||
|
DOMAIN=$(echo "$REPLY" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||||
|
fi
|
||||||
|
if [[ -n $DOMAIN && ! $DOMAIN =~ ^[a-z0-9.-]+$ ]]; then die "Кривое имя домена: $DOMAIN"; fi
|
||||||
|
|
||||||
|
if [[ -z $API_PORT ]]; then
|
||||||
|
ask "Порт api (нужен только для диагностики)" "8000"
|
||||||
|
API_PORT=$REPLY
|
||||||
|
fi
|
||||||
|
if [[ -z $FRONT_PORT ]]; then
|
||||||
|
ask "Порт сайта" "8080"
|
||||||
|
FRONT_PORT=$REPLY
|
||||||
|
fi
|
||||||
|
[[ $API_PORT =~ ^[0-9]+$ && $FRONT_PORT =~ ^[0-9]+$ ]] || die "Порты должны быть числами"
|
||||||
|
|
||||||
|
if [[ $MODE == system ]]; then
|
||||||
|
if [[ -z $RUN_USER ]]; then
|
||||||
|
ask "Пользователь, от которого будут работать службы" "${SUDO_USER:-$(id -un)}"
|
||||||
|
RUN_USER=$REPLY
|
||||||
|
fi
|
||||||
|
id "$RUN_USER" >/dev/null 2>&1 || die "Пользователь $RUN_USER не существует"
|
||||||
|
fi
|
||||||
|
if [[ -n $DOMAIN && -z $EMAIL ]]; then
|
||||||
|
ask "Почта для уведомлений Let's Encrypt" "admin@$DOMAIN"
|
||||||
|
EMAIL=$REPLY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $DRY_RUN == 1 ]]; then
|
||||||
|
log "План установки (dry-run, ничего не выполнено):"
|
||||||
|
echo " режим: $MODE"
|
||||||
|
echo " таблица: $LINK"
|
||||||
|
echo " домен: ${DOMAIN:-(не задан — nginx и https пропускаются, сайт на 127.0.0.1)}"
|
||||||
|
echo " порты: api $API_PORT, сайт $FRONT_PORT"
|
||||||
|
if [[ $MODE == system ]]; then
|
||||||
|
echo " службы от: $RUN_USER"
|
||||||
|
fi
|
||||||
|
if [[ -n $DOMAIN ]]; then
|
||||||
|
echo " сертификат: Let's Encrypt для $DOMAIN (почта: $EMAIL)"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- права ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
if [[ ${INSTALL_NO_SUDO:-0} == 1 ]]; then
|
||||||
|
err "Запущено без root (INSTALL_NO_SUDO=1) — часть шагов может не пройти"
|
||||||
|
else
|
||||||
|
log "Нужны права root — перезапускаюсь через sudo"
|
||||||
|
exec sudo bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- помощники ------------------------------------------------------------------
|
||||||
|
|
||||||
|
pkg_install() {
|
||||||
|
local missing=() p
|
||||||
|
for p in "$@"; do command -v "$p" >/dev/null 2>&1 || missing+=("$p"); done
|
||||||
|
if [[ ${#missing[@]} -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Ставлю пакеты: ${missing[*]}"
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get update -y
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y "${missing[@]}"
|
||||||
|
else
|
||||||
|
die "Не нашёл apt-get/dnf — поставьте вручную: ${missing[*]}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_health() {
|
||||||
|
local url="$1" name="$2" i
|
||||||
|
printf '%s' "==> Жду $name ($url)"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -sf -m 2 "$url" >/dev/null 2>&1; then echo " — готово"; return 0; fi
|
||||||
|
printf '.'; sleep 1
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
die "$name не отвечает на $url — смотрите логи (docker logs / journalctl)"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_env() {
|
||||||
|
cd "$ROOT"
|
||||||
|
if [[ -f .env && ! -f .env.install-backup ]]; then
|
||||||
|
cp .env .env.install-backup
|
||||||
|
fi
|
||||||
|
cat > .env <<EOF
|
||||||
|
# Сгенерировано install.sh
|
||||||
|
SCHEDULE_LINK=$LINK
|
||||||
|
SCHEDULE_INTERVAL_MINUTES=15
|
||||||
|
SCHEDULE_RETRY_AFTER=60
|
||||||
|
API_PORT=$API_PORT
|
||||||
|
FRONTEND_PORT=$FRONT_PORT
|
||||||
|
EOF
|
||||||
|
log ".env записан (прежний, если был, сохранён в .env.install-backup)"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_docker() {
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
log "Docker уже установлен"
|
||||||
|
else
|
||||||
|
log "Ставлю Docker..."
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
fi
|
||||||
|
if [[ $EUID -eq 0 ]]; then
|
||||||
|
systemctl enable --now docker
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_nginx() {
|
||||||
|
pkg_install nginx certbot curl
|
||||||
|
if [[ ! -d /etc/nginx/sites-available ]]; then
|
||||||
|
die "Нужен nginx с поддержкой sites-available (Debian/Ubuntu); на других системах настройте конфиг по README"
|
||||||
|
fi
|
||||||
|
log "Временный http-конфиг $DOMAIN — для выпуска сертификата"
|
||||||
|
cat > "/etc/nginx/sites-available/$DOMAIN" <<'NGINX'
|
||||||
|
# Сгенерировано install.sh: временный конфиг для certbot, потом заменяется
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name __DOMAIN__;
|
||||||
|
root /var/www/html;
|
||||||
|
location /.well-known/acme-challenge/ { }
|
||||||
|
}
|
||||||
|
NGINX
|
||||||
|
sed -i "s/__DOMAIN__/$DOMAIN/" "/etc/nginx/sites-available/$DOMAIN"
|
||||||
|
ln -sf "/etc/nginx/sites-available/$DOMAIN" "/etc/nginx/sites-enabled/$DOMAIN"
|
||||||
|
nginx -t && systemctl reload nginx
|
||||||
|
|
||||||
|
log "Выпускаю сертификат Let's Encrypt для $DOMAIN (webroot, без остановки nginx)"
|
||||||
|
certbot certonly --webroot -w /var/www/html -d "$DOMAIN" \
|
||||||
|
--non-interactive --agree-tos -m "$EMAIL"
|
||||||
|
|
||||||
|
log "Финальный конфиг сайта: https + прокси на 127.0.0.1:$FRONT_PORT"
|
||||||
|
"$ROOT/deploy/nginx-site.sh" --domain "$DOMAIN" --cert "$DOMAIN" --port "$FRONT_PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- установка ------------------------------------------------------------------
|
||||||
|
|
||||||
|
if [[ $MODE == docker ]]; then
|
||||||
|
install_docker
|
||||||
|
write_env
|
||||||
|
log "Собираю и запускаю контейнеры (первая сборка — несколько минут)..."
|
||||||
|
(cd "$ROOT" && docker compose up -d --build)
|
||||||
|
wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend"
|
||||||
|
else
|
||||||
|
log "Проверяю Python..."
|
||||||
|
pkg_install python3 python3-pip
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
apt-get install -y python3-venv # на Debian/Ubuntu venv без этого пакета не работает
|
||||||
|
fi
|
||||||
|
python3 - <<'PY' || die "Нужен Python 3.11 или новее (см. README, «Зависимости»)"
|
||||||
|
import sys
|
||||||
|
sys.exit(0 if sys.version_info >= (3, 11) else 1)
|
||||||
|
PY
|
||||||
|
write_env
|
||||||
|
log "Создаю виртуальное окружение и ставлю зависимости..."
|
||||||
|
[[ -d "$ROOT/.venv" ]] || python3 -m venv "$ROOT/.venv"
|
||||||
|
"$ROOT/.venv/bin/pip" install -q --upgrade pip
|
||||||
|
"$ROOT/.venv/bin/pip" install -q -r "$ROOT/requirements.txt"
|
||||||
|
|
||||||
|
log "Настраиваю службы systemd..."
|
||||||
|
cat > /etc/systemd/system/schedule-api.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule API (FastAPI)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$RUN_USER
|
||||||
|
WorkingDirectory=$ROOT/src/backend
|
||||||
|
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $API_PORT
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
cat > /etc/systemd/system/schedule-frontend.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Schedule frontend (FastAPI)
|
||||||
|
After=network-online.target schedule-api.service
|
||||||
|
Wants=network-online.target schedule-api.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=$RUN_USER
|
||||||
|
WorkingDirectory=$ROOT/src/frontend
|
||||||
|
Environment=SCHEDULE_API=http://127.0.0.1:$API_PORT
|
||||||
|
ExecStart=$ROOT/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port $FRONT_PORT
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
chown -R "$RUN_USER" "$ROOT"
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now schedule-api.service schedule-frontend.service
|
||||||
|
wait_health "http://127.0.0.1:$API_PORT/healthz" "api"
|
||||||
|
wait_health "http://127.0.0.1:$FRONT_PORT/healthz" "frontend"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $DOMAIN ]]; then
|
||||||
|
setup_nginx
|
||||||
|
else
|
||||||
|
log "Домен не задан — nginx и https пропущены. Сайт работает на http://127.0.0.1:$FRONT_PORT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- итог -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
log "Установка завершена."
|
||||||
|
if [[ -n $DOMAIN ]]; then
|
||||||
|
echo " Сайт: https://$DOMAIN"
|
||||||
|
else
|
||||||
|
echo " Сайт: http://127.0.0.1:$FRONT_PORT (с самого сервера)"
|
||||||
|
fi
|
||||||
|
echo " Таблица: $LINK"
|
||||||
|
echo " Настройки: $ROOT/.env"
|
||||||
|
if [[ $MODE == docker ]]; then
|
||||||
|
echo " Логи: sudo docker logs api / frontend"
|
||||||
|
echo " Обновление: cd $ROOT && sudo docker compose up -d --build"
|
||||||
|
echo " Напоминание: настройте ротацию логов Docker (/etc/docker/daemon.json), см. README"
|
||||||
|
else
|
||||||
|
echo " Службы: sudo systemctl status schedule-api schedule-frontend"
|
||||||
|
echo " Логи: journalctl -u schedule-api -u schedule-frontend"
|
||||||
|
echo " Обновление: заменить файлы проекта, затем sudo systemctl restart schedule-api schedule-frontend"
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user