Перенос в 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

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