Видно, изменился ли файл при обновлении (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`).
|
- **задать интервал обновления** в минутах (`1…1440`).
|
||||||
|
|
||||||
|
Каждое обновление (и по кнопке, и по интервалу) **всегда перечитывает таблицу**,
|
||||||
|
а в панели и в логе видно, изменился ли файл на Диске: `sha256`, время
|
||||||
|
последнего изменения содержимого и отметка «изменился / без изменений».
|
||||||
|
Если вы поправили таблицу, а после обновления написано «без изменений» — значит
|
||||||
|
публичная ссылка Яндекса отдаёт прежнюю версию файла (бывает с задержкой),
|
||||||
|
стоит повторить обновление через минуту.
|
||||||
|
|
||||||
Как это устроено:
|
Как это устроено:
|
||||||
|
|
||||||
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
|
- вход — `POST /api/admin/login`, сессия в подписанной HMAC-cookie
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -60,6 +61,10 @@ class ScheduleCache:
|
|||||||
self._modified: str | None = None
|
self._modified: str | None = None
|
||||||
self._error: str | None = None if self._link else NO_LINK_ERROR
|
self._error: str | None = None if self._link else NO_LINK_ERROR
|
||||||
self._last_failed: float = 0.0
|
self._last_failed: float = 0.0
|
||||||
|
# следим, менялось ли содержимое скачанного файла
|
||||||
|
self._content_hash: str | None = None
|
||||||
|
self._changed: bool = False
|
||||||
|
self._changed_at: float = 0.0
|
||||||
|
|
||||||
# --- публичное API ---------------------------------------------------------
|
# --- публичное API ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -184,6 +189,10 @@ class ScheduleCache:
|
|||||||
"error": self._error,
|
"error": self._error,
|
||||||
"groups": len(self._data) if self._data else 0,
|
"groups": len(self._data) if self._data else 0,
|
||||||
"has_data": self._data is not None,
|
"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:
|
def _ensure_fresh(self) -> None:
|
||||||
@@ -228,6 +237,9 @@ class ScheduleCache:
|
|||||||
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
log.info("Загружаю расписание по ссылке %s ...", self._link)
|
||||||
started = time.time()
|
started = time.time()
|
||||||
xlsx = download_schedule(self._link)
|
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)
|
sheets = parse_workbook(xlsx)
|
||||||
data = parsing.parse_schedule(sheets)
|
data = parsing.parse_schedule(sheets)
|
||||||
weeks = parsing.extract_week_ranges(sheets)
|
weeks = parsing.extract_week_ranges(sheets)
|
||||||
@@ -241,7 +253,17 @@ class ScheduleCache:
|
|||||||
self._modified = modified
|
self._modified = modified
|
||||||
self._loaded_at = now
|
self._loaded_at = now
|
||||||
self._error = None
|
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:
|
def _fail(self, exc: ScheduleError, now: float) -> None:
|
||||||
self._error = str(exc)
|
self._error = str(exc)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
@@ -12,18 +13,27 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
CLOUD_API = "https://cloud-api.yandex.net/v1/disk/public/resources"
|
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:
|
def download_schedule(public_url: str, timeout: float = 60.0) -> bytes:
|
||||||
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
"""Скачивает файл с диска; при любой ошибке бросает ScheduleError."""
|
||||||
try:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{CLOUD_API}/download",
|
f"{CLOUD_API}/download",
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
href = resp.json()["href"]
|
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()
|
file_resp.raise_for_status()
|
||||||
return file_resp.content
|
return file_resp.content
|
||||||
except (requests.RequestException, KeyError, ValueError) as exc:
|
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:
|
try:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
CLOUD_API,
|
CLOUD_API,
|
||||||
params={"public_key": public_url},
|
params={"public_key": public_url, **_bust()},
|
||||||
|
headers=_NO_CACHE,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|||||||
@@ -94,7 +94,8 @@
|
|||||||
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
|
<button class="admin-btn secondary" id="save-interval" type="button">Сохранить интервал</button>
|
||||||
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
|
<button class="admin-btn" id="refresh" type="button">Обновить сейчас</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.</div>
|
<div class="admin-hint">«Обновить сейчас» скачивает таблицу вне интервала; при большой таблице занимает несколько секунд.
|
||||||
|
Правки в таблице подхватываются этой кнопкой или очередным обновлением по интервалу; после обновления видно, изменился ли файл на Диске.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
@@ -146,6 +147,12 @@
|
|||||||
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
|
chips.appendChild(chip("Интервал обновления", `${state.interval_minutes} мин`));
|
||||||
chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
|
chips.appendChild(chip("Обновлено", fmt(state.last_updated)));
|
||||||
chips.appendChild(chip("Таблица изменена", fmt(state.table_modified)));
|
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("Групп в расписании", String(state.groups)));
|
||||||
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
|
chips.appendChild(chip("Текущая ссылка", state.link || "не задана"));
|
||||||
$("link").value = state.link || "";
|
$("link").value = state.link || "";
|
||||||
@@ -210,7 +217,14 @@
|
|||||||
|
|
||||||
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
$("refresh").addEventListener("click", () => withButton($("refresh"), "Обновляю…", async () => {
|
||||||
const r = await post("/refresh");
|
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();
|
await loadState();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import uvicorn
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI, HTTPException, Request, Response
|
from fastapi import FastAPI, HTTPException, Request, Response
|
||||||
from fastapi.concurrency import run_in_threadpool
|
from fastapi.concurrency import run_in_threadpool
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -51,7 +51,8 @@ def _proxy(path: str, params: dict) -> object:
|
|||||||
except Exception:
|
except Exception:
|
||||||
detail = resp.text
|
detail = resp.text
|
||||||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
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:
|
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,
|
content=resp.content,
|
||||||
status_code=resp.status_code,
|
status_code=resp.status_code,
|
||||||
media_type=resp.headers.get("content-type"),
|
media_type=resp.headers.get("content-type"),
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
)
|
)
|
||||||
# Set-Cookie (вход/выход админки) передаём как есть
|
# Set-Cookie (вход/выход админки) передаём как есть
|
||||||
raw_headers = getattr(resp.raw, "headers", None)
|
raw_headers = getattr(resp.raw, "headers", None)
|
||||||
|
|||||||
@@ -168,6 +168,38 @@ def test_interval_refresh_always_downloads(cache):
|
|||||||
assert cache.calls["modified"] >= 2
|
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):
|
def test_cache_stale_while_error(cache):
|
||||||
cache._ttl = 0 # форсим повторную попытку
|
cache._ttl = 0 # форсим повторную попытку
|
||||||
cache.get()
|
cache.get()
|
||||||
|
|||||||
Reference in New Issue
Block a user