Compare commits
14 Commits
3d5bfe3023
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d131adbcb8 | |||
| 172636f227 | |||
| a9d3f4d5cf | |||
| 2cbb19312e | |||
| ee5faeff77 | |||
| f635272128 | |||
| c69d38645d | |||
| b9967ddb24 | |||
| 163278c146 | |||
| 3977b0da47 | |||
| 34a46b6832 | |||
| 71f83a27dd | |||
| 72ca140b40 | |||
| 6f16736359 |
51
README.md
51
README.md
@@ -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`.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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("Страница админки включена по секретному пути")
|
||||||
|
|||||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||||
|
|||||||
@@ -28,10 +28,14 @@
|
|||||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</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">
|
||||||
|
|||||||
2
src/frontend/static/robots.txt
Normal file
2
src/frontend/static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]["ч"] # понедельник на месте
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user