Видно, изменился ли файл при обновлении (sha256 + отметка в админке)

Просьба с эксплуатации: после «Обновить сейчас» непонятно, подхватились ли
правки таблицы. Теперь кэш считает sha256 скачанного файла и сравнивает с
предыдущим чтением:

- в логе: «Расписание загружено: N групп …; файл изменился/не изменился
  (sha256 ab12cd34)»;
- состояние для админки дополнено полями changed / changed_at / content_hash;
- в панели чипы «Файл при последнем обновлении», «Содержимое менялось»,
  «sha256 файла», а после кнопки — понятное сообщение: «обнаружены изменения»
  либо «файл не изменился (на Диске та же версия)» с подсказкой про задержку
  публикации у Яндекса;
- ответы API отдаются с Cache-Control: no-store, чтобы браузер не показывал
  закэшированные данные после обновления;
- запросы к Яндекс.Диску идут с no-cache заголовками и меткой времени —
  меньше шансов получить закэшированную версию файла.

Тест: test_state_reports_file_changes (первая загрузка, изменение файла,
повторное чтение без изменений).
This commit is contained in:
2026-09-29 15:27:01 +03:00
parent d4b47c30bf
commit d177560f7d
6 changed files with 96 additions and 8 deletions

View File

@@ -103,6 +103,13 @@ nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
фонового обновления (интервал и так перечитывает таблицу целиком);
- **задать интервал обновления** в минутах (`1…1440`).
Каждое обновление (и по кнопке, и по интервалу) **всегда перечитывает таблицу**,
а в панели и в логе видно, изменился ли файл на Диске: `sha256`, время
последнего изменения содержимого и отметка «изменился / без изменений».
Если вы поправили таблицу, а после обновления написано «без изменений» — значит
публичная ссылка Яндекса отдаёт прежнюю версию файла (бывает с задержкой),
стоит повторить обновление через минуту.
Как это устроено:
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie

View File

@@ -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)

View File

@@ -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()

View File

@@ -94,7 +94,8 @@
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
</div>
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.</div>
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div>
</div>
<div class="admin-card">
@@ -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();
}));

View File

@@ -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)

View File

@@ -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()