Compare commits

21 Commits

Author SHA1 Message Date
d131adbcb8 Скрытое в таблице не парсим
Правило: если строка или столбец в таблице скрыт, его данные не попадают в
расписание — раньше скрытые столбцы пропускались только при поиске групп.

- xlsx: у листа появился набор скрытых строк (Worksheet.hidden_rows);
- расписание, недели: скрытые строки пропускаются;
- аудитории и справочник ФИО: скрытые столбцы не читаются вовсе (убрана
  двухпроходная логика «сначала видимые, потом скрытые»);
- тесты: билдер умеет скрытые строки, добавлены проверки «скрытое не парсится».

Проверено на таблице: группы и всё расписание совпали байт в байт (скрытых
строк нет), из аудиторий ушли только те, что лежат в скрытых столбцах
(402, 405, 415), справочник ФИО стал короче на 2 скрытые записи, у 223
осталось описание из видимого столбца (20 мест, интерактивная панель).
Тесты: 171.
2026-10-12 12:07:39 +03:00
172636f227 Аудитории 223: видимый столбец важнее скрытого дубля
В листе «аудитории» два столбца с номером 223: видимый («комп.класс
Linux/Windows; интерактивная панель; маркер; 20 мест») и скрытый
(«компьютерный класс для МО и СТ; 24 места»). Парсер читал скрытый, потому что
скрытые столбцы пропускались только при поиске групп, а в описаниях аудиторий
нет — и он затирал видимый: у 223 не было ни панели, ни 20 мест.

Теперь в описаниях аудиторий скрытые столбцы обрабатываются после видимых и не
перекрывают их (аудитория, у которой есть только скрытый столбец, как 415/405/402,
по-прежнему разбирается). Заодно тип понимает «комп.класс Linux/Windows».

Проверено на таблице: 223 -> 20 мест, оснащение ПК + интерактивная панель + маркер,
тип «комп.класс linux/windows». Тесты: 170.
2026-10-12 10:55:11 +03:00
a9d3f4d5cf Парсер: ФИО из трёх слов после предмета, порядок «Имя Отчество Фамилия»
В субботу у ПИЭ-41/42БО в название курса попадало «Денис»: правило разбиения
забирало в преподавателя только два последних слова, а здесь их три
(«Основы тестирования веб-приложений Денис Сергеевич Верховцев (Сигма)»).

- правило ФИО в конце строки теперь берёт 2–3 слова и использует нежадный
  префикс, поэтому первые слова предмета не «съедаются»;
- добавлена проверка, что это действительно человек: для трёх слов нужно
  отчество (-вич/-вна), для двух — «фамильное» окончание, иначе название
  вроде «Введение в Машинное Обучение» в преподаватели не уедет;
- ключ преподавателя собирается в обоих порядках («Верховцев Денис Сергеевич»
  и «Денис Сергеевич Верховцев») -> «Верховцев Д.С.»; организация вроде
  «ПравоТех» больше не теряется.

Проверено на живой таблице: предмет чистый, в списке преподавателей появился
«Верховцев Д.С.», подозрительных ФИО по всем группам нет. Тесты: 168.
2026-10-10 13:36:33 +03:00
2cbb19312e Аудитории: обозначения в нижнем регистре, аббревиатуры — заглавными
Тип, оснащение и пометки приводятся к одному виду: слова пишутся со строчной,
а аббревиатуры («ПК», «МО», «СТ») остаются полностью заглавными; в подписях
фильтров то же самое. В карточках теперь «комп.класс · 12 мест · ПК, маркер»,
«МО и СТ; без проектора». Тесты: 162.
2026-10-10 13:26:15 +03:00
ee5faeff77 Аудитории: из описания показываем только то, чего нет в атрибутах
Раньше описание выводилось целиком, и у аудиторий вида 405 («пк») получался
дубль: «ПК» в атрибутах и «пк» строкой ниже.

Теперь описание разбирается по частям: вместимость вырезается, а куски,
которые целиком состоят из типа/оснащения/дней/номера, не показываются.
Остаются только реальные дополнения: «без проектора», «МО и СТ», «амфитиатром».
Слова не режутся по частям (раньше появлялись осколки вида «а», «-», «мелова»);
короткие теги («ПК») учитываются, регистр сохраняется.
2026-10-10 13:21:45 +03:00
f635272128 Аудитории: пометка про дни — просто жёлтым текстом
- убрана подсказка «С ограничением по дням: N» из панели фильтров;
- в карточке убран префикс «в таблице:» — осталось «занятия только по
  четвергам и пятницам», выделенное жёлтым (в тёмной теме — светлее);
- класс .room-note.bad больше не использовался, удалён.
2026-10-10 13:17:45 +03:00
c69d38645d Фильтры аудиторий: счётчик «Показано N из M» прижат к низу панели
Раньше строку с кнопкой «Сбросить фильтры» и счётчиком перекрывала подсказка
(«скрыто …»), и счётчик висел в середине блока. Теперь порядок строк:
переключатели режима → фильтры → оснащение → подсказка → строка со сбросом и
счётчиком (выровнены по базовой линии, строка вплотную к нижнему отступу).
2026-10-10 13:11:42 +03:00
b9967ddb24 Аудитории: карточка 402 читается однозначно
- описание из таблицы не показываем, если оно сводится к номеру и дням
  («402 четверг пятница») — это дублировало пометку и путало;
- пометка про дни формулируется по-русски: «в таблице: занятия только
  по четвергам и пятницам»;
- убрана вторая строка «в этот день занятий в ней нет» (следовала из первой);
- подсказка в фильтрах вместо «аудитория отмечена как доступные…» — просто
  «с ограничением по дням: N», предложения начинаются с большой буквы.
2026-10-10 13:06:55 +03:00
163278c146 Шапка одной высоты с админкой, ховер кнопок как у остальных
- на сайте логотип лежит внутри ссылки и как строчный элемент добавлял 7px:
  шапка была 70px против 63px в админке. Теперь логотип блочный, а ссылка —
  inline-flex: обе шапки 63px (и min-height layout сходится точно);
- выход и «Админка» при наведении только меняют цвет (тёмно-красный и
  синий соответственно), без подъёма, тени и transform — как остальные
  кнопки проекта.
2026-10-10 13:02:43 +03:00
3977b0da47 README: как защищён путь админки (путь не попадает ни в HTML, ни в JSON) 2026-10-10 12:55:27 +03:00
34a46b6832 Админка: кнопки в шапке сайта, красный выход и путь, который нигде не утекает
Интерфейс:
- после входа на главной появляются «Админка» и «Выйти» — в шапке, перед
  переключателем темы и после него соответственно (порядок: Админка, тема,
  Выйти);
- «Выйти» теперь красная и заметно реагирует на наведение (темнеет,
  приподнимается, тень) — и на сайте, и в админке (одна кнопка в шапке,
  из карточки убрана);
- в админке убраны «осталось попыток», путь к файлу настроек и карточка
  «Данные расписания» (дублировала шапку); блокировка показывается отдельной
  плашкой только когда действительно активна.

Безопасность пути:
- путь к панели не отдаётся ни в HTML, ни в JSON (из /api/admin/state убран),
  ни в JS: на главной для вошедшего только флаг, а кнопка «Админка» ведёт на
  служебный /api/admin/goto, который отвечает 302 на секретный путь;
- без сессии /api/admin/goto и /api/admin/state отвечают 401, неизвестные пути
  дают 404 — перебором путь не находится;
- страница панели: noindex/nofollow/noarchive, Referrer-Policy: no-referrer,
  X-Frame-Options: DENY, Cache-Control: no-store;
- robots.txt без пути (только Disallow: /api/), внешние ссылки в панели —
  rel=noopener noreferrer.

Тесты: 161 (состояние без пути, /goto: 401 без входа и 302 с ним).
2026-10-10 12:54:54 +03:00
71f83a27dd Админка: страница переделана — понятнее и в стиле сайта
- шапка как на сайте (логотип факультета, «на сайт», переключатель светлой/тёмной
  темы — тема общая с сайтом и не мигает при загрузке);
- крупная шапка состояния: «Работает / Ожу данные / Ошибка загрузки», сколько
  обновлено назад, число групп и текущий интервал; рядом главная кнопка
  «Обновить сейчас»;
- вместо россыпи чипов — карточки: «Ссылка на расписание» (с кнопкой
  «открыть таблицу»), «Обновление» (интервал + факты: изменился ли файл, дата,
  отпечаток), «Входы в админку» (последняя попытка, последний успешный вход,
  остаток попыток или блокировка), «Данные расписания»;
- предупреждения (ошибка загрузки, невозможность сохранить настройки) вынесены
  отдельной плашкой под шапкой, а не спрятаны среди полей;
- верстка адаптивная: сетка карточек, на телефоне одна колонка и подписи
  под значениями; формы с подписями и подсказками, кнопки с ясной иерархией;
- уведомления и вся логика прежние; тесты 160.
2026-10-10 12:42:44 +03:00
72ca140b40 Аудитории: близкие типы объединены в фильтре, добавлен пункт «без типа»
В карточке остаётся формулировка из таблицы («комп.класс Linux»), а в фильтре
близкие варианты сведены к одной группе: «комп.класс» (комп.класс, комп.класс
Linux, компьютерный класс), «кабинет английского» (английский, кабинет
английского), «аудитория кафедры», «лекционная», «лекционно-практическая»,
«аудитория для практических». Добавлен пункт «без типа» — 4 аудитории, где в
таблице тип не указан.

Бэкенд отдаёт в /rooms-day и /rooms-free дополнительное поле type_group
(room_type_group), фронт фильтрует по нему. Тесты: 160.
2026-10-10 12:37:02 +03:00
6f16736359 Аудитории: фильтры по типу, вместимости и оснащению + режим «свободные на паре»
Бэкенд:
- parse_rooms_info разбирает описание аудитории (формат «тип; оснащение; N мест»)
  в атрибуты: type (фразой как в таблице), capacity (места, а для компьютерных
  классов — рабочие места; null, если данных нет), equipment (ПК, Проектор,
  Интерактивная панель, Телевизор, Маркер, Меловая доска) и available_days
  («402 четверг пятница»); «без проектора» трактуется как отсутствие;
- /rooms-day и /rooms-free отдают атрибуты (room_payload); аудитории с
  ограничением по дням не предлагаются в /rooms-free в остальные дни.

Фронтенд (вкладка «Аудитории»):
- панель фильтров: тип (значения из данных таблицы), «мест не меньше», оснащение
  чекбоксами с выбором условия «все (И)» / «любое (ИЛИ)»;
- счётчик «Показано N из M» и пояснения: сколько аудиторий скрыто из-за
  отсутствия данных о вместимости, сколько доступны только по отдельным дням;
- переключатель «Занятость за день» / «Свободные на паре» (с выбором пары) —
  вместе с фильтрами это «свободная аудитория на 30 человек с проектором в
  3-ю пару»; выбранные фильтры запоминаются в браузере;
- в карточке аудитории видны разобранные атрибуты и пометки из таблицы.

Тесты: разбор всех форматов описаний (включая отрицания и меловую доску),
API атрибутов и учёта доступных дней. Всего 159.
2026-10-10 12:25:16 +03:00
3d5bfe3023 README: убрана пустая строка в блоке структуры после удаления install.sh 2026-10-10 11:51:33 +03:00
b49ef93619 Убран install.sh: установка через deploy.sh
Для личного использования достаточно deploy.sh (проверяет окружение, собирает
образы и поднимает контейнеры). Раздел «Установка на сервер» переписан:
Docker через deploy.sh + вариант без Docker (systemd) и nginx, без интерактивного
установщика и его флагов.
2026-10-10 11:51:14 +03:00
5b3c9dc00a Админка в master: секретный путь, ссылка, интервал, force, блокировка входа, история
Админка нужна на «голом» сервере, поэтому возвращаю её в основную ветку. Она
выключена, пока не заданы 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-образе (том настроек, запись истории).
2026-10-10 11:12:29 +03:00
962681630f Добавлен установщик 2026-10-07 17:28:55 +03:00
cc194b8eee Исправлен деплой скрипт 2026-10-07 16:20:42 +03:00
d897601d88 Скорректирован сценарий запуска, исправление бага с предметом 2026-10-07 15:17:37 +03:00
34b975da69 Перенос в 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 (удалены админские), все зелёные.
2026-10-06 15:15:44 +03:00
29 changed files with 2457 additions and 756 deletions

View File

@@ -1,25 +1,30 @@
# --- расписание ------------------------------------------------------------- # --- расписание --------------------------------------------------------------
# Публичная ссылка на Яндекс.Диск (обязательна) # Публичная ссылка на Яндекс.Диск с таблицей расписания (обязательна)
SCHEDULE_LINK= SCHEDULE_LINK=
# Интервал обновления в минутах (переживает перезапуск: правится в админке)
SCHEDULE_INTERVAL_MINUTES=15
# Пауза между повторными попытками при сбое источника, секунд
SCHEDULE_RETRY_AFTER=60
# Устаревшее: интервал в секундах (используется, только если не задан SCHEDULE_INTERVAL_MINUTES)
SCHEDULE_CACHE_TTL=900
# --- админка (пусто = выключена) --------------------------------------------- # Как часто перечитывать таблицу, минут
# Секретный путь страницы админки, например /manage-7f3a91 SCHEDULE_INTERVAL_MINUTES=15
# Устаревшее: интервал в секундах (используется, только если не задан предыдущий)
SCHEDULE_CACHE_TTL=900
# Пауза между повторными попытками, если источник недоступен, секунд
SCHEDULE_RETRY_AFTER=60
# --- порты (только для docker) -------------------------------------------------
# Оба порта публикуются на 127.0.0.1 (жёстко задано в docker-compose.yml):
# снаружи сервисы отдаёт nginx, напрямую в интернет они не смотрят.
API_PORT=8000
FRONTEND_PORT=8080
# --- админка (необязательно) ----------------------------------------------------
# Пока эти переменные пустые, админка выключена и наружу ничего не торчит.
# Секретный путь страницы админки: придумайте длинный, например /panel-8f3a91x7
ADMIN_PATH= ADMIN_PATH=
# Логин и пароль администратора # Логин и пароль администратора
ADMIN_LOGIN= ADMIN_LOGIN=
ADMIN_PASSWORD= ADMIN_PASSWORD=
# Где хранить настройки (ссылка/интервал). # Сколько неудачных попыток входа допускается и на сколько минут блокировать после них
# В docker по умолчанию /data/settings.json (том schedule_data); ADMIN_MAX_ATTEMPTS=3
# локально можно оставить пустым — тогда настройки живут в памяти. ADMIN_LOCKOUT_MINUTES=15
# Где хранить настройки админки (ссылка, интервал, история входов).
# Пусто = data/settings.json в корне проекта; в docker задаётся автоматически.
SETTINGS_FILE= SETTINGS_FILE=
# --- порты и адреса ----------------------------------------------------------
API_PORT=8000
FRONTEND_PORT=8080
SCHEDULE_API=http://api:8000

6
.gitignore vendored
View File

@@ -6,3 +6,9 @@ __pycache__/
/.idea/ /.idea/
**/*.env **/*.env
.env .env
# локальные данные инструментов
.kilo/
# состояние админки (ссылка, интервал, история входов)
/data/

View File

@@ -1,32 +1,68 @@
.PHONY: build up down logs restart deploy ps dev test PY := $(CURDIR)/.venv/bin/python
PIP := $(CURDIR)/.venv/bin/pip
build: ## Собрать образы микросервисов .PHONY: help venv run dev run-api run-front test build up down restart logs ps deploy nginx-print nginx-install
help: ## Показать этот список команд
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}'
# --- локальный запуск (без Docker) -------------------------------------------
venv: ## Создать .venv и установить зависимости
@test -x $(PY) || python3 -m venv .venv
@test -x $(PIP) || { echo "Не удалось создать .venv — см. README, раздел «Если установка не идёт» (на Debian/Ubuntu нужен пакет python3-venv)"; exit 1; }
@$(PIP) install -q --upgrade pip
@$(PIP) install -q -r requirements-dev.txt
@echo "Зависимости готовы (.venv). Дальше: make run"
run: venv ## Запустить API и сайт локально, без Docker
@echo "API: http://127.0.0.1:8000/status"
@echo "Сайт: http://127.0.0.1:8080 (открыть в браузере)"
@echo "Остановить: Ctrl+C"
@trap 'kill 0' EXIT; \
(cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000) & \
(cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080) & \
wait
dev: run ## То же, что run
run-api: venv ## Запустить только API (http://127.0.0.1:8000)
cd src/backend && $(PY) -m uvicorn main:app --reload --port 8000
run-front: venv ## Запустить только сайт (http://127.0.0.1:8080)
cd src/frontend && $(PY) -m uvicorn main:app --reload --port 8080
test: venv ## Прогнать тесты
$(PY) -m pytest tests -q
# --- Docker (продакшен) ------------------------------------------------------
build: ## Собрать образы
docker compose build docker compose build
up: ## Запустить все микросервисы одной командой up: ## Запустить сервисы в фоне
docker compose up -d docker compose up -d
down: ## Остановить и удалить контейнеры down: ## Остановить и удалить контейнеры
docker compose down docker compose down
restart: ## Перезапустить микросервисы restart: ## Перезапустить сервисы
docker compose restart docker compose restart
logs: ## Логи всех микросервисов logs: ## Логи сервисов (Ctrl+C для выхода)
docker compose logs -f docker compose logs -f
ps: ## Статус микросервисов ps: ## Статус сервисов
docker compose ps docker compose ps
deploy: ## Собрать и запустить (деплой на сервере) deploy: ## Собрать и запустить (docker)
./deploy.sh ./deploy.sh
test: ## Прогнать тесты (локальный venv) # --- nginx (на сервере) ------------------------------------------------------
$(CURDIR)/.venv/bin/python -m pytest tests -q
dev: ## Запустить локально без Docker (бэкенд + фронт) # DOMAIN=sched.example.ru make nginx-print / nginx-install
@echo "Запуск бэкенда на :8000 и фронта на :8080..." nginx-print: ## Показать конфиг nginx (DOMAIN=домен), ничего не меняя
@trap 'kill 0' EXIT; \ deploy/nginx-site.sh --print --domain $(DOMAIN)
cd src/backend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8000 & \
cd src/frontend && $(CURDIR)/.venv/bin/python -m uvicorn main:app --reload --port 8080 & \ nginx-install: ## Сгенерировать, включить и перечитать конфиг nginx (DOMAIN=домен, с sudo)
wait sudo deploy/nginx-site.sh --domain $(DOMAIN)

572
README.md
View File

