Аудитории: фильтры по типу, вместимости и оснащению + режим «свободные на паре»
Бэкенд: - 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.
This commit is contained in:
23
README.md
23
README.md
@@ -413,6 +413,16 @@ tests/
|
||||
- Длинные ссылки в карточке переносятся по служебным символам и каждые
|
||||
24 символа (`<wbr>`), поэтому карточка не вылезает за экран даже в старых
|
||||
браузерах, где нет `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`.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = `
|
||||
<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"><span>Пара</span>
|
||||
<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("")}
|
||||
</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-row rf-actions">
|
||||
<button type="button" class="rf-reset" id="rf-reset">Сбросить фильтры</button>
|
||||
<div class="rf-summary" id="rf-summary"></div>
|
||||
</div>
|
||||
<div class="rf-hint hidden" id="rf-hint"></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 (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 = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
|
||||
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 ? `<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);
|
||||
});
|
||||
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 = `
|
||||
<div class="room-title">
|
||||
<span class="day-title">${escapeHTML(room.room)}</span>
|
||||
${attrs ? `<div class="room-attrs">${escapeHTML(attrs)}</div>` : ""}
|
||||
${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}
|
||||
${room.available_days ? `<div class="room-note">в таблице отмечено: только ${escapeHTML(room.available_days.join(", "))}</div>` : ""}
|
||||
${restricted ? `<div class="room-note bad">по таблице в этот день занятий в ней нет</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) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user