add: Трекать бд клиент
This commit is contained in:
parent
16ca6ef880
commit
a8bd42a531
2 changed files with 644 additions and 1 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,6 +9,5 @@ timetables/*.json
|
||||||
.timetracker
|
.timetracker
|
||||||
web/analytics/data.json
|
web/analytics/data.json
|
||||||
DEBUG
|
DEBUG
|
||||||
schedule_client.py
|
|
||||||
config.py
|
config.py
|
||||||
.env
|
.env
|
||||||
644
utils/schedule_client.py
Normal file
644
utils/schedule_client.py
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
"""Однофайловый клиент для SheetScheduleParser API.
|
||||||
|
|
||||||
|
Файл можно просто скопировать в другой проект. Внешние зависимости не нужны.
|
||||||
|
|
||||||
|
Пример::
|
||||||
|
|
||||||
|
import schedule_client as schedule
|
||||||
|
|
||||||
|
schedule.configure("http://schedule.example.com", cache_ttl=300)
|
||||||
|
db = schedule.ScheduleDB()
|
||||||
|
|
||||||
|
lessons = (
|
||||||
|
db.query(schedule.ScheduleEntry)
|
||||||
|
.filter_by(schedule_type="default", group_ids="АТ-501", weekday=1)
|
||||||
|
.order_by(schedule.Schedule.time_start)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Generic, TypeVar
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
# Эти значения можно изменить прямо в скопированном файле или через configure().
|
||||||
|
API_URL = os.getenv("SCHEDULE_API_URL", "http://127.0.0.1:8000")
|
||||||
|
# Должно в точности совпадать с app.version.API_VERSION на сервере.
|
||||||
|
API_VERSION = "1"
|
||||||
|
CACHE_TTL = float(os.getenv("SCHEDULE_CACHE_TTL", "300"))
|
||||||
|
REQUEST_TIMEOUT = float(os.getenv("SCHEDULE_REQUEST_TIMEOUT", "15"))
|
||||||
|
FAILURE_RETRY_DELAY = float(os.getenv("SCHEDULE_FAILURE_RETRY_DELAY", "15"))
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleClientError(RuntimeError):
|
||||||
|
"""Базовая ошибка клиента."""
|
||||||
|
|
||||||
|
|
||||||
|
class APIError(ScheduleClientError):
|
||||||
|
"""API недоступно или вернуло некорректный ответ."""
|
||||||
|
|
||||||
|
|
||||||
|
class IncompatibleAPIVersionError(ScheduleClientError):
|
||||||
|
"""Версия контракта API не поддерживается этим файлом клиента."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoResultFound(ScheduleClientError):
|
||||||
|
"""Query.one() не нашёл ни одной записи."""
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleResultsFound(ScheduleClientError):
|
||||||
|
"""Query.one() нашёл больше одной записи."""
|
||||||
|
|
||||||
|
|
||||||
|
class Expression:
|
||||||
|
def __init__(self, predicate: Callable[["Model"], bool], label: str = "") -> None:
|
||||||
|
self._predicate = predicate
|
||||||
|
self._label = label
|
||||||
|
|
||||||
|
def __call__(self, item: "Model") -> bool:
|
||||||
|
return self._predicate(item)
|
||||||
|
|
||||||
|
def __and__(self, other: "Expression") -> "Expression":
|
||||||
|
return Expression(lambda item: self(item) and other(item))
|
||||||
|
|
||||||
|
def __or__(self, other: "Expression") -> "Expression":
|
||||||
|
return Expression(lambda item: self(item) or other(item))
|
||||||
|
|
||||||
|
def __invert__(self) -> "Expression":
|
||||||
|
return Expression(lambda item: not self(item))
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return self._label or "Expression(...)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SortExpression:
|
||||||
|
name: str
|
||||||
|
reverse: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Field:
|
||||||
|
"""Дескриптор поля модели и строитель выражений для Query.filter()."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.name = ""
|
||||||
|
|
||||||
|
def __set_name__(self, owner: type["Model"], name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
|
||||||
|
def __get__(self, instance: "Model | None", owner: type["Model"]) -> Any:
|
||||||
|
if instance is None:
|
||||||
|
return self
|
||||||
|
return instance._data.get(self.name)
|
||||||
|
|
||||||
|
def _compare(self, operation: Callable[[Any, Any], bool], value: Any, symbol: str) -> Expression:
|
||||||
|
return Expression(
|
||||||
|
lambda item: operation(item._data.get(self.name), value),
|
||||||
|
f"{self.name} {symbol} {value!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __eq__(self, value: Any) -> Expression: # type: ignore[override]
|
||||||
|
return self._compare(lambda actual, expected: actual == expected, value, "==")
|
||||||
|
|
||||||
|
def __ne__(self, value: Any) -> Expression: # type: ignore[override]
|
||||||
|
return self._compare(lambda actual, expected: actual != expected, value, "!=")
|
||||||
|
|
||||||
|
def __lt__(self, value: Any) -> Expression:
|
||||||
|
return self._compare(_safe_lt, value, "<")
|
||||||
|
|
||||||
|
def __le__(self, value: Any) -> Expression:
|
||||||
|
return self._compare(_safe_le, value, "<=")
|
||||||
|
|
||||||
|
def __gt__(self, value: Any) -> Expression:
|
||||||
|
return self._compare(_safe_gt, value, ">")
|
||||||
|
|
||||||
|
def __ge__(self, value: Any) -> Expression:
|
||||||
|
return self._compare(_safe_ge, value, ">=")
|
||||||
|
|
||||||
|
def contains(self, value: Any) -> Expression:
|
||||||
|
return Expression(
|
||||||
|
lambda item: _contains(item._data.get(self.name), value),
|
||||||
|
f"{self.name}.contains({value!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def in_(self, values: Iterable[Any]) -> Expression:
|
||||||
|
choices = tuple(values)
|
||||||
|
return Expression(
|
||||||
|
lambda item: item._data.get(self.name) in choices,
|
||||||
|
f"{self.name}.in_({choices!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_(self, value: Any) -> Expression:
|
||||||
|
return Expression(lambda item: item._data.get(self.name) is value)
|
||||||
|
|
||||||
|
def startswith(self, value: str) -> Expression:
|
||||||
|
return Expression(
|
||||||
|
lambda item: str(item._data.get(self.name) or "").startswith(value)
|
||||||
|
)
|
||||||
|
|
||||||
|
def asc(self) -> SortExpression:
|
||||||
|
return SortExpression(self.name)
|
||||||
|
|
||||||
|
def desc(self) -> SortExpression:
|
||||||
|
return SortExpression(self.name, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Model:
|
||||||
|
"""Неизменяемая по соглашению запись из API с доступом через атрибуты."""
|
||||||
|
|
||||||
|
def __init__(self, data: Mapping[str, Any]) -> None:
|
||||||
|
self._data = dict(data)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return dict(self._data)
|
||||||
|
|
||||||
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
identity = self._data.get("id", self._data.get("name", "?"))
|
||||||
|
return f"{type(self).__name__}(id={identity!r})"
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(Model):
|
||||||
|
id = Field()
|
||||||
|
schedule_type = Field()
|
||||||
|
name = Field()
|
||||||
|
type = Field()
|
||||||
|
weekday = Field()
|
||||||
|
is_odd_week = Field()
|
||||||
|
is_even_week = Field()
|
||||||
|
time_start = Field()
|
||||||
|
time_end = Field()
|
||||||
|
datetime = Field()
|
||||||
|
group_ids = Field()
|
||||||
|
room_ids = Field()
|
||||||
|
teacher_ids = Field()
|
||||||
|
teacher_assignments = Field()
|
||||||
|
created_at = Field()
|
||||||
|
parsed_at = Field()
|
||||||
|
|
||||||
|
|
||||||
|
# Более явное имя для импорта; Schedule оставлено как короткий вариант.
|
||||||
|
ScheduleEntry = Schedule
|
||||||
|
|
||||||
|
|
||||||
|
class Group(Model):
|
||||||
|
id = Field()
|
||||||
|
name = Field()
|
||||||
|
faculty = Field()
|
||||||
|
course = Field()
|
||||||
|
parent_group_id = Field()
|
||||||
|
created_at = Field()
|
||||||
|
updated_at = Field()
|
||||||
|
last_seen_at = Field()
|
||||||
|
|
||||||
|
|
||||||
|
class Teacher(Model):
|
||||||
|
id = Field()
|
||||||
|
name = Field()
|
||||||
|
positions = Field()
|
||||||
|
created_at = Field()
|
||||||
|
updated_at = Field()
|
||||||
|
last_seen_at = Field()
|
||||||
|
|
||||||
|
|
||||||
|
class Room(Model):
|
||||||
|
id = Field()
|
||||||
|
name = Field()
|
||||||
|
created_at = Field()
|
||||||
|
updated_at = Field()
|
||||||
|
last_seen_at = Field()
|
||||||
|
|
||||||
|
|
||||||
|
class GroupHistory(Model):
|
||||||
|
faculty = Field()
|
||||||
|
course = Field()
|
||||||
|
parent_group_id = Field()
|
||||||
|
valid_from = Field()
|
||||||
|
valid_to = Field()
|
||||||
|
|
||||||
|
|
||||||
|
M = TypeVar("M", bound=Model)
|
||||||
|
|
||||||
|
|
||||||
|
class Query(Generic[M]):
|
||||||
|
"""Небольшое неизменяемое подмножество привычного SQLAlchemy Query API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: "ScheduleDB",
|
||||||
|
dataset: str,
|
||||||
|
model: type[M],
|
||||||
|
predicates: tuple[Expression, ...] = (),
|
||||||
|
sort_expressions: tuple[SortExpression, ...] = (),
|
||||||
|
offset_value: int = 0,
|
||||||
|
limit_value: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._client = client
|
||||||
|
self._dataset = dataset
|
||||||
|
self._model = model
|
||||||
|
self._predicates = predicates
|
||||||
|
self._sort_expressions = sort_expressions
|
||||||
|
self._offset = offset_value
|
||||||
|
self._limit = limit_value
|
||||||
|
|
||||||
|
def _clone(self, **changes: Any) -> "Query[M]":
|
||||||
|
values = {
|
||||||
|
"client": self._client,
|
||||||
|
"dataset": self._dataset,
|
||||||
|
"model": self._model,
|
||||||
|
"predicates": self._predicates,
|
||||||
|
"sort_expressions": self._sort_expressions,
|
||||||
|
"offset_value": self._offset,
|
||||||
|
"limit_value": self._limit,
|
||||||
|
}
|
||||||
|
values.update(changes)
|
||||||
|
return Query(**values)
|
||||||
|
|
||||||
|
def filter(self, *criteria: Expression | Callable[[M], bool]) -> "Query[M]":
|
||||||
|
expressions: list[Expression] = []
|
||||||
|
for criterion in criteria:
|
||||||
|
if isinstance(criterion, Expression):
|
||||||
|
expressions.append(criterion)
|
||||||
|
elif callable(criterion):
|
||||||
|
expressions.append(Expression(criterion))
|
||||||
|
else:
|
||||||
|
raise TypeError("filter() принимает выражения полей или callable")
|
||||||
|
return self._clone(predicates=self._predicates + tuple(expressions))
|
||||||
|
|
||||||
|
def filter_by(self, **values: Any) -> "Query[M]":
|
||||||
|
expressions: list[Expression] = []
|
||||||
|
for name, expected in values.items():
|
||||||
|
if not hasattr(self._model, name):
|
||||||
|
raise AttributeError(f"У {self._model.__name__} нет поля {name!r}")
|
||||||
|
expressions.append(
|
||||||
|
Expression(
|
||||||
|
lambda item, key=name, value=expected: _filter_by_match(
|
||||||
|
item._data.get(key), value
|
||||||
|
),
|
||||||
|
f"{name}={expected!r}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return self._clone(predicates=self._predicates + tuple(expressions))
|
||||||
|
|
||||||
|
def order_by(self, *fields: Field | SortExpression | str) -> "Query[M]":
|
||||||
|
expressions: list[SortExpression] = []
|
||||||
|
for value in fields:
|
||||||
|
if isinstance(value, Field):
|
||||||
|
expressions.append(value.asc())
|
||||||
|
elif isinstance(value, SortExpression):
|
||||||
|
expressions.append(value)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
expressions.append(SortExpression(value))
|
||||||
|
else:
|
||||||
|
raise TypeError("order_by() принимает поле, field.asc()/desc() или имя поля")
|
||||||
|
return self._clone(sort_expressions=tuple(expressions))
|
||||||
|
|
||||||
|
def limit(self, value: int) -> "Query[M]":
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("limit не может быть отрицательным")
|
||||||
|
return self._clone(limit_value=value)
|
||||||
|
|
||||||
|
def offset(self, value: int) -> "Query[M]":
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("offset не может быть отрицательным")
|
||||||
|
return self._clone(offset_value=value)
|
||||||
|
|
||||||
|
def all(self) -> list[M]:
|
||||||
|
records = list(self._client._records(self._dataset))
|
||||||
|
for predicate in self._predicates:
|
||||||
|
records = [item for item in records if predicate(item)]
|
||||||
|
for expression in reversed(self._sort_expressions):
|
||||||
|
records.sort(
|
||||||
|
key=lambda item, name=expression.name: _sort_key(item._data.get(name)),
|
||||||
|
reverse=expression.reverse,
|
||||||
|
)
|
||||||
|
end = None if self._limit is None else self._offset + self._limit
|
||||||
|
return records[self._offset : end]
|
||||||
|
|
||||||
|
def first(self) -> M | None:
|
||||||
|
records = self.limit(1).all()
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
def one_or_none(self) -> M | None:
|
||||||
|
records = self.limit(2).all()
|
||||||
|
if len(records) > 1:
|
||||||
|
raise MultipleResultsFound("Ожидалась максимум одна запись")
|
||||||
|
return records[0] if records else None
|
||||||
|
|
||||||
|
def one(self) -> M:
|
||||||
|
result = self.one_or_none()
|
||||||
|
if result is None:
|
||||||
|
raise NoResultFound("Запись не найдена")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self.all())
|
||||||
|
|
||||||
|
def get(self, entity_id: str) -> M | None:
|
||||||
|
return self.filter_by(id=entity_id).first()
|
||||||
|
|
||||||
|
def __iter__(self) -> Iterator[M]:
|
||||||
|
return iter(self.all())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _CacheState:
|
||||||
|
lock: threading.RLock = field(default_factory=threading.RLock)
|
||||||
|
datasets: dict[str, tuple[Model, ...]] = field(default_factory=dict)
|
||||||
|
refreshed_monotonic: float = 0.0
|
||||||
|
refreshed_at: str | None = None
|
||||||
|
retry_after: float = 0.0
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
_CACHE_LOCK = threading.Lock()
|
||||||
|
_GLOBAL_CACHE: dict[str, _CacheState] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
api_url: str,
|
||||||
|
*,
|
||||||
|
cache_ttl: float | None = None,
|
||||||
|
request_timeout: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Задаёт глобальные настройки для последующих ScheduleDB()."""
|
||||||
|
|
||||||
|
global API_URL, CACHE_TTL, REQUEST_TIMEOUT
|
||||||
|
API_URL = api_url.rstrip("/")
|
||||||
|
if cache_ttl is not None:
|
||||||
|
CACHE_TTL = float(cache_ttl)
|
||||||
|
if request_timeout is not None:
|
||||||
|
REQUEST_TIMEOUT = float(request_timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache(api_url: str | None = None) -> None:
|
||||||
|
"""Очищает один глобальный снимок или весь кеш процесса."""
|
||||||
|
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
if api_url is None:
|
||||||
|
states = list(_GLOBAL_CACHE.values())
|
||||||
|
_GLOBAL_CACHE.clear()
|
||||||
|
else:
|
||||||
|
state = _GLOBAL_CACHE.pop(api_url.rstrip("/"), None)
|
||||||
|
states = [state] if state is not None else []
|
||||||
|
# Сбрасываем и уже выданные клиентам state, а не только запись в словаре.
|
||||||
|
for state in states:
|
||||||
|
with state.lock:
|
||||||
|
state.datasets.clear()
|
||||||
|
state.refreshed_monotonic = 0.0
|
||||||
|
state.refreshed_at = None
|
||||||
|
state.retry_after = 0.0
|
||||||
|
state.last_error = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleDB:
|
||||||
|
"""Точка входа к кешированной копии всех основных справочников API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_url: str | None = None,
|
||||||
|
*,
|
||||||
|
cache_ttl: float | None = None,
|
||||||
|
request_timeout: float | None = None,
|
||||||
|
eager: bool = True,
|
||||||
|
) -> None:
|
||||||
|
self.api_url = (api_url or API_URL).rstrip("/")
|
||||||
|
self.cache_ttl = CACHE_TTL if cache_ttl is None else float(cache_ttl)
|
||||||
|
self.request_timeout = (
|
||||||
|
REQUEST_TIMEOUT if request_timeout is None else float(request_timeout)
|
||||||
|
)
|
||||||
|
with _CACHE_LOCK:
|
||||||
|
self._state = _GLOBAL_CACHE.setdefault(self.api_url, _CacheState())
|
||||||
|
# Проверяется при каждом создании клиента, даже если общий кеш уже заполнен.
|
||||||
|
self._check_compatibility()
|
||||||
|
if eager:
|
||||||
|
self._ensure_fresh(require_data=True, compatibility_checked=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def schedule(self) -> Query[Schedule]:
|
||||||
|
return Query(self, "schedule", Schedule)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default(self) -> Query[Schedule]:
|
||||||
|
return Query(self, "default", Schedule)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exam(self) -> Query[Schedule]:
|
||||||
|
return Query(self, "exam", Schedule)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def groups(self) -> Query[Group]:
|
||||||
|
return Query(self, "groups", Group)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def teachers(self) -> Query[Teacher]:
|
||||||
|
return Query(self, "teachers", Teacher)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rooms(self) -> Query[Room]:
|
||||||
|
return Query(self, "rooms", Room)
|
||||||
|
|
||||||
|
def query(self, model: type[M]) -> Query[M]:
|
||||||
|
"""SQLAlchemy-подобная форма: db.query(ScheduleEntry).filter(...)."""
|
||||||
|
|
||||||
|
datasets: dict[type[Model], str] = {
|
||||||
|
Schedule: "schedule",
|
||||||
|
Group: "groups",
|
||||||
|
Teacher: "teachers",
|
||||||
|
Room: "rooms",
|
||||||
|
}
|
||||||
|
dataset = datasets.get(model)
|
||||||
|
if dataset is None:
|
||||||
|
raise TypeError(
|
||||||
|
"query() поддерживает ScheduleEntry, Group, Teacher и Room; "
|
||||||
|
"для истории используйте group_history(group_id)"
|
||||||
|
)
|
||||||
|
return Query(self, dataset, model)
|
||||||
|
|
||||||
|
def group_history(self, group_id: str) -> Query[GroupHistory]:
|
||||||
|
"""Загружает историю группы лениво и дальше хранит в общем кеше."""
|
||||||
|
|
||||||
|
dataset = f"group_history:{group_id}"
|
||||||
|
return Query(self, dataset, GroupHistory)
|
||||||
|
|
||||||
|
def refresh(self) -> None:
|
||||||
|
"""Принудительно и атомарно обновляет все основные наборы или бросает APIError."""
|
||||||
|
|
||||||
|
with self._state.lock:
|
||||||
|
try:
|
||||||
|
self._refresh_locked()
|
||||||
|
except APIError as error:
|
||||||
|
self._state.last_error = str(error)
|
||||||
|
self._state.retry_after = time.monotonic() + FAILURE_RETRY_DELAY
|
||||||
|
raise
|
||||||
|
|
||||||
|
def cache_info(self) -> dict[str, Any]:
|
||||||
|
with self._state.lock:
|
||||||
|
return {
|
||||||
|
"api_url": self.api_url,
|
||||||
|
"refreshed_at": self._state.refreshed_at,
|
||||||
|
"last_error": self._state.last_error,
|
||||||
|
"counts": {
|
||||||
|
name: len(items)
|
||||||
|
for name, items in self._state.datasets.items()
|
||||||
|
if not name.startswith("group_history:")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _records(self, dataset: str) -> Sequence[Model]:
|
||||||
|
self._ensure_fresh(require_data=True)
|
||||||
|
with self._state.lock:
|
||||||
|
if dataset.startswith("group_history:") and dataset not in self._state.datasets:
|
||||||
|
group_id = dataset.partition(":")[2]
|
||||||
|
response = self._fetch(f"/groups/{quote(group_id, safe='')}/history")
|
||||||
|
self._state.datasets[dataset] = tuple(
|
||||||
|
GroupHistory(item) for item in response["result"]
|
||||||
|
)
|
||||||
|
return self._state.datasets.get(dataset, ())
|
||||||
|
|
||||||
|
def _ensure_fresh(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
require_data: bool,
|
||||||
|
compatibility_checked: bool = False,
|
||||||
|
) -> None:
|
||||||
|
with self._state.lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
has_data = bool(self._state.datasets)
|
||||||
|
stale = not has_data or now - self._state.refreshed_monotonic >= self.cache_ttl
|
||||||
|
if not stale or (has_data and now < self._state.retry_after):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._refresh_locked(check_compatibility=not compatibility_checked)
|
||||||
|
except APIError as error:
|
||||||
|
self._state.last_error = str(error)
|
||||||
|
self._state.retry_after = time.monotonic() + FAILURE_RETRY_DELAY
|
||||||
|
if require_data and not has_data:
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _refresh_locked(self, *, check_compatibility: bool = True) -> None:
|
||||||
|
if check_compatibility:
|
||||||
|
self._check_compatibility()
|
||||||
|
endpoints = {
|
||||||
|
"default": ("/schedule/default", Schedule),
|
||||||
|
"exam": ("/schedule/exam", Schedule),
|
||||||
|
"groups": ("/groups", Group),
|
||||||
|
"teachers": ("/teachers", Teacher),
|
||||||
|
"rooms": ("/rooms", Room),
|
||||||
|
}
|
||||||
|
fresh: dict[str, tuple[Model, ...]] = {}
|
||||||
|
for name, (path, model) in endpoints.items():
|
||||||
|
response = self._fetch(path)
|
||||||
|
records: list[Model] = []
|
||||||
|
for raw_item in response["result"]:
|
||||||
|
item = dict(raw_item)
|
||||||
|
if name in {"default", "exam"}:
|
||||||
|
item.setdefault("schedule_type", name)
|
||||||
|
records.append(model(item))
|
||||||
|
fresh[name] = tuple(records)
|
||||||
|
fresh["schedule"] = fresh["default"] + fresh["exam"]
|
||||||
|
|
||||||
|
# Истории групп инвалидируются: после обновления основной базы они могли измениться.
|
||||||
|
self._state.datasets = fresh
|
||||||
|
self._state.refreshed_monotonic = time.monotonic()
|
||||||
|
self._state.refreshed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
self._state.retry_after = 0.0
|
||||||
|
self._state.last_error = None
|
||||||
|
|
||||||
|
def _fetch(self, path: str) -> dict[str, Any]:
|
||||||
|
return _request_json(f"{self.api_url}{path}", self.request_timeout)
|
||||||
|
|
||||||
|
def _check_compatibility(self) -> None:
|
||||||
|
response = self._fetch("/version")
|
||||||
|
actual = response.get("api_version")
|
||||||
|
if actual != API_VERSION:
|
||||||
|
raise IncompatibleAPIVersionError(
|
||||||
|
f"Несовместимая версия API: сервер={actual!r}, "
|
||||||
|
f"клиент ожидает {API_VERSION!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_json(url: str, timeout: float) -> dict[str, Any]:
|
||||||
|
request = Request(url, headers={"Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
payload = json.load(response)
|
||||||
|
except HTTPError as error:
|
||||||
|
raise APIError(f"API вернуло HTTP {error.code} для {url}") from error
|
||||||
|
except (URLError, TimeoutError, OSError) as error:
|
||||||
|
raise APIError(f"Не удалось обратиться к {url}: {error}") from error
|
||||||
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||||
|
raise APIError(f"API вернуло невалидный JSON для {url}") from error
|
||||||
|
if not isinstance(payload, dict) or payload.get("ok") is not True:
|
||||||
|
raise APIError(f"Некорректный ответ API для {url}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_by_match(actual: Any, expected: Any) -> bool:
|
||||||
|
# Самый частый запрос — filter_by(group_ids="АТ-501"). Для списковых полей
|
||||||
|
# одиночное значение означает contains, а коллекция — точное сравнение.
|
||||||
|
if isinstance(actual, (list, tuple, set)) and not isinstance(
|
||||||
|
expected, (list, tuple, set)
|
||||||
|
):
|
||||||
|
return expected in actual
|
||||||
|
return actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
def _contains(container: Any, value: Any) -> bool:
|
||||||
|
try:
|
||||||
|
return value in container
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_key(value: Any) -> tuple[bool, Any]:
|
||||||
|
return value is None, value
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_lt(left: Any, right: Any) -> bool:
|
||||||
|
return left is not None and right is not None and left < right
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_le(left: Any, right: Any) -> bool:
|
||||||
|
return left is not None and right is not None and left <= right
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_gt(left: Any, right: Any) -> bool:
|
||||||
|
return left is not None and right is not None and left > right
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_ge(left: Any, right: Any) -> bool:
|
||||||
|
return left is not None and right is not None and left >= right
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"API_URL",
|
||||||
|
"API_VERSION",
|
||||||
|
"CACHE_TTL",
|
||||||
|
"APIError",
|
||||||
|
"IncompatibleAPIVersionError",
|
||||||
|
"NoResultFound",
|
||||||
|
"MultipleResultsFound",
|
||||||
|
"ScheduleDB",
|
||||||
|
"Schedule",
|
||||||
|
"ScheduleEntry",
|
||||||
|
"Group",
|
||||||
|
"Teacher",
|
||||||
|
"Room",
|
||||||
|
"GroupHistory",
|
||||||
|
"Query",
|
||||||
|
"configure",
|
||||||
|
"clear_cache",
|
||||||
|
]
|
||||||
Loading…
Add table
Reference in a new issue