274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
from __future__ import annotations
|
||
|
||
import datetime
|
||
import difflib
|
||
import html
|
||
import json
|
||
import re
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
from urllib.error import HTTPError, URLError
|
||
from urllib.parse import quote
|
||
from urllib.request import Request, urlopen
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import config
|
||
|
||
|
||
dayStrings = [
|
||
"Понедельник",
|
||
"Вторник",
|
||
"Среда",
|
||
"Четверг",
|
||
"Пятница",
|
||
"Суббота",
|
||
"Воскресенье",
|
||
]
|
||
|
||
MOSCOW = ZoneInfo("Europe/Moscow")
|
||
_CACHE_TTL = 300.0
|
||
_REQUEST_TIMEOUT = 10.0
|
||
_FAILURE_RETRY_DELAY = 30.0
|
||
_cache: dict[str, tuple[float, list[dict[str, Any]]]] = {}
|
||
_failures: dict[str, tuple[float, str]] = {}
|
||
_cache_lock = threading.RLock()
|
||
|
||
|
||
class ScheduleAPIError(RuntimeError):
|
||
pass
|
||
|
||
|
||
def _api_url(path: str) -> str:
|
||
return f"{config.SCHEDULE_BASE_URL.rstrip('/')}/{path.lstrip('/')}"
|
||
|
||
|
||
def _load_result(path: str) -> list[dict[str, Any]]:
|
||
now = time.monotonic()
|
||
with _cache_lock:
|
||
cached = _cache.get(path)
|
||
if cached and now - cached[0] < _CACHE_TTL:
|
||
return cached[1]
|
||
failure = _failures.get(path)
|
||
if failure and now < failure[0]:
|
||
if cached:
|
||
return cached[1]
|
||
raise ScheduleAPIError(failure[1])
|
||
|
||
request = Request(_api_url(path), headers={"Accept": "application/json"})
|
||
try:
|
||
with urlopen(request, timeout=_REQUEST_TIMEOUT) as response:
|
||
payload = json.load(response)
|
||
except (HTTPError, URLError, TimeoutError, OSError, ValueError) as error:
|
||
message = f"Не удалось получить {path}: {error}"
|
||
with _cache_lock:
|
||
_failures[path] = (now + _FAILURE_RETRY_DELAY, message)
|
||
if cached:
|
||
return cached[1]
|
||
raise ScheduleAPIError(message) from error
|
||
|
||
if payload.get("ok") is not True or not isinstance(payload.get("result"), list):
|
||
if cached:
|
||
return cached[1]
|
||
raise ScheduleAPIError(f"Сервер вернул некорректный ответ для {path}")
|
||
|
||
result = payload["result"]
|
||
with _cache_lock:
|
||
_cache[path] = (now, result)
|
||
_failures.pop(path, None)
|
||
return result
|
||
|
||
|
||
def clear_schedule_cache() -> None:
|
||
with _cache_lock:
|
||
_cache.clear()
|
||
_failures.clear()
|
||
|
||
|
||
def get_main_groups() -> list[dict[str, Any]]:
|
||
groups = _load_result("groups")
|
||
if not groups:
|
||
raise ScheduleAPIError("Сервер расписания пока не содержит групп")
|
||
return sorted(
|
||
(group for group in groups if group.get("parent_group_id") is None),
|
||
key=lambda group: str(group.get("name", "")).casefold(),
|
||
)
|
||
|
||
|
||
def _compact_group_name(value: str) -> str:
|
||
return re.sub(r"[^0-9a-zа-яё]", "", value.casefold())
|
||
|
||
|
||
def find_main_groups(query: str, limit: int = 8) -> list[str]:
|
||
query = query.strip()
|
||
if not query:
|
||
return []
|
||
|
||
groups = [str(group["name"]) for group in get_main_groups()]
|
||
compact_query = _compact_group_name(query)
|
||
exact = [name for name in groups if _compact_group_name(name) == compact_query]
|
||
if exact:
|
||
return exact[:1]
|
||
|
||
contains = [name for name in groups if compact_query in _compact_group_name(name)]
|
||
if contains:
|
||
return contains[:limit]
|
||
|
||
scored = sorted(
|
||
(
|
||
difflib.SequenceMatcher(None, compact_query, _compact_group_name(name)).ratio(),
|
||
name,
|
||
)
|
||
for name in groups
|
||
)
|
||
return [name for score, name in reversed(scored) if score >= 0.45][:limit]
|
||
|
||
|
||
def group_exists(group_name: str) -> bool:
|
||
target = _compact_group_name(group_name)
|
||
return any(
|
||
_compact_group_name(str(group["name"])) == target for group in get_main_groups()
|
||
)
|
||
|
||
|
||
def _minutes_to_time(value: int | None) -> list[int]:
|
||
if value is None:
|
||
return [0, 0]
|
||
return [value // 60, value % 60]
|
||
|
||
|
||
class Lesson:
|
||
def __init__(self, data: dict[str, Any], subgroup_names: list[str]) -> None:
|
||
self.start = _minutes_to_time(data.get("time_start"))
|
||
self.end = _minutes_to_time(data.get("time_end"))
|
||
self.strTime = f"{self.start[0]:02d}:{self.start[1]:02d} - {self.end[0]:02d}:{self.end[1]:02d}"
|
||
self.name = str(data.get("name") or "Без названия")
|
||
self.type = str(data.get("type") or "пара")
|
||
self.teacher_ids = [str(value) for value in data.get("teacher_ids", [])]
|
||
self.room_ids = [str(value) for value in data.get("room_ids", [])]
|
||
self.subgroup_names = subgroup_names
|
||
self.info = self.teacher_ids + [f"ауд. {room}" for room in self.room_ids]
|
||
self.is_odd_week = bool(data.get("is_odd_week"))
|
||
self.is_even_week = bool(data.get("is_even_week"))
|
||
|
||
def text(self, isToday: bool = False) -> str:
|
||
now = datetime.datetime.now(MOSCOW)
|
||
lesson_start = now.replace(
|
||
hour=self.start[0], minute=self.start[1], second=0, microsecond=0
|
||
)
|
||
lesson_end = now.replace(
|
||
hour=self.end[0], minute=self.end[1], second=0, microsecond=0
|
||
)
|
||
|
||
lesson_type = html.escape(self.type)
|
||
date_text = f"{lesson_type.capitalize()} <i>в {self.start[0]}:{self.start[1]:02d}</i>"
|
||
if isToday and lesson_start > now:
|
||
minutes = int((lesson_start - now).total_seconds() // 60)
|
||
if minutes < 30:
|
||
date_text = f"{lesson_type.capitalize()} <i>начнется через {max(minutes, 1)} мин.</i>"
|
||
elif isToday and lesson_start <= now < lesson_end:
|
||
minutes = int((lesson_end - now).total_seconds() // 60)
|
||
date_text = f"<i>Сейчас идет</i> {lesson_type}, закончится через {max(minutes, 1)} мин."
|
||
|
||
details: list[str] = []
|
||
details.extend(html.escape(teacher) for teacher in self.teacher_ids)
|
||
details.extend(
|
||
f'<a href="https://rasp.pgups.ru/schedule/room?room={quote(room)}">ауд. {html.escape(room)}</a>'
|
||
for room in self.room_ids
|
||
)
|
||
if self.subgroup_names:
|
||
details.append(html.escape(", ".join(self.subgroup_names)))
|
||
|
||
info = ", ".join(details) if details else "Дополнительная информация отсутствует"
|
||
return (
|
||
f"<blockquote><b>{html.escape(self.name)}</b>\n"
|
||
f"{date_text}\n{info}</blockquote>"
|
||
)
|
||
|
||
|
||
class Schedule:
|
||
def __init__(
|
||
self,
|
||
lessons: list[dict[str, Any]],
|
||
group_name: str,
|
||
groups: list[dict[str, Any]],
|
||
now: datetime.datetime | None = None,
|
||
) -> None:
|
||
current = now or datetime.datetime.now(MOSCOW)
|
||
self.weekNumber = current.isocalendar().week
|
||
self.weekDay = current.weekday()
|
||
|
||
base_group = next(
|
||
(
|
||
group
|
||
for group in groups
|
||
if _compact_group_name(str(group.get("name", "")))
|
||
== _compact_group_name(group_name)
|
||
and group.get("parent_group_id") is None
|
||
),
|
||
None,
|
||
)
|
||
if base_group is None:
|
||
raise ValueError(f"Группа {group_name!r} не найдена")
|
||
|
||
base_id = str(base_group["id"])
|
||
child_names = {
|
||
str(group["id"]): str(group["name"])
|
||
for group in groups
|
||
if group.get("parent_group_id") == base_id
|
||
}
|
||
target_ids = {base_id, *child_names}
|
||
self.days: list[list[Lesson]] = [[] for _ in range(5)]
|
||
|
||
for raw_lesson in lessons:
|
||
weekday = raw_lesson.get("weekday")
|
||
if not isinstance(weekday, int) or weekday not in range(1, 6):
|
||
continue
|
||
matched_ids = target_ids.intersection(map(str, raw_lesson.get("group_ids", [])))
|
||
if not matched_ids:
|
||
continue
|
||
subgroup_names = [
|
||
child_names[group_id] for group_id in matched_ids if group_id in child_names
|
||
]
|
||
self.days[weekday - 1].append(Lesson(raw_lesson, sorted(subgroup_names)))
|
||
|
||
for day in self.days:
|
||
day.sort(key=lambda lesson: (lesson.start, lesson.name.casefold()))
|
||
|
||
def today(self) -> list[Lesson]:
|
||
if self.weekDay not in range(5):
|
||
return []
|
||
even_week = self.weekNumber % 2 == 0
|
||
return [
|
||
lesson
|
||
for lesson in self.days[self.weekDay]
|
||
if (lesson.is_even_week if even_week else lesson.is_odd_week)
|
||
]
|
||
|
||
|
||
def get_group_schedule(group_name: str) -> Schedule:
|
||
groups = _load_result("groups")
|
||
if not groups:
|
||
raise ScheduleAPIError("Сервер расписания пока не содержит групп")
|
||
lessons = _load_result("schedule/default")
|
||
return Schedule(lessons, group_name, groups)
|
||
|
||
|
||
def generateDaySchedule(
|
||
schedule: Schedule, showOddWeekText: bool = True, isToday: bool = False
|
||
) -> str:
|
||
even_week = schedule.weekNumber % 2 == 0
|
||
weekday = schedule.weekDay
|
||
day_name = dayStrings[weekday]
|
||
header = (
|
||
f'<b>{day_name}, {"четная" if even_week else "нечетная"} неделя</b>\n'
|
||
if showOddWeekText
|
||
else f"<b>{day_name}</b>\n"
|
||
)
|
||
lessons = schedule.today()
|
||
if not lessons:
|
||
return header + "Пар нет 🥰"
|
||
|
||
result = header + "\n".join(lesson.text(isToday) for lesson in lessons)
|
||
last_end = max(lesson.end for lesson in lessons)
|
||
return result + f"\nПары закончатся в <i>{last_end[0]}:{last_end[1]:02d}</i>"
|