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

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