bot/models/schedule.py
2026-08-30 11:43:38 +00:00

274 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>"