@@ -1,8 +1,19 @@
# Расписание # Расписание
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на
университета (файл xlsx), который скачивается, разбирается, превращается в JSON Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних
API и отображается на фронте. Деплой: `schedule.our-place.ru`. библиотек и отдаётся как JSON API и как сайт, который можно «установить» на
главный экран телефона (PWA).
Умеет:
- расписание по группам, преподавателям и аудиториям с разделением
числитель/знаменатель и бейджем текущей недели;
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
- совпадающие недели показываются одним списком, различающиеся — двумя
колонками, пустая — «Окно»;
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
- PWA: манифест, service worker, иконки — «на главный экран».
## Архитектура ## Архитектура
@@ -10,20 +21,358 @@ API и отображается на фронте. Деплой: `schedule.our-p
Яндекс.Диск (xlsx) Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке │ скачивание по публичной ссылке
▼ ▼
api (FastAPI), порт 8000, доступ только с localhost api — FastAPI, порт 8000
• скачивает и разбирает xlsx без сторонних библиотек • качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error • кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий • один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети) ▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка) │
frontend (FastAPI: статика + прокси), прод :8090, dev :8080 frontend — FastAPI, порт из .env (по умолчанию 8080)
• отдаёт index.html / app.js / style.css / картинки • отдаёт index.html / app.js / style.css / иконки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда) • /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲ ▲
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
``` ```
## Как устроено расписание в таблице Роли:
- **docker** изолирует оба сервиса, перезапускает их при падении
(`restart: unless-stopped`) и следит за здоровьем (healthcheck на
`/healthz`); frontend стартует, только когда api стал healthy.
- Оба порта публикуются **только на 127.0.0.1** (жёстко задано в
`docker-compose.yml`): из интернета контейнеры напрямую недоступны.
- **nginx** — единственная дверь из интернета: принимает https и проксирует на
фронт. Его конфиг генерирует `deploy/nginx-site.sh` (это делает установщик).
## Установка на сервер
### Docker (deploy.sh)
Скачайте zip с проектом, распакуйте и заполните `.env`:
```bash
unzip schedule.zip && cd schedule
cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию
sudo ./deploy.sh # собрать образы и запустить
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
```
`deploy.sh` проверяет, что Docker и `.env` на месте, собирает образы и поднимает
контейнеры. Обновление — тем же скриптом: `git pull && sudo ./deploy.sh`.
Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят
от Python в системе и обновляются одной командой.
### Без Docker (systemd)
```bash
sudo apt install -y python3 python3-venv python3-pip # Python 3.11+
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка)
```
Две службы systemd (`/etc/systemd/system/`), затем
`sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend`:
```ini
# schedule-api.service
[Unit]
Description=Schedule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/backend
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
# schedule-frontend.service
[Unit]
Description=Schedule frontend (FastAPI)
After=network-online.target schedule-api.service
Wants=network-online.target schedule-api.service
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/frontend
Environment=SCHEDULE_API=http://127.0.0.1:8000
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
```
Если службы работают не от владельца каталога проекта, выдайте пользователю
службы права на запись в `data/` (там админка хранит ссылку, интервал и историю
входов) или задайте свой путь в `SETTINGS_FILE`.
**nginx + сертификат** (общий шаг для обоих способов; `.env` уже должен быть):
```bash
sudo apt install -y nginx certbot
# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html
sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru
sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080
curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово
```
`deploy/nginx-site.sh` генерирует финальный конфиг (http → https, прокси на
`FRONTEND_PORT` из `.env`, таймауты под холодный кэш), включает его, проверяет
`nginx -t` и перечитывает nginx. Посмотреть конфиг, ничего не меняя:
`deploy/nginx-site.sh --print --domain sched.example.ru`.
### Обслуживание
- **Обновление**: Docker — `cd <каталог проекта> && sudo ./deploy.sh`;
система — заменить файлы проекта и
`sudo systemctl restart schedule-api schedule-frontend`.
- **Диагностика**: Docker — `sudo docker compose ps` и
`sudo docker logs api --tail 50`; система — `systemctl status schedule-api`
и `journalctl -u schedule-api -u schedule-frontend`. Состояние кэша —
`/api/status` (смотреть поле `error`).
- **Ротация логов контейнеров** — настроить сразу: без лимита логи растут
вечно и однажды забивают диск. `/etc/docker/daemon.json`:
```json
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
```
затем `sudo systemctl restart docker`.
- **Диск**: следить `df -h /`; раз в месяц
`sudo docker system prune -af && sudo journalctl --vacuum-size=200M`.
## Локальная разработка
Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker).
```bash
python3 --version # должен быть 3.11 или новее
cp .env.example .env # вписать SCHEDULE_LINK
make run # создаст .venv, поставит зависимости, поднимет оба сервиса
```
Открыть <http://127.0.0.1:8080> (API — <http://127.0.0.1:8000>). Остановить —
`Ctrl+C`; отвязать от терминала — `nohup make run &`.
Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по
интервалу.
Полезные команды (`make help` — весь список):
| Команда | Что делает |
|---|---|
| `make venv` | создать `.venv` и установить зависимости |
| `make run` (или `make dev`) | запустить API и сайт |
| `make run-api` / `make run-front` | запустить только один из сервисов |
| `make test` | прогнать тесты |
| `make nginx-print` / `make nginx-install` | показать / установить конфиг nginx |
Без make — то же самое руками:
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend
.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend
```
### Тесты
```bash
make test # 121 тест, сеть не нужна
```
## Зависимости
**Системные требования**
| Что | Зачем |
|---|---|
| Python 3.11+ | локальная разработка и режим «в систему» |
| Docker + docker compose v2 | режим Docker (опционально) |
**Python-пакеты** — закреплены в `requirements.txt` (рабочие) и
`requirements-dev.txt` (тесты):
| Пакет | Версия | Назначение |
|---|---|---|
| fastapi | 0.141.1 | веб-фреймворк обоих сервисов |
| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы |
| python-dotenv | 1.2.3 | чтение `.env` |
| requests | 2.34.2 | скачивание файла с Я.Диска и прокси к API |
| pytest | 9.1.1 | тесты — только dev |
| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для `TestClient` — только dev |
Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных
нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен
только тестам; модуль импортируется как `httpx2` — httpx перешёл под опеку
Pydantic и был переименован в августе 2026, `import httpx` больше не работает.
### Если установка не идёт
- Проверьте `python3 --version`: нужен 3.11 или новее. Если под `python3`
скрывается старая версия, создайте окружение нужной явно:
`python3.12 -m venv .venv`.
- Debian/Ubuntu: если `make venv` падает на создании окружения, доставьте
пакет venv: `sudo apt install python3-venv` (для нестандартной версии —
например, `python3.12-venv`).
### Если pip не находит пакеты: зеркала
Если pip настроен на корпоративное/национальное зеркало, оно может отставать
от PyPI — свежих пакетов там ещё нет. Диагностика и обход:
```bash
pip index versions httpx2 # есть ли пакет на текущем индексе
pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало
pip config set global.index-url <зеркало> # закрепить зеркало навсегда
```
Зеркала, проверенные 07.10.2026 (пакеты `httpx2`/`httpcore2` на месте;
URL подставлять как есть в `-i` / `index-url`):
| Зеркало | URL |
|---|---|
| PyPI, официальный | `https://pypi.org/simple` |
| TUNA (Tsinghua) | `https://pypi.tuna.tsinghua.edu.cn/simple` |
| Huawei Cloud | `https://mirrors.huaweicloud.com/repository/pypi/simple` |
| BFSU | `https://mirrors.bfsu.edu.cn/pypi/web/simple` |
| SJTU | `https://mirror.sjtu.edu.cn/pypi/web/simple` |
| USTC | `https://mirrors.ustc.edu.cn/pypi/simple` |
| SUSTech | `https://mirrors.sustech.edu.cn/pypi/simple` |
Примечания: Alibaba Cloud (`https://mirrors.aliyun.com/pypi/simple`) обычно
тоже свежий, но из части сетей недоступен; Яндекс-зеркало (`mirror.yandex.ru`)
PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов
надёжнее разово ставить с официального индекса.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | как часто перечитывать таблицу, минут |
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `ADMIN_PATH` | — | api+frontend | секретный путь админки; пусто = админка выключена |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | логин и пароль администратора |
| `ADMIN_MAX_ATTEMPTS` | `3` | api | сколько неудачных попыток входа допускается |
| `ADMIN_LOCKOUT_MINUTES` | `15` | api | на сколько минут блокировать вход после них |
| `SETTINGS_FILE` | `data/settings.json` | api | где хранить ссылку, интервал и историю входов |
| `API_PORT` | `8000` | docker | внешний порт api (публикуется на 127.0.0.1) |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (публикуется на 127.0.0.1) |
`.env` ищется вверх от каталога модуля: и backend, и frontend находят общий
`.env` в корне репозитория; `load_dotenv` не перезаписывает уже выставленные
переменные окружения.
## Админка
Необязательная страница для управления источником данных. Отдельного входа на
сайте нет: панель открывается по секретному пути `ADMIN_PATH`, а пока
`ADMIN_LOGIN`/`ADMIN_PASSWORD` пусты, админка выключена и наружу ничего не
торчит (маршруты `/api/admin/*` не регистрируются).
Включить: заполнить в `.env` четыре переменные и перезапустить службу.
```ini
ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный
ADMIN_LOGIN=admin
ADMIN_PASSWORD=<длинный пароль>
```
Что умеет:
- **посмотреть состояние**: сколько групп, когда обновлялось, дата файла на
Диске, изменился ли файл при последнем чтении (`sha256`), есть ли ошибки;
- **вставить новую ссылку** на таблицу: она проверяется скачиванием и разбором
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **задать интервал обновления** в минутах (`1…1440`);
- **обновить сейчас**: скачивает таблицу принудительно, не дожидаясь интервала
(по интервалу файл перечитывается, только если на Диске изменилась дата);
- **видеть входы**: последняя попытка (откуда, когда, успешна ли) и последний
успешный вход — и в панели, и в логе службы.
Защита входа: после `ADMIN_MAX_ATTEMPTS` неудач (по умолчанию 3) вход с этого
адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия —
подписанная cookie на 12 часов.
### Как защищён путь админки
- путь задаётся только в `.env` и больше нигде не хранится: ни в HTML, ни в
JSON, ни в JS — неавторизованный пользователь его нигде не увидит;
- кнопка «Админка» на главной показывается только вошедшему (сервер проверяет
cookie и добавляет в страницу лишь флаг, без пути) и ведёт на служебный
`/api/admin/goto`, который отвечает редиректом (302) на секретный путь —
поэтому путь не попадает в разметку и его не вычитает краулер;
- без валидной сессии `/api/admin/*` отвечает `401`, на неизвестных путях сайт
отдаёт обычный 404 — перебором путь не находится;
- страница панели отдаётся с заголовками `X-Robots-Tag: noindex, nofollow,
noarchive`, `Referrer-Policy: no-referrer` (путь не утечёт во внешние ссылки
через Referer), `X-Frame-Options: DENY` и `Cache-Control: no-store`;
- в `robots.txt` пути нет (там только `Disallow: /api/`) — иначе мы бы сами его
опубликовали; внешние ссылки в панели открываются с `rel="noopener noreferrer"`;
- при желании путь можно убрать и из логов nginx: добавить в конфиг сайта
`location = <ADMIN_PATH> { access_log off; }`.
Остаётся только история браузера у самого админа — это неизбежно (браузер должен
знать, куда идти), поэтому панель лучше открывать с личного устройства, а путь
держать в менеджере паролей.
Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE`
(по умолчанию `data/settings.json` в корне проекта; в docker — в томе
`schedule_data`). Если файл недоступен для записи, изменения живут в памяти до
перезапуска, а панель предупредит об этом.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force
schedule/
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
скрытые столбцы, красный текст, серийные даты
source.py скачивание файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error, force
settings.py хранилище настроек админки (ссылка, интервал, история входов)
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
admin.html страница админки (вне static: прямой доступ закрыт)
static/
index.html, app.js, style.css
manifest.json PWA: имя, иконки, standalone-режим
sw.js минимальный service worker (без кэша) для установки приложения
img/ логотипы и иконки приложения
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
deploy/
nginx-site.sh генерация и включение конфига nginx (ручная настройка)
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
fixtures.py общая тестовая таблица
test_lessons.py все форматы ячеек
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
test_admin.py админка: вход, блокировка, история, ссылка/интервал/force
test_settings.py хранилище настроек админки
test_deploy.py проверка, что образы копируют все нужные файлы
```
Данные админки (`data/settings.json`) — рабочие, в git не попадают.
## Как устроена таблица-источник
Лист «Бакалавры и магистры» — это сетка: Лист «Бакалавры и магистры» — это сетка:
@@ -32,9 +381,7 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
столбцы 2–3 даты «знаменателя» столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня) столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары) столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу, порядок столбцов = порядок столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
групп на сайте (скрытые столбцы пропускаются; имена вида
«ИВТ-11БО», «ПИЭ-13МО», «ИВТ-ТОП-11БО»)
``` ```
Каждый день занимает блок строк, и строки идут парами: «числитель» / Каждый день занимает блок строк, и строки идут парами: «числитель» /
@@ -69,7 +416,10 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
В одной ячейке может быть несколько занятий: они разделяются пустой строкой, В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
новым маркером «к.в.» или началом нового предмета сразу после строки новым маркером «к.в.» или началом нового предмета сразу после строки
аудитории/преподавателя. аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с
целой строки) от предмета оторвался только преподаватель и/или аудитория, этот
кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе
в расписании аудитории оставались бы одни группы.
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
`lesson = {marker, subject, teacher, room, note}`. `lesson = {marker, subject, teacher, room, note}`.
@@ -85,94 +435,29 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
- Длинные ссылки в карточке переносятся по служебным символам и каждые - Длинные ссылки в карточке переносятся по служебным символам и каждые
24 символа (`<wbr>`), поэтому карточка не вылезает за экран даже в старых 24 символа (`<wbr>`), поэтому карточка не вылезает за экран даже в старых
браузерах, где нет `overflow-wrap`. браузерах, где нет `overflow-wrap`.
- На вкладке «Аудитории» есть фильтры: **тип** (в карточке — формулировка из
таблицы, в фильтре близкие варианты объединены: «комп.класс» включает
«комп.класс Linux» и «компьютерный класс», «кабинет английского» —
«английский»; есть пункт «без типа»),
**вместимость** («не меньше N мест») и **оснащение** чекбоксами с выбором
условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное
запоминается в браузере, под списком видно «Показано N из M» и пояснения:
сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие
доступны только по отдельным дням.
- Там же переключатель «Занятость за день» / **«Свободные на паре»**: во
втором режиме выбирается пара и показываются только свободные аудитории —
вместе с фильтрами получается «свободная аудитория на 30 человек с
проектором в 3-ю пару».
- Приложение можно «установить» на главный экран (манифест + service - Приложение можно «установить» на главный экран (манифест + service
worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`.
## Админка
Отдельного входа на сайте нет: панель открывается по секретному пути из
`ADMIN_PATH` (например `/manage-7f3a91`), логин и пароль — `ADMIN_LOGIN` /
`ADMIN_PASSWORD`. Если эти переменные пусты, админка полностью выключена и
маршруты `/api/admin/*` не регистрируются (прод можно не трогать).
Что умеет администратор:
- **посмотреть текущую ссылку** и состояние кэша (обновлено, изменена,
ошибка, число групп);
- **вставить новую ссылку**: она проверяется скачиванием и разбором таблицы
до сохранения — нерабочая не применится, данные на сайте не пострадают;
- **принудительно обновить расписание** — вне интервала: кнопка перечитывает
таблицу всегда, не глядя на дату изменения файла;
- **задать интервал обновления** в минутах (`1…1440`).
Фоновое обновление по интервалу работает как раньше: сначала сверяется
`modified` файла, и если таблица не менялась — скачивания не происходит.
Принудительное обновление (кнопка) скачивает файл **всегда**, поэтому его и
стоит нажимать сразу после правок в таблице. В панели и в логе при этом видно,
изменилось ли содержимое файла: `sha256`, время последнего изменения и отметка
«изменился / без изменений». Если вы поправили таблицу, а после обновления
написано «без изменений» — значит публичная ссылка Яндекса отдаёт прежнюю
версию файла (бывает с задержкой), стоит повторить обновление через минуту.
Как это устроено:
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
(`HttpOnly`, `SameSite=Lax`, `Secure` при https), ключ — пароль админа,
срок 12 часов; неудачные попытки логируются;
- все действия — под `/api/admin/*`, без cookie отвечают `401`;
- ссылка и интервал хранятся в `SETTINGS_FILE` (в docker — том
`schedule_data`, `/data/settings.json`), поэтому переживают перезапуск;
если файл недоступен, настройки живут в памяти;
- страница панели отдаётся фронтендом по `ADMIN_PATH` с заголовком
`X-Robots-Tag: noindex`; путь нигде не публикуется и не попадает в `/`.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, ссылка, интервал, принудительное обновление
schedule/
xlsx.py чтение xlsx: sharedStrings, мержи, скрытые столбцы,
красный текст, серийные даты
source.py загрузка файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py листы → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error
settings.py хранилище настроек админки (ссылка, интервал) в JSON-файле
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
admin.html страница админки (вне static: прямой доступ закрыт)
static/
index.html, app.js, style.css
manifest.json PWA: имя, иконки, standalone-режим
sw.js минимальный service worker (без кэша) для установки приложения
img/ логотипы (owl.svg, owl-white.svg), иконки приложения
(app-icon.svg, icon-192/512.png, apple-touch-icon.png, favicon-32.png)
deploy/
nginx-dev.conf сайт тестового стенда (dev.schedule.our-place.ru)
README-dev.md как поднять тестовый стенд рядом с продом
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
fixtures.py общая тестовая таблица
test_lessons.py все форматы ячеек
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
```
## API ## API
Полный список методов отдаёт сам бэкенд: `GET /`. Полный список методов отдаёт сам бэкенд: `GET /`.
| Метод | Путь | Описание | | Метод | Путь | Описание |
|---|---|---| |---|---|---|
| GET | `/groups` | группы в том порядке, в каком идут столбцы в таблице | | GET | `/groups` | список групп (сначала БО, потом МО) |
| GET | `/schedule/{group}` | расписание группы с разбором на занятия | | GET | `/schedule/{group}` | расписание группы с разбором на занятия |
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день | | GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
| GET | `/today?group={group}` | расписание группы на сегодня | | GET | `/today?group={group}` | расписание группы на сегодня |
@@ -180,96 +465,25 @@ tests/
| GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) | | GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) |
| GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») | | GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») |
| GET | `/room/{label}` | занятость аудитории | | GET | `/room/{label}` | занятость аудитории |
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день | | GET | `/rooms-day?day={день}` | занятость всех аудиторий за день (+ атрибуты) |
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории | | GET | `/rooms-free?day={день}&period={N}` | свободные аудитории (+ атрибуты) |
| GET | `/week` | недели семестра и текущая чётность | | GET | `/week` | недели семестра и текущая чётность |
| GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) | | GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) |
| GET | `/healthz` | живость процесса (не обращается к таблице) | | GET | `/healthz` | живость процесса (не обращается к таблице) |
В ответах `rooms-day` и `rooms-free` по каждой аудитории, кроме `room` и `info`
(описание как в таблице), идут разобранные атрибуты: `type` (тип фразой из
таблицы: «комп.класс», «кабинет английского», «лекционная»…) и `type_group`
(тот же тип, но близкие формулировки сведены к одной — «комп.класс»,
«комп.класс Linux» и «компьютерный класс» дают группу «комп.класс»),
`capacity`
(мест; для компьютерных классов — рабочие места; `null`, если в таблице нет
данных), `equipment` (список: «ПК», «Проектор», «Интерактивная панель»,
«Телевизор», «Маркер», «Меловая доска») и `available_days` (дни, если в
описании указано «только четверг, пятница»). Аудитории с ограничением по дням
не попадают в `/rooms-free` для остальных дней.
Фронт проксирует только то, что использует, под префиксом `/api/...`: Фронт проксирует только то, что использует, под префиксом `/api/...`:
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`, `groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`. `room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде. Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_INTERVAL_MINUTES` | `15` | api | интервал обновления, мин (правится в админке) |
| `SCHEDULE_CACHE_TTL` | `900` | api | устаревшее: интервал в секундах (если не задан предыдущий) |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `ADMIN_PATH` | — | api+frontend | секретный путь панели (пусто = админка выключена) |
| `ADMIN_LOGIN` / `ADMIN_PASSWORD` | — | api | учётные данные администратора |
| `SETTINGS_FILE` | `/data/settings.json` | api | где хранить ссылку и интервал |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `API_PORT` | `8000` | docker | внешний порт api |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
`.env` ищется вверх от каталога модуля (backend — до корня репозитория);
`load_dotenv` не перезаписывает уже выставленные переменные окружения.
## Запуск
### Продакшен (Docker)
```bash
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
./deploy.sh # = docker compose build && up -d
```
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
### Разработка
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
make dev # api :8000 и фронт :8080, оба с --reload
```
### Тесты
```bash
make test # 104 теста, сеть не нужна
```
## Развёртывание на сервере
Код хранится в Gitea (`git.our-place.ru`, web через nginx, git-ssh на порту
2222) и живёт в `/opt/schedule`:
```bash
# первый запуск
cd /opt/schedule
# .env: SCHEDULE_LINK=... , API_PORT=127.0.0.1:8000, FRONTEND_PORT=127.0.0.1:8090
sudo docker compose up -d --build
# обновление
cd /opt/schedule && git pull && sudo docker compose up -d --build
```
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback.
### Тестовый стенд
Новые штуки обкатываются на отдельном стенде `dev.schedule.our-place.ru`
(ветка `admin-panel`), прод при этом не меняется: другой каталог
(`/opt/schedule-dev`), другие порты (8001/8091), свой том настроек.
Пошаговая инструкция — `deploy/README-dev.md`, конфиг nginx —
`deploy/nginx-dev.conf`.
## Замечания
- Принудительное обновление и смена ссылки живут только в админке
(`/api/admin/*` под cookie-сессией): публичного `force` в API нет.
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
api его придётся выносить в общее хранилище (настройки админки уже в файле).
- `ADMIN_PATH` защищает только от «случайного» обнаружения панели; реальная
защита — логин и пароль. Стоит держать их длинными и не публиковать путь.
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
«нечётное число строк, недели могли сдвинуться».

View File

@@ -1,87 +0,0 @@
# Тестовый стенд (dev.schedule.our-place.ru)
Отдельный стенд для обкатки админки: свой каталог, свои порты, своя база
настроек. Прод (`/opt/schedule`, `schedule.our-place.ru`) не трогаем — он
продолжает работать как раньше.
Ветка: `admin-panel`.
## 1. Код
```bash
sudo mkdir -p /opt/schedule-dev && sudo chown "$USER" /opt/schedule-dev
git clone -b admin-panel <адрес репозитория> /opt/schedule-dev
```
Адрес — тот же, что у прода: если прод-клон забирает обновления по SSH,
берите SSH-адрес (`ssh://git@git.our-place.ru:2222/Mermutef/schedule.git`),
если по HTTPS — HTTPS-адрес с токеном:
```bash
git clone -b admin-panel https://Mermutef:<токен>@git.our-place.ru/Mermutef/schedule.git /opt/schedule-dev
```
## 2. .env
Скопировать `.env.example` в `/opt/schedule-dev/.env` и заполнить:
```ini
SCHEDULE_LINK=https://disk.yandex.ru/i/...
SCHEDULE_INTERVAL_MINUTES=15
SCHEDULE_RETRY_AFTER=60
ADMIN_PATH=/manage-<секретная-часть>
ADMIN_LOGIN=<логин>
ADMIN_PASSWORD=<пароль>
API_PORT=127.0.0.1:8001
FRONTEND_PORT=127.0.0.1:8091
SCHEDULE_API=http://api:8000
SETTINGS_FILE=/data/settings.json
```
Порты 8001/8091 выбраны, чтобы не пересечься с продом (8000/8090).
`ADMIN_PATH` — секретный путь страницы админки: его нужно знать, чтобы
попасть в панель; на сайте ссылки на него нет.
## 3. Запуск
```bash
cd /opt/schedule-dev
sudo docker compose up -d --build
sudo docker compose ps
```
Настройки админки лежат в томе `schedule-dev_schedule_data` и переживают
пересборку; посмотреть можно так:
```bash
sudo docker run --rm -v schedule-dev_schedule_data:/data alpine cat /data/settings.json
```
## 4. nginx и сертификат
В `deploy/nginx-dev.conf` порт `proxy_pass` должен совпадать с `FRONTEND_PORT`
из `.env` (по умолчанию 8091; если порт меняли — поправьте и в конфиге).
```bash
sudo cp /opt/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/dev.schedule.our-place.ru
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d dev.schedule.our-place.ru
```
## 5. Проверка
```bash
curl -sI https://dev.schedule.our-place.ru/ | head -3 # 200
curl -s -o /dev/null -w '%{http_code}\n' https://dev.schedule.our-place.ru/api/admin/state # 401 (нужен вход)
```
Дальше — открыть `https://dev.schedule.our-place.ru/<ADMIN_PATH>` в браузере,
войти логином/паролем из `.env` и проверить: смена ссылки, интервал,
«Обновить сейчас».
## Обновление стенда
```bash
cd /opt/schedule-dev && git pull && sudo docker compose up -d --build
```

View File

@@ -1,47 +0,0 @@
# Тестовый стенд dev.schedule.our-place.ru
#
# Ставится рядом с прод-сайтом schedule.our-place.ru (тот не трогаем):
# отдельный каталог /opt/schedule-dev, отдельные порты, отдельная база настроек.
#
# sudo cp ~/schedule-dev/deploy/nginx-dev.conf /etc/nginx/sites-available/dev.schedule.our-place.ru
# sudo ln -sf /etc/nginx/sites-available/dev.schedule.our-place.ru /etc/nginx/sites-enabled/
# sudo certbot --nginx -d dev.schedule.our-place.ru
# sudo nginx -t && sudo systemctl reload nginx
server {
listen 80;
listen [::]:80;
server_name dev.schedule.our-place.ru;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name dev.schedule.our-place.ru;
ssl_certificate /etc/letsencrypt/live/dev.schedule.our-place.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev.schedule.our-place.ru/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 8m;
location / {
# порт должен совпадать с FRONTEND_PORT из .env стенда
proxy_pass http://127.0.0.1:8091;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# принудительное обновление расписания из админки — до ~2 минут
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}

123
deploy/nginx-site.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Генерирует и включает конфиг nginx для сайта расписания.
#
# SSL обеспечивает сертификат родительского домена (обычно wildcard *.example.ru):
# отдельный certbot для поддомена не нужен — достаточно имени сертификата.
# Список имён: sudo ls /etc/letsencrypt/live/
#
# Имя сертификата берётся из --cert, иначе — родительский домен от --domain
# (sched.example.ru -> example.ru). Порт фронта берётся из --port, иначе из
# FRONTEND_PORT файла .env в корне проекта, иначе 8080.
#
# Примеры:
# deploy/nginx-site.sh --print --domain sched.example.ru # показать конфиг
# sudo deploy/nginx-site.sh --domain sched.example.ru # поставить и перечитать nginx
# sudo deploy/nginx-site.sh --domain sched.example.ru --cert example.ru
set -euo pipefail
usage() {
grep '^#' "$0" | sed 's/^# \{0,1\}//' | tail -n +2
}
die() { echo "Ошибка: $*" >&2; exit 2; }
DOMAIN=""
CERT_NAME=""
FRONTEND_PORT=""
PRINT=0
while [[ $# -gt 0 ]]; do
case "$1" in
--domain) DOMAIN="$2"; shift 2 ;;
--cert) CERT_NAME="$2"; shift 2 ;;
--port) FRONTEND_PORT="$2"; shift 2 ;;
--print) PRINT=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Неизвестный аргумент: $1 (см. --help)" >&2; exit 2 ;;
esac
done
[[ -n $DOMAIN ]] || die "Укажите домен: --domain sched.example.ru (см. --help)"
[[ $DOMAIN =~ ^[a-zA-Z0-9.-]+$ ]] || die "Кривое имя домена: $DOMAIN"
if [[ -z $CERT_NAME ]]; then
CERT_NAME="${DOMAIN#*.}" # sched.example.ru -> example.ru (wildcard родителя)
fi
[[ $CERT_NAME =~ ^[a-zA-Z0-9.-]+$ ]] || { echo "Кривое имя сертификата: $CERT_NAME" >&2; exit 2; }
if [[ -z $FRONTEND_PORT ]]; then
# FRONTEND_PORT из .env; значение вида «127.0.0.1:8090» трактуем как «порт после двоеточия»
FRONTEND_PORT=$(
grep -E '^FRONTEND_PORT=' .env 2>/dev/null | tail -1 | cut -d= -f2- \
| tr -d ' "' | awk -F: '{print $NF}'
) || FRONTEND_PORT=""
fi
FRONTEND_PORT=${FRONTEND_PORT:-8080}
[[ $FRONTEND_PORT =~ ^[0-9]+$ ]] || { echo "Не разобрал FRONTEND_PORT из .env, задайте --port" >&2; exit 2; }
read -r -d '' CONFIG <<'TEMPLATE' || true
# Сгенерировано deploy/nginx-site.sh — правки вносить в скрипт, не сюда.
server {
listen 80;
listen [::]:80;
server_name __DOMAIN__;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name __DOMAIN__;
# сертификат родительского домена (обычно wildcard), обновляет certbot родителя
ssl_certificate /etc/letsencrypt/live/__CERT__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__CERT__/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 8m;
location / {
# порт FRONTEND_PORT из .env стенда
proxy_pass http://127.0.0.1:__PORT__;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# холодный кэш: бэкенд качает таблицу, до ~2 минут
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}
TEMPLATE
CONFIG=${CONFIG//__DOMAIN__/$DOMAIN}
CONFIG=${CONFIG//__CERT__/$CERT_NAME}
CONFIG=${CONFIG//__PORT__/$FRONTEND_PORT}
if [[ $PRINT == 1 ]]; then
printf '%s\n' "$CONFIG"
exit 0
fi
if [[ $EUID -ne 0 ]]; then
echo "Установка требует root: sudo deploy/nginx-site.sh (посмотреть конфиг — deploy/nginx-site.sh --print)" >&2
exit 1
fi
DEST="/etc/nginx/sites-available/${DOMAIN}"
printf '%s\n' "$CONFIG" > "$DEST"
ln -sf "$DEST" "/etc/nginx/sites-enabled/${DOMAIN}"
if nginx -t 2>&1; then
systemctl reload nginx
echo "Готово: https://${DOMAIN} -> 127.0.0.1:${FRONTEND_PORT} (сертификат ${CERT_NAME})"
else
echo "nginx -t не прошёл: конфиг записан в ${DEST} и включён, но nginx НЕ перечитан. Разберитесь и выполните: sudo nginx -t && sudo systemctl reload nginx" >&2
exit 1
fi

View File

@@ -5,19 +5,22 @@ services:
target: api target: api
environment: environment:
SCHEDULE_LINK: ${SCHEDULE_LINK} SCHEDULE_LINK: ${SCHEDULE_LINK}
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15}
SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900} SCHEDULE_CACHE_TTL: ${SCHEDULE_CACHE_TTL:-900}
SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60} SCHEDULE_RETRY_AFTER: ${SCHEDULE_RETRY_AFTER:-60}
# админка: секретный путь панели и учётные данные (пусто = выключена) # админка: пусто = выключена (страница и /api/admin/* не появятся)
ADMIN_PATH: ${ADMIN_PATH:-} ADMIN_PATH: ${ADMIN_PATH:-}
ADMIN_LOGIN: ${ADMIN_LOGIN:-} ADMIN_LOGIN: ${ADMIN_LOGIN:-}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-}
SCHEDULE_INTERVAL_MINUTES: ${SCHEDULE_INTERVAL_MINUTES:-15} ADMIN_MAX_ATTEMPTS: ${ADMIN_MAX_ATTEMPTS:-3}
# настройки админки переживают пересборку благодаря тому /data ADMIN_LOCKOUT_MINUTES: ${ADMIN_LOCKOUT_MINUTES:-15}
# настройки админки (ссылка, интервал, история входов) живут в томе
SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json} SETTINGS_FILE: ${SETTINGS_FILE:-/data/settings.json}
volumes: volumes:
- schedule_data:/data - schedule_data:/data
ports: ports:
- "${API_PORT:-8000}:8000" # api нужен только фронтенду внутри docker-сети; наружу — 127.0.0.1 для отладки
- "127.0.0.1:${API_PORT:-8000}:8000"
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=5)"]
@@ -31,10 +34,13 @@ services:
context: . context: .
target: frontend target: frontend
environment: environment:
# внутри compose api доступен по имени сервиса; менять не нужно
SCHEDULE_API: ${SCHEDULE_API:-http://api:8000} SCHEDULE_API: ${SCHEDULE_API:-http://api:8000}
# страница админки отдаётся фронтендом по этому пути
ADMIN_PATH: ${ADMIN_PATH:-} ADMIN_PATH: ${ADMIN_PATH:-}
ports: ports:
- "${FRONTEND_PORT:-8080}:8080" # фронт обслуживает nginx; наружу напрямую не смотрит
- "127.0.0.1:${FRONTEND_PORT:-8080}:8080"
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
api: api:

View File

@@ -1,3 +1,2 @@
-r requirements.txt
pytest==9.1.1 pytest==9.1.1
httpx2==2.12.0 httpx2==2.12.0

View File

@@ -1,15 +1,18 @@
"""Админка расписания. """Админка расписания.
Отдельного входа на сайте нет: панель открывается по секретному пути из Отдельного входа на сайте нет: панель открывается по секретному пути из
ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. После ADMIN_PATH, логин и пароль задаются в ADMIN_LOGIN/ADMIN_PASSWORD. Если они
входа ставим подписанную HMAC-cookie (ключ — пароль), поэтому состояние пусты, админка выключена и маршруты /api/admin/* не регистрируются.
между запросами не хранится.
После нескольких неудачных попыток вход блокируется на время из
ADMIN_LOCKOUT_MINUTES (по умолчанию: 3 попытки и 15 минут). Каждая попытка
запоминается (откуда, когда, успешна ли) — это видно в панели и пишется в лог.
Что умеет администратор: Что умеет администратор:
GET /api/admin/state — текущая ссылка, интервал, состояние кэша; GET /api/admin/state — состояние кэша, ссылка, интервал, история входов;
POST /api/admin/link — проверить и поставить новую ссылку; POST /api/admin/link — проверить и поставить новую ссылку на таблицу;
POST /api/admin/interval — интервал обновления в минутах; POST /api/admin/interval — интервал обновления в минутах;
POST /api/admin/refresh — принудительно обновить расписание сейчас. POST /api/admin/refresh — принудительно перечитать таблицу сейчас.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,62 +25,33 @@ import logging
import threading import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from schedule import ScheduleCache, ScheduleError from schedule import ScheduleCache, ScheduleError
from schedule.settings import SettingsStore
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
COOKIE_NAME = "schedule_admin" COOKIE_NAME = "schedule_admin"
SESSION_TTL_SECONDS = 12 * 3600 SESSION_TTL_SECONDS = 12 * 3600
# защита от перебора пароля: не больше N неудач с одного адреса за окно DEFAULT_MAX_ATTEMPTS = 3
LOGIN_MAX_FAILURES = 10 DEFAULT_LOCKOUT_MINUTES = 15
LOGIN_WINDOW_SECONDS = 300.0
class LoginThrottle:
"""Простейший счётчик неудачных попыток входа по IP."""
def __init__(
self,
max_failures: int = LOGIN_MAX_FAILURES,
window: float = LOGIN_WINDOW_SECONDS,
) -> None:
self._max = max_failures
self._window = window
self._lock = threading.Lock()
self._failures: dict[str, list[float]] = {}
def _recent(self, key: str, now: float) -> list[float]:
return [ts for ts in self._failures.get(key, []) if now - ts < self._window]
def blocked(self, key: str) -> bool:
now = time.time()
with self._lock:
recent = self._recent(key, now)
self._failures[key] = recent
return len(recent) >= self._max
def register_failure(self, key: str) -> None:
now = time.time()
with self._lock:
self._failures[key] = [*self._recent(key, now), now]
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
@dataclass @dataclass
class AdminConfig: class AdminConfig:
"""Настройки админки: секретный путь и учётные данные из .env.""" """Настройки админки: секретный путь, учётные данные и защита от перебора."""
path: str = "" path: str = ""
login: str = "" login: str = ""
password: str = "" password: str = ""
max_attempts: int = DEFAULT_MAX_ATTEMPTS
lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES
@property @property
def enabled(self) -> bool: def enabled(self) -> bool:
@@ -98,9 +72,7 @@ def _same(left: str, right: str) -> bool:
def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str: def make_token(login: str, password: str, ttl: int = SESSION_TTL_SECONDS) -> str:
payload = json.dumps( payload = json.dumps({"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")).encode()
{"sub": login, "exp": int(time.time()) + ttl}, separators=(",", ":")
).encode()
sig = hmac.new(password.encode(), payload, hashlib.sha256).digest() sig = hmac.new(password.encode(), payload, hashlib.sha256).digest()
return f"{_b64e(payload)}.{_b64e(sig)}" return f"{_b64e(payload)}.{_b64e(sig)}"
@@ -117,6 +89,46 @@ def verify_token(token: str, password: str) -> bool:
return False return False
class LoginThrottle:
"""Счётчик неудачных попыток: N промахов — пауза на lockout_minutes."""
def __init__(self, max_attempts: int = DEFAULT_MAX_ATTEMPTS, lockout_minutes: int = DEFAULT_LOCKOUT_MINUTES) -> None:
self.max_attempts = max(1, int(max_attempts))
self.lockout_seconds = max(1, int(lockout_minutes)) * 60
self._lock = threading.Lock()
self._failures: dict[str, int] = {}
self._blocked_until: dict[str, float] = {}
def locked_until(self, key: str) -> float | None:
with self._lock:
until = self._blocked_until.get(key)
if until is None:
return None
if until <= time.time():
self._blocked_until.pop(key, None)
self._failures.pop(key, None)
return None
return until
def attempts_left(self, key: str) -> int:
with self._lock:
return max(0, self.max_attempts - self._failures.get(key, 0))
def register_failure(self, key: str) -> None:
with self._lock:
failures = self._failures.get(key, 0) + 1
if failures >= self.max_attempts:
self._blocked_until[key] = time.time() + self.lockout_seconds
self._failures[key] = 0
else:
self._failures[key] = failures
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
self._blocked_until.pop(key, None)
class LoginBody(BaseModel): class LoginBody(BaseModel):
login: str login: str
password: str password: str
@@ -130,9 +142,30 @@ class IntervalBody(BaseModel):
interval_minutes: int interval_minutes: int
def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter: def _client_ip(request: Request) -> str:
"""Реальный адрес клиента: за nginx он приходит в X-Forwarded-For."""
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[0].strip() or "неизвестно"
return request.client.host if request.client else "неизвестно"
def _human_dt(iso: str | None) -> str:
if not iso:
return "—"
try:
return datetime.fromisoformat(iso).strftime("%d.%m.%Y %H:%M:%S")
except ValueError:
return iso
def create_admin_router(
cache: ScheduleCache,
settings: SettingsStore | None,
config: AdminConfig,
) -> APIRouter:
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
throttle = LoginThrottle() throttle = LoginThrottle(config.max_attempts, config.lockout_minutes)
def require_auth(request: Request) -> None: def require_auth(request: Request) -> None:
token = request.cookies.get(COOKIE_NAME, "") token = request.cookies.get(COOKIE_NAME, "")
@@ -143,24 +176,55 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme proto = request.headers.get("x-forwarded-proto", "") or request.url.scheme
return proto.split(",")[0].strip() == "https" return proto.split(",")[0].strip() == "https"
def _client_key(request: Request) -> str: def _remember(ip: str, ok: bool) -> None:
return request.client.host if request.client else "unknown" if settings is not None:
settings.record_login(ip, ok)
@router.post("/login") @router.post("/login")
def login(body: LoginBody, request: Request, response: Response) -> dict: def login(body: LoginBody, request: Request, response: Response) -> dict:
key = _client_key(request) ip = _client_ip(request)
if throttle.blocked(key): until = throttle.locked_until(ip)
log.warning("Вход в админку: слишком много неудачных попыток с %s", key) if until is not None:
minutes = max(1, round((until - time.time()) / 60))
log.warning("Вход в админку заблокирован (%s), осталось ~%d мин", ip, minutes)
raise HTTPException( raise HTTPException(
status_code=429, detail="Слишком много попыток входа, попробуйте позже" status_code=429,
detail=f"Слишком много неудачных попыток. Вход заблокирован ещё на ~{minutes} мин.",
) )
ok_login = _same(body.login.strip(), config.login) ok_login = _same(body.login.strip(), config.login)
ok_password = _same(body.password, config.password) ok_password = _same(body.password, config.password)
if not (ok_login and ok_password): if not (ok_login and ok_password):
throttle.register_failure(key) throttle.register_failure(ip)
log.warning("Неудачная попытка входа в админку (логин %r)", body.login[:40]) _remember(ip, False)
raise HTTPException(status_code=401, detail="Неверный логин или пароль") left = throttle.attempts_left(ip)
throttle.reset(key) if throttle.locked_until(ip) is not None:
log.warning(
"Неудачная попытка входа (%s): исчерпаны попытки, пауза %d мин",
ip,
config.lockout_minutes,
)
raise HTTPException(
status_code=429,
detail=f"Неверный логин или пароль. Попытки исчерпаны — вход заблокирован на {config.lockout_minutes} мин.",
)
log.warning("Неудачная попытка входа (%s), осталось попыток: %d", ip, left)
raise HTTPException(
status_code=401,
detail=f"Неверный логин или пароль. Осталось попыток: {left}.",
)
throttle.reset(ip)
previous = settings.last_attempt if settings is not None else None
_remember(ip, True)
if previous:
log.info(
"Успешный вход в админку (%s). Предыдущая попытка: %s, %s, %s",
ip,
_human_dt(previous.get("at")),
previous.get("ip"),
"успешная" if previous.get("ok") else "неудачная",
)
else:
log.info("Успешный вход в админку (%s)", ip)
response.set_cookie( response.set_cookie(
COOKIE_NAME, COOKIE_NAME,
make_token(config.login, config.password), make_token(config.login, config.password),
@@ -170,7 +234,6 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
secure=_secure(request), secure=_secure(request),
path="/", path="/",
) )
log.info("Вход в админку выполнен")
return {"ok": True} return {"ok": True}
@router.post("/logout") @router.post("/logout")
@@ -179,8 +242,33 @@ def create_admin_router(cache: ScheduleCache, config: AdminConfig) -> APIRouter:
return {"ok": True} return {"ok": True}
@router.get("/state", dependencies=[Depends(require_auth)]) @router.get("/state", dependencies=[Depends(require_auth)])
def state() -> dict: def state(request: Request) -> dict:
return cache.state() ip = _client_ip(request)
until = throttle.locked_until(ip)
payload = cache.state()
# путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в
# HTML — переход в панель идёт через /api/admin/goto (редирект)
payload["throttle"] = {
"max_attempts": config.max_attempts,
"lockout_minutes": config.lockout_minutes,
"attempts_left": throttle.attempts_left(ip),
"locked_until": datetime.fromtimestamp(until).astimezone().isoformat() if until else None,
}
return payload
@router.get("/goto", dependencies=[Depends(require_auth)])
def goto() -> Response:
"""Переход в панель.
Так секретный путь не хранится в разметке главной страницы: кнопка
«Админка» дёргает этот адрес, и браузер уже получает редирект.
Без валидной сессии ответ — 401, то есть ботам тут ничего не видно.
"""
if not config.path:
raise HTTPException(status_code=500, detail="ADMIN_PATH не задан")
return RedirectResponse(
url=config.path, status_code=302, headers={"Cache-Control": "no-store"}
)
@router.post("/refresh", dependencies=[Depends(require_auth)]) @router.post("/refresh", dependencies=[Depends(require_auth)])
def refresh() -> dict: def refresh() -> dict:

View File

@@ -1,4 +1,9 @@
"""API-приложение расписания (FastAPI).""" """API-приложение расписания (FastAPI).
Кроме публичных данных здесь живёт админка (backend/admin.py): она включается
только если заданы ADMIN_LOGIN и ADMIN_PASSWORD, и открывается по секретному
пути ADMIN_PATH. Все параметры — переменные окружения, см. .env.example.
"""
from __future__ import annotations from __future__ import annotations
@@ -22,7 +27,7 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
def _find_dotenv() -> Path | None: def _find_dotenv() -> Path | None:
"""Ищем .env вверх от каталога модуля (локальная разработка).""" """Ищем .env вверх от каталога модуля (общий .env в корне проекта)."""
path = Path(__file__).resolve().parent path = Path(__file__).resolve().parent
for _ in range(5): for _ in range(5):
env = path / ".env" env = path / ".env"
@@ -49,12 +54,25 @@ def _normalize_admin_path(raw: str | None) -> str:
return path.rstrip("/") or "/" return path.rstrip("/") or "/"
def _int_env(name: str, default: int) -> int:
raw = (os.getenv(name) or "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
log.warning("%s=%r не число, беру %d", name, raw, default)
return default
SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip() SCHEDULE_LINK = (os.getenv("SCHEDULE_LINK") or "").strip()
RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60")) RETRY_AFTER = float(os.getenv("SCHEDULE_RETRY_AFTER", "60"))
SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip() SETTINGS_FILE = (os.getenv("SETTINGS_FILE") or "").strip()
ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH")) ADMIN_PATH = _normalize_admin_path(os.getenv("ADMIN_PATH"))
ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip() ADMIN_LOGIN = (os.getenv("ADMIN_LOGIN") or "").strip()
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or "" ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD") or ""
ADMIN_MAX_ATTEMPTS = _int_env("ADMIN_MAX_ATTEMPTS", 3)
ADMIN_LOCKOUT_MINUTES = _int_env("ADMIN_LOCKOUT_MINUTES", 15)
def default_settings() -> SettingsStore: def default_settings() -> SettingsStore:
@@ -90,10 +108,17 @@ def _day_arg(day: str) -> str:
def create_app( def create_app(
cache: ScheduleCache | None = None, cache: ScheduleCache | None = None,
admin: AdminConfig | None = None, admin: AdminConfig | None = None,
settings: SettingsStore | None = None,
) -> FastAPI: ) -> FastAPI:
cache = cache or default_cache() if cache is None:
settings = settings or default_settings()
cache = ScheduleCache(settings=settings, retry_after=RETRY_AFTER)
admin = admin if admin is not None else AdminConfig( admin = admin if admin is not None else AdminConfig(
path=ADMIN_PATH, login=ADMIN_LOGIN, password=ADMIN_PASSWORD path=ADMIN_PATH,
login=ADMIN_LOGIN,
password=ADMIN_PASSWORD,
max_attempts=ADMIN_MAX_ATTEMPTS,
lockout_minutes=ADMIN_LOCKOUT_MINUTES,
) )
@asynccontextmanager @asynccontextmanager
@@ -110,13 +135,13 @@ def create_app(
app.state.admin = admin app.state.admin = admin
if admin.enabled: if admin.enabled:
app.include_router(create_admin_router(cache, admin)) app.include_router(create_admin_router(cache, settings, admin))
if admin.path: if admin.path:
log.info("Админка включена (секретный путь задан)") log.info("Админка включена (путь задан в ADMIN_PATH)")
else: else:
log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна") log.warning("Админка включена, но ADMIN_PATH не задан — страница панели недоступна")
else: else:
log.info("Админка выключена: не заданы ADMIN_LOGIN/ADMIN_PASSWORD") log.info("Админка выключена (не заданы ADMIN_LOGIN/ADMIN_PASSWORD)")
def _data() -> tuple[dict, list[dict]]: def _data() -> tuple[dict, list[dict]]:
try: try:
@@ -148,7 +173,7 @@ def create_app(
return { return {
"title": "Расписание", "title": "Расписание",
"endpoints": { "endpoints": {
"/groups": "Список групп", "/groups": "Список групп (в порядке столбцов таблицы)",
"/schedule/{group}": "Расписание группы", "/schedule/{group}": "Расписание группы",
"/schedule/{group}/{day}": "Расписание группы на день", "/schedule/{group}/{day}": "Расписание группы на день",
"/today?group={group}": "Расписание группы на сегодня", "/today?group={group}": "Расписание группы на сегодня",
@@ -239,10 +264,14 @@ def create_app(
room_info = cache.room_info room_info = cache.room_info
free = [] free = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)): for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {})
# аудитория может быть доступна только по отдельным дням («402 четверг пятница»)
if info.get("available_days") and normalized not in info["available_days"]:
continue
occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {}) occupied = rooms_map.get(label, {}).get(normalized, {}).get(str(period), {})
busy = bool((occupied.get("ч") or []) or (occupied.get("з") or [])) busy = bool((occupied.get("ч") or []) or (occupied.get("з") or []))
if not busy: if not busy:
free.append({"room": label, "info": room_info.get(label, {}).get("info", "")}) free.append(parsing.room_payload(label, info))
return free return free
@app.get("/rooms-day") @app.get("/rooms-day")
@@ -253,7 +282,7 @@ def create_app(
day_times = cache.get_day_times().get(normalized, {}) day_times = cache.get_day_times().get(normalized, {})
rooms_out = [] rooms_out = []
for label in parsing.sort_rooms(set(rooms_map) | set(room_info)): for label in parsing.sort_rooms(set(rooms_map) | set(room_info)):
info = room_info.get(label, {}).get("info", "") info = room_info.get(label, {})
occupied_day = rooms_map.get(label, {}).get(normalized, {}) occupied_day = rooms_map.get(label, {}).get(normalized, {})
periods = {} periods = {}
for period in sorted(set(occupied_day) | set(day_times), key=int): for period in sorted(set(occupied_day) | set(day_times), key=int):
@@ -263,7 +292,9 @@ def create_app(
"ч": slot.get("ч") or [], "ч": slot.get("ч") or [],
"з": slot.get("з") or [], "з": slot.get("з") or [],
} }
rooms_out.append({"room": label, "info": info, "periods": periods}) room = parsing.room_payload(label, info)
room["periods"] = periods
rooms_out.append(room)
return {"day": normalized, "rooms": rooms_out} return {"day": normalized, "rooms": rooms_out}
@app.get("/week") @app.get("/week")

View File

@@ -9,8 +9,7 @@
удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд удачное состояние и повторяем попытку не чаще, чем раз в retry_after секунд
(вместо скачивания на каждый запрос); (вместо скачивания на каждый запрос);
- настройки администратора (ссылка, интервал) живут в SettingsStore и - настройки администратора (ссылка, интервал) живут в SettingsStore и
применяются на лету: смена ссылки проверяется до сохранения, принудительное применяются на лету: смена ссылки проверяется до сохранения.
обновление идёт вне расписания.
""" """
from __future__ import annotations from __future__ import annotations
@@ -101,22 +100,24 @@ class ScheduleCache:
return self._day_times return self._day_times
def status(self) -> dict: def status(self) -> dict:
"""Состояние кэша (публичный /status): без ссылки и без админских данных."""
with self._lock: with self._lock:
return { return self._status()
"cache_ttl_seconds": self._ttl,
"interval_minutes": self.interval_minutes,
"last_updated": self._iso(self._loaded_at),
"table_modified": self._modified,
"error": self._error,
}
def state(self) -> dict: def state(self) -> dict:
"""Полное состояние для админки.""" """Состояние для админки: то же плюс текущая ссылка и настройки."""
with self._lock: with self._lock:
return self._state() state = self._status()
state["link"] = self._link
if self._settings is not None:
state["settings_file"] = str(self._settings.path) if self._settings.path else None
state["settings_save_error"] = self._settings.save_error
state["last_attempt"] = self._settings.last_attempt
state["last_success"] = self._settings.last_success
return state
def warmup(self) -> None: def warmup(self) -> None:
"""Первый загруз данных на старте приложения (ошибка не фатальна).""" """Первая загрузка данных на старте приложения (ошибка не фатальна)."""
try: try:
self.get() self.get()
except ScheduleError as exc: except ScheduleError as exc:
@@ -125,7 +126,7 @@ class ScheduleCache:
# --- действия администратора ------------------------------------------------ # --- действия администратора ------------------------------------------------
def force_reload(self) -> dict: def force_reload(self) -> dict:
"""Принудительно обновить расписание вне интервала (кнопка в админке).""" """Принудительно обновить расписание: скачиваем таблицу в любом случае."""
with self._lock: with self._lock:
now = time.time() now = time.time()
modified = fetch_table_modified(self._link) if self._link else None modified = fetch_table_modified(self._link) if self._link else None
@@ -136,7 +137,7 @@ class ScheduleCache:
self._last_failed = now self._last_failed = now
log.warning("Принудительное обновление не удалось: %s", exc) log.warning("Принудительное обновление не удалось: %s", exc)
raise raise
return self._state() return self.state()
def apply_link(self, link: str) -> dict: def apply_link(self, link: str) -> dict:
"""Проверить новую ссылку и, если она рабочая, переключить кэш на неё.""" """Проверить новую ссылку и, если она рабочая, переключить кэш на неё."""
@@ -161,11 +162,11 @@ class ScheduleCache:
raise ScheduleError(f"Ссылка не подошла: {exc}") from exc raise ScheduleError(f"Ссылка не подошла: {exc}") from exc
if self._settings is not None: if self._settings is not None:
self._settings.set_link(link) self._settings.set_link(link)
return self._state() return self.state()
def set_interval_minutes(self, minutes: int | str) -> int: def set_interval_minutes(self, minutes: int | str) -> int:
if self._settings is None: if self._settings is None:
raise ScheduleError("Настройки не подключены: интервал задаётся через .env") raise ScheduleError("Настройки не подключены: интервал задаётся в .env")
value = self._settings.set_interval_minutes(minutes) value = self._settings.set_interval_minutes(minutes)
with self._lock: with self._lock:
self._ttl = value * 60 self._ttl = value * 60
@@ -180,16 +181,15 @@ class ScheduleCache:
return None return None
return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat() return datetime.fromtimestamp(ts, datetime.now().astimezone().tzinfo).isoformat()
def _state(self) -> dict: def _status(self) -> dict:
return { return {
"link": self._link, "cache_ttl_seconds": self._ttl,
"interval_minutes": max(1, round(self._ttl / 60)), "interval_minutes": max(1, round(self._ttl / 60)),
"last_updated": self._iso(self._loaded_at), "last_updated": self._iso(self._loaded_at),
"table_modified": self._modified, "table_modified": self._modified,
"error": self._error, "error": self._error,
"groups": len(self._data) if self._data else 0, "groups": len(self._data) if self._data else 0,
"has_data": self._data is not None, "has_data": self._data is not None,
# при последнем чтении файла: изменилось ли его содержимое
"changed": self._changed, "changed": self._changed,
"changed_at": self._iso(self._changed_at), "changed_at": self._iso(self._changed_at),
"content_hash": (self._content_hash or "")[:12], "content_hash": (self._content_hash or "")[:12],
@@ -205,9 +205,9 @@ class ScheduleCache:
) )
if fresh: if fresh:
return return
self._reload(now) self._refresh(now)
def _reload(self, now: float) -> None: def _refresh(self, now: float) -> None:
# служим старые данные, пока не истекло время повторной попытки # служим старые данные, пока не истекло время повторной попытки
if ( if (
self._data is not None self._data is not None
@@ -249,6 +249,7 @@ class ScheduleCache:
digest = hashlib.sha256(xlsx).hexdigest() digest = hashlib.sha256(xlsx).hexdigest()
first_load = self._content_hash is None first_load = self._content_hash is None
changed = not first_load and digest != self._content_hash changed = not first_load and digest != self._content_hash
sheets = parse_workbook(xlsx) sheets = parse_workbook(xlsx)
data = parsing.parse_schedule(sheets) data = parsing.parse_schedule(sheets)
weeks = parsing.extract_week_ranges(sheets) weeks = parsing.extract_week_ranges(sheets)

View File

@@ -159,6 +159,25 @@ def _line_is_room(line: str) -> bool:
) )
_PATRONYMIC_END = re.compile(r"(вич|вна|ична|инична)$", re.I)
_SURNAME_END = re.compile(r"(ов|ев|ёв|ин|ын|ский|цкий|ая|ко|ук|юк)$", re.I)
def _looks_like_person(text: str) -> bool:
"""Похоже ли «Имя Отчество Фамилия» на ФИО (а не на название предмета).
Для трёх слов требуем отчество (-вич/-вна), для двух — «фамильное»
окончание хотя бы у одного слова: иначе «Машинное Обучение» уедет
в преподаватели.
"""
parts = [part for part in text.split() if part]
if len(parts) >= 3:
return any(_PATRONYMIC_END.search(part) for part in parts[1:])
if len(parts) == 2:
return bool(_SURNAME_END.search(parts[0]) or _SURNAME_END.search(parts[1]))
return False
def _classify_line(line: str) -> _LineRoles: def _classify_line(line: str) -> _LineRoles:
roles = _LineRoles() roles = _LineRoles()
@@ -259,11 +278,13 @@ def _classify_line(line: str) -> _LineRoles:
if m: if m:
roles.subject, roles.teacher = m.group(1).strip(), m.group(2).strip() roles.subject, roles.teacher = m.group(1).strip(), m.group(2).strip()
return roles return roles
# «Предмет Денис Верховцев (Сигма)» — ФИО без отчества в конце строки # «Предмет Денис Верховцев (Сигма)» / «Предмет Денис Сергеевич Верховцев (Сигма)» —
# ФИО в конце строки, может быть из двух или трёх слов
m = re.search( m = re.search(
r"^(.*\s)([А-ЯЁ][а-яё]{2,}\s+[А-ЯЁ][а-яё]{2,})(\s*\([^()]*\))?\s*$", subject r"^(.*?\s)([А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2})(\s*\([^()]*\))?\s*$",
subject,
) )
if m and len(subject.split()) >= 4: if m and len(subject.split()) >= 4 and _looks_like_person(m.group(2)):
teacher = _squish(m.group(2) + " " + (m.group(3) or "")) teacher = _squish(m.group(2) + " " + (m.group(3) or ""))
roles.subject, roles.teacher = m.group(1).strip(), teacher roles.subject, roles.teacher = m.group(1).strip(), teacher
return roles return roles
@@ -495,6 +516,40 @@ def _normalize_marker_of(line: str) -> Optional[str]:
return _normalize_marker(num or None) return _normalize_marker(num or None)
def _merge_detached_fragments(lessons: list[Lesson]) -> list[Lesson]:
"""Достраивает занятие из оторвавшихся пустой строкой преподавателя/аудитории.
Пустая строка внутри ячейки (или красная заметка, снятая с целой строки)
отрывает хвост от предмета: «Предмет\nЛарина Ю.А.\n\n ауд.210» разбирается
как занятие без аудитории и «ауд. 210» без предмета — в индексе аудиторий
от такого занятия остаётся только список групп. Кусок без предмета цепляем
к предыдущему занятию, но только если у того этих полей ещё нет, иначе
это похоже на отдельное занятие параллельной подгруппы.
"""
merged: list[Lesson] = []
for lesson in lessons:
prev = merged[-1] if merged else None
if (
prev is not None
and prev.subject
and not lesson.subject
and (lesson.teacher or lesson.room)
and (not lesson.marker or lesson.marker == prev.marker)
and (not lesson.teacher or not prev.teacher)
and (not lesson.room or not prev.room)
):
notes = [n for n in (prev.note + "\n" + lesson.note).split("\n") if n]
merged[-1] = replace(
prev,
teacher=lesson.teacher or prev.teacher,
room=lesson.room or prev.room,
note="\n".join(dict.fromkeys(notes)),
)
else:
merged.append(lesson)
return merged
def parse_lesson_cell(cell_text: str) -> list[Lesson]: def parse_lesson_cell(cell_text: str) -> list[Lesson]:
"""Текст ячейки -> список занятий. """Текст ячейки -> список занятий.
@@ -525,6 +580,8 @@ def parse_lesson_cell(cell_text: str) -> list[Lesson]:
lesson = replace(lesson, marker=last_marker) lesson = replace(lesson, marker=last_marker)
lessons.append(lesson) lessons.append(lesson)
lessons = _merge_detached_fragments(lessons)
if not cell_notes: if not cell_notes:
return lessons return lessons
if not lessons: if not lessons:
@@ -543,6 +600,34 @@ def is_teacher_name(text: str) -> bool:
return bool(_NAME_INITIALS_RE.search(text) or _FULL_NAME_RE.match(text.strip())) return bool(_NAME_INITIALS_RE.search(text) or _FULL_NAME_RE.match(text.strip()))
# 2-3 слова с большой буквы — ФИО в любом порядке (без «НПО Криста»: там Криста с большой,
# но первое слово «НПО» — аббревиатура и не подходит под [А-ЯЁ][а-яё]{2,})
_FULL_NAME_WORDS_RE = re.compile(r"^[А-ЯЁ][а-яё]{2,}(?:\s+[А-ЯЁ][а-яё]{2,}){1,2}$")
def _short_from_parts(parts: list[str]) -> str:
"""ФИО -> «Фамилия И.О.» с учётом порядка слов.
В таблице встречаются оба порядка: «Верховцев Денис Сергеевич» и
«Денис Сергеевич Верховцев». Фамилию определяем по отчеству (-вич/-вна),
а для двух слов — по «фамильному» окончанию.
"""
if len(parts) >= 3 and _PATRONYMIC_END.search(parts[1]):
surname, given, patronymic = parts[2], parts[0], parts[1]
elif len(parts) == 2 and _SURNAME_END.search(parts[1]) and not _SURNAME_END.search(parts[0]):
surname, given, patronymic = parts[1], parts[0], ""
else:
surname = parts[0]
given = parts[1] if len(parts) > 1 else ""
patronymic = parts[2] if len(parts) > 2 else ""
short = surname
if given:
short += f" {given[0].upper()}."
if patronymic:
short += f"{patronymic[0].upper()}."
return short
def extract_teacher_names(teacher: str) -> list[str]: def extract_teacher_names(teacher: str) -> list[str]:
"""Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса). """Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса).
@@ -554,16 +639,9 @@ def extract_teacher_names(teacher: str) -> list[str]:
names = [m.strip() for m in _NAME_INITIALS_RE.findall(teacher) if m.strip()] names = [m.strip() for m in _NAME_INITIALS_RE.findall(teacher) if m.strip()]
if not names: if not names:
full = re.sub(r"\s*\([^()]*\)", "", teacher).strip() full = re.sub(r"\s*\([^()]*\)", "", teacher).strip()
full_m = _FULL_NAME_RE.match(full) full_m = _FULL_NAME_RE.match(full) or _FULL_NAME_WORDS_RE.match(full)
if full_m: if full_m:
parts = full_m.group(0).split() return [_short_from_parts(full_m.group(0).split())]
short = parts[0] + " " + parts[1][0].upper() + "." if len(teacher) <= 30 and not re.search(r"\d", teacher):
if len(parts) > 2: return [teacher] # организация: «ПравоТех», «НПО Криста»
short += parts[2][0].upper() + "."
return [short]
if (
not re.search(r"\d|\s", teacher)
or (len(teacher) <= 30 and " " in teacher)
) and not re.search(r"[а-яё][А-ЯЁ]", teacher):
return [teacher] # организация одним словом или «НПО Криста»
return names return names

View File

@@ -83,7 +83,9 @@ def extract_week_ranges(sheets: dict[str, Worksheet]) -> list[dict]:
return [] return []
ranges: list[dict] = [] ranges: list[dict] = []
seen: set[tuple[str, date]] = set() seen: set[tuple[str, date]] = set()
for row in sheet.rows[1:]: for row_idx, row in enumerate(sheet.rows[1:], start=1):
if row_idx in sheet.hidden_rows:
continue # скрытые строки не парсим
if not any(row[i] for i in range(4)): if not any(row[i] for i in range(4)):
continue continue
for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)): for parity, start_col, end_col in (("ч", 0, 1), ("з", 2, 3)):
@@ -148,7 +150,9 @@ def parse_schedule(sheets: dict[str, Worksheet]) -> dict:
parity: str | None = None parity: str | None = None
rows_in_day = 0 rows_in_day = 0
for row in rows[1:]: for row_idx, row in enumerate(rows[1:], start=1):
if row_idx in sheet.hidden_rows:
continue # скрытые строки не парсим
if not any(row[i] for i in range(4)): if not any(row[i] for i in range(4)):
continue continue
day = _normalize_header(row[_COL_DAY]).lower() day = _normalize_header(row[_COL_DAY]).lower()
@@ -344,26 +348,182 @@ def build_room_schedule(rooms: dict, label: str, day_times: dict | None = None)
# --- вспомогательные листы ------------------------------------------------------ # --- вспомогательные листы ------------------------------------------------------
# --- атрибуты аудиторий из строки описания ------------------------------------
# В листе «аудитории» под номером лежит описание вида
# «комп.класс МО и СТ; без проектора; маркер; 12 мест» (части через «;», но
# встречаются и запятые). Разбираем его в тип, вместимость, оснащение и дни.
# Тип берём фразой как в таблице (порядок — от специфичных к общим).
_ROOM_TYPE_PATTERNS = [
re.compile(r"комп\.?\s*класс(?:\s+(?:Linux|Windows|OS)(?:\s*[/,]\s*(?:Linux|Windows|OS))*)?", re.I),
re.compile(
r"компьютерн\w*\s+класс(?:\s+(?:Linux|Windows|OS)(?:\s*[/,]\s*(?:Linux|Windows|OS))*)?",
re.I,
),
re.compile(r"кабинет\s+англ\w*", re.I),
re.compile(r"лекционно-практическая", re.I),
re.compile(r"лекционная", re.I),
re.compile(r"аудитория\s+для\s+практических", re.I),
re.compile(r"\bангл\w*", re.I),
re.compile(r"ауд\.?\s*каф\.?\s*[А-ЯA-Z]{0,4}\.?", re.I),
]
# Оснащение: тег -> как искать в тексте.
_ROOM_EQUIPMENT = [
("ПК", re.compile(r"\bпк\b|комп\.?\s*класс|компьютерн", re.I)),
("Проектор", re.compile(r"проектор", re.I)),
("Интерактивная панель", re.compile(r"интерактивн", re.I)),
("Телевизор", re.compile(r"телевизор", re.I)),
("Маркер", re.compile(r"маркер", re.I)),
("Меловая доска", re.compile(r"мелов", re.I)), # «меловая доска», «мелова доска»
]
# Корни дней недели: «четверг», «по пятницам» и т.п.
_WEEKDAY_STEMS = [
("понедельник", "понедельник"),
("вторник", "вторник"),
("среда", "сред"),
("четверг", "четверг"),
("пятница", "пятниц"),
("суббота", "суббот"),
("воскресенье", "воскресен"),
]
_ROOM_PART_SPLIT = re.compile(r"[;,]")
_NEGATION_RE = re.compile(r"\bбез\b\s*(.*)", re.I)
_WORD_RE = re.compile(r"[A-Za-zА-ЯЁа-яё]+")
def lower_except_abbr(text: str) -> str:
"""Слова в нижний регистр; аббревиатуры («ПК», «МО», «СТ») остаёмся заглавными."""
def repl(match: re.Match) -> str:
word = match.group(0)
return word if len(word) >= 2 and word.isupper() else word.lower()
return _WORD_RE.sub(repl, text)
def room_capacity(text: str) -> int | None:
"""Вместимость: «N мест», «N раб.» (рабочие места в комп.классе)."""
m = re.search(r"(\d+)\s*мест", text, re.I)
if m:
return int(m.group(1))
m = re.search(r"(\d+)\s*раб", text, re.I) # «12 раб. + 1 осн.» — считаем рабочие
if m:
return int(m.group(1))
return None
def room_type(text: str) -> str:
"""Тип аудитории фразой из таблицы; пустая строка, если типа нет."""
for pattern in _ROOM_TYPE_PATTERNS:
m = pattern.search(text)
if m:
return lower_except_abbr(re.sub(r"\s{2,}", " ", m.group(0).strip(" .,")))
return ""
def room_type_group(type_text: str) -> str:
"""Общий тип для фильтра: близкие формулировки сводим к одной.
«комп.класс», «комп.класс Linux», «компьютерный класс» -> «комп.класс»;
«английский», «кабинет английского» -> «кабинет английского».
В карточке при этом остаётся формулировка из таблицы.
"""
low = (type_text or "").lower()
if not low:
return ""
if "комп" in low and "класс" in low:
return "комп.класс"
if "англ" in low:
return "кабинет английского"
if "каф" in low:
return "аудитория кафедры"
if low.startswith("лекционно"):
return "лекционно-практическая"
if low.startswith("лекционн"):
return "лекционная"
if "практическ" in low:
return "аудитория для практических"
return type_text
def room_equipment(text: str) -> list[str]:
"""Оснащение тегами; «без проектора» означает, что проектора нет."""
negated: set[str] = set()
for part in _ROOM_PART_SPLIT.split(text):
m = _NEGATION_RE.search(part)
if not m:
continue
for tag, pattern in _ROOM_EQUIPMENT:
if pattern.search(m.group(1)):
negated.add(tag)
return [
lower_except_abbr(tag)
for tag, pattern in _ROOM_EQUIPMENT
if tag not in negated and pattern.search(text)
]
def room_weekdays(text: str) -> list[str] | None:
"""Дни, если в описании перечислены («402 четверг пятница») — иначе None."""
low = text.lower()
found = [name for name, stem in _WEEKDAY_STEMS if stem in low]
return found or None
def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict: def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict:
"""Лист «аудитории»: 'ауд. N' -> {'room', 'info'} (оснащение, вместимость).""" """Лист «аудитории»: 'ауд. N' -> атрибуты аудитории.
{'room', 'info' (как в таблице), 'type', 'capacity', 'equipment', 'available_days'}
"""
sheet = sheets.get(ROOMS_SHEET) sheet = sheets.get(ROOMS_SHEET)
if sheet is None or len(sheet.rows) < 2: if sheet is None or len(sheet.rows) < 2:
return {} return {}
header, desc = sheet.rows[0], sheet.rows[1] header, desc = sheet.rows[0], sheet.rows[1]
info: dict = {} info: dict = {}
for col in range(_FIRST_GROUP_COL, max(len(header), len(desc))):
def add(col: int) -> None:
m = re.search(r"(\d+)", str(header[col])) m = re.search(r"(\d+)", str(header[col]))
if not m: if not m:
continue return
number = m.group(1) label = f"ауд. {m.group(1)}"
text = " ".join( if label in info:
ln.strip() for ln in str(desc[col]).split("\n") if ln.strip() return # дубль с тем же номером: первый столбец важнее
text = _strip_marks(
" ".join(ln.strip() for ln in str(desc[col]).split("\n") if ln.strip())
) )
label = f"ауд. {number}" room_kind = room_type(text)
info[label] = {"room": label, "info": _strip_marks(text)} info[label] = {
"room": label,
"info": text,
"type": room_kind,
"type_group": room_type_group(room_kind),
"capacity": room_capacity(text),
"equipment": room_equipment(text),
"available_days": room_weekdays(text),
}
for col in range(_FIRST_GROUP_COL, max(len(header), len(desc))):
if col not in sheet.hidden_cols: # скрытое не парсим
add(col)
return info return info
def room_payload(label: str, info: dict | None = None) -> dict:
"""Аудитория для API: номер, описание как в таблице и разобранные атрибуты."""
info = info or {}
return {
"room": label,
"info": info.get("info", ""),
"type": info.get("type", ""),
"type_group": info.get("type_group", ""),
"capacity": info.get("capacity"),
"equipment": list(info.get("equipment") or []),
"available_days": info.get("available_days"),
}
def _initials_from_parts(first: str, patronymic: str) -> str: def _initials_from_parts(first: str, patronymic: str) -> str:
initials = "" initials = ""
if first: if first:
@@ -381,6 +541,8 @@ def parse_teachers_full(sheets: dict[str, Worksheet]) -> dict:
header = sheet.rows[1] header = sheet.rows[1]
result: dict = {} result: dict = {}
for col in range(_FIRST_GROUP_COL, len(header)): for col in range(_FIRST_GROUP_COL, len(header)):
if col in sheet.hidden_cols: # скрытое не парсим
continue
value = _strip_marks(str(header[col])) value = _strip_marks(str(header[col]))
parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()] parts = [p.strip() for p in re.split(r"[\s\n]+", value) if p.strip()]
if not parts: if not parts:

View File

@@ -1,8 +1,10 @@
"""Настройки расписания, которые правит администратор. """Настройки, которые правит администратор.
Хранятся в JSON-файле (если задан путь) — так ссылка и интервал переживают Хранятся в JSON-файле (`SETTINGS_FILE`, по умолчанию `data/settings.json` в
перезапуск контейнера. Если путь не задан, настройки живут только в памяти корне проекта), поэтому переживают перезапуск службы: ссылка на таблицу,
(поведение прежних версий, прод можно не трогать). интервал обновления и история входов в админку. Если файл недоступен для
записи — настройки живут в памяти, а сервис продолжает работать (об этом
пишется предупреждение в лог и в состояние админки).
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,7 +13,8 @@ import json
import logging import logging
import os import os
import threading import threading
from dataclasses import asdict, dataclass from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
from .errors import ScheduleError from .errors import ScheduleError
@@ -22,14 +25,40 @@ DEFAULT_INTERVAL_MINUTES = 15
MIN_INTERVAL_MINUTES = 1 MIN_INTERVAL_MINUTES = 1
MAX_INTERVAL_MINUTES = 24 * 60 MAX_INTERVAL_MINUTES = 24 * 60
def _default_settings_path() -> Path:
"""`<корень проекта>/data/settings.json`.
В репозитории это три уровня вверх от пакета, а в docker-образе файлы лежат
плоско (/app/...), поэтому ищем корень по маркеру — requirements.txt.
"""
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "requirements.txt").is_file():
return parent / "data" / "settings.json"
return here.parent / "data" / "settings.json"
DEFAULT_SETTINGS_PATH = _default_settings_path()
@dataclass @dataclass
class Settings: class Settings:
link: str = "" link: str = ""
interval_minutes: int = DEFAULT_INTERVAL_MINUTES interval_minutes: int = DEFAULT_INTERVAL_MINUTES
last_attempt: dict | None = None # {"at": iso, "ip": str, "ok": bool}
last_success: dict | None = None # {"at": iso, "ip": str}
def as_dict(self) -> dict: def as_dict(self) -> dict:
return asdict(self) return {
"link": self.link,
"interval_minutes": self.interval_minutes,
"last_attempt": self.last_attempt,
"last_success": self.last_success,
}
def clamp_interval(minutes: int) -> int:
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes)))
def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int: def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
@@ -39,18 +68,19 @@ def interval_from_env(default: int = DEFAULT_INTERVAL_MINUTES) -> int:
try: try:
return clamp_interval(int(float(raw_minutes))) return clamp_interval(int(float(raw_minutes)))
except ValueError: except ValueError:
log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру значение по умолчанию", raw_minutes) log.warning("SCHEDULE_INTERVAL_MINUTES=%r не число, беру по умолчанию", raw_minutes)
raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip() raw_ttl = (os.getenv("SCHEDULE_CACHE_TTL") or "").strip()
if raw_ttl: if raw_ttl:
try: try:
return clamp_interval(round(float(raw_ttl) / 60) or 1) return clamp_interval(round(float(raw_ttl) / 60) or 1)
except ValueError: except ValueError:
log.warning("SCHEDULE_CACHE_TTL=%r не число, беру значение по умолчанию", raw_ttl) log.warning("SCHEDULE_CACHE_TTL=%r не число, беру по умолчанию", raw_ttl)
return default return default
def clamp_interval(minutes: int) -> int: def settings_path_from_env() -> Path | None:
return max(MIN_INTERVAL_MINUTES, min(MAX_INTERVAL_MINUTES, int(minutes))) raw = (os.getenv("SETTINGS_FILE") or "").strip()
return Path(raw) if raw else DEFAULT_SETTINGS_PATH
class SettingsStore: class SettingsStore:
@@ -64,6 +94,7 @@ class SettingsStore:
) -> None: ) -> None:
self._path = Path(path) if path else None self._path = Path(path) if path else None
self._lock = threading.RLock() self._lock = threading.RLock()
self._save_error: str | None = None
self._settings = Settings( self._settings = Settings(
link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes) link=(link or "").strip(), interval_minutes=clamp_interval(interval_minutes)
) )
@@ -71,6 +102,10 @@ class SettingsStore:
# --- чтение ----------------------------------------------------------------- # --- чтение -----------------------------------------------------------------
@property
def path(self) -> Path | None:
return self._path
@property @property
def link(self) -> str: def link(self) -> str:
with self._lock: with self._lock:
@@ -81,6 +116,22 @@ class SettingsStore:
with self._lock: with self._lock:
return self._settings.interval_minutes return self._settings.interval_minutes
@property
def last_attempt(self) -> dict | None:
with self._lock:
return dict(self._settings.last_attempt) if self._settings.last_attempt else None
@property
def last_success(self) -> dict | None:
with self._lock:
return dict(self._settings.last_success) if self._settings.last_success else None
@property
def save_error(self) -> str | None:
"""Текст ошибки, если настройки не удаётся сохранить на диск."""
with self._lock:
return self._save_error
def as_dict(self) -> dict: def as_dict(self) -> dict:
with self._lock: with self._lock:
return self._settings.as_dict() return self._settings.as_dict()
@@ -110,6 +161,17 @@ class SettingsStore:
self._save() self._save()
return value return value
def record_login(self, ip: str, ok: bool) -> dict:
"""Запомнить попытку входа: откуда, когда и чем закончилась."""
now = datetime.now().astimezone().replace(microsecond=0)
attempt = {"at": now.isoformat(), "ip": ip or "неизвестно", "ok": bool(ok)}
with self._lock:
self._settings.last_attempt = attempt
if ok:
self._settings.last_success = {"at": attempt["at"], "ip": attempt["ip"]}
self._save()
return attempt
# --- файл ------------------------------------------------------------------- # --- файл -------------------------------------------------------------------
def _load(self) -> None: def _load(self) -> None:
@@ -118,7 +180,7 @@ class SettingsStore:
try: try:
raw = json.loads(self._path.read_text(encoding="utf-8")) raw = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: except (OSError, ValueError) as exc:
log.warning("Не читается файл настроек %s: %s (остаюсь на значениях из окружения)", self._path, exc) log.warning("Не читается файл настроек %s: %s (беру значения из окружения)", self._path, exc)
return return
if not isinstance(raw, dict): if not isinstance(raw, dict):
log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path) log.warning("Файл настроек %s не JSON-объект, игнорирую", self._path)
@@ -131,6 +193,10 @@ class SettingsStore:
self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"])) self._settings.interval_minutes = clamp_interval(int(raw["interval_minutes"]))
except (TypeError, ValueError): except (TypeError, ValueError):
log.warning("interval_minutes в %s не число, игнорирую", self._path) log.warning("interval_minutes в %s не число, игнорирую", self._path)
for key in ("last_attempt", "last_success"):
value = raw.get(key)
if isinstance(value, dict) and value.get("at"):
setattr(self._settings, key, value)
log.info( log.info(
"Настройки из %s: интервал %s мин, ссылка %s", "Настройки из %s: интервал %s мин, ссылка %s",
self._path, self._path,
@@ -149,5 +215,7 @@ class SettingsStore:
encoding="utf-8", encoding="utf-8",
) )
tmp.replace(self._path) tmp.replace(self._path)
self._save_error = None
except OSError as exc: except OSError as exc:
self._save_error = str(exc)
log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc) log.warning("Не удалось сохранить настройки в %s: %s (живу в памяти)", self._path, exc)

View File

@@ -14,7 +14,7 @@ import io
import re import re
import zipfile import zipfile
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass, field
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from .errors import ScheduleError from .errors import ScheduleError
@@ -32,6 +32,7 @@ _REF_RE = re.compile(r"^([A-Z]+)(\d+)$")
class Worksheet: class Worksheet:
rows: list[list[str]] rows: list[list[str]]
hidden_cols: set[int] hidden_cols: set[int]
hidden_rows: set[int] = field(default_factory=set)
def cell(self, row_idx: int, col_idx: int) -> str: def cell(self, row_idx: int, col_idx: int) -> str:
"""Значение ячейки, "" вне границ матрицы.""" """Значение ячейки, "" вне границ матрицы."""
@@ -135,11 +136,14 @@ def _parse_sheet(xml_bytes: bytes, shared: list[str]) -> Worksheet:
if col.get("hidden") == "1": if col.get("hidden") == "1":
hidden_cols.update(range(c_min - 1, c_max)) hidden_cols.update(range(c_min - 1, c_max))
hidden_rows: set[int] = set()
for row in root.iter(f"{{{XLSX_NS}}}row"): for row in root.iter(f"{{{XLSX_NS}}}row"):
row_num = row.get("r") row_num = row.get("r")
if not row_num: if not row_num:
continue continue
r = int(row_num) - 1 r = int(row_num) - 1
if row.get("hidden") == "1":
hidden_rows.add(r)
for cell in row.iter(f"{{{XLSX_NS}}}c"): for cell in row.iter(f"{{{XLSX_NS}}}c"):
ref = cell.get("r") ref = cell.get("r")
if not ref: if not ref:
@@ -194,7 +198,7 @@ def _parse_sheet(xml_bytes: bytes, shared: list[str]) -> Worksheet:
[cells.get((r, c), "") for c in range(max_col + 1)] [cells.get((r, c), "") for c in range(max_col + 1)]
for r in range(max_row + 1) for r in range(max_row + 1)
] ]
return Worksheet(rows=rows, hidden_cols=hidden_cols) return Worksheet(rows=rows, hidden_cols=hidden_cols, hidden_rows=hidden_rows)
def _iter_text(node: ET.Element): def _iter_text(node: ET.Element):

View File

@@ -4,151 +4,229 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Админ кабинет</title> <title>Админка расписания</title>
<link rel="icon" type="image/svg+xml" href="/img/app-icon.svg"> <link rel="icon" type="image/svg+xml" href="/img/app-icon.svg">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
<script>
// тему применяем до отрисовки, чтобы не мигало
try {
const saved = localStorage.getItem("theme");
document.documentElement.setAttribute("data-theme", saved === "dark" ? "dark" : "light");
} catch (_) {}
</script>
<style> <style>
.admin-wrap { max-width: 720px; margin: 0 auto; padding: 26px 18px 60px; } body { background: var(--bg); }
.admin-head { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.admin-head img { width: 40px; height: 40px; } .admin-main { max-width: 1080px; margin: 0 auto; padding: 22px 18px 70px; }
.admin-title { font-size: 22px; font-weight: 700; color: var(--text); } .admin-actions { display: flex; align-items: center; gap: 8px; }
.admin-sub { color: var(--muted); font-size: 14px; margin-bottom: 18px; } .admin-ghost {
.admin-card { display: inline-flex; align-items: center; height: 36px; padding: 0 12px;
background: var(--surface); border: 1px solid var(--border); border: 1px solid var(--border-2); background: var(--surface); color: var(--muted);
padding: 16px 18px; margin-bottom: 14px; font-size: 13px; font-weight: 600; text-decoration: none;
} }
.admin-card h2 { font-size: 15px; margin-bottom: 10px; color: var(--text); } .admin-ghost:hover { color: var(--accent); border-color: var(--accent); }
.admin-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
.admin-field { display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0; } /* ---- вход ---- */
.admin-field label { font-size: 13px; color: var(--muted); } .admin-login {
max-width: 420px; margin: 6vh auto 0; padding: 30px 28px 26px;
background: var(--surface); border: 1px solid var(--border);
box-shadow: 0 14px 40px var(--shadow); text-align: center;
}
.admin-login-logo {
width: 58px; height: 58px; padding: 10px; margin: 0 auto 14px;
background: linear-gradient(160deg, var(--nav-1), var(--nav-2));
border-radius: 14px; display: block;
}
.admin-login h1 { font-size: 20px; color: var(--text); margin-bottom: 6px; }
.admin-login-hint { font-size: 13px; color: var(--muted); margin-bottom: 20px; }
.admin-login form { display: flex; flex-direction: column; gap: 12px; text-align: left; }
.admin-login .admin-btn { margin-top: 4px; width: 100%; }
/* ---- шапка состояния ---- */
.admin-hero {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--border); border-left: 4px solid var(--accent);
padding: 16px 18px; margin-bottom: 16px;
}
.admin-hero.is-ok { border-left-color: #2f9e5f; }
.admin-hero.is-error { border-left-color: var(--accent-red-bright); }
.admin-hero-state { display: flex; align-items: center; gap: 9px; font-size: 18px; font-weight: 700; color: var(--text); }
.admin-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--muted-2); }
.admin-hero.is-ok .admin-dot { background: #2f9e5f; }
.admin-hero.is-error .admin-dot { background: var(--accent-red-bright); }
.admin-hero-sub { color: var(--muted); font-size: 13px; margin-top: 5px; }
/* ---- карточки ---- */
.admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); gap: 14px; }
.admin-card {
background: var(--surface); border: 1px solid var(--border); padding: 16px 18px 14px;
display: flex; flex-direction: column; gap: 10px; min-width: 0;
}
.admin-card h2 {
font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px;
color: var(--muted); font-weight: 700;
}
.admin-card-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.admin-inline { display: flex; align-items: flex-end; gap: 10px; flex-wrap: wrap; }
.admin-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.admin-field > span { font-size: 12px; color: var(--muted); }
.admin-field-narrow { flex: 0 1 160px; }
.admin-field input { .admin-field input {
font-size: 14px; padding: 8px 10px; border: 1px solid var(--border-2); font-size: 14px; padding: 9px 11px; width: 100%; min-width: 0;
background: var(--surface-2); color: var(--text); width: 100%; min-width: 0; border: 1px solid var(--border-2); background: var(--surface-2); color: var(--text);
} }
.admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; } .admin-field input:focus { outline: 2px solid var(--accent-blue); outline-offset: -1px; }
.admin-btn {
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer;
border: 1px solid var(--accent); background: var(--accent); color: #fff;
}
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.admin-btn.danger { background: var(--accent-red-bright); border-color: var(--accent-red-bright); }
.admin-btn[disabled] { opacity: .55; cursor: default; }
.admin-status { display: flex; flex-wrap: wrap; gap: 10px; }
.admin-chip {
background: var(--surface-2); border: 1px solid var(--border); padding: 8px 12px; min-width: 0;
}
.admin-chip .k { font-size: 12px; color: var(--muted); }
.admin-chip .v { font-size: 14px; font-weight: 600; color: var(--text); word-break: break-word; overflow-wrap: anywhere; }
.admin-error { color: var(--accent-red-bright); font-size: 13px; margin-top: 10px; word-break: break-word; overflow-wrap: anywhere; }
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
.hidden { display: none !important; }
abbr.code { font-family: monospace; font-size: 13px; color: var(--text); word-break: break-all; }
/* всплывающие уведомления вместо alert(): сами исчезают через 15 секунд */ .admin-btn {
font-size: 14px; font-weight: 600; padding: 9px 14px; cursor: pointer; white-space: nowrap;
border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent);
}
.admin-btn:hover { filter: brightness(1.06); }
.admin-btn.secondary { background: var(--surface-2); color: var(--text); border-color: var(--border-2); }
.admin-btn-lg { font-size: 15px; padding: 11px 18px; }
.admin-btn[disabled] { opacity: .55; cursor: default; filter: none; }
.admin-link { font-size: 13px; color: var(--accent-blue); text-decoration: none; }
.admin-link:hover { text-decoration: underline; }
/* ---- факты ---- */
.admin-facts { display: flex; flex-direction: column; gap: 6px; margin: 0; }
.admin-facts > div { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; }
.admin-facts dt { font-size: 13px; color: var(--muted); flex: 0 0 auto; }
.admin-facts dd {
margin: 0; font-size: 14px; color: var(--text); font-weight: 600; text-align: right;
min-width: 0; overflow-wrap: anywhere;
}
.admin-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 500; font-size: 13px; }
.good { color: #2f9e5f; }
.bad { color: var(--accent-red-bright); }
.admin-hint { font-size: 12px; color: var(--muted-2); line-height: 1.45; }
.admin-alert {
font-size: 13px; padding: 10px 12px; margin-bottom: 14px;
background: var(--error-bg); border: 1px solid var(--error-border); color: var(--error-text);
overflow-wrap: anywhere;
}
.admin-alert.warn { background: color-mix(in srgb, #b8860b 12%, var(--surface)); border-color: #b8860b; color: #8a6300; }
.hidden { display: none !important; }
/* ---- уведомления ---- */
#toast-wrap { #toast-wrap {
position: fixed; position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
left: 50%; z-index: 9999; display: flex; flex-direction: column; gap: 8px;
bottom: 18px;
transform: translateX(-50%);
z-index: 9999;
display: flex;
flex-direction: column;
gap: 8px;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
} }
.toast { .toast {
display: flex; display: flex; align-items: flex-start; gap: 10px;
align-items: flex-start; background: var(--surface); border: 1px solid var(--border-2); border-left: 4px solid var(--accent-blue);
gap: 10px; box-shadow: 0 10px 30px var(--shadow);
background: var(--surface); padding: 12px 14px; font-size: 14px; color: var(--text);
border: 1px solid var(--border-2); white-space: pre-line; animation: toast-in .18s ease-out;
border-left: 4px solid var(--accent-blue);
box-shadow: 0 8px 24px var(--shadow);
padding: 12px 14px;
font-size: 14px;
color: var(--text);
white-space: pre-line;
animation: toast-in .18s ease-out;
} }
.toast.ok { border-left-color: #2f9e5f; } .toast.ok { border-left-color: #2f9e5f; }
.toast.error { border-left-color: var(--accent-red-bright); } .toast.error { border-left-color: var(--accent-red-bright); }
.toast.hide { opacity: 0; transition: opacity .3s ease; } .toast.hide { opacity: 0; transition: opacity .3s ease; }
.toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; } .toast .toast-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.toast .toast-close { .toast .toast-close {
flex: 0 0 auto; flex: 0 0 auto; border: none; background: none; color: var(--muted);
border: none; font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px;
background: none;
color: var(--muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
} }
.toast .toast-close:hover { color: var(--text); } .toast .toast-close:hover { color: var(--text); }
@keyframes toast-in { @keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: none; } @media (max-width: 640px) {
.admin-main { padding: 16px 12px 60px; }
.admin-hero { padding: 14px; }
.admin-hero .admin-btn { width: 100%; }
.admin-login { margin-top: 2vh; padding: 24px 18px; }
.admin-facts > div { flex-direction: column; gap: 2px; }
.admin-facts dd { text-align: left; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="admin-wrap">
<div class="admin-head"> <div class="topbar">
<img src="/img/owl.svg" alt=""> <div class="topbar-brand">
<div class="admin-title">Админ кабинет</div> <img class="topbar-logo" src="/img/owl.svg" alt="">
<div class="topbar-text">
<span class="topbar-university">Расписание — админка</span>
<span class="topbar-faculty">источник данных и обновление</span>
</div>
</div>
<div class="admin-actions">
<a class="admin-ghost" href="/">Расписание</a>
<button class="theme-toggle" id="theme-toggle" title="Переключить тему">
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="topbar-logout" id="logout" type="button">Выйти</button>
</div>
</div> </div>
<section id="login-card" class="admin-card hidden"> <main class="admin-main">
<h2>Вход</h2> <section id="login-card" class="admin-login hidden">
<form id="login-form" autocomplete="off"> <img class="admin-login-logo" src="/img/owl-white.svg" alt="">
<div class="admin-field"><label for="login">Логин</label><input id="login" required></div> <h1>Вход в админку</h1>
<div class="admin-field" style="margin-top:10px"><label for="password">Пароль</label><input id="password" type="password" required></div> <p class="admin-login-hint">Страница открывается только по секретной ссылке. После нескольких неудачных попыток вход временно блокируется.</p>
<div class="admin-row" style="margin-top:12px"> <form id="login-form" autocomplete="on">
<label class="admin-field"><span>Логин</span><input id="login" autocomplete="username" required></label>
<label class="admin-field"><span>Пароль</span><input id="password" type="password" autocomplete="current-password" required></label>
<button class="admin-btn" type="submit">Войти</button> <button class="admin-btn" type="submit">Войти</button>
</div>
</form> </form>
<div id="login-error" class="admin-error hidden"></div> <div id="login-error" class="admin-alert hidden" style="margin:14px 0 0"></div>
</section> </section>
<section id="panel" class="hidden"> <section id="panel" class="hidden">
<div class="admin-card"> <div class="admin-hero" id="hero">
<h2>Состояние</h2> <div>
<div class="admin-status" id="status-chips"></div> <div class="admin-hero-state"><span class="admin-dot"></span><span id="hero-title">Загружаю…</span></div>
<div id="state-error" class="admin-error hidden"></div> <div class="admin-hero-sub" id="hero-sub"></div>
</div> </div>
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
</div>
<div id="hero-alert" class="admin-alert hidden"></div>
<div class="admin-card"> <div class="admin-grid">
<article class="admin-card">
<h2>Ссылка на расписание</h2> <h2>Ссылка на расписание</h2>
<div class="admin-row"> <label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
<div class="admin-field">
<label for="link">Публичная ссылка Яндекс.Диска</label>
<input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/..."> <input id="link" spellcheck="false" placeholder="https://disk.yandex.ru/i/...">
</div> </label>
<div class="admin-card-actions">
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button> <button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button>
<a class="admin-link" id="link-open" href="#" target="_blank" rel="noopener noreferrer">открыть таблицу</a>
</div> </div>
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними. </article>
Это значение хранится в настройках стенда и <b>имеет приоритет над .env</b> — именно по нему сайт берёт расписание.</div>
</div>
<div class="admin-card"> <article class="admin-card">
<h2>Обновление</h2> <h2>Обновление</h2>
<div class="admin-row"> <div class="admin-inline">
<div class="admin-field" style="flex:0 1 200px"> <label class="admin-field admin-field-narrow"><span>Интервал, минут</span>
<label for="interval">Интервал, минут</label>
<input id="interval" type="number" min="1" max="1440" step="1"> <input id="interval" type="number" min="1" max="1440" step="1">
</label>
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить</button>
</div> </div>
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button> <dl class="admin-facts">
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button> <div><dt>Файл при последнем обновлении</dt><dd id="fact-changed">—</dd></div>
</div> <div><dt>Дата файла на Диске</dt><dd id="fact-modified">—</dd></div>
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд. <div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div> </dl>
</div> </article>
<div class="admin-card"> <article class="admin-card">
<button class="admin-btn danger" id="logout" type="button">Выйти</button> <h2>Входы в админку</h2>
<dl class="admin-facts">
<div><dt>Последняя попытка</dt><dd id="auth-attempt">—</dd></div>
<div><dt>Последний успешный вход</dt><dd id="auth-success">—</dd></div>
</dl>
<div id="auth-lock" class="admin-alert warn hidden" style="margin:10px 0 0"></div>
</article>
</div> </div>
</section> </section>
</div> </main>
<div id="toast-wrap"></div> <div id="toast-wrap"></div>
@@ -171,7 +249,6 @@
close.setAttribute("aria-label", "Закрыть"); close.setAttribute("aria-label", "Закрыть");
el.append(text, close); el.append(text, close);
wrap.appendChild(el); wrap.appendChild(el);
const hide = () => { const hide = () => {
clearTimeout(timer); clearTimeout(timer);
el.classList.add("hide"); el.classList.add("hide");
@@ -208,33 +285,84 @@
return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" }); return isNaN(d) ? String(value) : d.toLocaleString("ru-RU", { dateStyle: "short", timeStyle: "short" });
} }
function chip(key, value) { /** «5 минут назад» для свежести данных. */
const div = document.createElement("div"); function ago(value) {
div.className = "admin-chip"; if (!value) return "";
div.innerHTML = `<div class="k">${key}</div><div class="v"></div>`; const d = new Date(value);
div.querySelector(".v").textContent = value; if (isNaN(d)) return "";
return div; const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
if (sec < 60) return "только что";
const min = Math.round(sec / 60);
if (min < 60) return `${min} мин назад`;
const hours = Math.round(min / 60);
if (hours < 24) return `${hours} ч назад`;
return `${Math.round(hours / 24)} дн назад`;
}
function loginText(info) {
if (!info) return "нет данных";
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
return `${fmt(info.at)} · ${info.ip}${status}`;
}
function setText(id, value, cls) {
const el = $(id);
if (!el) return;
el.textContent = value;
el.classList.remove("good", "bad");
if (cls) el.classList.add(cls);
} }
function renderState(state) { function renderState(state) {
const chips = $("status-chips"); // крупная строка состояния
chips.innerHTML = ""; const hero = $("hero");
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`)); const hasError = Boolean(state.error);
chips.appendChild(chip("Обновлено", fmt(state.last_updated))); hero.classList.toggle("is-error", hasError);
chips.appendChild(chip("Таблица изменена", fmt(state.table_modified))); hero.classList.toggle("is-ok", !hasError && state.has_data);
chips.appendChild(chip( $("hero-title").textContent = hasError
"Файл при последнем обновлении", ? "Ошибка загрузки расписания"
state.changed ? "изменился ✓" : "без изменений (та же версия)", : (state.has_data ? "Работает" : "Жду данные");
)); const parts = [];
chips.appendChild(chip("Содержимое менялось", fmt(state.changed_at))); if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`);
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash)); parts.push(`групп: ${state.groups}`);
chips.appendChild(chip("Групп в расписании", String(state.groups))); parts.push(`интервал ${state.interval_minutes} мин`);
chips.appendChild(chip("Текущая ссылка", state.link || "не задана")); $("hero-sub").textContent = parts.join(" · ");
const alert = $("hero-alert");
const warnings = [];
if (state.error) warnings.push("Ошибка загрузки: " + state.error);
if (state.settings_save_error) {
warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error +
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env");
}
alert.classList.toggle("warn", !state.error && Boolean(state.settings_save_error));
alert.textContent = warnings.join(" ");
alert.classList.toggle("hidden", !warnings.length);
// ссылка
$("link").value = state.link || ""; $("link").value = state.link || "";
const open = $("link-open");
open.href = state.link || "#";
open.classList.toggle("hidden", !state.link);
// обновление
$("interval").value = state.interval_minutes; $("interval").value = state.interval_minutes;
const err = $("state-error"); setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null);
err.textContent = state.error ? "Ошибка загрузки: " + state.error : ""; setText("fact-modified", fmt(state.table_modified));
err.classList.toggle("hidden", !state.error); setText("fact-hash", state.content_hash || "—");
// входы
const attempt = state.last_attempt;
setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
setText("auth-success", loginText(state.last_success));
const throttle = state.throttle || {};
const lock = $("auth-lock");
if (throttle.locked_until) {
lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
lock.classList.remove("hidden");
} else {
lock.classList.add("hidden");
}
} }
function showLogin(show) { function showLogin(show) {
@@ -250,19 +378,22 @@
return () => { button.disabled = false; button.textContent = original; }; return () => { button.disabled = false; button.textContent = original; };
} }
async function loadState() {
const r = await api("/state");
if (r.status === 401) { showLogin(true); return; }
if (!r.ok) { toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error"); return; }
showLogin(false);
renderState(r.data);
}
async function withButton(button, text, fn) { async function withButton(button, text, fn) {
const done = busy(button, text); const done = busy(button, text);
try { await fn(); } finally { done(); } try { await fn(); } finally { done(); }
} }
async function loadState() {
const r = await api("/state");
if (r.status === 401) { showLogin(true); return; }
if (!r.ok) {
toast("Не удалось получить состояние: " + ((r.data && r.data.detail) || r.status), "error");
return;
}
showLogin(false);
renderState(r.data);
}
$("login-form").addEventListener("submit", async (e) => { $("login-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const error = $("login-error"); const error = $("login-error");
@@ -319,6 +450,13 @@
showLogin(true); showLogin(true);
}); });
$("theme-toggle").addEventListener("click", () => {
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
const next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try { localStorage.setItem("theme", next); } catch (_) {}
});
loadState(); loadState();
</script> </script>
</body> </body>

