Compare commits

..

14 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
17 changed files with 1357 additions and 242 deletions

View File

@@ -302,6 +302,28 @@ ADMIN_PASSWORD=<длинный пароль>
адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия — адреса блокируется на `ADMIN_LOCKOUT_MINUTES` минут (по умолчанию 15). Сессия —
подписанная cookie на 12 часов. подписанная 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` Настройки (ссылка, интервал, история входов) хранятся в `SETTINGS_FILE`
(по умолчанию `data/settings.json` в корне проекта; в docker — в томе (по умолчанию `data/settings.json` в корне проекта; в docker — в томе
`schedule_data`). Если файл недоступен для записи, изменения живут в памяти до `schedule_data`). Если файл недоступен для записи, изменения живут в памяти до
@@ -413,6 +435,19 @@ tests/
- Длинные ссылки в карточке переносятся по служебным символам и каждые - Длинные ссылки в карточке переносятся по служебным символам и каждые
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`.
@@ -430,12 +465,24 @@ 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` | состояние кэша (`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`.

View File

@@ -28,6 +28,7 @@ from dataclasses import dataclass
from datetime import datetime 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
@@ -245,6 +246,8 @@ def create_admin_router(
ip = _client_ip(request) ip = _client_ip(request)
until = throttle.locked_until(ip) until = throttle.locked_until(ip)
payload = cache.state() payload = cache.state()
# путь к панели здесь НЕ отдаём: он не должен попадать ни в JSON, ни в
# HTML — переход в панель идёт через /api/admin/goto (редирект)
payload["throttle"] = { payload["throttle"] = {
"max_attempts": config.max_attempts, "max_attempts": config.max_attempts,
"lockout_minutes": config.lockout_minutes, "lockout_minutes": config.lockout_minutes,
@@ -253,6 +256,20 @@ def create_admin_router(
} }
return payload 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:
try: try:

View File

@@ -264,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")
@@ -278,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):
@@ -288,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

@@ -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
@@ -579,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]:
"""Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса). """Строка преподавателя -> список коротких имён «Фамилия И.О.» (ключи индекса).
@@ -590,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

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

@@ -7,46 +7,112 @@
<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: 760px; 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-warn { color: #b8860b; font-size: 13px; margin-top: 10px; word-break: break-word; }
.admin-hint { color: var(--muted-2); font-size: 12px; margin-top: 8px; }
.hidden { display: none !important; }
.bad { color: var(--accent-red-bright); }
.good { color: #2f9e5f; }
/* всплывающие уведомления вместо 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; left: 50%; bottom: 18px; transform: translateX(-50%); position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%);
z-index: 9999; display: flex; flex-direction: column; gap: 8px; z-index: 9999; display: flex; flex-direction: column; gap: 8px;
@@ -54,9 +120,8 @@
} }
.toast { .toast {
display: flex; align-items: flex-start; gap: 10px; display: flex; align-items: flex-start; gap: 10px;
background: var(--surface); border: 1px solid var(--border-2); background: var(--surface); border: 1px solid var(--border-2); border-left: 4px solid var(--accent-blue);
border-left: 4px solid var(--accent-blue); box-shadow: 0 10px 30px var(--shadow);
box-shadow: 0 8px 24px var(--shadow);
padding: 12px 14px; font-size: 14px; color: var(--text); padding: 12px 14px; font-size: 14px; color: var(--text);
white-space: pre-line; animation: toast-in .18s ease-out; white-space: pre-line; animation: toast-in .18s ease-out;
} }
@@ -69,77 +134,99 @@
font-size: 15px; line-height: 1; cursor: pointer; padding: 2px 4px; 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">
<img src="/img/owl.svg" alt="">
<div class="admin-title">Админка расписания</div>
</div>
<div class="admin-sub">Управление источником данных и обновлением. Страница доступна только по секретной ссылке.</div>
<section id="login-card" class="admin-card hidden"> <div class="topbar">
<h2>Вход</h2> <div class="topbar-brand">
<form id="login-form" autocomplete="off"> <img class="topbar-logo" src="/img/owl.svg" alt="">
<div class="admin-field"><label for="login">Логин</label><input id="login" required></div> <div class="topbar-text">
<div class="admin-field" style="margin-top:10px"><label for="password">Пароль</label><input id="password" type="password" required></div> <span class="topbar-university">Расписание — админка</span>
<div class="admin-row" style="margin-top:12px"> <span class="topbar-faculty">источник данных и обновление</span>
<button class="admin-btn" type="submit">Войти</button> </div>
</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>
<main class="admin-main">
<section id="login-card" class="admin-login hidden">
<img class="admin-login-logo" src="/img/owl-white.svg" alt="">
<h1>Вход в админку</h1>
<p class="admin-login-hint">Страница открывается только по секретной ссылке. После нескольких неудачных попыток вход временно блокируется.</p>
<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>
</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 id="state-warn" class="admin-warn hidden"></div> </div>
<button class="admin-btn admin-btn-lg" id="refresh" type="button">Обновить сейчас</button>
</div> </div>
<div id="hero-alert" class="admin-alert hidden"></div>
<div class="admin-card"> <div class="admin-grid">
<h2>Входы в админку</h2> <article class="admin-card">
<div class="admin-status" id="auth-chips"></div> <h2>Ссылка на расписание</h2>
<div class="admin-hint">Каждая попытка входа запоминается: откуда, когда и чем закончилась. После нескольких неудач вход временно блокируется.</div> <label class="admin-field"><span>Публичная ссылка Яндекс.Диска</span>
</div>
<div class="admin-card">
<h2>Ссылка на расписание</h2>
<div class="admin-row">
<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/...">
</label>
<div class="admin-card-actions">
<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>
<button class="admin-btn" id="save-link" type="button">Сохранить ссылку</button> </article>
</div>
<div class="admin-hint">Ссылка проверяется скачиванием: если не открывается — не сохранится, данные на сайте останутся прежними.
Значение хранится в файле настроек и <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> <div><dt>Отпечаток файла</dt><dd id="fact-hash" class="admin-mono">—</dd></div>
</div> </dl>
</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>
@@ -198,71 +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, cls) { /** «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);
const v = div.querySelector(".v"); if (isNaN(d)) return "";
v.textContent = value; const sec = Math.max(0, Math.round((Date.now() - d.getTime()) / 1000));
if (cls) v.classList.add(cls); if (sec < 60) return "только что";
return div; 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) { function loginText(info) {
if (!info) return "нет данных"; if (!info) return "нет данных";
// у успешного входа поля ok нет — там итог и так известен
const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`; const status = info.ok === undefined ? "" : ` · ${info.ok ? "успешно" : "неудачно"}`;
return `${fmt(info.at)} · ${info.ip}${status}`; 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( hero.classList.toggle("is-ok", !hasError && state.has_data);
"Файл при последнем обновлении", $("hero-title").textContent = hasError
state.changed ? "изменился" : "без изменений", ? "Ошибка загрузки расписания"
state.changed ? "good" : null, : (state.has_data ? "Работает" : "Жду данные");
)); const parts = [];
chips.appendChild(chip("Дата файла на Диске", fmt(state.table_modified))); if (state.last_updated) parts.push(`обновлено ${fmt(state.last_updated)} (${ago(state.last_updated)})`);
chips.appendChild(chip("Групп в расписании", String(state.groups))); parts.push(`групп: ${state.groups}`);
if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash)); parts.push(`интервал ${state.interval_minutes} мин`);
chips.appendChild(chip("Текущая ссылка", state.link || "не задана")); $("hero-sub").textContent = parts.join(" · ");
if (state.settings_file) chips.appendChild(chip("Файл настроек", state.settings_file));
const auth = $("auth-chips"); const alert = $("hero-alert");
auth.innerHTML = ""; const warnings = [];
const attempt = state.last_attempt; if (state.error) warnings.push("Ошибка загрузки: " + state.error);
auth.appendChild(chip( if (state.settings_save_error) {
"Последняя попытка входа", warnings.push("Настройки не сохраняются на диск: " + state.settings_save_error +
loginText(attempt), " — после перезапуска службы ссылка и интервал вернутся к значениям из .env");
attempt ? (attempt.ok ? "good" : "bad") : null,
));
auth.appendChild(chip("Последний успешный вход", loginText(state.last_success)));
const throttle = state.throttle || {};
if (throttle.locked_until) {
auth.appendChild(chip("Вход заблокирован до", fmt(throttle.locked_until), "bad"));
} else if (throttle.max_attempts) {
auth.appendChild(chip(
"Осталось попыток",
`${throttle.attempts_left} из ${throttle.max_attempts}`,
));
} }
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;
setText("fact-changed", state.changed ? "изменился" : "без изменений", state.changed ? "good" : null);
setText("fact-modified", fmt(state.table_modified));
setText("fact-hash", state.content_hash || "—");
const err = $("state-error"); // входы
err.textContent = state.error ? "Ошибка загрузки: " + state.error : ""; const attempt = state.last_attempt;
err.classList.toggle("hidden", !state.error); setText("auth-attempt", loginText(attempt), attempt ? (attempt.ok ? "good" : "bad") : null);
setText("auth-success", loginText(state.last_success));
const warn = $("state-warn"); const throttle = state.throttle || {};
warn.textContent = state.settings_save_error const lock = $("auth-lock");
? "Настройки не сохраняются на диск: " + state.settings_save_error + if (throttle.locked_until) {
" — после перезапуска службы ссылка и интервал вернутся к значениям из .env." lock.textContent = `Вход заблокирован до ${fmt(throttle.locked_until)} — слишком много неудачных попыток.`;
: ""; lock.classList.remove("hidden");
warn.classList.toggle("hidden", !state.settings_save_error); } else {
lock.classList.add("hidden");
}
} }
function showLogin(show) { function showLogin(show) {
@@ -278,6 +378,11 @@
return () => { button.disabled = false; button.textContent = original; }; return () => { button.disabled = false; button.textContent = original; };
} }
async function withButton(button, text, fn) {
const done = busy(button, text);
try { await fn(); } finally { done(); }
}
async function loadState() { async function loadState() {
const r = await api("/state"); const r = await api("/state");
if (r.status === 401) { showLogin(true); return; } if (r.status === 401) { showLogin(true); return; }
@@ -289,11 +394,6 @@
renderState(r.data); renderState(r.data);
} }
async function withButton(button, text, fn) {
const done = busy(button, text);
try { await fn(); } finally { done(); }
}
$("login-form").addEventListener("submit", async (e) => { $("login-form").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const error = $("login-error"); const error = $("login-error");
@@ -350,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

@@ -15,7 +15,7 @@ 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__)
@@ -48,6 +48,32 @@ 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="Расписание — фронт")
@@ -82,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}")
@@ -91,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 []:
@@ -177,8 +209,21 @@ async def api_admin(rest: str, request: Request):
@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:
@@ -187,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,53 +589,233 @@ 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 = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры.";
const head = document.createElement("div"); return list;
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 free = roomsState.mode === "free";
card.appendChild(head); if (!free) {
const periods = room.periods || {}; const periodNums = [];
periodNums.forEach((p) => { roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => {
const slot = periods[p] || {}; if (!periodNums.includes(p)) periodNums.push(p);
const row = document.createElement("div"); }));
row.className = "room-row"; periodNums.sort((a, b) => Number(a) - Number(b));
const left = document.createElement("div"); rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums)));
left.className = "room-row-num"; return list;
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`; }
row.appendChild(left); rooms.forEach((room) => list.appendChild(roomsFreeCard(room)));
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
body.innerHTML = lessonsColHTML(chEntries, znEntries);
}
row.appendChild(body);
card.appendChild(row);
});
list.appendChild(card);
});
return list; return list;
} }
function roomsCardHead(room) {
const head = document.createElement("div");
head.className = "day-head";
const attrs = roomAttrsText(room);
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 || {};
periodNums.forEach((p) => {
const slot = periods[p] || {};
const row = document.createElement("div");
row.className = "room-row";
const left = document.createElement("div");
left.className = "room-row-num";
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
row.appendChild(left);
const body = document.createElement("div");
body.className = "room-row-body";
const chEntries = slot.ч || [];
const znEntries = slot.з || [];
if (!chEntries.length && !znEntries.length) {
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
} else {
body.innerHTML = lessonsColHTML(chEntries, znEntries);
}
row.appendChild(body);
card.appendChild(row);
});
return card;
}
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) {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c])); return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
} }
@@ -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>
<button id="theme-toggle" class="theme-toggle" title="Переключить тему"> <div class="topbar-actions">
<button id="admin-link" class="topbar-admin hidden" type="button">Админка</button>
<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

@@ -75,6 +75,18 @@ def test_login_and_state(client, cache):
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 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):

View File

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

@@ -384,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"
@@ -420,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

@@ -176,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):
@@ -205,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

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