Перенос в 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 34b975da69
14 changed files with 511 additions and 145 deletions

View File

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

View File

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

View File

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

View File

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

View File

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