Перенос в master всего, кроме админки: ТОП-группы, порядок групп, запуск без Docker
Админка осталась отдельной ветвью admin-panel; в master её нет (ни кода, ни страниц, ни переменных окружения, ни тома настроек). Что перенесено: - parsing: имена групп вида «ИВТ-ТОП-11БО» распознаются, порядок групп = порядок столбцов таблицы (похожие заголовки с пробелами группой не считаются); - main: GET /groups отдаёт группы в порядке столбцов; интервал обновления из SCHEDULE_INTERVAL_MINUTES (со старым SCHEDULE_CACHE_TTL как fallback); - cache: по интервалу сверяем modified — если файл не менялся, не скачиваем; при загрузке считаем sha256 и пишем в лог и /status, изменился ли файл; - source: запросы к Яндекс.Диску с no-cache и меткой времени; - frontend: ответы API с Cache-Control: no-store, общий .env из корня проекта (и backend, и frontend), страницы/прокси админки нет; - Dockerfile/compose/.env.example: без ADMIN_* и без тома настроек; в api-образ попадают все модули backend (проверяется тестом test_deploy); - Makefile: make help/venv/run/run-api/run-front/test + docker-цели — «make run» поднимает всё локально без Docker; - README: быстрый старт, полный список зависимостей, запуск локально и в Docker; - deploy/: пример nginx и инструкция «второй стенд рядом с основным». Тесты: 114 (удалены админские), все зелёные.
This commit is contained in:
10
.env.example
10
.env.example
@@ -1,6 +1,14 @@
|
|||||||
|
# --- расписание --------------------------------------------------------------
|
||||||
|
# Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна)
|
||||||
SCHEDULE_LINK=
|
SCHEDULE_LINK=
|
||||||
|
|
||||||
|
# Как часто перечитывать таблицу, минут
|
||||||
|
SCHEDULE_INTERVAL_MINUTES=15
|
||||||
|
# Устаревшее: интервал в секундах (используется, только если не задан предыдущий)
|
||||||
SCHEDULE_CACHE_TTL=900
|
SCHEDULE_CACHE_TTL=900
|
||||||
|
# Пауза между повторными попытками, если источник недоступен, секунд
|
||||||
SCHEDULE_RETRY_AFTER=60
|
SCHEDULE_RETRY_AFTER=60
|
||||||
|
|
||||||
|
# --- порты (только для docker) -------------------------------------------------
|
||||||
API_PORT=8000
|
API_PORT=8000
|
||||||
FRONTEND_PORT=8080
|
FRONTEND_PORT=8080
|
||||||
SCHEDULE_API=http://api:8000
|
|
||||||
|
|||||||
62
Makefile
62
Makefile
@@ -1,32 +1,58 @@
|
|||||||
.PHONY: build up down logs restart deploy ps dev test
|
PY := $(CURDIR)/.venv/bin/python
|
||||||
|
PIP := $(CURDIR)/.venv/bin/pip
|
||||||
|
|
||||||
build: ## Собрать образы микросервисов
|
.PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy
|
||||||
|
|
||||||
|
help: ## Показать этот список команд
|
||||||
|
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
# --- локальный запуск (без Docker) -------------------------------------------
|
||||||
|
|
||||||
|
venv: ## Создать .venv и установить зависимости
|
||||||
|
@test -x $(PY) || python3 -m venv .venv
|
||||||
|
@$(PIP) install -q --upgrade pip
|
||||||
|
@$(PIP) install -q -r requirements-dev.txt
|
||||||
|
@echo "Зависимости готовы (.venv). Дальше: make run"
|
||||||
|
|
||||||
|
run: venv ## Запустить API и сайт локально, без Docker
|
||||||
|
@echo "API: http://127.0.0.1:8000/status"
|
||||||
|
@echo "Сайт: http://127.0.0.1:8080 (открыть в браузере)"
|
||||||
|
@echo "Остановить: Ctrl+C"
|
||||||
|
@trap 'kill 0' EXIT; \
|
||||||
|
(cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000) & \
|
||||||
|
(cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080) & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
dev: run ## То же, что run
|
||||||
|
|
||||||
|
run-api: venv ## Запустить только API (http://127.0.0.1:8000)
|
||||||
|
cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000
|
||||||
|
|
||||||
|
run-front: venv ## Запустить только сайт (http://127.0.0.1:8080)
|
||||||
|
cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080
|
||||||
|
|
||||||
|
test: venv ## Прогнать тесты
|
||||||
|
$(PY) -m pytest tests -q
|
||||||
|
|
||||||
|
# --- Docker (продакшен) ------------------------------------------------------
|
||||||
|
|
||||||
|
build: ## Собрать образы
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
up: ## Запустить все микросервисы одной командой
|
up: ## Запустить сервисы в фоне
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
down: ## Остановить и удалить контейнеры
|
down: ## Остановить и удалить контейнеры
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
restart: ## Перезапустить микросервисы
|
restart: ## Перезапустить сервисы
|
||||||
docker compose restart
|
docker compose restart
|
||||||
|
|
||||||
logs: ## Логи всех микросервисов
|
logs: ## Логи сервисов (Ctrl+C для выхода)
|
||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
|
|
||||||
ps: ## Статус микросервисов
|
ps: ## Статус сервисов
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
|
||||||
deploy: ## Собрать и запустить (деплой на сервере)
|
deploy: ## Собрать и запустить (docker)
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
|
|
||||||
test: ## Прогнать тесты (локальный venv)
|
|
||||||
$(CURDIR)/.venv/bin/python -m pytest tests -q
|
|
||||||
|
|
||||||
dev: ## Запустить локально без Docker (бэкенд + фронт)
|
|
||||||
@echo "Запуск бэкенда на :8000 и фронта на :8080..."
|
|
||||||
@trap 'kill 0' EXIT; \
|
|
||||||
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
|
|
||||||
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8080 & \
|
|
||||||
wait
|
|
||||||
|
|||||||
108
README.md
108
README.md
@@ -4,6 +4,18 @@
|
|||||||
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
|
||||||
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
|
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
|
||||||
|
|
||||||
|
## Быстрый старт (без Docker)
|
||||||
|
|
||||||
|
Нужен только Python 3.11+ и публичная ссылка на таблицу.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска
|
||||||
|
make run # создаст .venv, поставит зависимости и запустит оба сервиса
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть <http://127.0.0.1:8080>. Остановить — `Ctrl+C`.
|
||||||
|
Подробнее — разделы «Зависимости» и «Запуск» ниже.
|
||||||
|
|
||||||
## Архитектура
|
## Архитектура
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -98,7 +110,7 @@ src/
|
|||||||
source.py скачивание файла по публичной ссылке Я.Диска
|
source.py скачивание файла по публичной ссылке Я.Диска
|
||||||
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
lessons.py разбор ячеек занятий (общий конвейер ролей)
|
||||||
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
|
||||||
cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error
|
cache.py ScheduleCache: блокировка, интервал, stale-while-error
|
||||||
errors.py ScheduleError
|
errors.py ScheduleError
|
||||||
frontend/
|
frontend/
|
||||||
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
|
||||||
@@ -110,6 +122,9 @@ src/
|
|||||||
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
|
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
|
||||||
tools/
|
tools/
|
||||||
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
|
||||||
|
deploy/
|
||||||
|
nginx-dev.conf пример сайта nginx для отдельного стенда
|
||||||
|
README-dev.md как поднять второй стенд рядом с основным
|
||||||
tests/
|
tests/
|
||||||
conftest.py общие фикстуры
|
conftest.py общие фикстуры
|
||||||
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
|
||||||
@@ -117,7 +132,8 @@ tests/
|
|||||||
test_lessons.py все форматы ячеек
|
test_lessons.py все форматы ячеек
|
||||||
test_xlsx.py чтение книги
|
test_xlsx.py чтение книги
|
||||||
test_parsing.py разбор листов, недели, индексы
|
test_parsing.py разбор листов, недели, индексы
|
||||||
test_api.py API и поведение кэша (TTL/modified/stale, 404/400/502)
|
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
|
||||||
|
test_deploy.py проверка, что образы копируют все нужные файлы
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -150,19 +166,84 @@ tests/
|
|||||||
| Переменная | По умолчанию | Где | Описание |
|
| Переменная | По умолчанию | Где | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
|
||||||
| `SCHEDULE_CACHE_TTL` | `900` | api | время жизни кэша, сек |
|
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут |
|
||||||
|
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
|
||||||
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
|
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
|
||||||
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
|
||||||
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
|
||||||
| `API_PORT` | `8000` | docker | внешний порт api |
|
| `API_PORT` | `8000` | docker | внешний порт api |
|
||||||
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
|
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
|
||||||
|
|
||||||
`.env` ищется вверх от каталога модуля (backend — до корня репозитория);
|
`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий
|
||||||
`load_dotenv` не перезаписывает уже выставленные переменные окружения.
|
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
|
||||||
|
переменные окружения.
|
||||||
|
|
||||||
|
## Зависимости
|
||||||
|
|
||||||
|
Проект состоит из двух сервисов на Python (FastAPI): `api` и `frontend`.
|
||||||
|
Всё ставится одной командой `make venv` (или вручную, см. ниже).
|
||||||
|
|
||||||
|
**Системные требования**
|
||||||
|
|
||||||
|
| Что | Зачем |
|
||||||
|
|---|---|
|
||||||
|
| Python 3.11+ (проверено на 3.12 и 3.14) | оба сервиса |
|
||||||
|
| Docker + docker compose v2 | только для запуска в контейнерах (опционально) |
|
||||||
|
| `rsvg-convert` (пакет `librsvg2-bin`) | только для пересборки иконок `tools/make_icons.py` |
|
||||||
|
|
||||||
|
**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-клиент для `TestClient` в тестах — только dev |
|
||||||
|
|
||||||
|
Ничего, кроме этого, не нужно: xlsx разбирается стандартной библиотекой
|
||||||
|
(`zipfile` + `xml.etree`), базы данных нет, кэш — в памяти процесса.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
### Продакшен (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> (его и открывать в браузере).
|
||||||
|
|
||||||
|
Остановить — `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
|
```bash
|
||||||
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
|
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
|
||||||
@@ -172,18 +253,10 @@ cp .env.example .env # вписать SCHEDULE_LINK (и порты при
|
|||||||
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
|
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
|
||||||
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
|
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
|
||||||
|
|
||||||
### Разработка
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m venv .venv
|
|
||||||
.venv/bin/pip install -r requirements-dev.txt
|
|
||||||
make dev # api :8000 и фронт :8080, оба с --reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### Тесты
|
### Тесты
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # 104 теста, сеть не нужна
|
make test # 114 тестов, сеть не нужна
|
||||||
```
|
```
|
||||||
|
|
||||||
## Развёртывание на сервере
|
## Развёртывание на сервере
|
||||||
@@ -206,8 +279,9 @@ nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:80
|
|||||||
|
|
||||||
## Замечания
|
## Замечания
|
||||||
|
|
||||||
- Принудительное обновление таблицы (пропуск кэша, `force`) из API намеренно
|
- Публичного принудительного обновления в API нет: расписание обновляется по
|
||||||
убрано; планируется вернуть вместе с авторизацией и ролью администратора.
|
интервалу (`SCHEDULE_INTERVAL_MINUTES`), а если таблица не менялась,
|
||||||
|
скачивание не повторяется (сверяется `modified`).
|
||||||
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
|
||||||
api его придётся выносить в общее хранилище.
|
api его придётся выносить в общее хранилище.
|
||||||
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
|
||||||
|
|||||||
61
deploy/README-dev.md
Normal file
61
deploy/README-dev.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Второй стенд рядом с основным
|
||||||
|
|
||||||
|
Как поднять ещё один экземпляр проекта (например, для проверки правок в таблице
|
||||||
|
на отдельном домене): свой каталог, свои порты, своя ссылка на таблицу.
|
||||||
|
Основной стенд при этом не трогаем.
|
||||||
|
|
||||||
|
## 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=127.0.0.1:8001
|
||||||
|
FRONTEND_PORT=127.0.0.1:8091
|
||||||
|
```
|
||||||
|
|
||||||
|
Порты 8001/8091 выбраны, чтобы не пересечься с основным стендом (8000/8090).
|
||||||
|
Привязка к `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-dev.conf` порт `proxy_pass` должен совпадать с
|
||||||
|
`FRONTEND_PORT` из `.env` (по умолчанию 8091).
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
47
deploy/nginx-dev.conf
Normal file
47
deploy/nginx-dev.conf
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Тестовый стенд 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ services:
|
|||||||
target: api
|
target: api
|
||||||
environment:
|
environment:
|
||||||
SCHEDULE_LINK: ${SCHEDULE_LINK}
|
SCHEDULE_LINK: ${SCHEDULE_LINK}
|
||||||
|
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
|
||||||
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
|
||||||
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
|
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
|
||||||
ports:
|
ports:
|
||||||
@@ -22,6 +23,7 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
target: frontend
|
target: frontend
|
||||||
environment:
|
environment:
|
||||||
|
# внутри compose api доступен по имени сервиса; менять не нужно
|
||||||
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-8080}:8080"
|
- "${FRONTEND_PORT:-8080}:8080"
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
from schedule import ScheduleCache, ScheduleError
|
from schedule import ScheduleCache, ScheduleError
|
||||||
from schedule import parsing
|
from schedule import parsing
|
||||||
@@ -37,40 +36,34 @@ _dotenv_path = _find_dotenv()
|
|||||||
if _dotenv_path:
|
if _dotenv_path:
|
||||||
load_dotenv(_dotenv_path)
|
load_dotenv(_dotenv_path)
|
||||||
|
|
||||||
|
DEFAULT_INTERVAL_MINUTES = 15
|
||||||
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
|
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
|
||||||
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "900"))
|
|
||||||
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
|
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
|
||||||
|
|
||||||
|
|
||||||
def _default_cache() -> ScheduleCache:
|
def interval_seconds() -> float:
|
||||||
try:
|
"""Интервал обновления: SCHEDULE_INTERVAL_MINUTES, иначе старый SCHEDULE_CACHE_TTL (сек)."""
|
||||||
return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER)
|
raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip()
|
||||||
except ScheduleError:
|
if raw_minutes:
|
||||||
log.error("SCHEDULE_LINK не задан — API данных работать не будет")
|
try:
|
||||||
return _BrokenCache()
|
return max(1.0, float(raw_minutes)) * 60
|
||||||
|
except ValueError:
|
||||||
|
log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes)
|
||||||
|
raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip()
|
||||||
|
if raw_ttl:
|
||||||
|
try:
|
||||||
|
return max(1.0, float(raw_ttl))
|
||||||
|
except ValueError:
|
||||||
|
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl)
|
||||||
|
return DEFAULT_INTERVAL_MINUTES * 60
|
||||||
|
|
||||||
|
|
||||||
class _BrokenCache(ScheduleCache):
|
def default_cache() -> ScheduleCache:
|
||||||
"""Кэш без ссылки: все запросы данных отвечают понятной ошибкой."""
|
return ScheduleCache(
|
||||||
|
link=SCHEDULE_LINK,
|
||||||
def __init__(self) -> None:
|
ttl_seconds=interval_seconds(),
|
||||||
self._link = None
|
retry_after=RETRY_AFTER,
|
||||||
self._ttl = CACHE_TTL
|
)
|
||||||
self._retry_after = RETRY_AFTER
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
self._data = None
|
|
||||||
self._weeks = []
|
|
||||||
self._index = None
|
|
||||||
self._day_times = {}
|
|
||||||
self._teacher_full = {}
|
|
||||||
self._room_info = {}
|
|
||||||
self._loaded_at = 0.0
|
|
||||||
self._modified = None
|
|
||||||
self._error = "Не задана переменная окружения SCHEDULE_LINK"
|
|
||||||
self._last_failed = 0.0
|
|
||||||
|
|
||||||
def _ensure_fresh(self) -> None:
|
|
||||||
raise ScheduleError(self._error)
|
|
||||||
|
|
||||||
|
|
||||||
def _day_arg(day: str) -> str:
|
def _day_arg(day: str) -> str:
|
||||||
@@ -84,7 +77,7 @@ def _day_arg(day: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
||||||
cache = cache or _default_cache()
|
cache = cache or default_cache()
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -128,7 +121,7 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
|||||||
return {
|
return {
|
||||||
"title": "Расписание",
|
"title": "Расписание",
|
||||||
"endpoints": {
|
"endpoints": {
|
||||||
"/groups": "Список групп",
|
"/groups": "Список групп (в порядке столбцов таблицы)",
|
||||||
"/schedule/{group}": "Расписание группы",
|
"/schedule/{group}": "Расписание группы",
|
||||||
"/schedule/{group}/{day}": "Расписание группы на день",
|
"/schedule/{group}/{day}": "Расписание группы на день",
|
||||||
"/today?group={group}": "Расписание группы на сегодня",
|
"/today?group={group}": "Расписание группы на сегодня",
|
||||||
@@ -145,11 +138,9 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@app.get("/groups")
|
@app.get("/groups")
|
||||||
def groups() -> list[str]:
|
def groups() -> list[str]:
|
||||||
|
"""Группы в том порядке, в каком идут столбцы в таблице."""
|
||||||
data, _ = _data()
|
data, _ = _data()
|
||||||
return sorted(
|
return list(data.keys())
|
||||||
data.keys(),
|
|
||||||
key=lambda g: (0 if g.endswith("БО") else (1 if g.endswith("МО") else 2), g),
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/teachers")
|
@app.get("/teachers")
|
||||||
def teachers() -> list[str]:
|
def teachers() -> list[str]:
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
|
- однопоточная перезагрузка (lock): параллельные запросы во время загрузки
|
||||||
ждут один download вместо N скачиваний;
|
ждут один download вместо N скачиваний;
|
||||||
- TTL, а также проверка modified таблицы: если таблица не менялась,
|
- интервал обновления: по его истечении сверяем modified таблицы — если файл
|
||||||
скачивание не повторяется;
|
не менялся, скачивание не повторяется;
|
||||||
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
|
- stale-while-error: если таблица перестала скачиваться, отдаём последнее
|
||||||
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
|
||||||
(вместо скачивания на каждый запрос).
|
(вместо скачивания на каждый запрос).
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -23,19 +24,19 @@ from .xlsx import parse_workbook
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NO_LINK_ERROR = "Ссылка на расписание не задана (SCHEDULE_LINK)"
|
||||||
|
|
||||||
|
|
||||||
class ScheduleCache:
|
class ScheduleCache:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
link: str | None,
|
link: str | None = None,
|
||||||
ttl_seconds: float = 900.0,
|
ttl_seconds: float = 900.0,
|
||||||
retry_after: float = 60.0,
|
retry_after: float = 60.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not link:
|
self._link = (link or "").strip()
|
||||||
raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK")
|
self._ttl = float(ttl_seconds)
|
||||||
self._link = link
|
self._retry_after = float(retry_after)
|
||||||
self._ttl = ttl_seconds
|
|
||||||
self._retry_after = retry_after
|
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
self._data: dict | None = None
|
self._data: dict | None = None
|
||||||
@@ -46,8 +47,12 @@ class ScheduleCache:
|
|||||||
self._room_info: dict = {}
|
self._room_info: dict = {}
|
||||||
self._loaded_at: float = 0.0
|
self._loaded_at: float = 0.0
|
||||||
self._modified: str | None = None
|
self._modified: str | None = None
|
||||||
self._error: str | None = None
|
self._error: str | None = None if self._link else NO_LINK_ERROR
|
||||||
self._last_failed: float = 0.0
|
self._last_failed: float = 0.0
|
||||||
|
# следим, менялось ли содержимое скачанного файла
|
||||||
|
self._content_hash: str | None = None
|
||||||
|
self._changed: bool = False
|
||||||
|
self._changed_at: float = 0.0
|
||||||
|
|
||||||
# --- публичное API ---------------------------------------------------------
|
# --- публичное API ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -82,13 +87,20 @@ class ScheduleCache:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return {
|
return {
|
||||||
"cache_ttl_seconds": self._ttl,
|
"cache_ttl_seconds": self._ttl,
|
||||||
|
"interval_minutes": max(1, round(self._ttl / 60)),
|
||||||
"last_updated": self._iso(self._loaded_at),
|
"last_updated": self._iso(self._loaded_at),
|
||||||
"table_modified": self._modified,
|
"table_modified": self._modified,
|
||||||
"error": self._error,
|
"error": self._error,
|
||||||
|
"groups": len(self._data) if self._data else 0,
|
||||||
|
"has_data": self._data is not None,
|
||||||
|
# при последнем чтении файла: изменилось ли его содержимое
|
||||||
|
"changed": self._changed,
|
||||||
|
"changed_at": self._iso(self._changed_at),
|
||||||
|
"content_hash": (self._content_hash or "")[:12],
|
||||||
}
|
}
|
||||||
|
|
||||||
def warmup(self) -> None:
|
def warmup(self) -> None:
|
||||||
"""Первый запуск данных на старте приложения (ошибка не фатальна)."""
|
"""Первая загрузка данных на старте приложения (ошибка не фатальна)."""
|
||||||
try:
|
try:
|
||||||
self.get()
|
self.get()
|
||||||
except ScheduleError as exc:
|
except ScheduleError as exc:
|
||||||
@@ -112,9 +124,9 @@ class ScheduleCache:
|
|||||||
)
|
)
|
||||||
if fresh:
|
if fresh:
|
||||||
return
|
return
|
||||||
self._reload(now)
|
self._refresh(now)
|
||||||
|
|
||||||
def _reload(self, now: float) -> None:
|
def _refresh(self, now: float) -> None:
|
||||||
# служим старые данные, пока не истекло время повторной попытки
|
# служим старые данные, пока не истекло время повторной попытки
|
||||||
if (
|
if (
|
||||||
self._data is not None
|
self._data is not None
|
||||||
@@ -122,6 +134,9 @@ class ScheduleCache:
|
|||||||
and now - self._last_failed < self._retry_after
|
and now - self._last_failed < self._retry_after
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
if not self._link:
|
||||||
|
self._fail(ScheduleError(NO_LINK_ERROR), now)
|
||||||
|
return
|
||||||
modified = fetch_table_modified(self._link)
|
modified = fetch_table_modified(self._link)
|
||||||
if (
|
if (
|
||||||
self._data is not None
|
self._data is not None
|
||||||
@@ -133,31 +148,49 @@ class ScheduleCache:
|
|||||||
self._error = None
|
self._error = None
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
self._load(now, modified)
|
||||||
started = time.time()
|
|
||||||
xlsx = download_schedule(self._link)
|
|
||||||
sheets = parse_workbook(xlsx)
|
|
||||||
data = parsing.parse_schedule(sheets)
|
|
||||||
weeks = parsing.extract_week_ranges(sheets)
|
|
||||||
index = parsing.build_teacher_room_index(data)
|
|
||||||
self._data = data
|
|
||||||
self._weeks = weeks
|
|
||||||
self._index = index
|
|
||||||
self._day_times = parsing.build_day_times(data)
|
|
||||||
self._teacher_full = parsing.parse_teachers_full(sheets)
|
|
||||||
self._room_info = parsing.parse_rooms_info(sheets)
|
|
||||||
self._modified = modified
|
|
||||||
self._loaded_at = now
|
|
||||||
self._error = None
|
|
||||||
log.info(
|
|
||||||
"Расписание загружено: %d групп за %.1f с", len(data), time.time() - started
|
|
||||||
)
|
|
||||||
except ScheduleError as exc:
|
except ScheduleError as exc:
|
||||||
self._fail(exc, now)
|
self._fail(exc, now)
|
||||||
except Exception as exc: # непредвиденное в парсинге/сети
|
except Exception as exc: # непредвиденное в парсинге/сети
|
||||||
log.exception("Ошибка при загрузке расписания")
|
log.exception("Ошибка при загрузке расписания")
|
||||||
self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now)
|
self._fail(ScheduleError(f"Не удалось обработать таблицу: {exc}"), now)
|
||||||
|
|
||||||
|
def _load(self, now: float, modified: str | None) -> None:
|
||||||
|
"""Скачать и разобрать таблицу, заменить состояние. Бросает ScheduleError."""
|
||||||
|
if not self._link:
|
||||||
|
raise ScheduleError(NO_LINK_ERROR)
|
||||||
|
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
||||||
|
started = time.time()
|
||||||
|
xlsx = download_schedule(self._link)
|
||||||
|
digest = hashlib.sha256(xlsx).hexdigest()
|
||||||
|
first_load = self._content_hash is None
|
||||||
|
changed = not first_load and digest != self._content_hash
|
||||||
|
|
||||||
|
sheets = parse_workbook(xlsx)
|
||||||
|
data = parsing.parse_schedule(sheets)
|
||||||
|
weeks = parsing.extract_week_ranges(sheets)
|
||||||
|
index = parsing.build_teacher_room_index(data)
|
||||||
|
self._data = data
|
||||||
|
self._weeks = weeks
|
||||||
|
self._index = index
|
||||||
|
self._day_times = parsing.build_day_times(data)
|
||||||
|
self._teacher_full = parsing.parse_teachers_full(sheets)
|
||||||
|
self._room_info = parsing.parse_rooms_info(sheets)
|
||||||
|
self._modified = modified
|
||||||
|
self._loaded_at = now
|
||||||
|
self._error = None
|
||||||
|
self._content_hash = digest
|
||||||
|
self._changed = changed
|
||||||
|
if changed or first_load:
|
||||||
|
self._changed_at = now
|
||||||
|
log.info(
|
||||||
|
"Расписание загружено: %d групп за %.1f с; файл %s (sha256 %s)",
|
||||||
|
len(data),
|
||||||
|
time.time() - started,
|
||||||
|
"изменился" if changed else ("первая загрузка" if first_load else "не изменился"),
|
||||||
|
digest[:12],
|
||||||
|
)
|
||||||
|
|
||||||
def _fail(self, exc: ScheduleError, now: float) -> None:
|
def _fail(self, exc: ScheduleError, now: float) -> None:
|
||||||
self._error = str(exc)
|
self._error = str(exc)
|
||||||
self._last_failed = now
|
self._last_failed = now
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ _COL_DAY = 4 # день недели
|
|||||||
_COL_PERIOD = 5 # пара и время
|
_COL_PERIOD = 5 # пара и время
|
||||||
_FIRST_GROUP_COL = 6
|
_FIRST_GROUP_COL = 6
|
||||||
|
|
||||||
_GROUP_RE = re.compile(r"^[А-ЯЁ]{2,4}-\d")
|
# Имя группы: буквенные части через дефис, номер потока и необязательный
|
||||||
|
# суффикс: «ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО», «ИТ-ТОП-12БО».
|
||||||
|
_GROUP_RE = re.compile(r"^[А-ЯЁ]+(?:-[А-ЯЁ]+)*-\d{1,2}[А-ЯЁ]{0,3}$")
|
||||||
_PERIOD_RE = re.compile(r"^\s*(\d{1,2})\s*(.*)$")
|
_PERIOD_RE = re.compile(r"^\s*(\d{1,2})\s*(.*)$")
|
||||||
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I)
|
_ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I)
|
||||||
_SEMESTER_DATE_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{2})")
|
_SEMESTER_DATE_RE = re.compile(r"(\d{1,2}\.\d{1,2}\.\d{2})")
|
||||||
@@ -108,13 +110,16 @@ def current_parity(week_ranges: list[dict], today: date | None = None) -> str |
|
|||||||
|
|
||||||
|
|
||||||
def _find_group_columns(header: list[str], hidden_cols: set[int]) -> dict[int, str]:
|
def _find_group_columns(header: list[str], hidden_cols: set[int]) -> dict[int, str]:
|
||||||
|
"""Столбцы групп: имя из заголовка, порядок — как столбцы в таблице."""
|
||||||
columns: dict[int, str] = {}
|
columns: dict[int, str] = {}
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for idx, cell in enumerate(header):
|
for idx, cell in enumerate(header):
|
||||||
if idx in hidden_cols:
|
if idx in hidden_cols:
|
||||||
continue
|
continue
|
||||||
name = _normalize_header(cell)
|
name = _normalize_header(cell)
|
||||||
if name and _GROUP_RE.match(name) and name not in seen:
|
if not name or name in seen or len(name) > 24:
|
||||||
|
continue
|
||||||
|
if _GROUP_RE.match(name):
|
||||||
columns[idx] = name
|
columns[idx] = name
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
return columns
|
return columns
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -12,18 +13,27 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
||||||
|
|
||||||
|
# Публичная ссылка может кэшироваться на стороне Диска: просим не кэшировать
|
||||||
|
# и добавляем в запрос метку времени, чтобы получать текущую версию файла.
|
||||||
|
_NO_CACHE = {"Cache-Control": "no-cache", "Pragma": "no-cache"}
|
||||||
|
|
||||||
|
|
||||||
|
def _bust() -> dict:
|
||||||
|
return {"_": int(time.time() * 1000)}
|
||||||
|
|
||||||
|
|
||||||
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
||||||
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{CLOUD_API}/download",
|
f"{CLOUD_API}/download",
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
href = resp.json()["href"]
|
href = resp.json()["href"]
|
||||||
file_resp = requests.get(href, timeout=timeout)
|
file_resp = requests.get(href, headers=_NO_CACHE, timeout=timeout)
|
||||||
file_resp.raise_for_status()
|
file_resp.raise_for_status()
|
||||||
return file_resp.content
|
return file_resp.content
|
||||||
except (requests.RequestException, KeyError, ValueError) as exc:
|
except (requests.RequestException, KeyError, ValueError) as exc:
|
||||||
@@ -35,7 +45,8 @@ def fetch_table_modified(public_url: str, timeout: float = 20.0) -> str | None:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
CLOUD_API,
|
CLOUD_API,
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -2,24 +2,42 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Query
|
from fastapi import FastAPI, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
load_dotenv(Path(__file__).parent / ".env")
|
|
||||||
|
|
||||||
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
def _find_dotenv() -> Path | None:
|
||||||
|
"""Ищем .env вверх от каталога модуля: один общий .env в корне проекта."""
|
||||||
|
path = Path(__file__).resolve().parent
|
||||||
|
for _ in range(5):
|
||||||
|
env = path / ".env"
|
||||||
|
if env.is_file():
|
||||||
|
return env
|
||||||
|
if path.parent == path:
|
||||||
|
break
|
||||||
|
path = path.parent
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_dotenv_path = _find_dotenv()
|
||||||
|
if _dotenv_path:
|
||||||
|
load_dotenv(_dotenv_path)
|
||||||
|
|
||||||
|
# Адрес бэкенда: в docker это http://api:8000 (задаётся в docker-compose),
|
||||||
|
# локально — http://127.0.0.1:8000.
|
||||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
|
||||||
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
|
||||||
STATIC_DIR = Path(__file__).parent / "static"
|
STATIC_DIR = Path(__file__).parent / "static"
|
||||||
|
|
||||||
@@ -43,7 +61,8 @@ def _proxy(path: str, params: dict) -> object:
|
|||||||
except Exception:
|
except Exception:
|
||||||
detail = resp.text
|
detail = resp.text
|
||||||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||||||
return resp.json()
|
# данные не кэшируем: браузер всегда показывает актуальное расписание
|
||||||
|
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
# --- API-маршруты -----------------------------------------------------------
|
# --- API-маршруты -----------------------------------------------------------
|
||||||
|
|||||||
@@ -46,6 +46,21 @@ def test_groups(client):
|
|||||||
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
|
assert client.get("/groups").json() == ["ИВТ-11БО", "ИТ-12БО"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_groups_keep_sheet_order(monkeypatch):
|
||||||
|
"""GET /groups отдаёт группы в порядке столбцов таблицы, а не по алфавиту."""
|
||||||
|
from fixtures import fixture_sheets
|
||||||
|
from xlsx_builder import build_workbook
|
||||||
|
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets["Бакалавры и магистры"].rows[0]
|
||||||
|
header[6] = "ЯЯ-ТОП-11БО\nсеместр до 11.01.27"
|
||||||
|
header[7] = "АА-11БО\nсеместр до 29.12.26"
|
||||||
|
|
||||||
|
cache, _, _ = make_cache(monkeypatch, build_workbook(sheets))
|
||||||
|
with TestClient(main.create_app(cache)) as client:
|
||||||
|
assert client.get("/groups").json() == ["ЯЯ-ТОП-11БО", "АА-11БО"]
|
||||||
|
|
||||||
|
|
||||||
def test_teachers(client):
|
def test_teachers(client):
|
||||||
assert client.get("/teachers").json() == [
|
assert client.get("/teachers").json() == [
|
||||||
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
|
"Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.",
|
||||||
@@ -155,14 +170,52 @@ def test_cache_no_repeated_download(client, cache):
|
|||||||
assert cache.calls["download"] == 1
|
assert cache.calls["download"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cache_modified_unchanged_no_download(cache):
|
def test_modified_unchanged_no_download(cache):
|
||||||
cache._ttl = 0 # даже с истёкшим TTL
|
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем."""
|
||||||
|
cache._ttl = 0
|
||||||
cache.get()
|
cache.get()
|
||||||
cache.get()
|
cache.get()
|
||||||
assert cache.calls["download"] == 1
|
assert cache.calls["download"] == 1
|
||||||
assert cache.calls["modified"] >= 2
|
assert cache.calls["modified"] >= 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_reports_file_changes(monkeypatch):
|
||||||
|
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
|
||||||
|
import schedule.cache as cache_mod
|
||||||
|
from fixtures import fixture_sheets
|
||||||
|
from xlsx_builder import build_workbook
|
||||||
|
|
||||||
|
def xlsx_with_room(room: str) -> bytes:
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
|
||||||
|
return build_workbook(sheets)
|
||||||
|
|
||||||
|
files = {"data": xlsx_with_room("215"), "modified": "m1"}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
cache_mod, "fetch_table_modified", lambda url, timeout=20.0: files["modified"]
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"])
|
||||||
|
cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60)
|
||||||
|
|
||||||
|
cache.get()
|
||||||
|
first = cache.status()
|
||||||
|
assert first["content_hash"]
|
||||||
|
assert first["changed"] is False # первая загрузка — не «изменение»
|
||||||
|
assert first["changed_at"] is not None
|
||||||
|
|
||||||
|
files["data"] = xlsx_with_room("216")
|
||||||
|
files["modified"] = "m2"
|
||||||
|
cache.get() # ttl=0 и новый modified — перечитываем таблицу
|
||||||
|
changed = cache.status()
|
||||||
|
assert changed["changed"] is True
|
||||||
|
assert changed["content_hash"] != first["content_hash"]
|
||||||
|
|
||||||
|
# новая ревизия файла с тем же содержимым → скачали, но изменений нет
|
||||||
|
files["modified"] = "m3"
|
||||||
|
cache.get()
|
||||||
|
assert cache.status()["changed"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_cache_stale_while_error(cache):
|
def test_cache_stale_while_error(cache):
|
||||||
cache._ttl = 0 # форсим повторную попытку
|
cache._ttl = 0 # форсим повторную попытку
|
||||||
cache.get()
|
cache.get()
|
||||||
@@ -196,10 +249,14 @@ def test_cache_failure_without_data_raises(cache):
|
|||||||
assert cache.status()["last_updated"] is None
|
assert cache.status()["last_updated"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_broken_cache_http(client=None):
|
def test_cache_without_link():
|
||||||
|
"""Без ссылки приложение живёт: /healthz есть, данные отвечают понятной ошибкой."""
|
||||||
from fastapi.testclient import TestClient as TC
|
from fastapi.testclient import TestClient as TC
|
||||||
|
|
||||||
app = main.create_app(main._BrokenCache())
|
from schedule import ScheduleCache
|
||||||
|
|
||||||
|
cache = ScheduleCache(link="", retry_after=60)
|
||||||
|
app = main.create_app(cache)
|
||||||
with TC(app) as c:
|
with TC(app) as c:
|
||||||
assert c.get("/healthz").status_code == 200
|
assert c.get("/healthz").status_code == 200
|
||||||
assert c.get("/groups").status_code == 502
|
assert c.get("/groups").status_code == 502
|
||||||
|
|||||||
40
tests/test_deploy.py
Normal file
40
tests/test_deploy.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Проверки сборки образов: все нужные файлы должны копироваться.
|
||||||
|
|
||||||
|
Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте,
|
||||||
|
а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим
|
||||||
|
статически, без docker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DOCKERFILE = ROOT / "Dockerfile"
|
||||||
|
COMPOSE = ROOT / "docker-compose.yml"
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(name: str) -> str:
|
||||||
|
dockerfile = DOCKERFILE.read_text(encoding="utf-8")
|
||||||
|
part = dockerfile.split(f"FROM base AS {name}", 1)[1]
|
||||||
|
for other in ("api", "frontend"):
|
||||||
|
if other != name:
|
||||||
|
part = part.split(f"FROM base AS {other}", 1)[0]
|
||||||
|
return part
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_image_copies_every_backend_module():
|
||||||
|
stage = _stage("api")
|
||||||
|
for module in sorted((ROOT / "src" / "backend").glob("*.py")):
|
||||||
|
assert module.name in stage, f"{module.name} не копируется в образ api"
|
||||||
|
assert "src/backend/schedule" in stage, "пакет schedule не копируется в образ api"
|
||||||
|
|
||||||
|
|
||||||
|
def test_frontend_image_copies_static():
|
||||||
|
stage = _stage("frontend")
|
||||||
|
for path in ("src/frontend/main.py", "src/frontend/static"):
|
||||||
|
assert path in stage, f"{path} не копируется в образ frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_passes_required_env():
|
||||||
|
compose = COMPOSE.read_text(encoding="utf-8")
|
||||||
|
for var in ("SCHEDULE_LINK", "SCHEDULE_INTERVAL_MINUTES", "SCHEDULE_API"):
|
||||||
|
assert var in compose, f"{var} не передаётся контейнерам"
|
||||||
@@ -23,6 +23,28 @@ def test_hidden_group_excluded(data):
|
|||||||
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_names_with_top_and_sheet_order():
|
||||||
|
"""ТОП-группы распознаются, а порядок групп — как столбцы в таблице."""
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets[parsing.MAIN_SHEET].rows[0]
|
||||||
|
header[6] = "ИТ-ТОП-11БО\nсеместр до 11.01.27"
|
||||||
|
header[7] = "АА-11БО\nсеместр до 29.12.26"
|
||||||
|
|
||||||
|
data = parsing.parse_schedule(make_sheets(sheets))
|
||||||
|
assert list(data) == ["ИТ-ТОП-11БО", "АА-11БО"] # не по алфавиту, а по столбцам
|
||||||
|
assert data["ИТ-ТОП-11БО"]["days"]["понедельник"][1]["ч"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_not_a_group_headers_ignored():
|
||||||
|
"""Похожие, но не групповые заголовки столбцами групп не становятся."""
|
||||||
|
sheets = fixture_sheets()
|
||||||
|
header = sheets[parsing.MAIN_SHEET].rows[0]
|
||||||
|
header[9] = "ИВТ 11БО" # пробел вместо дефиса — это не имя группы
|
||||||
|
|
||||||
|
data = parsing.parse_schedule(make_sheets(sheets))
|
||||||
|
assert set(data) == {"ИВТ-11БО", "ИТ-12БО"}
|
||||||
|
|
||||||
|
|
||||||
def test_semester(data):
|
def test_semester(data):
|
||||||
assert data["ИВТ-11БО"]["semester_until"] == "11.01.27"
|
assert data["ИВТ-11БО"]["semester_until"] == "11.01.27"
|
||||||
assert data["ИТ-12БО"]["semester_until"] == "29.12.26"
|
assert data["ИТ-12БО"]["semester_until"] == "29.12.26"
|
||||||
|
|||||||
Reference in New Issue
Block a user