Files
schedule/src/backend/main.py
Vladislav Kopnin 475eb3801e Группы: ТОП-имена и порядок как в таблице
В таблице появились группы с «ТОП» в имени (ИВТ-ТОП-11БО, ИТ-ТОП-12БО):
шаблон требовал цифру сразу после первого дефиса, поэтому такие столбцы
пропускались целиком. Теперь имя группы — буквенные части через дефис,
номер потока и необязательный суффикс, т.е. распознаются и «ИВТ-11БО»,
и «ПИЭ-13МО», и «ИВТ-ТОП-11БО» (похожие заголовки с пробелами — нет).

GET /groups больше не сортирует по алфавиту с приоритетом БО/МО, а отдаёт
группы в порядке столбцов таблицы — как просили, «как в расписании».
Состав групп (появление/переименование/скрытие) подхватывается сам при
следующем обновлении, потому что источник истины — заголовок листа.

Тесты: ТОП-имена + порядок столбцов, отсев похожих не-групповых заголовков.
2026-09-30 09:42:27 +03:00

289 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""API-приложение расписания (FastAPI)."""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from datetime import date
from pathlib import Path
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from admin import AdminConfig, create_admin_router
from schedule import ScheduleCache, ScheduleError
from schedule import parsing
from schedule.settings import SettingsStore, interval_from_env
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
def _find_dotenv() -> Path | None:
"""Ищем .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)
def _normalize_admin_path(raw: str | None) -> str:
"""Секретный путь панели: всегда с ведущим слэшем и без хвостового."""
path = (raw or "").strip()
if not path:
return ""
if not path.startswith("/"):
path = "/" + path
return path.rstrip("/") or "/"
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or ""
def default_settings() -> SettingsStore:
return SettingsStore(
link=SCHEDULE_LINK,
interval_minutes=interval_from_env(),
path=SETTINGS_FILE or None,
)
def default_cache(settings: SettingsStore | None = None) -> ScheduleCache:
settings = settings or default_settings()
if SCHEDULE_LINK and settings.link and settings.link != SCHEDULE_LINK:
log.warning(
"Ссылка в настройках (%s) отличается от SCHEDULE_LINK из окружения (%s); "
"работаем по ссылке из настроек — её видно и можно поменять в админке",
settings.link,
SCHEDULE_LINK,
)
return ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
def _day_arg(day: str) -> str:
normalized = day.strip().lower()
if normalized not in parsing.DAYS:
raise HTTPException(
status_code=400,
detail=f"День должен быть одним из: {', '.join(parsing.DAYS)}",
)
return normalized
def create_app(
cache: ScheduleCache | None = None,
admin: AdminConfig | None = None,
) -> FastAPI:
cache = cache or default_cache()
admin = admin if admin is not None else AdminConfig(
path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD
)
@asynccontextmanager
async def lifespan(app: FastAPI):
cache.warmup()
yield
app = FastAPI(
title="Расписание",
description="Расписание групп из Яндекс.Таблицы",
lifespan=lifespan,
)
app.state.cache = cache
app.state.admin = admin
if admin.enabled:
app.include_router(create_admin_router(cache, admin))
if admin.path:
log.info("Админка включена (секретный путь задан)")
else:
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
else:
log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD")
def _data() -> tuple[dict, list[dict]]:
try:
return cache.get()
except ScheduleError as exc:
raise HTTPException(status_code=502, detail=str(exc))
def _index() -> tuple[dict, dict]:
try:
return cache.get_index()
except ScheduleError as exc:
raise HTTPException(status_code=502, detail=str(exc))
# --- обслуживание -----------------------------------------------------------
@app.get("/healthz")
def healthz() -> dict:
"""Живость процесса; не зависит от доступности таблицы."""
return {"ok": True}
@app.get("/status")
def status() -> dict:
return cache.status()
# --- справочники -------------------------------------------------------------
@app.get("/")
def index() -> dict:
return {
"title": "Расписание",
"endpoints": {
"/groups": "Список групп",
"/schedule/{group}": "Расписание группы",
"/schedule/{group}/{day}": "Расписание группы на день",
"/today?group={group}": "Расписание группы на сегодня",
"/teachers": "Список преподавателей",
"/teacher/{name}": "Расписание преподавателя",
"/rooms": "Список аудиторий",
"/room/{label}": "Занятость аудитории",
"/rooms-day?day={день}": "Занятость всех аудиторий на день",
"/rooms-free?day={день}&period={N}": "Свободные аудитории",
"/week": "Недели семестра и текущая чётность",
"/status": "Состояние кэша",
},
}
@app.get("/groups")
def groups() -> list[str]:
"""Группы в том порядке, в каком идут столбцы в таблице."""
data, _ = _data()
return list(data.keys())
@app.get("/teachers")
def teachers() -> list[str]:
teachers_map, _ = _index()
return sorted(teachers_map.keys())
@app.get("/rooms")
def rooms() -> list[str]:
_, rooms_map = _index()
return parsing.sort_rooms(set(rooms_map) | set(cache.room_info))
# --- занятия -----------------------------------------------------------------
@app.get("/schedule/{group}")
def schedule(group: str) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
return parsing.build_group_schedule(data, group)
@app.get("/schedule/{group}/{day}")
def schedule_day(group: str, day: str) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
normalized = _day_arg(day)
sched = parsing.build_group_schedule(data, group)
day_data = sched["days"].get(normalized, {})
return {"group": group, "day": normalized, "periods": day_data}
@app.get("/today")
def today(group: str) -> dict:
data, _ = _data()
if group not in data:
raise HTTPException(status_code=404, detail=f"Группа «{group}» не найдена")
day_name = parsing.DAYS[date.today().weekday()]
sched = parsing.build_group_schedule(data, group)
return {
"group": group,
"date": date.today().isoformat(),
"day": day_name,
"periods": sched["days"].get(day_name, {}),
}
@app.get("/teacher/{name}")
def teacher(name: str) -> dict:
teachers_map, _ = _index()
result = parsing.build_teacher_schedule(teachers_map, name)
if result is None:
raise HTTPException(status_code=404, detail=f"Преподаватель «{name}» не найден")
result["full_name"] = cache.teacher_full.get(name, name)
return result
@app.get("/room/{label}")
def room(label: str) -> dict:
_, rooms_map = _index()
result = parsing.build_room_schedule(rooms_map, label, cache.get_day_times())
if result is None:
raise HTTPException(status_code=404, detail=f"Аудитория «{label}» не найдена")
result["info"] = cache.room_info.get(label, {}).get("info", "")
return result
@app.get("/rooms-free")
def rooms_free(day: str, period: int) -> list[dict]:
normalized = _day_arg(day)
if period < 1 or period > 8:
raise HTTPException(status_code=400, detail="Пара должна быть от 1 до 8")
_, rooms_map = _index()
room_info = cache.room_info
free = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
if not busy:
free.append({"room": label, "info": room_info.get(label, {}).get("info", "")})
return free
@app.get("/rooms-day")
def rooms_day(day: str) -> dict:
normalized = _day_arg(day)
_, rooms_map = _index()
room_info = cache.room_info
day_times = cache.get_day_times().get(normalized, {})
rooms_out = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {}).get("info", "")
occupied_day = rooms_map.get(label, {}).get(normalized, {})
periods = {}
for period in sorted(set(occupied_day) | set(day_times), key=int):
slot = occupied_day.get(period, {})
periods[period] = {
"time": slot.get("time") or day_times.get(period, ""),
"ч": slot.get("ч") or [],
"з": slot.get("з") or [],
}
rooms_out.append({"room": label, "info": info, "periods": periods})
return {"day": normalized, "rooms": rooms_out}
@app.get("/week")
def week() -> dict:
_, weeks = _data()
today = date.today()
return {
"today": today.isoformat(),
"parity": parsing.current_parity(weeks, today),
"weeks": [
{"parity": w["parity"], "start": w["start"].isoformat(), "end": w["end"].isoformat()}
for w in weeks
],
}
return app
app = create_app()
if __name__ == "__main__":
uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=True)