View File

@@ -1,7 +1,7 @@
"""Фронтенд расписания: статики, прокси к API бэкенда и страница админки. """Фронтенд расписания: статики, прокси к API и страница админки.
Админка живёт по секретному пути из ADMIN_PATH (см. backend/admin.py): Страница админки отдаётся по секретному пути из ADMIN_PATH; сам API админки —
сам путь не публикуется, входа с сайта нет. /api/admin/* (проксируется в backend). Если ADMIN_PATH не задан, страницы нет.
""" """
from __future__ import annotations from __future__ import annotations
@@ -15,21 +15,65 @@ import uvicorn
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request, Response from fastapi import FastAPI, HTTPException, Request, Response
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") 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("/") SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
# Бэкенд на холодном кэше скачивает таблицу (до 2 минут), таймаут выше.
PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150")) PROXY_TIMEOUT = float(os.getenv("SCHEDULE_PROXY_TIMEOUT", "150"))
ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/") ADMIN_PATH = (os.getenv("ADMIN_PATH") or "").strip().rstrip("/")
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# страница админки лежит ВНЕ static: прямой доступ по /admin.html запрещён # страница админки вне static: прямой доступ по /admin.html закрыт
ADMIN_PAGE = Path(__file__).parent / "admin.html" ADMIN_PAGE = Path(__file__).parent / "admin.html"
INDEX_PAGE = STATIC_DIR / "index.html"
# проверка «это админ?» при отдаче главной — свой короткий таймаут
ADMIN_CHECK_TIMEOUT = 2.0
def admin_session_active(request: Request) -> bool:
"""Есть ли валидная админская сессия у запроса.
Сам путь к панели при этом не читаем и никому не отдаём: главная страница
получает только булев флаг, по которому показывает кнопку «Админка».
"""
if not ADMIN_PATH:
return False
cookie = request.headers.get("cookie")
if not cookie:
return False
try:
resp = requests.get(
f"{SCHEDULE_API}/api/admin/state",
headers={"Cookie": cookie},
timeout=ADMIN_CHECK_TIMEOUT,
)
except requests.RequestException:
return False
return resp.status_code == 200
app = FastAPI(title="Расписание — фронт") app = FastAPI(title="Расписание — фронт")
@@ -51,7 +95,7 @@ def _proxy(path: str, params: dict) -> object:
except Exception: except Exception:
detail = resp.text detail = resp.text
raise HTTPException(status_code=resp.status_code, detail=detail) raise HTTPException(status_code=resp.status_code, detail=detail)
# данные не кэшируем: после «Обновить сейчас» браузер должен видеть свежее # данные не кэшируем: браузер всегда показывает актуальное расписание
return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"}) return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"})
@@ -64,6 +108,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
data=body or None, data=body or None,
headers=headers, headers=headers,
timeout=PROXY_TIMEOUT, timeout=PROXY_TIMEOUT,
# редирект /api/admin/goto отдаём браузеру как есть: он и уводит
# на страницу панели, чтобы путь не появлялся в разметке
allow_redirects=False,
) )
except requests.RequestException as exc: except requests.RequestException as exc:
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}") raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
@@ -73,6 +120,9 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response
media_type=resp.headers.get("content-type"), media_type=resp.headers.get("content-type"),
headers={"Cache-Control": "no-store"}, headers={"Cache-Control": "no-store"},
) )
location = resp.headers.get("location")
if location:
out.headers["location"] = location
# Set-Cookie (вход/выход админки) передаём как есть # Set-Cookie (вход/выход админки) передаём как есть
raw_headers = getattr(resp.raw, "headers", None) raw_headers = getattr(resp.raw, "headers", None)
for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []: for cookie in raw_headers.getlist("Set-Cookie") if raw_headers is not None else []:
@@ -144,17 +194,36 @@ async def api_admin(rest: str, request: Request):
cookie = request.headers.get("cookie") cookie = request.headers.get("cookie")
if cookie: if cookie:
headers["Cookie"] = cookie headers["Cookie"] = cookie
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
headers["X-Forwarded-For"] = forwarded
forwarded_proto = request.headers.get("x-forwarded-proto")
if forwarded_proto:
headers["X-Forwarded-Proto"] = forwarded_proto
return await run_in_threadpool( return await run_in_threadpool(
_proxy_admin, request.method, f"api/admin/{rest}", body, headers _proxy_admin, request.method, f"api/admin/{rest}", body, headers
) )
# --- статики ----------------------------------------------------------------- # --- статики и админка --------------------------------------------------------
@app.get("/") @app.get("/")
def index(): def index(request: Request):
return FileResponse(STATIC_DIR / "index.html") """Главная страница.
Вошедшему админу добавляем только флаг (без пути!): по нему страница
покажет кнопку «Админка», которая ведёт на /api/admin/goto — сервер сам
ответит редиректом на секретный путь. Так путь не попадает ни в разметку,
ни в данные, и неавторизованный пользователь его нигде не увидит.
"""
if admin_session_active(request):
html = INDEX_PAGE.read_text(encoding="utf-8")
html = html.replace(
"<body>", "<body>\n <script>window.__SCHEDULE_ADMIN__ = true;</script>", 1
)
return HTMLResponse(html, headers={"Cache-Control": "no-store"})
return FileResponse(INDEX_PAGE, headers={"Cache-Control": "no-store"})
if ADMIN_PATH: if ADMIN_PATH:
@@ -163,7 +232,13 @@ if ADMIN_PATH:
"""Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет.""" """Страница админки: путь задаётся в ADMIN_PATH, входа с сайта нет."""
return FileResponse( return FileResponse(
ADMIN_PAGE, ADMIN_PAGE,
headers={"X-Robots-Tag": "noindex, nofollow", "Cache-Control": "no-store"}, headers={
"X-Robots-Tag": "noindex, nofollow, noarchive",
"Cache-Control": "no-store",
# путь не должен утекать во внешние ссылки через Referer
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
},
) )
log.info("Страница админки включена по секретному пути") log.info("Страница админки включена по секретному пути")

View File

@@ -405,31 +405,181 @@ function renderTeacher(data, status) {
} }
} }
/* ---------- аудитории: занятость по выбранному дню ---------- */ /* ---------- аудитории: занятость по дню и свободные на паре ---------- */
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"]; const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
const ROOMS_FILTER_KEY = "rooms-filter";
// порядок показа оснащения; сами теги берём из данных таблицы, чтобы новые
// пункты появлялись автоматически
const EQUIPMENT_ORDER = ["ПК", "Проектор", "Интерактивная панель", "Телевизор", "Маркер", "Меловая доска"];
const ROOM_MODES = { busy: "Занятость за день", free: "Свободные на паре" };
const ROOM_PERIODS = [1, 2, 3, 4, 5, 6, 7, 8];
let roomsState = { day: null, mode: "busy", period: 1, loaded: null, status: null, filter: loadRoomsFilter() };
/** Фильтры аудиторий живут между заходами. */
function loadRoomsFilter() {
try {
const raw = JSON.parse(localStorage.getItem(ROOMS_FILTER_KEY) || "{}");
return {
type: typeof raw.type === "string" ? raw.type : "",
capacity: raw.capacity ? String(raw.capacity) : "",
equipment: Array.isArray(raw.equipment) ? raw.equipment.filter((t) => typeof t === "string") : [],
mode: raw.mode === "or" ? "or" : "and",
};
} catch (_) {
return { type: "", capacity: "", equipment: [], mode: "and" };
}
}
function saveRoomsFilter() {
localStorage.setItem(ROOMS_FILTER_KEY, JSON.stringify(roomsState.filter));
}
/** Слова в нижний регистр, аббревиатуры («ПК», «МО») остаются заглавными. */
function lcWords(text) {
return String(text || "").replace(/[A-Za-zА-ЯЁа-яё]+/g, (word) =>
word.length >= 2 && word === word.toUpperCase() ? word : word.toLowerCase(),
);
}
/** «Четверг» -> «четвергам» (для фразы «по четвергам и пятницам»). */
function weekdayPlural(day) {
if (day.endsWith("ье")) return day.slice(0, -2) + "ьям"; // воскресенье
if (day.endsWith("а")) return day + "м"; // пятница, среда
return day + "ам"; // четверг, вторник
}
function daysPhrase(days) {
const forms = days.map(weekdayPlural);
if (forms.length === 1) return `по ${forms[0]}`;
return `по ${forms.slice(0, -1).join(", ")} и ${forms[forms.length - 1]}`;
}
/**
* Дополнение из таблицы: оставляем только фразы, которых нет в разобранных
* атрибутах («без проектора», «МО и СТ»), не разрезая слова. Если описание
* целиком сводится к типу/оснащению/вместимости/номеру/дням — не показываем.
*/
function roomInfoText(room) {
const info = (room.info || "").trim();
if (!info) return "";
const known = new Set();
const addText = (text) =>
(text || "")
.toLowerCase()
.split(/[\s;,.:()\/-]+/)
.forEach((word) => { if (word.length >= 2) known.add(word); });
addText(room.type);
addText(room.type_group);
(room.equipment || []).forEach(addText);
(room.available_days || []).forEach(addText);
const STOP = new Set(["и", "в", "на", "для", "по", "с", "из", "до", "за", "от", "к", "о", "об", "при", "под", "над", "у"]);
const CAPACITY = /\d+\s*(?:мест[а-яё]*|раб\.?|осн\.?)/gi;
const isKnown = (token) => {
const clean = token.toLowerCase().replace(/[^а-яёa-z0-9]+/g, "");
if (!clean || !/[а-яёa-z]/.test(clean)) return true; // числа и знаки
if (clean.length < 2) return true;
if (STOP.has(clean)) return true;
if (/^(мест|раб|осн)/.test(clean)) return true; // «место», «раб.»
return [...known].some(
(k) => clean.startsWith(k.slice(0, 4)) || k.startsWith(clean.slice(0, 4)),
);
};
return info
.replace(CAPACITY, " ")
.split(/[;,]/)
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const tokens = part.split(/\s+/);
if (tokens.every(isKnown)) return "";
const rest = [...tokens];
while (rest.length && isKnown(rest[0])) rest.shift(); // убираем ведущие известные слова
return lcWords(rest.join(" "));
})
.filter(Boolean)
.join("; ");
}
/** Русское склонение существительного при числе. */
function pluralRu(count, one, few, many) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return one;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return few;
return many;
}
function roomsAll() {
return (roomsState.loaded && roomsState.loaded.rooms) || [];
}
/** Теги оснащения: сначала известные по порядку, затем всё остальное из данных. */
function equipmentTags() {
const found = new Set();
roomsAll().forEach((room) => (room.equipment || []).forEach((tag) => found.add(tag)));
const known = EQUIPMENT_ORDER.filter((tag) => found.has(tag));
const extra = [...found].filter((tag) => !EQUIPMENT_ORDER.includes(tag)).sort((a, b) => a.localeCompare(b, "ru"));
return [...known, ...extra];
}
/** Разобранные атрибуты аудитории: «комп.класс · 24 места · ПК, Проектор». */
function roomAttrsText(room) {
const parts = [];
if (room.type) parts.push(room.type);
if (room.capacity != null) parts.push(`${room.capacity} мест`);
if ((room.equipment || []).length) parts.push(room.equipment.join(", "));
return parts.join(" · ");
}
function roomMatchesFilter(room) {
const filter = roomsState.filter;
const group = room.type_group || "";
if (filter.type === "__none__") {
if (group) return false; // только аудитории без указанного типа
} else if (filter.type && group !== filter.type) {
return false;
}
const min = parseInt(filter.capacity, 10);
if (!isNaN(min) && min > 0 && (room.capacity == null || room.capacity < min)) return false;
const wanted = filter.equipment;
if (wanted.length) {
const has = new Set(room.equipment || []);
const ok = filter.mode === "or" ? wanted.some((t) => has.has(t)) : wanted.every((t) => has.has(t));
if (!ok) return false;
}
return true;
}
function roomFreeAt(room, period) {
const slot = (room.periods || {})[String(period)] || {};
return !((slot.ч || []).length || (slot.з || []).length);
}
function roomAvailableOn(room) {
const days = room.available_days;
return !days || days.includes(roomsState.day);
}
async function renderRoomsView(day) { async function renderRoomsView(day) {
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник"); roomsState.day = FREE_WEEKDAYS.includes(day)
? day
: (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
const seq = ++requestSeq; const seq = ++requestSeq;
scheduleEl.innerHTML = ""; scheduleEl.innerHTML = "";
errorEl.classList.add("hidden"); errorEl.classList.add("hidden");
loadingEl.classList.remove("hidden"); loadingEl.classList.remove("hidden");
try { try {
const status = await loadStatus(); const status = await loadStatus();
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`); const data = await fetchJSON(`/api/rooms-day?day=${enc(roomsState.day)}`);
if (seq !== requestSeq) return; if (seq !== requestSeq) return;
scheduleEl.appendChild(Object.assign(document.createElement("div"), { roomsState.loaded = data;
className: "schedule-title", roomsState.status = status;
textContent: "Занятость аудиторий", drawRoomsScreen(status);
}));
const top = statusRowHTML(status);
if (top) scheduleEl.appendChild(top);
const wrap = document.createElement("div");
wrap.className = "rooms-wrap";
scheduleEl.appendChild(wrap);
wrap.appendChild(roomsDayTable(data));
} catch (err) { } catch (err) {
if (seq !== requestSeq) return; if (seq !== requestSeq) return;
errorEl.textContent = "Ошибка: " + err.message; errorEl.textContent = "Ошибка: " + err.message;
@@ -439,27 +589,198 @@ async function renderRoomsView(day) {
} }
} }
function roomsDayTable(data) { function drawRoomsScreen(status) {
const periodNums = []; scheduleEl.innerHTML = "";
data.rooms.forEach((r) => { scheduleEl.appendChild(Object.assign(document.createElement("div"), {
Object.keys(r.periods || {}).forEach((p) => { className: "schedule-title",
if (!periodNums.includes(p)) periodNums.push(p); textContent: "Аудитории",
}); }));
}); const top = statusRowHTML(status);
periodNums.sort((a, b) => Number(a) - Number(b)); if (top) scheduleEl.appendChild(top);
return buildRoomsList(data, periodNums); scheduleEl.appendChild(roomsToolbar());
const wrap = document.createElement("div");
wrap.className = "rooms-wrap";
wrap.appendChild(roomsListBlock());
scheduleEl.appendChild(wrap);
updateRoomsSummary(); // панель уже в DOM — можно заполнить счётчик
} }
function buildRoomsList(data, periodNums) { function roomsToolbar() {
const all = roomsAll();
// близкие формулировки типов объединены в общий тип (room_type_group)
const types = [...new Set(all.map((r) => r.type_group).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru"));
const filter = roomsState.filter;
const box = document.createElement("div");
box.className = "rooms-filters";
box.innerHTML = `
<div class="rf-row rf-tabs">
${Object.entries(ROOM_MODES).map(([key, label]) =>
`<button type="button" class="rf-tab${roomsState.mode === key ? " active" : ""}" data-mode="${key}">${label}</button>`
).join("")}
${roomsState.mode === "free" ? `
<label class="rf-field rf-narrow">
<select id="rf-period">${ROOM_PERIODS.map((p) =>
`<option value="${p}"${roomsState.period === p ? " selected" : ""}>Пара ${p}</option>`).join("")}</select>
</label>` : ""}
</div>
<div class="rf-row">
<label class="rf-field"><span>Тип аудитории</span>
<select id="rf-type">
<option value="">все типы</option>
${types.map((t) => `<option value="${escapeHTML(t)}"${filter.type === t ? " selected" : ""}>${escapeHTML(t)}</option>`).join("")}
<option value="__none__"${filter.type === "__none__" ? " selected" : ""}>без типа</option>
</select>
</label>
<label class="rf-field rf-narrow"><span>Мест не меньше</span>
<input id="rf-capacity" type="number" min="1" step="1" placeholder="например 30" value="${escapeHTML(filter.capacity)}">
</label>
<label class="rf-field rf-narrow"><span>Оснащение</span>
<select id="rf-mode">
<option value="and"${filter.mode === "and" ? " selected" : ""}>все выбранные (И)</option>
<option value="or"${filter.mode === "or" ? " selected" : ""}>любое из выбранных (ИЛИ)</option>
</select>
</label>
</div>
<div class="rf-equip">
${equipmentTags().map((tag) => `
<label class="rf-check"><input type="checkbox" data-equip="${escapeHTML(tag)}"${filter.equipment.includes(tag) ? " checked" : ""}> ${escapeHTML(tag)}</label>
`).join("")}
</div>
<div class="rf-hint hidden" id="rf-hint"></div>
<div class="rf-row rf-actions">
<button type="button" class="rf-reset" id="rf-reset">Сбросить фильтры</button>
<div class="rf-summary" id="rf-summary"></div>
</div>
`;
box.querySelectorAll(".rf-tab").forEach((btn) => {
btn.addEventListener("click", () => {
roomsState.mode = btn.dataset.mode;
drawRoomsScreen(roomsState.status);
});
});
const periodSel = box.querySelector("#rf-period");
if (periodSel) {
periodSel.addEventListener("change", () => {
roomsState.period = Number(periodSel.value);
refreshRoomsList();
});
}
box.querySelector("#rf-type").addEventListener("change", (e) => {
roomsState.filter.type = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelector("#rf-capacity").addEventListener("input", (e) => {
roomsState.filter.capacity = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelector("#rf-mode").addEventListener("change", (e) => {
roomsState.filter.mode = e.target.value;
saveRoomsFilter();
refreshRoomsList();
});
box.querySelectorAll("[data-equip]").forEach((input) => {
input.addEventListener("change", () => {
const tag = input.dataset.equip;
const set = new Set(roomsState.filter.equipment);
if (input.checked) set.add(tag); else set.delete(tag);
roomsState.filter.equipment = [...set];
saveRoomsFilter();
refreshRoomsList();
});
});
box.querySelector("#rf-reset").addEventListener("click", () => {
roomsState.filter = { type: "", capacity: "", equipment: [], mode: "and" };
saveRoomsFilter();
drawRoomsScreen(roomsState.status);
});
return box;
}
/** Перерисовать только список аудиторий (фильтры и поля ввода не трогаем). */
function refreshRoomsList() {
const old = scheduleEl.querySelector(".rooms-list, .rooms-empty");
const next = roomsListBlock();
if (old) old.replaceWith(next); else (scheduleEl.querySelector(".rooms-wrap") || scheduleEl).appendChild(next);
updateRoomsSummary();
}
function updateRoomsSummary() {
const summary = scheduleEl.querySelector("#rf-summary");
const hint = scheduleEl.querySelector("#rf-hint");
if (!summary) return;
const all = roomsAll();
const shown = visibleRooms();
const pieces = [`Показано ${shown.length} из ${all.length} аудиторий`];
if (roomsState.mode === "free") pieces.push(`свободны на паре ${roomsState.period}`);
summary.textContent = pieces.join(" · ");
const notes = [];
const min = parseInt(roomsState.filter.capacity, 10);
if (!isNaN(min) && min > 0) {
const unknown = all.filter((r) => r.capacity == null).length;
if (unknown) {
notes.push(`скрыто ${unknown} ${pluralRu(unknown, "аудитория", "аудитории", "аудиторий")}: в таблице нет данных о вместимости`);
}
}
if (hint) {
hint.textContent = notes
.map((text) => text.charAt(0).toUpperCase() + text.slice(1))
.join(". ");
hint.classList.toggle("hidden", !notes.length);
}
}
function visibleRooms() {
const filterFn = (room) => roomMatchesFilter(room);
if (roomsState.mode !== "free") return roomsAll().filter(filterFn);
return roomsAll().filter((room) => filterFn(room) && roomAvailableOn(room) && roomFreeAt(room, roomsState.period));
}
function roomsListBlock() {
const rooms = visibleRooms();
const list = document.createElement("div"); const list = document.createElement("div");
list.className = "rooms-list"; list.className = "rooms-list";
data.rooms.forEach((room) => { if (!rooms.length) {
const card = document.createElement("div"); list.className = "rooms-empty";
card.className = "room-card"; list.textContent = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры.";
return list;
}
const free = roomsState.mode === "free";
if (!free) {
const periodNums = [];
roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => {
if (!periodNums.includes(p)) periodNums.push(p);
}));
periodNums.sort((a, b) => Number(a) - Number(b));
rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums)));
return list;
}
rooms.forEach((room) => list.appendChild(roomsFreeCard(room)));
return list;
}
function roomsCardHead(room) {
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "day-head"; head.className = "day-head";
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`; const attrs = roomAttrsText(room);
card.appendChild(head); const info = roomInfoText(room);
head.innerHTML = `
<div class="room-title">
<span class="day-title">${escapeHTML(room.room)}</span>
${attrs ? `<div class="room-attrs">${escapeHTML(attrs)}</div>` : ""}
${info ? `<div class="room-info">${escapeHTML(info)}</div>` : ""}
${room.available_days ? `<div class="room-note days">занятия только ${escapeHTML(daysPhrase(room.available_days))}</div>` : ""}
</div>`;
return head;
}
function roomsDayCard(room, periodNums) {
const card = document.createElement("div");
card.className = "room-card";
card.appendChild(roomsCardHead(room));
const periods = room.periods || {}; const periods = room.periods || {};
periodNums.forEach((p) => { periodNums.forEach((p) => {
const slot = periods[p] || {}; const slot = periods[p] || {};
@@ -481,9 +802,18 @@ function buildRoomsList(data, periodNums) {
row.appendChild(body); row.appendChild(body);
card.appendChild(row); card.appendChild(row);
}); });
list.appendChild(card); return card;
}); }
return list;
function roomsFreeCard(room) {
const card = document.createElement("div");
card.className = "room-card room-card-free";
card.appendChild(roomsCardHead(room));
const note = document.createElement("div");
note.className = "room-free-note";
note.textContent = `Свободна на паре ${roomsState.period} (${roomsState.day})`;
card.appendChild(note);
return card;
} }
function escapeHTML(s) { function escapeHTML(s) {
@@ -521,6 +851,24 @@ themeToggle.addEventListener("click", () => {
initTheme(); initTheme();
/* Кнопки админки в шапке: сервер подставляет путь, только если сессия валидна */
const adminLink = document.getElementById("admin-link");
const adminLogout = document.getElementById("admin-logout");
if (adminLink && adminLogout && window.__SCHEDULE_ADMIN__) {
// путь к панели в разметке не храним: сервер сам ответит редиректом
adminLink.classList.remove("hidden");
adminLogout.classList.remove("hidden");
adminLink.addEventListener("click", () => { window.location.href = "/api/admin/goto"; });
adminLogout.addEventListener("click", async () => {
try {
await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" });
} catch (_) {}
adminLink.classList.add("hidden");
adminLogout.classList.add("hidden");
});
}
document.querySelectorAll(".side-menu a").forEach((link) => { document.querySelectorAll(".side-menu a").forEach((link) => {
link.addEventListener("click", (e) => { link.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();

View File

@@ -28,10 +28,14 @@
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a> <a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и&nbsp;вычислительной техники</a>
</div> </div>
</div> </div>
<div class="topbar-actions">
<button id="admin-link" class="topbar-admin hidden" type="button">Админка</button>
<button id="theme-toggle" class="theme-toggle" title="Переключить тему"> <button id="theme-toggle" class="theme-toggle" title="Переключить тему">
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> <svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg> <svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button> </button>
<button id="admin-logout" class="topbar-logout hidden" type="button">Выйти</button>
</div>
</div> </div>
<div class="layout"> <div class="layout">

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /api/

View File

@@ -112,7 +112,10 @@ a:hover { color: var(--accent-red); }
min-width: 0; min-width: 0;
} }
.topbar-brand > a { display: inline-flex; }
.topbar-logo { .topbar-logo {
display: block;
width: 46px; width: 46px;
height: 46px; height: 46px;
flex-shrink: 0; flex-shrink: 0;
@@ -151,6 +154,40 @@ a:hover { color: var(--accent-red); }
} }
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); } .theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
/* ---- кнопки в шапке: админка и выход ---- */
.topbar-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.topbar-admin {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--on-accent);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: background .15s ease, border-color .15s ease;
}
.topbar-admin:hover { background: var(--accent-blue); border-color: var(--accent-blue); }
/* красная кнопка выхода: при наведении темнеет, как остальные кнопки */
.topbar-logout {
display: inline-flex;
align-items: center;
height: 36px;
padding: 0 14px;
border: 1px solid var(--accent-red-bright);
background: var(--accent-red-bright);
color: #fff;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.topbar-logout:hover { background: var(--accent-red); border-color: var(--accent-red); }
.theme-toggle .icon { width: 18px; height: 18px; } .theme-toggle .icon { width: 18px; height: 18px; }
.icon-sun { display: none; } .icon-sun { display: none; }
@@ -651,6 +688,77 @@ main {
/* ---- аудитории: список карточками (мобильная версия) ---- */ /* ---- аудитории: список карточками (мобильная версия) ---- */
.rooms-list { display: flex; flex-direction: column; } .rooms-list { display: flex; flex-direction: column; }
/* ---- фильтры аудиторий ---- */
.rooms-filters {
background: var(--surface);
border: 1px solid var(--border);
padding: 12px 14px;
margin-bottom: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rf-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
.rf-tabs { align-items: center; }
.rf-tab {
font-size: 14px;
font-weight: 600;
padding: 7px 12px;
cursor: pointer;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
}
.rf-tab.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.rf-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 0 1 260px; }
.rf-field > span { font-size: 12px; color: var(--muted); }
.rf-field select,
.rf-field input {
font-size: 14px;
padding: 7px 9px;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
width: 100%;
min-width: 0;
}
.rf-narrow { flex: 0 1 170px; }
.rf-equip { display: flex; flex-wrap: wrap; gap: 8px 16px; padding-top: 2px; }
.rf-check { display: flex; align-items: center; gap: 6px; font-size: 14px; color: var(--text); cursor: pointer; }
.rf-actions { justify-content: space-between; align-items: baseline; }
.rf-reset {
font-size: 13px;
padding: 7px 10px;
cursor: pointer;
border: 1px solid var(--border-2);
background: var(--surface-2);
color: var(--text);
}
.rf-summary { font-size: 13px; color: var(--muted); }
.rf-hint { font-size: 12px; color: #b8860b; }
.rooms-empty {
background: var(--surface);
border: 1px dashed var(--border-2);
padding: 18px;
color: var(--muted);
font-size: 14px;
}
.room-card-free { padding-bottom: 12px; }
.room-attrs { font-size: 13px; font-weight: 600; color: var(--text); margin-top: 3px; }
.room-note { font-size: 12px; color: var(--muted); margin-top: 2px; }
/* пометка про дни доступности — заметная, жёлтым */
.room-note.days { color: #b8860b; font-weight: 600; }
[data-theme="dark"] .room-note.days { color: #e8b339; }
.room-free-note { font-size: 13px; color: var(--muted); margin-top: 10px; }
.room-card { .room-card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);

View File

@@ -1,13 +1,12 @@
"""Тесты админки: вход по cookie, смена ссылки/интервала, принудительное обновление.""" """Тесты админки: вход, блокировка после неудач, история входов, ссылка/интервал/force."""
import json import json
import time
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
import main import main
from admin import LOGIN_MAX_FAILURES, AdminConfig, make_token, verify_token from admin import AdminConfig, make_token, verify_token
from fixtures import fixture_xlsx, make_cache from fixtures import fixture_xlsx, make_cache
from schedule import ScheduleError from schedule import ScheduleError
from schedule.settings import SettingsStore from schedule.settings import SettingsStore
@@ -29,8 +28,8 @@ def cache(monkeypatch, store):
@pytest.fixture() @pytest.fixture()
def client(cache): def client(cache, store):
app = main.create_app(cache, admin=ADMIN) app = main.create_app(cache, admin=ADMIN, settings=store)
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
@@ -75,6 +74,19 @@ def test_login_and_state(client, cache):
assert state["interval_minutes"] == 15 assert state["interval_minutes"] == 15
assert state["groups"] == 2 assert state["groups"] == 2
assert state["error"] is None assert state["error"] is None
assert state["throttle"]["max_attempts"] == ADMIN.max_attempts
# путь к панели не должен утекать в JSON: переход идёт через /goto
assert "admin_path" not in state
assert ADMIN.path not in str(state)
def test_goto_redirects_to_panel_only_for_authorized(client):
"""Переход в панель: без сессии 401, с сессией — редирект на секретный путь."""
assert client.get("/api/admin/goto", follow_redirects=False).status_code == 401
do_login(client)
resp = client.get("/api/admin/goto", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["location"] == ADMIN.path
def test_logout(client): def test_logout(client):
@@ -84,14 +96,6 @@ def test_logout(client):
assert client.get("/api/admin/state").status_code == 401 assert client.get("/api/admin/state").status_code == 401
def test_login_throttled_after_failures(client):
for _ in range(LOGIN_MAX_FAILURES):
assert do_login(client, password="неверный").status_code == 401
# дальше — отказ, даже с верным паролем, пока не истекло окно
assert do_login(client, password="неверный").status_code == 429
assert do_login(client, password="s3cret").status_code == 429
def test_token_helpers(): def test_token_helpers():
token = make_token("admin", "s3cret", ttl=60) token = make_token("admin", "s3cret", ttl=60)
assert verify_token(token, "s3cret") assert verify_token(token, "s3cret")
@@ -101,7 +105,51 @@ def test_token_helpers():
assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret") assert not verify_token(make_token("admin", "s3cret", ttl=-5), "s3cret")
# --- интервал ------------------------------------------------------------------ # --- блокировка после неудачных попыток ------------------------------------------
def test_lockout_after_configured_attempts(client):
"""Разрешённые попытки тратятся, следующая попытка включает паузу."""
for _ in range(ADMIN.max_attempts - 1):
resp = do_login(client, password="неверный")
assert resp.status_code == 401
assert "Осталось попыток" in resp.json()["detail"]
exhausted = do_login(client, password="неверный")
assert exhausted.status_code == 429
assert "заблокирован" in exhausted.json()["detail"].lower()
# пауза действует и для верного пароля
assert do_login(client, password="s3cret").status_code == 429
def test_successful_login_resets_failures(client):
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
# счётчик сброшен: ещё две неудачи не блокируют
assert do_login(client, password="неверный").status_code == 401
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
# --- история входов ---------------------------------------------------------------
def test_login_history_visible_and_saved(client, store):
assert do_login(client, password="неверный").status_code == 401
assert do_login(client).status_code == 200
state = client.get("/api/admin/state").json()
assert state["last_attempt"]["ok"] is True
assert state["last_attempt"]["ip"] # откуда
assert state["last_attempt"]["at"] # когда
assert state["last_success"]["ip"] == state["last_attempt"]["ip"]
saved = json.loads(store.path.read_text(encoding="utf-8"))
assert saved["last_attempt"]["ok"] is True
assert saved["last_success"]["at"]
# --- интервал --------------------------------------------------------------------
def test_set_interval(client, cache, store): def test_set_interval(client, cache, store):
@@ -112,11 +160,9 @@ def test_set_interval(client, cache, store):
assert cache.interval_minutes == 30 assert cache.interval_minutes == 30
assert cache._ttl == 1800 assert cache._ttl == 1800
assert client.get("/api/admin/state").json()["interval_minutes"] == 30 assert client.get("/api/admin/state").json()["interval_minutes"] == 30
# сохранилось на диск saved = json.loads(store.path.read_text(encoding="utf-8"))
saved = json.loads((store._path).read_text(encoding="utf-8"))
assert saved["interval_minutes"] == 30 assert saved["interval_minutes"] == 30
# новый процесс видит сохранённое значение assert SettingsStore(path=store.path).interval_minutes == 30
assert SettingsStore(path=store._path).interval_minutes == 30
@pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)]) @pytest.mark.parametrize("value, status", [(0, 400), (1441, 400), (-5, 400), ("abc", 422)])
@@ -134,10 +180,9 @@ def test_set_link(client, cache, store):
before = cache.calls["download"] before = cache.calls["download"]
resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "}) resp = client.post("/api/admin/link", json={"link": " https://disk.yandex.ru/i/new "})
assert resp.status_code == 200 assert resp.status_code == 200
state = resp.json() assert resp.json()["link"] == "https://disk.yandex.ru/i/new"
assert state["link"] == "https://disk.yandex.ru/i/new"
assert cache.calls["download"] == before + 1 # перечитали таблицу assert cache.calls["download"] == before + 1 # перечитали таблицу
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] == "https://disk.yandex.ru/i/new" assert json.loads(store.path.read_text(encoding="utf-8"))["link"] == "https://disk.yandex.ru/i/new"
def test_set_link_rejects_broken(client, cache, store): def test_set_link_rejects_broken(client, cache, store):
@@ -147,12 +192,10 @@ def test_set_link_rejects_broken(client, cache, store):
resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"}) resp = client.post("/api/admin/link", json={"link": "https://disk.yandex.ru/i/broken"})
assert resp.status_code == 400 assert resp.status_code == 400
assert "не подошла" in resp.json()["detail"] assert "не подошла" in resp.json()["detail"]
# ссылка и данные не изменились
assert cache.state()["link"] == old_link assert cache.state()["link"] == old_link
assert cache.state()["has_data"] is True assert cache.state()["has_data"] is True
# в файл настроек нерабочая ссылка не попала if store.path.exists():
if store._path.exists(): assert json.loads(store.path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
assert json.loads(store._path.read_text(encoding="utf-8"))["link"] != "https://disk.yandex.ru/i/broken"
def test_set_empty_link(client, cache): def test_set_empty_link(client, cache):
@@ -167,9 +210,9 @@ def test_set_empty_link(client, cache):
def test_force_refresh_ignores_ttl_and_modified(client, cache): def test_force_refresh_ignores_ttl_and_modified(client, cache):
do_login(client) do_login(client)
client.get("/api/admin/state") # кэш свежий, дальше без принуждения не качали бы client.get("/api/admin/state")
downloads = cache.calls["download"] downloads = cache.calls["download"]
cache._ttl = 3600 cache._ttl = 3600 # кэш свежий; обычное обновление не сработало бы
resp = client.post("/api/admin/refresh") resp = client.post("/api/admin/refresh")
assert resp.status_code == 200 assert resp.status_code == 200
assert cache.calls["download"] == downloads + 1 assert cache.calls["download"] == downloads + 1
@@ -182,15 +225,14 @@ def test_force_refresh_reports_error(client, cache):
resp = client.post("/api/admin/refresh") resp = client.post("/api/admin/refresh")
assert resp.status_code == 502 assert resp.status_code == 502
assert cache.state()["error"] is not None assert cache.state()["error"] is not None
# старые данные не потеряли assert cache.state()["has_data"] is True # старые данные не потеряли
assert cache.state()["has_data"] is True
# --- выключенная админка ----------------------------------------------------------- # --- выключенная админка -----------------------------------------------------------
def test_admin_disabled_without_credentials(cache): def test_admin_disabled_without_credentials(cache, store):
app = main.create_app(cache, admin=AdminConfig()) app = main.create_app(cache, admin=AdminConfig(), settings=store)
with TestClient(app) as c: with TestClient(app) as c:
assert c.get("/api/admin/state").status_code == 404 assert c.get("/api/admin/state").status_code == 404
assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404 assert c.post("/api/admin/login", json={"login": "a", "password": "b"}).status_code == 404

View File

@@ -171,11 +171,7 @@ def test_cache_no_repeated_download(client, cache):
def test_modified_unchanged_no_download(cache): def test_modified_unchanged_no_download(cache):
"""По интервалу сверяем modified: если таблица не менялась — не скачиваем. """По интервалу сверяем modified: если таблица не менялась — не скачиваем."""
Принудительное обновление из админки при этом качает файл всегда
(см. test_force_refresh_ignores_ttl_and_modified).
"""
cache._ttl = 0 cache._ttl = 0
cache.get() cache.get()
cache.get() cache.get()
@@ -183,7 +179,7 @@ def test_modified_unchanged_no_download(cache):
assert cache.calls["modified"] >= 2 assert cache.calls["modified"] >= 2
def test_state_reports_file_changes(monkeypatch): def test_status_reports_file_changes(monkeypatch):
"""Кэш показывает, изменилось ли содержимое файла при последнем чтении.""" """Кэш показывает, изменилось ли содержимое файла при последнем чтении."""
import schedule.cache as cache_mod import schedule.cache as cache_mod
from fixtures import fixture_sheets from fixtures import fixture_sheets
@@ -194,25 +190,30 @@ def test_state_reports_file_changes(monkeypatch):
sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}" sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}"
return build_workbook(sheets) return build_workbook(sheets)
files = {"data": xlsx_with_room("215")} files = {"data": xlsx_with_room("215"), "modified": "m1"}
monkeypatch.setattr(cache_mod, "fetch_table_modified", lambda url, timeout=20.0: "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"]) monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"])
cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60) cache = ScheduleCache("test-link", ttl_seconds=0, retry_after=60)
cache.get() cache.get()
first = cache.state() first = cache.status()
assert first["content_hash"] assert first["content_hash"]
assert first["changed"] is False # первая загрузка — не «изменение» assert first["changed"] is False # первая загрузка — не «изменение»
assert first["changed_at"] is not None assert first["changed_at"] is not None
files["data"] = xlsx_with_room("216") files["data"] = xlsx_with_room("216")
cache.force_reload() files["modified"] = "m2"
changed = cache.state() cache.get() # ttl=0 и новый modified — перечитываем таблицу
changed = cache.status()
assert changed["changed"] is True assert changed["changed"] is True
assert changed["content_hash"] != first["content_hash"] assert changed["content_hash"] != first["content_hash"]
cache.force_reload() # новая ревизия файла с тем же содержимым → скачали, но изменений нет
assert cache.state()["changed"] is False # тот же файл — изменений нет files["modified"] = "m3"
cache.get()
assert cache.status()["changed"] is False
def test_cache_stale_while_error(cache): def test_cache_stale_while_error(cache):
@@ -262,3 +263,31 @@ def test_cache_without_link():
assert c.get("/groups").status_code == 502 assert c.get("/groups").status_code == 502
body = c.get("/status").json() body = c.get("/status").json()
assert "Ссылка" in body["error"] assert "Ссылка" in body["error"]
def test_rooms_day_and_free_include_attributes(monkeypatch):
"""Аудитории отдаются с типом/вместимостью/оснащением и учётом доступных дней."""
from fixtures import fixture_sheets
from xlsx_builder import build_workbook
sheets = fixture_sheets()
rooms = sheets["аудитории"]
rooms.rows[0].append("402")
rooms.rows[1].append("402 четверг пятница")
cache, _, _ = make_cache(monkeypatch, build_workbook(sheets))
with TestClient(main.create_app(cache)) as client:
day = client.get("/rooms-day", params={"day": "понедельник"}).json()
by_label = {r["room"]: r for r in day["rooms"]}
room215 = by_label["ауд. 215"]
assert room215["type"] == "комп.класс"
assert room215["capacity"] == 12
assert room215["equipment"] == ["ПК"]
assert by_label["ауд. 402"]["available_days"] == ["четверг", "пятница"]
free_monday = client.get("/rooms-free", params={"day": "понедельник", "period": 1}).json()
assert not any(r["room"] == "ауд. 402" for r in free_monday)
assert all("capacity" in r and "equipment" in r for r in free_monday)
free_thursday = client.get("/rooms-free", params={"day": "четверг", "period": 1}).json()
assert any(r["room"] == "ауд. 402" for r in free_thursday)

View File

@@ -1,7 +1,8 @@
"""Проверки сборки образов: все нужные файлы должны копироваться. """Проверки сборки образов: все нужные файлы должны копироваться.
Ошибка «в Dockerfile забыли COPY admin.py» роняет контейнер на импорте, а Ошибка «в Dockerfile забыли COPY какого-то модуля» роняет контейнер на импорте,
локальные тесты её не видят (они запускаются из исходников). Ловим статически. а тесты из исходников её не видят (они запускаются из рабочей копии). Ловим
статически, без docker.
""" """
from pathlib import Path from pathlib import Path
@@ -33,15 +34,18 @@ def test_frontend_image_copies_page_and_static():
assert path in stage, f"{path} не копируется в образ frontend" assert path in stage, f"{path} не копируется в образ frontend"
def test_compose_passes_admin_settings(): def test_compose_passes_required_env():
compose = COMPOSE.read_text(encoding="utf-8") compose = COMPOSE.read_text(encoding="utf-8")
for var in ( for var in (
"SCHEDULE_LINK",
"SCHEDULE_INTERVAL_MINUTES",
"SCHEDULE_API",
"ADMIN_PATH", "ADMIN_PATH",
"ADMIN_LOGIN", "ADMIN_LOGIN",
"ADMIN_PASSWORD", "ADMIN_PASSWORD",
"SCHEDULE_INTERVAL_MINUTES", "ADMIN_MAX_ATTEMPTS",
"ADMIN_LOCKOUT_MINUTES",
"SETTINGS_FILE", "SETTINGS_FILE",
"SCHEDULE_API",
): ):
assert var in compose, f"{var} не передаётся контейнерам" assert var in compose, f"{var} не передаётся контейнерам"
assert "schedule_data:/data" in compose, "том для настроек админки не подключён" assert "schedule_data:/data" in compose, "том для настроек админки не подключён"

View File

@@ -14,7 +14,7 @@ def lessons(text):
def one(text): def one(text):
ls = parse_lesson_cell(text) ls = parse_lesson_cell(text)
assert len(ls) == 1, f"ожидалося 1 занятие, got: {ls}" assert len(ls) == 1, f"ожидалось 1 занятие, got: {ls}"
return ls[0].to_dict() return ls[0].to_dict()
@@ -295,6 +295,53 @@ def test_lesson_with_red_between_lessons():
assert ls[1]["subject"] == "Язык UML и CASE-системы" assert ls[1]["subject"] == "Язык UML и CASE-системы"
# --- оторвавшиеся пустой строкой преподаватель/аудитория ---------------------------
def test_room_detached_by_blank_line():
# ПИЭ-31БО, вторник: «ауд.210» оторвана пустой строкой от предмета
result = one("Программная инженерия\nЛарина Ю.А.\n\n ауд.210")
assert result["subject"] == "Программная инженерия"
assert result["teacher"] == "Ларина Ю.А."
assert result["room"] == "ауд. 210"
def test_teacher_and_room_detached_by_red_note():
# ИВТ-13БО, четверг: красная заметка снята со своей строки, осталась пустая
raw = f"Практикум по матанализу\n{R}пара для тех кто не ТОП{Cc}\nШабаршина Г.В.\nауд. 219"
result = one(raw)
assert result["subject"] == "Практикум по матанализу"
assert result["teacher"] == "Шабаршина Г.В."
assert result["room"] == "ауд. 219"
assert result["note"] == "пара для тех кто не ТОП"
def test_detached_room_inherits_marker():
ls = lessons("к.в. - 2\nФизика\nИванов И.И.\n\nауд. 301")
assert len(ls) == 1
assert ls[0]["marker"] == "к.в. - 2"
assert ls[0]["room"] == "ауд. 301"
def test_detached_room_kept_if_prev_has_room():
ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nауд. 222")
assert len(ls) == 2
assert ls[0]["room"] == "ауд. 221"
def test_detached_teacher_kept_if_prev_has_teacher():
ls = lessons("Физика\nСмирнов А.В.\nауд. 221\n\nПетров П.П.\nауд. 222")
assert len(ls) == 2
assert ls[0]["teacher"] == "Смирнов А.В."
def test_lesson_after_detached_room_starts_fresh():
ls = lessons("Физика\nСмирнов А.В.\n\nауд. 221\n\nХимия\nПетров П.П.\nауд. 222")
assert len(ls) == 2
assert ls[0]["room"] == "ауд. 221"
assert ls[1]["subject"] == "Химия"
# --- пустые и экзотические ---------------------------------------------------------- # --- пустые и экзотические ----------------------------------------------------------
@@ -337,6 +384,14 @@ def test_inline_lesson_after_room_no_blank_line():
assert ls[1]["room"] == "ауд. 231" assert ls[1]["room"] == "ауд. 231"
def test_full_name_three_words_after_subject():
"""«Предмет Денис Сергеевич Верховцев (Сигма)» — ФИО не должно попасть в предмет."""
result = one("Основы тестирования веб-приложений Денис Сергеевич Верховцев (Сигма)\nонлайн")
assert result["subject"] == "Основы тестирования веб-приложений"
assert result["teacher"] == "Денис Сергеевич Верховцев (Сигма)"
assert result["room"] == "онлайн"
def test_plain_time_paren_line(): def test_plain_time_paren_line():
result = one("Параллельное программирование (пр)\nМещеряков И.А.\nауд. 213\n(начало пары в 12:30)") result = one("Параллельное программирование (пр)\nМещеряков И.А.\nауд. 213\n(начало пары в 12:30)")
assert result["note"] == "начало пары в 12:30" assert result["note"] == "начало пары в 12:30"
@@ -373,3 +428,20 @@ def test_inline_note_stays_with_its_lesson():
ls = lessons(raw) ls = lessons(raw)
assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет" assert ls[0]["note"] == "с 7.09 по 20.09 пары не будет"
assert ls[1]["note"] == "" assert ls[1]["note"] == ""
@pytest.mark.parametrize(
"teacher, expected",
[
("Верховцев Денис Сергеевич", ["Верховцев Д.С."]),
("Денис Сергеевич Верховцев", ["Верховцев Д.С."]),
("Денис Верховцев (Сигма)", ["Верховцев Д."]),
("Лабеев Игорь Владимирович", ["Лабеев И.В."]),
("ПравоТех", ["ПравоТех"]),
],
)
def test_teacher_keys_order(teacher, expected):
"""Ключ преподавателя — «Фамилия И.О.» в любом порядке ФИО в таблице."""
from schedule.lessons import extract_teacher_names
assert extract_teacher_names(teacher) == expected

View File

@@ -145,6 +145,18 @@ def test_teacher_room_index(data):
assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"] assert room_301["2"]["ч"][0]["groups"] == ["ИВТ-11БО"]
def test_room_index_detached_room():
"""Аудитория, оторванная пустой строкой, попадает в индекс с предметом и преподавателем."""
sheets = fixture_sheets()
sheets[parsing.MAIN_SHEET].rows[6][7] = "Химия\nПетров П.П.\n\n ауд. 201"
data = parsing.parse_schedule(make_sheets(sheets))
_, rooms = parsing.build_teacher_room_index(data)
entry = rooms["ауд. 201"]["вторник"]["1"]["ч"][0]
assert entry["subject"] == "Химия"
assert entry["teacher"] == "Петров П.П."
assert entry["groups"] == ["ИТ-12БО"]
def test_day_times(data): def test_day_times(data):
times = parsing.build_day_times(data) times = parsing.build_day_times(data)
assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"} assert times["понедельник"] == {"1": "9:00-10:35", "2": "10:45-12:20"}
@@ -164,11 +176,20 @@ def test_group_schedule_shape(data):
def test_rooms_info(sheets): def test_rooms_info(sheets):
info = parsing.parse_rooms_info(sheets) info = parsing.parse_rooms_info(sheets)
assert info == { assert set(info) == {"ауд. 215", "ауд. 301", "ауд. 220"}
"ауд. 215": {"room": "ауд. 215", "info": "комп.класс, 12 мест"}, assert info["ауд. 215"]["info"] == "комп.класс, 12 мест"
"ауд. 301": {"room": "ауд. 301", "info": "интерактивная панель, 24 места"}, assert info["ауд. 215"]["type"] == "комп.класс"
"ауд. 220": {"room": "ауд. 220", "info": "20 мест"}, assert info["ауд. 215"]["capacity"] == 12
} assert info["ауд. 215"]["equipment"] == ["ПК"]
assert info["ауд. 301"]["info"] == "интерактивная панель, 24 места"
assert info["ауд. 301"]["capacity"] == 24
assert info["ауд. 301"]["equipment"] == ["интерактивная панель"]
assert info["ауд. 220"]["info"] == "20 мест"
assert info["ауд. 220"]["type"] == ""
assert info["ауд. 220"]["capacity"] == 20
assert info["ауд. 220"]["equipment"] == []
def test_teachers_full(sheets): def test_teachers_full(sheets):
@@ -193,3 +214,144 @@ def test_build_room_schedule(data):
assert set(out["days"]) == {"понедельник", "вторник"} assert set(out["days"]) == {"понедельник", "вторник"}
assert out["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Физика" assert out["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Физика"
assert parsing.build_room_schedule(rooms, "ауд. 999", times) is None assert parsing.build_room_schedule(rooms, "ауд. 999", times) is None
def test_room_attributes_from_descriptions():
"""Атрибуты аудиторий разбираются из описаний вида «тип; оснащение; N мест»."""
from xlsx_builder import SheetSpec
descriptions = {
"201": "комп.класс МО и СТ; без проектора; маркер; 12 мест",
"203": "аудитория для практических; без проектора; меловая доска; 44 места",
"210": "комп.класс; проектор; маркер; 28 мест",
"305": "кабинет английского; телевизор; маркер; 24 места",
"402": "402 четверг пятница",
"405": "пк",
"410": "",
}
spec = SheetSpec(rows=[
[None] * 6 + list(descriptions.keys()),
[None] * 6 + list(descriptions.values()),
])
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
assert info["ауд. 201"]["type"] == "комп.класс"
assert info["ауд. 201"]["capacity"] == 12
# «без проектора» — проектора нет, хотя слово в тексте есть
assert info["ауд. 201"]["equipment"] == ["ПК", "маркер"]
assert info["ауд. 201"]["available_days"] is None
assert info["ауд. 203"]["type"] == "аудитория для практических"
assert info["ауд. 203"]["equipment"] == ["меловая доска"]
assert info["ауд. 203"]["capacity"] == 44
assert info["ауд. 210"]["equipment"] == ["ПК", "проектор", "маркер"]
assert info["ауд. 305"]["type"] == "кабинет английского"
assert info["ауд. 305"]["equipment"] == ["телевизор", "маркер"]
assert info["ауд. 402"]["available_days"] == ["четверг", "пятница"]
assert info["ауд. 402"]["capacity"] is None
assert info["ауд. 402"]["type"] == ""
assert info["ауд. 405"]["equipment"] == ["ПК"]
assert info["ауд. 410"]["info"] == ""
assert info["ауд. 410"]["type"] == ""
def test_room_payload_shape():
payload = parsing.room_payload("ауд. 215", {
"info": "лекционная; проектор; 96 мест",
"type": "лекционная",
"type_group": "лекционная",
"capacity": 96,
"equipment": ["проектор"],
"available_days": None,
})
assert payload == {
"room": "ауд. 215",
"info": "лекционная; проектор; 96 мест",
"type": "лекционная",
"type_group": "лекционная",
"capacity": 96,
"equipment": ["проектор"],
"available_days": None,
}
def test_room_type_group_merges_similar():
"""Близкие формулировки типов сводятся к общему типу для фильтра."""
assert parsing.room_type_group("комп.класс") == "комп.класс"
assert parsing.room_type_group("комп.класс Linux") == "комп.класс"
assert parsing.room_type_group("компьютерный класс") == "комп.класс"
assert parsing.room_type_group("английский") == "кабинет английского"
assert parsing.room_type_group("кабинет английского") == "кабинет английского"
assert parsing.room_type_group("ауд. каф. ДА") == "аудитория кафедры"
assert parsing.room_type_group("лекционная") == "лекционная"
assert parsing.room_type_group("лекционно-практическая") == "лекционно-практическая"
assert parsing.room_type_group("аудитория для практических") == "аудитория для практических"
assert parsing.room_type_group("") == ""
def test_lowercase_except_abbreviations():
"""Обозначения — в нижнем регистре, аббревиатуры остаются заглавными."""
assert parsing.room_type("комп.класс Linux; 10 мест") == "комп.класс linux"
assert parsing.room_type("Кабинет Английского") == "кабинет английского"
assert parsing.room_equipment("ПК, Проектор, интернет МО") == ["ПК", "проектор"]
assert parsing.lower_except_abbr("МО и СТ; без проектора") == "МО и СТ; без проектора"
def test_room_visible_column_wins_over_hidden_duplicate():
"""Скрытый столбец-дубль (у 223) не парсится вовсе — берём видимый."""
from xlsx_builder import SheetSpec
spec = SheetSpec(
rows=[
[None] * 6 + ["223", "223"],
[None] * 6 + [
"комп.класс Linux/Windows; интерактивная панель; маркер; 20 мест",
"компьютерный класс для МО и СТ; 24 места",
],
],
hidden_cols=[(7, 7)],
)
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
room = info["ауд. 223"]
assert room["capacity"] == 20
assert "интерактивная панель" in room["equipment"]
assert room["type"] == "комп.класс linux/windows"
def test_room_only_in_hidden_column_not_parsed():
"""Скрытое не парсим: аудитория со скрытым столбцом описания не получает."""
from xlsx_builder import SheetSpec
spec = SheetSpec(
rows=[
[None] * 6 + ["402", "410"],
[None] * 6 + ["402 четверг пятница", "проектор; 30 мест"],
],
hidden_cols=[(6, 7)],
)
info = parsing.parse_rooms_info(make_sheets({parsing.ROOMS_SHEET: spec}))
assert info == {}
def test_hidden_rows_not_parsed(monkeypatch):
"""Скрытые строки листа в расписание не попадают."""
from fixtures import fixture_sheets
from xlsx_builder import SheetSpec, build_workbook
sheets = fixture_sheets()
main = sheets[parsing.MAIN_SHEET]
# строки: 0 заголовок, 1 с/по, 2..5 понедельник, 6..7 вторник — скроем строку с первым вторником
spec = SheetSpec(
rows=main.rows,
merges=main.merges,
hidden_cols=main.hidden_cols,
hidden_rows=[6],
)
data = parsing.parse_schedule(make_sheets({parsing.MAIN_SHEET: spec}))
tuesday = data["ИВТ-11БО"]["days"].get("вторник", {})
assert not tuesday, "строка вторника скрыта — занятий быть не должно"
assert data["ИВТ-11БО"]["days"]["понедельник"][1]["ч"] # понедельник на месте

View File

@@ -1,4 +1,4 @@
"""Тесты хранилища настроек администратора (ссылка, интервал).""" """Тесты хранилища настроек админки: ссылка, интервал, история входов."""
import json import json
@@ -17,6 +17,8 @@ def test_defaults():
store = SettingsStore() store = SettingsStore()
assert store.link == "" assert store.link == ""
assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES assert store.interval_minutes == DEFAULT_INTERVAL_MINUTES
assert store.last_attempt is None
assert store.last_success is None
def test_persist_and_reload(tmp_path): def test_persist_and_reload(tmp_path):
@@ -62,20 +64,46 @@ def test_corrupt_file_is_tolerated(tmp_path):
assert store.interval_minutes == 7 assert store.interval_minutes == 7
def test_no_path_keeps_settings_in_memory(): def test_login_history_saved(tmp_path):
store = SettingsStore(link="l", interval_minutes=3) path = tmp_path / "settings.json"
assert store.set_interval_minutes(10) == 10 store = SettingsStore(link="l", path=path)
assert store.interval_minutes == 10
assert store.set_link("l2") == "l2" store.record_login("10.0.0.1", ok=False)
assert store.link == "l2" assert store.last_attempt["ip"] == "10.0.0.1"
assert store.last_attempt["ok"] is False
assert store.last_attempt["at"]
assert store.last_success is None # успешных ещё не было
store.record_login("10.0.0.2", ok=True)
assert store.last_success["ip"] == "10.0.0.2"
# история переживает перезапуск: читаем тем же путём
again = SettingsStore(path=path)
assert again.last_attempt["ip"] == "10.0.0.2"
assert again.last_attempt["ok"] is True
assert again.last_success["ip"] == "10.0.0.2"
def test_unwritable_path_does_not_crash(tmp_path): def test_unwritable_path_marks_save_error(tmp_path):
blocker = tmp_path / "blocker" blocker = tmp_path / "blocker"
blocker.write_text("x", encoding="utf-8") blocker.write_text("x", encoding="utf-8")
store = SettingsStore(link="l", interval_minutes=3, path=blocker / "settings.json") store = SettingsStore(link="l", interval_minutes=3, path=blocker / "settings.json")
assert store.set_interval_minutes(10) == 10 assert store.set_interval_minutes(10) == 10 # в памяти меняется
assert store.interval_minutes == 10 assert store.interval_minutes == 10
assert store.save_error # и видно, что на диск не сохранилось
def test_default_settings_path_is_inside_project():
"""Путь по умолчанию вычисляется от корня проекта, а не от глубины файла.
В docker-образе файлы лежат плоско (/app/...), поэтому глубину не считаем.
"""
from schedule.settings import DEFAULT_SETTINGS_PATH
assert DEFAULT_SETTINGS_PATH.name == "settings.json"
assert DEFAULT_SETTINGS_PATH.parent.name == "data"
root = DEFAULT_SETTINGS_PATH.parent.parent
assert (root / "requirements.txt").is_file() # корень найден по маркеру
def test_clamp_interval(): def test_clamp_interval():

View File

@@ -28,6 +28,7 @@ class SheetSpec:
rows: list[list] rows: list[list]
merges: list[str] = field(default_factory=list) merges: list[str] = field(default_factory=list)
hidden_cols: list[tuple[int, int]] = field(default_factory=list) # 0-based, инклязивно hidden_cols: list[tuple[int, int]] = field(default_factory=list) # 0-based, инклязивно
hidden_rows: list[int] = field(default_factory=list) # 0-based
def build_workbook(sheets: dict[str, SheetSpec]) -> bytes: def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
@@ -83,7 +84,8 @@ def build_workbook(sheets: dict[str, SheetSpec]) -> bytes:
t = f' t="{t_attr}"' if t_attr else "" t = f' t="{t_attr}"' if t_attr else ""
cells.append(f'<c r="{col_letter(c)}{r}"{t}><v>{escape(str(v))}</v></c>') cells.append(f'<c r="{col_letter(c)}{r}"{t}><v>{escape(str(v))}</v></c>')
if cells: if cells:
row_xml.append(f'<row r="{r}">{"".join(cells)}</row>') hidden = ' hidden="1"' if (r - 1) in spec.hidden_rows else ""
row_xml.append(f'<row r="{r}"{hidden}>{"".join(cells)}</row>')
merges_xml = "" merges_xml = ""
if spec.merges: if spec.merges: