Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она выключена, пока не заданы ADMIN_LOGIN/ADMIN_PASSWORD: маршруты /api/admin/* не регистрируются, страницы нет — обычная установка ничего не замечает. Панель (открывается по секретному пути ADMIN_PATH, кнопки входа на сайте нет, вход по логину/паролю): - состояние: группы, когда обновлялось, дата файла на Диске, изменился ли файл при последнем чтении (sha256), ошибки, путь файла настроек; - новая ссылка на таблицу: проверяется скачиванием, при ошибке откат; - интервал обновления (TTL) в минутах; - «Обновить сейчас»: принудительно перечитывает таблицу (качает файл всегда, интервал — только если на Диске изменилась дата); - история входов: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы. Защита входа: ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3), затем блокировка на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15) — всё из .env. Реализация: - backend/admin.py: cookie-сессия на HMAC (ключ — пароль, 12 ч, HttpOnly, SameSite, Secure за https), сравнение за постоянное время, реальный IP из X-Forwarded-For, логирование каждой попытки; - schedule/settings.py: ссылка, интервал и история входов в JSON; путь по умолчанию data/settings.json (в docker — том schedule_data), при недоступности файла настройки живут в памяти и панель предупреждает; - cache.py: apply_link (с проверкой и откатом), force_reload, state(); - frontend: страница админки по ADMIN_PATH (вне static, noindex) и прокси /api/admin/* с телом и Set-Cookie; - install.sh пишет переменные админки в .env, Dockerfile копирует admin.py. Тесты: 156. Проверено локально (страница, 3 попытки -> блокировка, история) и в собранном docker-образе (том настроек, запись истории).
112 lines
4.5 KiB
Python
112 lines
4.5 KiB
Python
"""Общая тестовая таблица: 2 группы, 2 дня, мержи, скрытый столбец, красный текст."""
|
||
|
||
from xlsx_builder import SheetSpec, build_workbook
|
||
|
||
MAIN = "Бакалавры и магистры"
|
||
ROOMS = "аудитории"
|
||
TEACHERS = "Преподаватели"
|
||
|
||
# серии Excel
|
||
# недели ч: 08.31-09.06, 14.09-20.09, 28.09-04.10, 12.10-18.10
|
||
# недели з: 07.09-13.09, 21.09-27.09, 05.10-11.10, 19.10-25.10
|
||
W1 = (46265, 46271, 46272, 46278)
|
||
W2 = (46279, 46285, 46286, 46292)
|
||
W3 = (46293, 46299, 46300, 46306)
|
||
W4 = (46307, 46313, 46314, 46320)
|
||
|
||
|
||
def main_sheet() -> SheetSpec:
|
||
g1 = "ИВТ-11БО\nсеместр до 11.01.27"
|
||
g2 = "ИТ-12БО\nсеместр до 29.12.26"
|
||
hidden = "СКРЫ-11БО\nсеместр до 1.1.27"
|
||
math_red = [
|
||
("Математика\nСмирнов А.В.\nауд. 215\n", False),
|
||
("пара 14.09 не будет", True),
|
||
]
|
||
rows = [
|
||
# 0: заголовок
|
||
["числитель", "", "знаменатель", "", "день недели", "пара\nвремя",
|
||
g1, g2, hidden, None],
|
||
# 1: с/по
|
||
["с", "по", "с", "по", "день недели", "пара\nвремя", None, None, None, None],
|
||
# 2: понедельник, пара 1, ч
|
||
[*W1, "понедельник", "1\n9:00-\n10:35", math_red, "Физика\nИванов И.И.\nауд. 301"],
|
||
# 3: понедельник, пара 1, з
|
||
[*W2, "", "", "Математика\nСмирнов А.В.\nауд. 216", None],
|
||
# 4: понедельник, пара 2, ч
|
||
[*W3, "", "2\n10:45- 12:20", "к.в. - 2\nФизика\nИванов И.И.\nауд. 301",
|
||
"Химия\nПетров П.П.\nонлайн"],
|
||
# 5: понедельник, пара 2, з
|
||
[*W4, "", "", None, "к.в.\nХимия\nПетров П.П.\nауд. 201"],
|
||
# 6: вторник, пара 1, ч
|
||
[*W1, "вторник", "1\n9:00-\n10:35", "Биология\nСидорова С.С.\nауд. 220", None],
|
||
# 7: вторник, пара 1, з
|
||
[*W2, "", "", None, None],
|
||
]
|
||
return SheetSpec(
|
||
rows=rows,
|
||
merges=["F3:F4", "F5:F6", "F7:F8"],
|
||
hidden_cols=[(8, 8)],
|
||
)
|
||
|
||
|
||
def rooms_sheet() -> SheetSpec:
|
||
rows = [
|
||
[None, None, None, None, None, None, "215", "301", "220"],
|
||
[None, None, None, None, None, None, "комп.класс, 12 мест",
|
||
"интерактивная панель, 24 места", "20 мест"],
|
||
]
|
||
return SheetSpec(rows=rows)
|
||
|
||
|
||
def teachers_sheet() -> SheetSpec:
|
||
rows = [
|
||
["числитель", "числитель", "знаменатель", "знаменатель", "день недели", "пара\nвремя"],
|
||
[None, None, None, None, None, None,
|
||
"Смирнов\nАлександр Викторович", "Иванов\nИван Иванович"],
|
||
[*W1, "понедельник", "1\n9:00-\n10:35",
|
||
"Математика\nСмирнов А.В.\nауд. 215", "Физика\nИванов И.И.\nауд. 301"],
|
||
]
|
||
return SheetSpec(rows=rows)
|
||
|
||
|
||
def fixture_sheets() -> dict[str, SheetSpec]:
|
||
return {MAIN: main_sheet(), ROOMS: rooms_sheet(), TEACHERS: teachers_sheet()}
|
||
|
||
|
||
def fixture_xlsx() -> bytes:
|
||
return build_workbook(fixture_sheets())
|
||
|
||
|
||
def make_sheets(sheets: dict[str, SheetSpec]):
|
||
"""SheetSpec-фикстуры -> те же объекты, что парсит parse_workbook."""
|
||
from schedule.xlsx import parse_workbook
|
||
|
||
return parse_workbook(build_workbook(sheets))
|
||
|
||
|
||
def make_cache(monkeypatch, xlsx: bytes | None, modified: str = "fixed-modified", settings=None):
|
||
"""ScheduleCache с заменёнными сетевыми функциями."""
|
||
import schedule.cache as cache_mod
|
||
from schedule import ScheduleCache
|
||
|
||
calls = {"download": 0, "modified": 0}
|
||
fail = {"download": False, "modified": False}
|
||
|
||
def fake_download(url, timeout=60.0):
|
||
calls["download"] += 1
|
||
if fail["download"]:
|
||
from schedule import ScheduleError
|
||
|
||
raise ScheduleError("сетевой сбой (тест)")
|
||
return xlsx
|
||
|
||
def fake_modified(url, timeout=20.0):
|
||
calls["modified"] += 1
|
||
return None if fail["modified"] else modified
|
||
|
||
monkeypatch.setattr(cache_mod, "download_schedule", fake_download)
|
||
monkeypatch.setattr(cache_mod, "fetch_table_modified", fake_modified)
|
||
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60, settings=settings)
|
||
return cache, calls, fail
|