From 688bb44ff1281b4cd93ce339d05e2ad30380d159 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Tue, 6 Oct 2026 14:59:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=BD=D0=BE=D1=81=20?= =?UTF-8?q?=D0=B2=20master=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE,=20=D0=BA?= =?UTF-8?q?=D1=80=D0=BE=D0=BC=D0=B5=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8:=20=D0=A2=D0=9E=D0=9F-=D0=B3=D1=80=D1=83=D0=BF=D0=BF?= =?UTF-8?q?=D1=8B,=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=D0=B3?= =?UTF-8?q?=D1=80=D1=83=D0=BF=D0=BF,=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=B1=D0=B5=D0=B7=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Админка осталась отдельной ветвью 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 (удалены админские), все зелёные. --- .env.example | 10 ++- Makefile | 62 ++++++++++++------ README.md | 108 +++++++++++++++++++++++++++----- deploy/README-dev.md | 61 ++++++++++++++++++ deploy/nginx-dev.conf | 47 ++++++++++++++ docker-compose.yml | 2 + src/backend/main.py | 63 ++++++++----------- src/backend/schedule/cache.py | 95 +++++++++++++++++++--------- src/backend/schedule/parsing.py | 9 ++- src/backend/schedule/source.py | 17 ++++- src/frontend/main.py | 31 +++++++-- tests/test_api.py | 65 +++++++++++++++++-- tests/test_deploy.py | 40 ++++++++++++ tests/test_parsing.py | 22 +++++++ 14 files changed, 514 insertions(+), 118 deletions(-) create mode 100644 deploy/README-dev.md create mode 100644 deploy/nginx-dev.conf create mode 100644 tests/test_deploy.py diff --git a/.env.example b/.env.example index 570529b..4544f00 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,14 @@ +# --- расписание -------------------------------------------------------------- +# Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна) SCHEDULE_LINK= + +# Как часто перечитывать таблицу, минут +SCHEDULE_INTERVAL_MINUTES=15 +# Устаревшее: интервал в секундах (используется, только если не задан предыдущий) SCHEDULE_CACHE_TTL=900 +# Пауза между повторными попытками, если источник недоступен, секунд SCHEDULE_RETRY_AFTER=60 + +# --- порты (только для docker) ------------------------------------------------- API_PORT=8000 FRONTEND_PORT=8080 -SCHEDULE_API=http://api:8000 diff --git a/Makefile b/Makefile index a528fde..fc1b02c 100644 --- a/Makefile +++ b/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 -up: ## Запустить все микросервисы одной командой +up: ## Запустить сервисы в фоне docker compose up -d -down: ## Остановить и удалить контейнеры +down: ## Остановить и удалить контейнеры docker compose down -restart: ## Перезапустить микросервисы +restart: ## Перезапустить сервисы docker compose restart -logs: ## Логи всех микросервисов +logs: ## Логи сервисов (Ctrl+C для выхода) docker compose logs -f -ps: ## Статус микросервисов +ps: ## Статус сервисов docker compose ps -deploy: ## Собрать и запустить (деплой на сервере) +deploy: ## Собрать и запустить (docker) ./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 diff --git a/README.md b/README.md index ea32b4c..7660190 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,18 @@ университета (файл xlsx), который скачивается, разбирается, превращается в JSON API и отображается на фронте. Деплой: `schedule.our-place.ru`. +## Быстрый старт (без Docker) + +Нужен только Python 3.11+ и публичная ссылка на таблицу. + +```bash +cp .env.example .env # в SCHEDULE_LINK вписать публичную ссылку Я.Диска +make run # создаст .venv, поставит зависимости и запустит оба сервиса +``` + +Открыть . Остановить — `Ctrl+C`. +Подробнее — разделы «Зависимости» и «Запуск» ниже. + ## Архитектура ``` @@ -98,7 +110,7 @@ src/ source.py скачивание файла по публичной ссылке Я.Диска lessons.py разбор ячеек занятий (общий конвейер ролей) parsing.py лист → расписание, недели, индексы преподавателей/аудиторий - cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error + cache.py ScheduleCache: блокировка, интервал, stale-while-error errors.py ScheduleError frontend/ 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) tools/ make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert) +deploy/ + nginx-dev.conf пример сайта nginx для отдельного стенда + README-dev.md как поднять второй стенд рядом с основным tests/ conftest.py общие фикстуры xlsx_builder.py генерация xlsx в памяти (без openpyxl) @@ -117,7 +132,8 @@ tests/ test_lessons.py все форматы ячеек test_xlsx.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 @@ -150,19 +166,84 @@ tests/ | Переменная | По умолчанию | Где | Описание | |---|---|---|---| | `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** | -| `SCHEDULE_CACHE_TTL` | `900` | 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 | | `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) | -`.env` ищется вверх от каталога модуля (backend — до корня репозитория); -`load_dotenv` не перезаписывает уже выставленные переменные окружения. +`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий +`.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 — (например, `/status` и `/groups`); +- сайт — (его и открывать в браузере). + +Остановить — `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 (и порты при необходимости) @@ -172,18 +253,10 @@ cp .env.example .env # вписать SCHEDULE_LINK (и порты при У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того, как 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 -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 его придётся выносить в общее хранилище. - Строки внутри дня в таблице строго чередуются «числитель / знаменатель». diff --git a/deploy/README-dev.md b/deploy/README-dev.md new file mode 100644 index 0000000..5e41fdd --- /dev/null +++ b/deploy/README-dev.md @@ -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 +``` diff --git a/deploy/nginx-dev.conf b/deploy/nginx-dev.conf new file mode 100644 index 0000000..15d1485 --- /dev/null +++ b/deploy/nginx-dev.conf @@ -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; + } +} diff --git a/docker-compose.yml b/docker-compose.yml index d37650f..c32123c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: target: api environment: SCHEDULE_LINK: ${SCHEDULE_LINK} + SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} ports: @@ -22,6 +23,7 @@ services: context: . target: frontend environment: + # внутри compose api доступен по имени сервиса; менять не нужно SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} ports: - "${FRONTEND_PORT:-8080}:8080" diff --git a/src/backend/main.py b/src/backend/main.py index c911a3b..902e2ce 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -4,14 +4,13 @@ from __future__ import annotations import logging import os -import threading from contextlib import asynccontextmanager from datetime import date from pathlib import Path import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Query +from fastapi import FastAPI, HTTPException from schedule import ScheduleCache, ScheduleError from schedule import parsing @@ -37,40 +36,34 @@ _dotenv_path = _find_dotenv() if _dotenv_path: load_dotenv(_dotenv_path) +DEFAULT_INTERVAL_MINUTES = 15 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")) -def _default_cache() -> ScheduleCache: - try: - return ScheduleCache(SCHEDULE_LINK, ttl_seconds=CACHE_TTL, retry_after=RETRY_AFTER) - except ScheduleError: - log.error("SCHEDULE_LINK не задан — API данных работать не будет") - return _BrokenCache() +def interval_seconds() -> float: + """Интервал обновления: SCHEDULE_INTERVAL_MINUTES, иначе старый SCHEDULE_CACHE_TTL (сек).""" + raw_minutes = (os.getenv("SCHEDULE_INTERVAL_MINUTES") or "").strip() + if raw_minutes: + try: + 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 __init__(self) -> None: - self._link = None - 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 default_cache() -> ScheduleCache: + return ScheduleCache( + link=SCHEDULE_LINK, + ttl_seconds=interval_seconds(), + retry_after=RETRY_AFTER, + ) def _day_arg(day: str) -> str: @@ -84,7 +77,7 @@ def _day_arg(day: str) -> str: def create_app(cache: ScheduleCache | None = None) -> FastAPI: - cache = cache or _default_cache() + cache = cache or default_cache() @asynccontextmanager async def lifespan(app: FastAPI): @@ -128,7 +121,7 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI: return { "title": "Расписание", "endpoints": { - "/groups": "Список групп", + "/groups": "Список групп (в порядке столбцов таблицы)", "/schedule/{group}": "Расписание группы", "/schedule/{group}/{day}": "Расписание группы на день", "/today?group={group}": "Расписание группы на сегодня", @@ -145,11 +138,9 @@ def create_app(cache: ScheduleCache | None = None) -> FastAPI: @app.get("/groups") def groups() -> list[str]: + """Группы в том порядке, в каком идут столбцы в таблице.""" data, _ = _data() - return sorted( - data.keys(), - key=lambda g: (0 if g.endswith("БО") else (1 if g.endswith("МО") else 2), g), - ) + return list(data.keys()) @app.get("/teachers") def teachers() -> list[str]: diff --git a/src/backend/schedule/cache.py b/src/backend/schedule/cache.py index 13042b7..3f0d9d3 100644 --- a/src/backend/schedule/cache.py +++ b/src/backend/schedule/cache.py @@ -2,8 +2,8 @@ - однопоточная перезагрузка (lock): параллельные запросы во время загрузки ждут один download вместо N скачиваний; -- TTL, а также проверка modified таблицы: если таблица не менялась, - скачивание не повторяется; +- интервал обновления: по его истечении сверяем modified таблицы — если файл + не менялся, скачивание не повторяется; - stale-while-error: если таблица перестала скачиваться, отдаём последнее удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд (вместо скачивания на каждый запрос). @@ -11,6 +11,7 @@ from __future__ import annotations +import hashlib import logging import threading import time @@ -23,19 +24,19 @@ from .xlsx import parse_workbook log = logging.getLogger(__name__) +NO_LINK_ERROR = "Ссылка на расписание не задана (SCHEDULE_LINK)" + class ScheduleCache: def __init__( self, - link: str | None, + link: str | None = None, ttl_seconds: float = 900.0, retry_after: float = 60.0, ) -> None: - if not link: - raise ScheduleError("Не задана переменная окружения SCHEDULE_LINK") - self._link = link - self._ttl = ttl_seconds - self._retry_after = retry_after + self._link = (link or "").strip() + self._ttl = float(ttl_seconds) + self._retry_after = float(retry_after) self._lock = threading.RLock() self._data: dict | None = None @@ -46,8 +47,12 @@ class ScheduleCache: self._room_info: dict = {} self._loaded_at: float = 0.0 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._content_hash: str | None = None + self._changed: bool = False + self._changed_at: float = 0.0 # --- публичное API --------------------------------------------------------- @@ -82,13 +87,20 @@ class ScheduleCache: with self._lock: return { "cache_ttl_seconds": self._ttl, + "interval_minutes": max(1, round(self._ttl / 60)), "last_updated": self._iso(self._loaded_at), "table_modified": self._modified, "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: - """Первый запуск данных на старте приложения (ошибка не фатальна).""" + """Первая загрузка данных на старте приложения (ошибка не фатальна).""" try: self.get() except ScheduleError as exc: @@ -112,9 +124,9 @@ class ScheduleCache: ) if fresh: return - self._reload(now) + self._refresh(now) - def _reload(self, now: float) -> None: + def _refresh(self, now: float) -> None: # служим старые данные, пока не истекло время повторной попытки if ( self._data is not None @@ -122,6 +134,9 @@ class ScheduleCache: and now - self._last_failed < self._retry_after ): return + if not self._link: + self._fail(ScheduleError(NO_LINK_ERROR), now) + return modified = fetch_table_modified(self._link) if ( self._data is not None @@ -133,31 +148,49 @@ class ScheduleCache: self._error = None return try: - log.info("Загружаю расписание по ссылке %s ...", self._link) - 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 - ) + self._load(now, modified) except ScheduleError as exc: self._fail(exc, now) except Exception as exc: # непредвиденное в парсинге/сети log.exception("Ошибка при загрузке расписания") 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: self._error = str(exc) self._last_failed = now diff --git a/src/backend/schedule/parsing.py b/src/backend/schedule/parsing.py index e3ea942..8d14091 100644 --- a/src/backend/schedule/parsing.py +++ b/src/backend/schedule/parsing.py @@ -35,7 +35,9 @@ _COL_DAY = 4 # день недели _COL_PERIOD = 5 # пара и время _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*(.*)$") _ROOM_RE = re.compile(r"ауд\.\s*([\w\-]+)", re.I) _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]: + """Столбцы групп: имя из заголовка, порядок — как столбцы в таблице.""" columns: dict[int, str] = {} seen: set[str] = set() for idx, cell in enumerate(header): if idx in hidden_cols: continue 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 seen.add(name) return columns diff --git a/src/backend/schedule/source.py b/src/backend/schedule/source.py index dc40451..eb92059 100644 --- a/src/backend/schedule/source.py +++ b/src/backend/schedule/source.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time import requests @@ -12,18 +13,27 @@ log = logging.getLogger(__name__) 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: """Скачивает файл с диска; при любой ошибке бросает ScheduleError.""" try: resp = requests.get( f"{CLOUD_API}/download", - params={"public_key": public_url}, + params={"public_key": public_url, **_bust()}, + headers=_NO_CACHE, timeout=timeout, ) resp.raise_for_status() 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() return file_resp.content 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: resp = requests.get( CLOUD_API, - params={"public_key": public_url}, + params={"public_key": public_url, **_bust()}, + headers=_NO_CACHE, timeout=timeout, ) resp.raise_for_status() diff --git a/src/frontend/main.py b/src/frontend/main.py index ddc220e..590960e 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -2,24 +2,42 @@ from __future__ import annotations -import os import logging +import os from pathlib import Path import requests import uvicorn from dotenv import load_dotenv -from fastapi import FastAPI, HTTPException, Query -from fastapi.responses import FileResponse +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles log = logging.getLogger(__name__) 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("/") +# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше. PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) STATIC_DIR = Path(__file__).parent / "static" @@ -43,7 +61,8 @@ def _proxy(path: str, params: dict) -> object: except Exception: detail = resp.text raise HTTPException(status_code=resp.status_code, detail=detail) - return resp.json() + # данные не кэшируем: браузер всегда показывает актуальное расписание + return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"}) # --- API-маршруты ----------------------------------------------------------- diff --git a/tests/test_api.py b/tests/test_api.py index 3c1c858..5a03f05 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -46,6 +46,21 @@ def test_groups(client): 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): assert client.get("/teachers").json() == [ "Иванов И.И.", "Петров П.П.", "Сидорова С.С.", "Смирнов А.В.", @@ -155,14 +170,52 @@ def test_cache_no_repeated_download(client, cache): assert cache.calls["download"] == 1 -def test_cache_modified_unchanged_no_download(cache): - cache._ttl = 0 # даже с истёкшим TTL +def test_modified_unchanged_no_download(cache): + """По интервалу сверяем modified: если таблица не менялась — не скачиваем.""" + cache._ttl = 0 cache.get() cache.get() assert cache.calls["download"] == 1 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): cache._ttl = 0 # форсим повторную попытку cache.get() @@ -196,10 +249,14 @@ def test_cache_failure_without_data_raises(cache): 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 - 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: assert c.get("/healthz").status_code == 200 assert c.get("/groups").status_code == 502 diff --git a/tests/test_deploy.py b/tests/test_deploy.py new file mode 100644 index 0000000..f2fb81c --- /dev/null +++ b/tests/test_deploy.py @@ -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} не передаётся контейнерам" diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 64ca6a7..5cc3d2d 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -23,6 +23,28 @@ def test_hidden_group_excluded(data): 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): assert data["ИВТ-11БО"]["semester_until"] == "11.01.27" assert data["ИТ-12БО"]["semester_until"] == "29.12.26"