Перенос в 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:
2026-10-06 14:59:51 +03:00
parent 0e62d5ca8d
commit 688bb44ff1
14 changed files with 514 additions and 118 deletions

View File

@@ -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

View File

@@ -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
View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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"

View File

@@ -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]:

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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-маршруты -----------------------------------------------------------

View File

@@ -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
View 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} не передаётся контейнерам"

View File

@@ -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"