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