Как и просили: фоновое обновление по интервалу сначала смотрит на дату
изменения файла и, если таблица не менялась, не скачивает её. Всегда
перечитывать таблицу — только принудительное обновление из админки
(force_reload, кнопка «Обновить сейчас»).
Тесты: по интервалу — без скачивания при неизменном modified; кнопка —
скачивает независимо от modified (test_force_refresh_ignores_ttl_and_modified).
В таблице появились группы с «ТОП» в имени (ИВТ-ТОП-11БО, ИТ-ТОП-12БО):
шаблон требовал цифру сразу после первого дефиса, поэтому такие столбцы
пропускались целиком. Теперь имя группы — буквенные части через дефис,
номер потока и необязательный суффикс, т.е. распознаются и «ИВТ-11БО»,
и «ПИЭ-13МО», и «ИВТ-ТОП-11БО» (похожие заголовки с пробелами — нет).
GET /groups больше не сортирует по алфавиту с приоритетом БО/МО, а отдаёт
группы в порядке столбцов таблицы — как просили, «как в расписании».
Состав групп (появление/переименование/скрытие) подхватывается сам при
следующем обновлении, потому что источник истины — заголовок листа.
Тесты: ТОП-имена + порядок столбцов, отсев похожих не-групповых заголовков.
alert() блокирует страницу и требует клика; заменил на тост внизу экрана:
появляется с анимацией, сам скрывается через 15 секунд (вход — 5 сек),
закрывается крестиком. Цвет полосы зависит от исхода: зелёный — успех,
красный — ошибка, синий — информация (например, «файл не изменился»).
Сообщения тех же действий: вход, сохранение ссылки, сохранение интервала,
принудительное обновление, ошибки запросов.
Просьба с эксплуатации: после «Обновить сейчас» непонятно, подхватились ли
правки таблицы. Теперь кэш считает sha256 скачанного файла и сравнивает с
предыдущим чтением:
- в логе: «Расписание загружено: N групп …; файл изменился/не изменился
(sha256 ab12cd34)»;
- состояние для админки дополнено полями changed / changed_at / content_hash;
- в панели чипы «Файл при последнем обновлении», «Содержимое менялось»,
«sha256 файла», а после кнопки — понятное сообщение: «обнаружены изменения»
либо «файл не изменился (на Диске та же версия)» с подсказкой про задержку
публикации у Яндекса;
- ответы API отдаются с Cache-Control: no-store, чтобы браузер не показывал
закэшированные данные после обновления;
- запросы к Яндекс.Диску идут с no-cache заголовками и меткой времени —
меньше шансов получить закэшированную версию файла.
Тест: test_state_reports_file_changes (первая загрузка, изменение файла,
повторное чтение без изменений).
Поле modified у публичной ссылки Яндекса может «застыть» (кэш на стороне
Диска), и проверка «modified не изменился → не скачиваем» держала старые
данные до неопределённого момента: стенд показывал прежнее расписание, хотя
в таблице правки уже были. Теперь на каждое обновление (и по интервалу, и по
кнопке) таблица скачивается заново; modified только показывается в админке.
Плюс к этому:
- при старте пишем в лог предупреждение, если ссылка в настройках отличается
от SCHEDULE_LINK из окружения (именно так терялось время в прошлый раз:
на стенде в настройках была сохранена другая ссылка);
- на странице админки у поля ссылки пояснение, что значение хранится в
настройках стенда и имеет приоритет над .env.
Dockerfile для stage api копировал только main.py и schedule/, поэтому в
контейнере 'from admin import ...' давал ModuleNotFoundError — api стартовал
и сразу падал, compose останавливался на 'dependency failed to start'.
Добавлен COPY admin.py и тест tests/test_deploy.py: проверяет, что в образ
api попадают все модули backend, в frontend — страница админки и статика,
а compose передаёт ADMIN_* и подключает том настроек.
- не больше 10 неудачных входов с одного адреса за 5 минут, дальше 429
(панель доступна из интернета, простой перебор закрываем);
успешный вход сбрасывает счётчик;
- .env.example: SETTINGS_FILE пустой — локально настройки живут в памяти,
в docker compose подставляет /data/settings.json по умолчанию.
Панель без публичного входа: страница живёт по ADMIN_PATH, логин/пароль —
ADMIN_LOGIN/ADMIN_PASSWORD из .env (пусто = админка выключена и маршруты
/api/admin/* не регистрируются, прод можно не трогать).
Умеет: посмотреть текущую ссылку и состояние кэша, вставить новую ссылку
(проверяется скачиванием и разбором до сохранения, при ошибке откат),
принудительно обновить таблицу вне интервала и без проверки modified,
задать интервал в минутах (1..1440).
Реализация:
- backend/admin.py — cookie-сессия на HMAC (ключ = пароль, 12 часов,
HttpOnly/SameSite/Secure-за-https), сравнение за постоянное время;
compare_digest по байтам — кириллица в логине не роняет сервер;
- schedule/settings.py — ссылка и интервал в JSON (SETTINGS_FILE), атомарная
запись, терпимость к битому/недоступному файлу (тогда живёт в памяти);
- schedule/cache.py — интервал вместо TTL, apply_link с откатом,
force_reload, state() для панели; кэш без ссылки больше не роняет старт;
- frontend: страница admin.html (вне static, прямой /admin.html закрыт) и
прокси /api/admin/* с телом и Set-Cookie;
- docker-compose: том schedule_data для настроек, ADMIN_* в окружении;
- deploy/: nginx-конфиг и инструкция для стенда dev.schedule.our-place.ru.
Тесты: 138 (новые — админ-API, сессии, ссылка/интервал, force, хранилище).
Ссылка на онлайн-курс в поле аудитории — одно длинное слово. CSS-фикс
(overflow-wrap/word-break) работает в современных движках, но на телефоне
пользователя переполнение оставалось: старый движок или закэшированный CSS.
Теперь фронтенд сам расставляет <wbr>: после служебных символов ссылок
(/, ?, &, =, ., _, -) и не реже, чем каждые 24 символа; экранирование
сохраняется. Перенос работает в любом браузере.
Проверено в headless Chromium (390px и 320px): переполнений нет как с
включёнными CSS-переносами, так и с принудительно отключёнными
(эмуляция старого движка).
На телефонах ссылка вида https://next.uniyar.ac.ru/course/view.php?id=407
в поле аудитории растягивала карточку занятия и страницу по горизонтали.
- .period и .room-row: grid-колонка контента minmax(0, 1fr), иначе
трек не может сжаться ниже min-content длинного слова
- .lesson и .room-card: min-width: 0 + word-break: break-word +
overflow-wrap: anywhere (перенос по буквам, работает и в старых iOS)
- у main min-width: 0 уже был, так что переполнение ушло
- пары с одинаковыми ч/з показываются одним списком без пометки недели
(группы, преподаватели, аудитории); раньше на страницах преподавателей
и аудиторий такое занятие дублировалось в обеих колонках
- бейдж «Обе недели» убран как информационный шум (отсутствие пометки
и означает «любая неделя»)
- favicon: квадрат с градиентом боковой панели (#1b2dd8 → #167cf2) и совой
- PWA: manifest.json, sw.js, apple-touch-icon и иконки 192/512 —
при добавлении на главный экран смартфона появляется логотип
- tools/make_icons.py — генерация иконок из логотипа (rsvg-convert)
- удалён неиспользуемый roomPeriodCell и мёртвый favicon.png
В ячейке с несколькими занятиями (курсы по выбору) красная заметка,
занимающая строку(и) целиком, относится ко всем занятиям ячейки
(пара отменяется целиком) и раздаётся каждому занятию. Заметка,
написанная в одной строке с предметом/аудиторией, остаётся
привязанной к своему занятию. Многострочные standalone-заметки
сохраняют переводы строк.
Кейс: ПИЭ-13МО, понедельник, 5-я пара. Diff по 33 группам: затронуто
только поле note в multi-lesson ячейках; порядок «inline->ячейка».
В интерфейсе в объединённом блоке видно только верхне-левую ячейку;
значения, оставшиеся в остальных ячейках мержа (занесли текст, а сверху
наложили пустой мерж), в таблице не видны. Раньше парсер их отдавал:
реальный кейс — ПИЭ-41БО, вторник, 1 пара, знаменатель (мерж AH14:AI15).
По листу таких ячеек ровно одна; по 33 группам diff только этот слот.