diff --git a/README.md b/README.md index 3cd8add..64cb587 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,13 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090 фонового обновления (интервал и так перечитывает таблицу целиком); - **задать интервал обновления** в минутах (`1…1440`). +Каждое обновление (и по кнопке, и по интервалу) **всегда перечитывает таблицу**, +а в панели и в логе видно, изменился ли файл на Диске: `sha256`, время +последнего изменения содержимого и отметка «изменился / без изменений». +Если вы поправили таблицу, а после обновления написано «без изменений» — значит +публичная ссылка Яндекса отдаёт прежнюю версию файла (бывает с задержкой), +стоит повторить обновление через минуту. + Как это устроено: - вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie diff --git a/src/backend/schedule/cache.py b/src/backend/schedule/cache.py index 0c5eb79..6c8c801 100644 --- a/src/backend/schedule/cache.py +++ b/src/backend/schedule/cache.py @@ -15,6 +15,7 @@ from __future__ import annotations +import hashlib import logging import threading import time @@ -60,6 +61,10 @@ class ScheduleCache: self._modified: str | None = None self._error: str | None = None if self._link else NO_LINK_ERROR self._last_failed: float = 0.0 + # следим, менялось ли содержимое скачанного файла + self._content_hash: str | None = None + self._changed: bool = False + self._changed_at: float = 0.0 # --- публичное API --------------------------------------------------------- @@ -184,6 +189,10 @@ class ScheduleCache: "error": self._error, "groups": len(self._data) if self._data else 0, "has_data": self._data is not None, + # при последнем чтении файла: изменилось ли его содержимое + "changed": self._changed, + "changed_at": self._iso(self._changed_at), + "content_hash": (self._content_hash or "")[:12], } def _ensure_fresh(self) -> None: @@ -228,6 +237,9 @@ class ScheduleCache: log.info("Загружаю расписание по ссылке %s ...", self._link) started = time.time() xlsx = download_schedule(self._link) + digest = hashlib.sha256(xlsx).hexdigest() + first_load = self._content_hash is None + changed = not first_load and digest != self._content_hash sheets = parse_workbook(xlsx) data = parsing.parse_schedule(sheets) weeks = parsing.extract_week_ranges(sheets) @@ -241,7 +253,17 @@ class ScheduleCache: self._modified = modified self._loaded_at = now self._error = None - log.info("Расписание загружено: %d групп за %.1f с", len(data), time.time() - started) + self._content_hash = digest + self._changed = changed + if changed or first_load: + self._changed_at = now + log.info( + "Расписание загружено: %d групп за %.1f с; файл %s (sha256 %s)", + len(data), + time.time() - started, + "изменился" if changed else ("первая загрузка" if first_load else "не изменился"), + digest[:12], + ) def _fail(self, exc: ScheduleError, now: float) -> None: self._error = str(exc) diff --git a/src/backend/schedule/source.py b/src/backend/schedule/source.py index dc40451..eb92059 100644 --- a/src/backend/schedule/source.py +++ b/src/backend/schedule/source.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import time import requests @@ -12,18 +13,27 @@ log = logging.getLogger(__name__) CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources" +# Публичная ссылка может кэшироваться на стороне Диска: просим не кэшировать +# и добавляем в запрос метку времени, чтобы получать текущую версию файла. +_NO_CACHE = {"Cache-Control": "no-cache", "Pragma": "no-cache"} + + +def _bust() -> dict: + return {"_": int(time.time() * 1000)} + def download_schedule(public_url: str, timeout: float = 60.0) -> bytes: """Скачивает файл с диска; при любой ошибке бросает ScheduleError.""" try: resp = requests.get( f"{CLOUD_API}/download", - params={"public_key": public_url}, + params={"public_key": public_url, **_bust()}, + headers=_NO_CACHE, timeout=timeout, ) resp.raise_for_status() href = resp.json()["href"] - file_resp = requests.get(href, timeout=timeout) + file_resp = requests.get(href, headers=_NO_CACHE, timeout=timeout) file_resp.raise_for_status() return file_resp.content except (requests.RequestException, KeyError, ValueError) as exc: @@ -35,7 +45,8 @@ def fetch_table_modified(public_url: str, timeout: float = 20.0) -> str | None: try: resp = requests.get( CLOUD_API, - params={"public_key": public_url}, + params={"public_key": public_url, **_bust()}, + headers=_NO_CACHE, timeout=timeout, ) resp.raise_for_status() diff --git a/src/frontend/admin.html b/src/frontend/admin.html index 4ec6887..b38ba3d 100644 --- a/src/frontend/admin.html +++ b/src/frontend/admin.html @@ -94,7 +94,8 @@ -
«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.
+
«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд. + Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.
@@ -146,6 +147,12 @@ chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`)); chips.appendChild(chip("Обновлено", fmt(state.last_updated))); chips.appendChild(chip("Таблица изменена", fmt(state.table_modified))); + chips.appendChild(chip( + "Файл при последнем обновлении", + state.changed ? "изменился ✓" : "без изменений (та же версия)", + )); + chips.appendChild(chip("Содержимое менялось", fmt(state.changed_at))); + if (state.content_hash) chips.appendChild(chip("sha256 файла", state.content_hash)); chips.appendChild(chip("Групп в расписании", String(state.groups))); chips.appendChild(chip("Текущая ссылка", state.link || "не задана")); $("link").value = state.link || ""; @@ -210,7 +217,14 @@ $("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => { const r = await post("/refresh"); - if (!r.ok) { alert("Обновить не удалось: " + ((r.data && r.data.detail) || r.status)); } + if (!r.ok) { + alert("Обновить не удалось: " + ((r.data && r.data.detail) || r.status)); + } else if (r.data) { + renderState(r.data); + alert(r.data.changed + ? "Готово: в файле обнаружены изменения — расписание обновлено." + : "Готово: файл не изменился (на Диске та же версия).\n\nЕсли вы только что правили таблицу, значит Диск ещё не отдаёт новую версию — попробуйте через минуту."); + } await loadState(); })); diff --git a/src/frontend/main.py b/src/frontend/main.py index 79e5a2f..e281fe0 100644 --- a/src/frontend/main.py +++ b/src/frontend/main.py @@ -15,7 +15,7 @@ import uvicorn from dotenv import load_dotenv from fastapi import FastAPI, HTTPException, Request, Response from fastapi.concurrency import run_in_threadpool -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles log = logging.getLogger(__name__) @@ -51,7 +51,8 @@ def _proxy(path: str, params: dict) -> object: except Exception: detail = resp.text raise HTTPException(status_code=resp.status_code, detail=detail) - return resp.json() + # данные не кэшируем: после «Обновить сейчас» браузер должен видеть свежее + return JSONResponse(content=resp.json(), headers={"Cache-Control": "no-store"}) def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response: @@ -70,6 +71,7 @@ def _proxy_admin(method: str, path: str, body: bytes, headers: dict) -> Response content=resp.content, status_code=resp.status_code, media_type=resp.headers.get("content-type"), + headers={"Cache-Control": "no-store"}, ) # Set-Cookie (вход/выход админки) передаём как есть raw_headers = getattr(resp.raw, "headers", None) diff --git a/tests/test_api.py b/tests/test_api.py index 38e0830..a00eebc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -168,6 +168,38 @@ def test_interval_refresh_always_downloads(cache): assert cache.calls["modified"] >= 2 +def test_state_reports_file_changes(monkeypatch): + """Кэш показывает, изменилось ли содержимое файла при последнем чтении.""" + import schedule.cache as cache_mod + from fixtures import fixture_sheets + from xlsx_builder import build_workbook + + def xlsx_with_room(room: str) -> bytes: + sheets = fixture_sheets() + sheets["Бакалавры и магистры"].rows[2][6] = f"Математика\nСмирнов А.В.\nауд. {room}" + return build_workbook(sheets) + + files = {"data": xlsx_with_room("215")} + monkeypatch.setattr(cache_mod, "fetch_table_modified", lambda url, timeout=20.0: "m1") + monkeypatch.setattr(cache_mod, "download_schedule", lambda url, timeout=60.0: files["data"]) + cache = ScheduleCache("test-link", ttl_seconds=3600, retry_after=60) + + cache.get() + first = cache.state() + assert first["content_hash"] + assert first["changed"] is False # первая загрузка — не «изменение» + assert first["changed_at"] is not None + + files["data"] = xlsx_with_room("216") + cache.force_reload() + changed = cache.state() + assert changed["changed"] is True + assert changed["content_hash"] != first["content_hash"] + + cache.force_reload() + assert cache.state()["changed"] is False # тот же файл — изменений нет + + def test_cache_stale_while_error(cache): cache._ttl = 0 # форсим повторную попытку cache.get()