From 6f1673635938115487ac0e36a2c10827b4d6dcc6 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sat, 10 Oct 2026 12:25:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=83=D0=B4=D0=B8=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B8:=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D0=B8=D0=BF=D1=83,=20=D0=B2=D0=BC=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=81=D0=BD=D0=B0=D1=89=D0=B5=D0=BD=D0=B8=D1=8E=20+=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20=C2=AB=D1=81=D0=B2=D0=BE=D0=B1?= =?UTF-8?q?=D0=BE=D0=B4=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бэкенд: - parse_rooms_info разбирает описание аудитории (формат «тип; оснащение; N мест») в атрибуты: type (фразой как в таблице), capacity (места, а для компьютерных классов — рабочие места; null, если данных нет), equipment (ПК, Проектор, Интерактивная панель, Телевизор, Маркер, Меловая доска) и available_days («402 четверг пятница»); «без проектора» трактуется как отсутствие; - /rooms-day и /rooms-free отдают атрибуты (room_payload); аудитории с ограничением по дням не предлагаются в /rooms-free в остальные дни. Фронтенд (вкладка «Аудитории»): - панель фильтров: тип (значения из данных таблицы), «мест не меньше», оснащение чекбоксами с выбором условия «все (И)» / «любое (ИЛИ)»; - счётчик «Показано N из M» и пояснения: сколько аудиторий скрыто из-за отсутствия данных о вместимости, сколько доступны только по отдельным дням; - переключатель «Занятость за день» / «Свободные на паре» (с выбором пары) — вместе с фильтрами это «свободная аудитория на 30 человек с проектором в 3-ю пару»; выбранные фильтры запоминаются в браузере; - в карточке аудитории видны разобранные атрибуты и пометки из таблицы. Тесты: разбор всех форматов описаний (включая отрицания и меловую доску), API атрибутов и учёта доступных дней. Всего 159. --- README.md | 23 +- src/backend/main.py | 12 +- src/backend/schedule/parsing.py | 115 +++++++++- src/frontend/static/app.js | 367 +++++++++++++++++++++++++++----- src/frontend/static/style.css | 69 ++++++ tests/test_api.py | 28 +++ tests/test_parsing.py | 80 ++++++- 7 files changed, 626 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index b862282..b07609f 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,16 @@ tests/ - Длинные ссылки в карточке переносятся по служебным символам и каждые 24 символа (``), поэтому карточка не вылезает за экран даже в старых браузерах, где нет `overflow-wrap`. +- На вкладке «Аудитории» есть фильтры: **тип** (фразой, как в таблице), + **вместимость** («не меньше N мест») и **оснащение** чекбоксами с выбором + условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное + запоминается в браузере, под списком видно «Показано N из M» и пояснения: + сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие + доступны только по отдельным дням. +- Там же переключатель «Занятость за день» / **«Свободные на паре»**: во + втором режиме выбирается пара и показываются только свободные аудитории — + вместе с фильтрами получается «свободная аудитория на 30 человек с + проектором в 3-ю пару». - Приложение можно «установить» на главный экран (манифест + service worker + иконки приложения); на iOS иконку берёт `apple-touch-icon`. @@ -430,12 +440,21 @@ tests/ | GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) | | GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») | | GET | `/room/{label}` | занятость аудитории | -| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день | -| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории | +| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день (+ атрибуты) | +| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории (+ атрибуты) | | GET | `/week` | недели семестра и текущая чётность | | GET | `/status` | состояние кэша (`last_updated`, `table_modified`, `error`, …) | | GET | `/healthz` | живость процесса (не обращается к таблице) | +В ответах `rooms-day` и `rooms-free` по каждой аудитории, кроме `room` и `info` +(описание как в таблице), идут разобранные атрибуты: `type` (тип фразой из +таблицы: «комп.класс», «кабинет английского», «лекционная»…), `capacity` +(мест; для компьютерных классов — рабочие места; `null`, если в таблице нет +данных), `equipment` (список: «ПК», «Проектор», «Интерактивная панель», +«Телевизор», «Маркер», «Меловая доска») и `available_days` (дни, если в +описании указано «только четверг, пятница»). Аудитории с ограничением по дням +не попадают в `/rooms-free` для остальных дней. + Фронт проксирует только то, что использует, под префиксом `/api/...`: `groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`, `room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`. diff --git a/src/backend/main.py b/src/backend/main.py index ed009bb..69a6b0c 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -264,10 +264,14 @@ def create_app( room_info = cache.room_info free = [] 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), {}) busy = bool((occupied.get("ч") or []) or (occupied.get("з") or [])) if not busy: - free.append({"room": label, "info": room_info.get(label, {}).get("info", "")}) + free.append(parsing.room_payload(label, info)) return free @app.get("/rooms-day") @@ -278,7 +282,7 @@ def create_app( day_times = cache.get_day_times().get(normalized, {}) rooms_out = [] 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, {}) periods = {} 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 [], } - 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} @app.get("/week") diff --git a/src/backend/schedule/parsing.py b/src/backend/schedule/parsing.py index 8d14091..c7e3946 100644 --- a/src/backend/schedule/parsing.py +++ b/src/backend/schedule/parsing.py @@ -344,8 +344,95 @@ 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))?", re.I), + re.compile(r"компьютерн\w*\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) + + +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 re.sub(r"\s{2,}", " ", m.group(0).strip(" .,")) + return "" + + +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 [ + 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: - """Лист «аудитории»: 'ауд. N' -> {'room', 'info'} (оснащение, вместимость).""" + """Лист «аудитории»: 'ауд. N' -> атрибуты аудитории. + + {'room', 'info' (как в таблице), 'type', 'capacity', 'equipment', 'available_days'} + """ sheet = sheets.get(ROOMS_SHEET) if sheet is None or len(sheet.rows) < 2: return {} @@ -356,14 +443,34 @@ def parse_rooms_info(sheets: dict[str, Worksheet]) -> dict: if not m: continue number = m.group(1) - text = " ".join( - ln.strip() for ln in str(desc[col]).split("\n") if ln.strip() + text = _strip_marks( + " ".join(ln.strip() for ln in str(desc[col]).split("\n") if ln.strip()) ) label = f"ауд. {number}" - info[label] = {"room": label, "info": _strip_marks(text)} + info[label] = { + "room": label, + "info": text, + "type": room_type(text), + "capacity": room_capacity(text), + "equipment": room_equipment(text), + "available_days": room_weekdays(text), + } 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", ""), + "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: initials = "" if first: diff --git a/src/frontend/static/app.js b/src/frontend/static/app.js index b7d5ef2..6bac157 100644 --- a/src/frontend/static/app.js +++ b/src/frontend/static/app.js @@ -405,31 +405,107 @@ function renderTeacher(data, status) { } } -/* ---------- аудитории: занятость по выбранному дню ---------- */ +/* ---------- аудитории: занятость по дню и свободные на паре ---------- */ 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 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; + if (filter.type && (room.type || "") !== 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) { - 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; scheduleEl.innerHTML = ""; errorEl.classList.add("hidden"); loadingEl.classList.remove("hidden"); try { 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; - scheduleEl.appendChild(Object.assign(document.createElement("div"), { - className: "schedule-title", - textContent: "Занятость аудиторий", - })); - 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)); + roomsState.loaded = data; + roomsState.status = status; + drawRoomsScreen(status); } catch (err) { if (seq !== requestSeq) return; errorEl.textContent = "Ошибка: " + err.message; @@ -439,53 +515,236 @@ async function renderRoomsView(day) { } } -function roomsDayTable(data) { - const periodNums = []; - data.rooms.forEach((r) => { - Object.keys(r.periods || {}).forEach((p) => { - if (!periodNums.includes(p)) periodNums.push(p); - }); - }); - periodNums.sort((a, b) => Number(a) - Number(b)); - return buildRoomsList(data, periodNums); +function drawRoomsScreen(status) { + scheduleEl.innerHTML = ""; + scheduleEl.appendChild(Object.assign(document.createElement("div"), { + className: "schedule-title", + textContent: "Аудитории", + })); + const top = statusRowHTML(status); + if (top) scheduleEl.appendChild(top); + 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(); + const types = [...new Set(all.map((r) => r.type).filter(Boolean))].sort((a, b) => a.localeCompare(b, "ru")); + const filter = roomsState.filter; + const box = document.createElement("div"); + box.className = "rooms-filters"; + box.innerHTML = ` +
+ ${Object.entries(ROOM_MODES).map(([key, label]) => + `` + ).join("")} + ${roomsState.mode === "free" ? ` + ` : ""} +
+
+ + + +
+
+ ${equipmentTags().map((tag) => ` + + `).join("")} +
+
+ +
+
+ + `; + + 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 (roomsState.mode !== "free") { + const restricted = all.filter((r) => !roomAvailableOn(r)).length; + if (restricted) { + notes.push(`${pluralRu(restricted, "аудитория отмечена", "аудитории отмечены", "аудиторий отмечено")} как доступные только по отдельным дням`); + } + } + if (hint) { + hint.textContent = notes.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"); list.className = "rooms-list"; - data.rooms.forEach((room) => { - const card = document.createElement("div"); - card.className = "room-card"; - const head = document.createElement("div"); - head.className = "day-head"; - head.innerHTML = `
${escapeHTML(room.room)}${room.info ? `
${escapeHTML(room.info)}
` : ""}
`; - card.appendChild(head); - 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 ? `
${escapeHTML(slot.time)}
` : ""}`; - 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 = `
${parityColHTML("", OKNO_HTML)}
`; - } else { - body.innerHTML = lessonsColHTML(chEntries, znEntries); - } - row.appendChild(body); - card.appendChild(row); - }); - list.appendChild(card); - }); + if (!rooms.length) { + list.className = "rooms-empty"; + list.textContent = "Под фильтры ничего не подошло — ослабьте условия или сбросьте фильтры."; + return list; + } + const free = roomsState.mode === "free"; + if (!free) { + const periodNums = []; + roomsAll().forEach((r) => Object.keys(r.periods || {}).forEach((p) => { + if (!periodNums.includes(p)) periodNums.push(p); + })); + periodNums.sort((a, b) => Number(a) - Number(b)); + rooms.forEach((room) => list.appendChild(roomsDayCard(room, periodNums))); + return list; + } + rooms.forEach((room) => list.appendChild(roomsFreeCard(room))); return list; } +function roomsCardHead(room) { + const head = document.createElement("div"); + head.className = "day-head"; + const attrs = roomAttrsText(room); + const restricted = room.available_days && !roomAvailableOn(room); + head.innerHTML = ` +
+ ${escapeHTML(room.room)} + ${attrs ? `
${escapeHTML(attrs)}
` : ""} + ${room.info ? `
${escapeHTML(room.info)}
` : ""} + ${room.available_days ? `
в таблице отмечено: только ${escapeHTML(room.available_days.join(", "))}
` : ""} + ${restricted ? `
по таблице в этот день занятий в ней нет
` : ""} +
`; + 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 ? `
${escapeHTML(slot.time)}
` : ""}`; + 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 = `
${parityColHTML("", OKNO_HTML)}
`; + } 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) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } diff --git a/src/frontend/static/style.css b/src/frontend/static/style.css index 2045735..759ddbb 100644 --- a/src/frontend/static/style.css +++ b/src/frontend/static/style.css @@ -651,6 +651,75 @@ main { /* ---- аудитории: список карточками (мобильная версия) ---- */ .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: center; } +.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.bad { color: var(--accent-red-bright); font-weight: 600; } +.room-free-note { font-size: 13px; color: var(--muted); margin-top: 10px; } + + .room-card { background: var(--surface); border: 1px solid var(--border); diff --git a/tests/test_api.py b/tests/test_api.py index 59af1a9..c64350d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -263,3 +263,31 @@ def test_cache_without_link(): assert c.get("/groups").status_code == 502 body = c.get("/status").json() 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) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index b126265..8d7ab87 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -176,11 +176,20 @@ def test_group_schedule_shape(data): def test_rooms_info(sheets): info = parsing.parse_rooms_info(sheets) - assert info == { - "ауд. 215": {"room": "ауд. 215", "info": "комп.класс, 12 мест"}, - "ауд. 301": {"room": "ауд. 301", "info": "интерактивная панель, 24 места"}, - "ауд. 220": {"room": "ауд. 220", "info": "20 мест"}, - } + assert set(info) == {"ауд. 215", "ауд. 301", "ауд. 220"} + assert info["ауд. 215"]["info"] == "комп.класс, 12 мест" + assert info["ауд. 215"]["type"] == "комп.класс" + 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): @@ -205,3 +214,64 @@ def test_build_room_schedule(data): assert set(out["days"]) == {"понедельник", "вторник"} assert out["days"]["понедельник"]["1"]["ч"][0]["subject"] == "Физика" 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": "лекционная", + "capacity": 96, + "equipment": ["Проектор"], + "available_days": None, + }) + assert payload == { + "room": "ауд. 215", + "info": "лекционная; проектор; 96 мест", + "type": "лекционная", + "capacity": 96, + "equipment": ["Проектор"], + "available_days": None, + }