1
0
Fork 0
mirror of https://github.com/EDeev/mospolytech-mcp.git synced 2026-10-07 20:49:52 +03:00

prototype

This commit is contained in:
Деев Егор Викторович 2026-09-16 21:09:41 +03:00
parent f8306ac574
commit 89813c4cd9
64 changed files with 5578 additions and 5 deletions

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
# Учётные данные личного кабинета e.mospolytech.ru
MPU_LOGIN=
MPU_PASSWORD=
# Подключение к PostgreSQL
DATABASE_URL=

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
src/*.egg-info/
.pytest_cache/
.env
*.db

138
README.md
View file

@ -1,6 +1,134 @@
# mospolytech-mcp
# Mospolytech MCP
### Студенты группы 241-327:
#### Деев Егор Викторович
#### Шмыговский Никита Сергеевич
#### Старков Руслан Владимирович
Наш учебный проект по дисциплине «Автоматизация процессов жизненного цикла
программных средств» (Московский Политех, гр. 241–327). Делаем MCP-сервер,
который даёт LLM-агенту (например, Claude) доступ к данным университета —
и к открытому справочнику (группы, расписание), и к личному кабинету
конкретного авторизованного пользователя (расписание, оценки, уведомления,
платежи, заявки, сообщения).
Команда — три человека:
- Деев Егор Викторович
- Шмыговский Никита Сергеевич
- Старков Руслан Владимирович
Официальное техническое задание, которое сдавали на лаб. №1 —
[`docs/official/lab01-tech-spec.pdf`](docs/official/lab01-tech-spec.pdf).
Наш рабочий план по всем 8 лабораторным (архитектура, черновая схема БД,
открытые риски) — [`docs/working/plan.md`](docs/working/plan.md). Подробнее
про то, как мы делим документацию на официальную и рабочую — в
[`docs/README.md`](docs/README.md).
## Что уже есть
Мы собрали обе половины университетского API в этот репозиторий и
объединили их в один объект `UniversityAPI`, чтобы MCP-серверу (и нам
самим) не приходилось таскать два клиента порознь:
```python
from mospolytech_mcp import UniversityAPI
async with UniversityAPI() as uni:
groups = await uni.open.get_groups() # открытые данные, без авторизации
await uni.lk.login(login, password) # а тут уже личный кабинет
my_schedule = await uni.lk.get_my_schedule()
```
- **`open_api`** — открытые справочные данные университета. По ТЗ (ТПО-004)
здесь используется готовая библиотека
[mospolytech_api](https://github.com/r4nd0lph-c/mospolytech_api), а не
наша реализация — мы завендорили её код как есть в `open_api/_vendor/` и
сверху накрутили тонкую async-обёртку (`asyncio.to_thread`), чтобы она не
блокировала event loop рядом с остальным асинхронным кодом.
- **`lk_api`** — личный кабинет. Тут по ТЗ (ТПО-005) наоборот: логику
авторизации и работы с API переносим с существующей Go-библиотеки
[MPU_LK_API](https://github.com/GODIMONGO/MPU_LK_API) на Python, своими
руками. Сделали: логин/логаут/обновление токенов (обе системы — легаси
token и JWT+refresh), расписание, успеваемость, платежи и договоры,
заявки, уведомления и объявления, диалоги и (осторожно,
неподтверждено — см. `docs/working/plan.md`) отправку сообщений.
Сам MCP-сервер (`server.py`) уже запускается и отвечает на первый
инструмент — `list_groups` (ФОД-001), через полный стек: протокол MCP →
`UniversityAPI.open` → кэш в PostgreSQL (`cache.py` + `db/`, миграции
Alembic в `alembic/`). Живьём проверяли: 592 группы забрались с
rasp.dmami.ru, записались в БД, второй вызов уже идёт из кэша. Остальные
инструменты (ФЛК-*, оставшиеся ФХД) дописываем по плану в
`docs/working/plan.md`, раздел 3.1.
## Требования
- Python 3.14 (у нас работает через `py -3.14`)
- Доступ к PostgreSQL — свой пользователь `mospolytech_mcp` на общем сервере
команды (не `postgres`/admin), спросите данные в чате, если не сохранили
## Установка
```powershell
py -3.14 -m venv .venv
.venv\Scripts\pip install -e ".[dev]"
copy .env.example .env
# впишите DATABASE_URL (и MPU_LOGIN/MPU_PASSWORD, если нужен check_lk_api.py)
.venv\Scripts\alembic upgrade head
```
## Тесты
Всё, что не требует реального логина, покрыто офлайн-тестами — сеть не
трогаем, HTTP подменяем (`httpx.MockTransport` для `lk_api`, подмена
`requests.get` для `open_api`):
```powershell
.venv\Scripts\python -m pytest -q
```
## Проверка на реальном аккаунте
`open_api` не требует авторизации, поэтому его можно проверить вживую хоть
прямо сейчас (реальный запрос к rasp.dmami.ru, без секретов):
```powershell
.venv\Scripts\python -c "import asyncio; from mospolytech_mcp.open_api import OpenDataClient; asyncio.run(OpenDataClient().get_groups())"
```
Для `lk_api` нужен свой логин/пароль от e.mospolytech.ru — впишите
MPU_LOGIN и MPU_PASSWORD в свой `.env`, дальше `scripts/check_lk_api.py`
логинится и читает профиль, расписание, успеваемость, уведомления,
платежи, заявки и диалоги (ничего не меняет):
```powershell
.venv\Scripts\python scripts\check_lk_api.py
```
Пароль используется один раз при вызове `login()` и нигде не сохраняется.
`.env` в `.gitignore`, в репозиторий не попадёт.
## Запуск сервера
```powershell
.venv\Scripts\python -m mospolytech_mcp.server
```
Поднимается на stdio-транспорте — так подключается локальный MCP-клиент
(например, Claude Desktop/Code). Пока один инструмент, `list_groups`
(ФОД-001) — список групп с кэшем в БД на 15 минут.
## Структура репозитория
```
docs/
official/ — официальные документы для сдачи (ТЗ и т.д.), PDF, не правим задним числом
working/ — наш рабочий план, заметки, черновики — правим постоянно
sdo/ — методички по всем 8 лабораторным и справочные ГОСТ/IEEE из СДО
src/mospolytech_mcp/
api.py — UniversityAPI, точка входа: держит open_api + lk_api вместе
server.py — сам MCP-сервер (регистрация tools)
cache.py — кэширующие обёртки над UniversityAPI, пишут в БД (ФХД-003)
open_api/ — открытые данные (вендор mospolytech_api + наша async-обёртка)
lk_api/ — личный кабинет (наш порт MPU_LK_API с Go на Python)
db/ — модели SQLAlchemy (схема версионируется в alembic/)
alembic/ — миграции БД (alembic upgrade head накатывает схему)
tests/ — офлайн-тесты (pytest, без сети)
scripts/ — ручные проверочные скрипты (против реального ЛК)
```

149
alembic.ini Normal file
View file

@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View file

@ -0,0 +1 @@
Generic single-database configuration with an async dbapi.

71
alembic/env.py Normal file
View file

@ -0,0 +1,71 @@
# Alembic env: настройка офлайн/онлайн-миграций и подключение к БД из .env.
import asyncio
import os
import sys
from logging.config import fileConfig
from pathlib import Path
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from mospolytech_mcp._dotenv import load_dotenv
from mospolytech_mcp.db import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
if database_url := os.environ.get("DATABASE_URL"):
config.set_main_option("sqlalchemy.url", database_url)
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View file

@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,37 @@
"""create groups_cache
Revision ID: d3ae49746cfb
Revises:
Create Date: 2026-09-15 18:20:07.435834
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'd3ae49746cfb'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('groups_cache',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('groups', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('fetched_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('groups_cache')
# ### end Alembic commands ###

23
docs/README.md Normal file
View file

@ -0,0 +1,23 @@
# Документация проекта
Делим на два вида, чтобы не путаться:
- **`official/`** — официальные бумажки, которые сдаём преподавателю по
каждой лабораторной, в формате PDF, оформленные по ГОСТ (как и требует
ТЗ, раздел 7). Мы их не правим "по ходу дела" — раз сдали, значит,
зафиксировали. Следующая лабораторная — новый файл.
- **`working/`** — наши рабочие записи для себя: план, заметки по рискам,
черновик схемы БД и т.п. Эти документы мы правим постоянно, они не для
сдачи, а чтобы не терять контекст между лабораторными и держать в одном
месте то, что иначе разошлось бы по трём головам.
Технической документации к коду (описание MCP-инструментов, схема БД,
инструкция по развёртыванию — как требует ТЗ, раздел 7) тоже место здесь,
в Markdown, по мере появления соответствующего кода.
## Файлы
| Файл | Что это |
|---|---|
| [`official/lab01-tech-spec.pdf`](official/lab01-tech-spec.pdf) | Техническое задание — сдано на лаб. №1 |
| [`working/plan.md`](working/plan.md) | Наш план по всем 8 лабораторным, архитектура, риски |

Binary file not shown.

2494
docs/sdo/gost_34.602-89.pdf Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

229
docs/working/plan.md Normal file
View file

@ -0,0 +1,229 @@
# План проекта «Mospolytech MCP»
Команда: Деев Егор Викторович, Шмыговский Никита Сергеевич, Старков Руслан
Владимирович (гр. 241–327).
Это наш рабочий план — не сдаём его никому, правим постоянно. Основан на
официальных методичках всех 8 лабораторных из СДО
([`docs/sdo/`](../sdo/)) и на нашем [техническом задании](../official/lab01-tech-spec.pdf).
## 0. Важное уточнение
Предыдущая версия этого файла угадывала содержание лабораторных №2–8 (мы
на тот момент их не видели) и придумала, что они про поэтапную разработку
функций MCP-сервера — БД, потом инструменты, потом авторизация и т.д. Это
оказалось неверно. Реальные методички описывают совсем другой курс — про
DevOps-практики: виртуализацию, контейнеризацию, CI/CD, статический анализ,
автотесты, миграции БД. Этот файл переписан заново по факту.
## 1. Два параллельных трека
Тут важно не путать две разные вещи:
- **Продуктовый трек** — сам MCP-сервер (то, что мы пишем в `src/`). Он не
привязан к номерам лабораторных — методички вообще не говорят, что
конкретно писать и когда. Наша задача — держать его в рабочем состоянии,
потому что начиная с ЛР2 он становится "объектом", который мы
виртуализируем, контейнеризируем, гоняем через CI/CD, статически
анализируем и тестируем. Без работающего прототипа лабораторные 2–8
выполнять физически не на чем.
- **Инфраструктурный трек** — собственно 8 лабораторных. Это последовательность
DevOps-практик, каждая следующая опирается на результат предыдущей
(нельзя настроить CI, не имея Docker-образа; нельзя гонять автотесты в
пайплайне, не имея пайплайна).
## 2. Логические этапы лабораторных (2–8)
Реальные лабораторные естественно группируются в 4 этапа — каждый следующий
физически невозможен без результата предыдущего.
### Этап А. Стенды (ЛР 2–3)
| ЛР | Тема | Что делаем |
|---|---|---|
| 2 | Виртуализация | Гипервизор, 3+ ВМ (test/stage/prod), объединяем в общую сеть (ping "от всех ко всем"), на test выкладываем проект через git, разворачиваем тулчейн, запускаем прототип |
| 3 | Контейнеризация | Docker на всех ВМ; Dockerfile для сборки и запуска приложения; docker-compose.yaml с приложением + БД/другим сервисом, volumes для сохранения данных между рестартами |
Итог этапа: на трёх ВМ поднята сеть, на test приложение собирается и
запускается в Docker вместе с БД, данные переживают рестарт контейнера.
### Этап Б. Конвейер CI/CD (ЛР 4–5)
| ЛР | Тема | Что делаем |
|---|---|---|
| 4 | Непрерывная интеграция | Пушим собранный образ в DockerHub; разворачиваем TeamCity server; заводим пользователей (по одному на каждого из нас); подключаем репозиторий; настраиваем сборку → образ → push в DockerHub для веток dev и prod |
| 5 | Непрерывная доставка | Добавляем шаг деплоя на stage из образа в DockerHub вместе с БД; разворачиваем TeamCity agent на test; переходим на GitFlow (или другую модель веток); feature-ветка → PR в dev с ревью от другого участника команды → после мержа сборка + автодеплой на stage |
Итог этапа: у нас настоящий CI/CD-конвейер — коммит в feature-ветку
собирается, PR в dev требует ревью, после мержа приложение само уезжает на
stage.
### Этап В. Ворота качества в конвейере (ЛР 6–7)
| ЛР | Тема | Что делаем |
|---|---|---|
| 6 | Статический анализ кода | Минимум 2 анализатора (качество кода / безопасность / поиск секретов), шаги в пайплайне TeamCity — билд падает при находках максимальной критичности |
| 7 | Автоматизированное тестирование | Юнит + интеграционные тесты, покрытие ≥40% функционала из ТЗ; шаг тестирования в пайплайне для всех веток; демонстрация и провала, и прохождения на feature/dev/prod |
Итог этапа: код, который не проходит статический анализ или тесты, до
stage/prod физически не доезжает — пайплайн его останавливает.
У нас тут есть fora — часть офлайн-тестов (`tests/`) уже написана заранее
(лк_api + open_api, 14 тестов). Они пригодятся напрямую в ЛР7, но 40% от
всего функционала ТЗ ими одними не наберём — MCP-сервера и слоя БД ещё нет,
их тоже придётся покрывать тестами.
### Этап Г. Жизненный цикл базы данных в конвейере (ЛР 8)
| ЛР | Тема | Что делаем |
|---|---|---|
| 8 | Миграции БД | Директория со схемой БД в репозитории; статический анализатор безопасности SQL как шаг пайплайна; ручной, а затем автоматизированный деплой схемы на stage (со сверкой test/stage); отдельный prod-пайплайн с бэкапом БД перед изменениями |
Самая тяжёлая лабораторная — по сути весь этап Б, применённый отдельно к
базе данных. Логично начинать только после того, как этапы А–В полностью
стабильны, иначе непонятно, на чём проверять сверку схем.
## 3. Как отсюда следует планировать работу
Порядок такой, что физически нельзя пропустить шаг:
1. ~~Прямо сейчас~~ **Сделано** — продуктовый трек: минимальный работающий
MCP-сервер (`server.py` поверх `lk_api`/`open_api`) + слой БД
(SQLAlchemy-модели, первая миграция Alembic, реальный PostgreSQL на
общем сервере команды). Один инструмент (`list_groups`, ФОД-001) уже
реально ходит в кэш и обратно — то, что нужно было к ЛР2 (пункт
"запустить прототип"). Подробности — раздел 6.
2. **ЛР2 → ЛР3** — как только прототип есть, поднимаем стенды и
контейнеризируем. Здесь же нужна настоящая БД в docker-compose — та,
что мы придумали в разделе 4 ниже.
3. **ЛР4 → ЛР5** — пайплайн настраиваем один раз и дальше живём с ним
постоянно: каждая следующая лабораторная (6, 7, 8) — это новые шаги в
том же самом пайплайне, а не новый пайплайн с нуля.
4. **ЛР8 — в конце**, когда схема БД уже не черновик, а что-то, что реально
меняется по ходу разработки (иначе нечего мигрировать).
Отчёты по каждой лабораторной — отдельный текстовый документ со
скриншотами (см. задания в каждой методичке), кладём в `docs/official/`
рядом с ТЗ, по мере сдачи. Устная защита есть на каждой — со стороны
методичек 2–8 всегда есть блок "Вопросы для изучения", их надо реально
понимать, а не просто списать шаги.
### 3.1. По каким лабораторным двигаем функционал продукта, а где — фиксируем
Методички сами по себе не требуют конкретного объёма функций ни на одном
шаге, кроме одного жёсткого места — ЛР7 требует покрытие тестами ≥40%
функционала из ТЗ. Это и есть якорь, вокруг которого стоит планировать:
- **До ЛР2 — сделано.** MCP-сервер запускается и отвечает на ФОД-001
(`list_groups`) через `open_api`.
- **Между ЛР2 и ЛР3 — тоже сделано.** Слой БД (SQLAlchemy + Alembic) и
реальная запись — кэш списка групп (ФХД-003, `cache.py`). Проверили
живьём: 638 групп с rasp.dmami.ru легли в `groups_cache` на реальном
PostgreSQL, повторный вызов идёт уже из кэша. Ровно то, что нужно для
демонстрации в ЛР3 (записать → рестарт контейнера → данные на месте).
Лог обращений к ЛК (ФХД-004) — кандидат на следующую функцию с записью,
не обязательно прямо сейчас.
- **ЛР4–5 — основной спринт разработки функционала.** Сами эти лабы не
требуют нового кода продукта (там настраивается TeamCity и ветки), а
значит это окно, где можно спокойно писать оставшиеся функции —
ФЛК-001..007 целиком, оставшиеся ФОД, ФХД-001/002. Цель — закончить
**весь функционал раздела 4.2 ТЗ к концу ЛР5**.
- **ЛР6–7 — новый функционал не пишем, только фиксы.** Это осознанное
решение, а не жёсткое требование методичек: как только заработал
статический анализ и тесты (этап В), любой новый код надо будет через
них проводить, а переписывать тесты под ещё нестабильный функционал —
двойная работа. Дешевле сначала закончить фичи (ЛР4-5), потом накрыть их
готовыми тестами и спокойно поймать находки анализаторов. В ЛР7 40%
покрытия наберётся без напряжения, раз к этому моменту реализовано
фактически всё.
- **ЛР8 — точечное изменение схемы, не новая фича.** Заданию нужно, чтобы
было что мигрировать (реальное изменение схемы БД + кода). Это может
быть небольшая, специально приберёгнутая правка (например, добавить
таблицу/поле под одну из уже реализованных функций) — не ради
пользовательской ценности, а чтобы через неё честно продемонстрировать
процесс миграции.
Если по ходу дела окажется, что часть функционала (раздел 4.2 ТЗ) реально
не укладывается в ЛР4-5 по времени — переносим доделку на ЛР6-7 без
проблем, план не жёсткий. Но если можем — лучше не тащить фиче-работу до
последних лаб: чем раньше код "заморожен", тем меньше сюрпризов от
анализаторов и тестов ближе к сдаче.
## 4. Продуктовый трек: черновая схема базы данных
Нужна к ЛР3 (для docker-compose) и будет меняться к ЛР8 (миграции), так что
это живой черновик, не финал:
- `users` — id, guid (из ЛК), login, отображаемое имя, created_at
- `user_sessions` — user_id → legacy token, jwt, jwt_refresh, updated_at
(пароль **не хранится** нигде и никогда — используется один раз при login)
- `groups_cache`, `schedule_cache` — справочные данные с TTL (ФХД-003)
- `performance_history`, `payments_history`, `requests_history` — append-only
снимки (ФХД-002): каждое новое значение — новая строка, а не UPDATE
- `notifications` — уведомления пользователя с внешним id для дедупликации
- `lk_access_log` — user_id, action, status, created_at (ФХД-004; для
открытого API строк не создаётся — ТЭ-001)
## 5. Открытые вопросы и риски (продуктовый трек)
1. **ФОД-003 (расписание преподавателя) фактически не доступно без
авторизации.** Публичный `rasp.dmami.ru` (на который опирается
`mospolytech_api`) не имеет маршрута для поиска по преподавателю — мы
проверили руками (`site/teacher` не существует, `teachers-list.json`
отсутствует). Единственный найденный источник — `getScheduleTeacher` в
лёгаси-API личного кабинета, требует авторизации и в самой Go-библиотеке
MPU_LK_API помечен как неподтверждённый (🔎 в её API.md). Формально
противоречит ТЭ-001 ("без авторизации"). Пока реализовано через ЛК-сессию
как временное решение (`LKClient.get_teacher_schedule`).
2. **ФЛК-007 (отправка сообщения) не имеет подтверждённого формата
запроса.** Го-библиотека документирует действие только как
`POST newMessage=1&<...>`, реальные поля формы нигде не зафиксированы.
Реализован best-effort вариант (`LKClient.send_message`, поля `id` +
`text`) — надо проверить на реальном аккаунте перед тем, как полагаться
на него.
3. **ТСФ-003 (10 запросов/сек)** пока не проверялось — станет актуальным,
когда появится реальный MCP-сервер и нагрузочные тесты (перекликается с
ЛР7, раздел про нагрузочное тестирование в теории, хотя оно явно не
входит в задания).
## 6. Что уже сделано (продуктовый трек, заранее)
`src/mospolytech_mcp/`:
- **`lk_api/`** — наш Python-порт Go-библиотеки MPU_LK_API (личный кабинет):
авторизация (обе системы токенов), расписание, успеваемость, платежи,
заявки, уведомления/объявления, диалоги, отправка сообщений (см. риски
выше), плюс `lk_get`/`lk_post_form` как escape hatch.
- **`open_api/`** — обёртка над вендоренной `mospolytech_api` (открытые
данные: группы, расписание группы). Код библиотеки лежит в
`open_api/_vendor/` нетронутым — так и задумано автором (ТПО-004).
- **`api.py`** — `UniversityAPI`: один объект, который держит `open` и `lk`
вместе.
- **`db/`** — SQLAlchemy-модели (пока одна — `GroupsCache`), схема
версионируется через Alembic (`alembic/`, `alembic upgrade head`
применяет миграции).
- **`cache.py`** — `GroupsCacheStore`: TTL-кэш списка групп поверх БД
(ФХД-003), плюс fallback на устаревший кэш при недоступности источника
(ТН-001).
- **`server.py`** — сам MCP-сервер (официальный Python SDK, ТПО-002),
сейчас с одним инструментом — `list_groups`.
14/14 офлайн-тестов зелёные (`tests/`). `open_api` живьём прогнан против
настоящего `rasp.dmami.ru`. MCP-сервер живьём прогнан целиком: протокол →
`list_groups` → `open_api` → запись в `groups_cache` на реальном
PostgreSQL → повторный вызов из кэша. Для ручной проверки `lk_api` на
своём аккаунте — `scripts/check_lk_api.py`.
**БД:** общий PostgreSQL-сервер команды (не наш локальный, реальный
удалённый инстанс). У каждого свой пользователь — не шарим `postgres`/
admin. Подключение — через `DATABASE_URL` в `.env` (см. `.env.example`),
сам файл не коммитится. Если у вас его нет — спросите данные в чате
команды, пересылать их через репозиторий/коммиты не будем.
**Дальше по плану (раздел 3.1):** окно ЛР4-5 — дописываем ФЛК-001..007
целиком и оставшиеся ФОД/ФХД, дальше по разделу 3.1 фичи не трогаем.
Не портировано из MPU_LK_API (вне текущей области ТЗ, добавим при
необходимости): finance.go (банковские карты), pep.go, roles.go,
vaccines.go, directory.go, profile.go (семья/дети/проектная
деятельность/смена группы/справки).

34
pyproject.toml Normal file
View file

@ -0,0 +1,34 @@
[project]
name = "mospolytech-mcp"
version = "0.1.0"
description = "MCP-сервер для работы с данными Московского Политехнического университета"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"httpx>=0.27",
"requests>=2.28",
"sqlalchemy>=2.0",
"alembic>=1.13",
"psycopg[binary]>=3.1",
"mcp>=1.2",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
"mospolytech_mcp.open_api._vendor" = ["hash_salt.txt", "*.md"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

101
scripts/check_lk_api.py Normal file
View file

@ -0,0 +1,101 @@
# Ручная офлайн-проверка LKClient на реальном аккаунте (MPU_LOGIN/MPU_PASSWORD).
from __future__ import annotations
import asyncio
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from mospolytech_mcp._dotenv import load_dotenv # noqa: E402
from mospolytech_mcp.lk_api import LKClient, LKError # noqa: E402
async def main() -> int:
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
login = os.environ.get("MPU_LOGIN")
password = os.environ.get("MPU_PASSWORD")
if not login or not password:
print(
"Не заданы MPU_LOGIN / MPU_PASSWORD (переменные окружения или .env).",
file=sys.stderr,
)
return 1
async with LKClient() as client:
print(f"[1/9] Авторизация как {login}...")
try:
tokens = await client.login(login, password)
except LKError as e:
print(f" ОШИБКА логина: {e}")
return 1
print(f" OK: token={tokens.token[:12]}..., jwt={'есть' if tokens.jwt else 'нет'}")
print("[2/9] get_user()...")
try:
user = await client.get_user()
print(f" OK: {user.surname} {user.name}, группа {user.group}, курс {user.course}")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[3/9] get_my_schedule()...")
try:
schedule = await client.get_my_schedule()
print(f" OK: {len(schedule)} дней в ответе")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[4/9] get_academic_performance(semester=1)...")
try:
perf = await client.get_academic_performance(1)
print(f" OK: {len(perf)} записей")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[5/9] get_notifications()...")
try:
notifications = await client.get_notifications()
print(f" OK: {len(notifications)} уведомлений")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[6/9] get_alerts()...")
try:
alerts = await client.get_alerts()
print(f" OK: {len(alerts)} объявлений")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[7/9] get_payments()...")
try:
payments = await client.get_payments()
print(
f" OK: {len(payments.education)} договоров об обучении, "
f"{len(payments.dormitory)} по общежитию"
)
except LKError as e:
print(f" ОШИБКА: {e}")
print("[8/9] get_app_requests()...")
try:
requests_ = await client.get_app_requests()
print(f" OK: {len(requests_)} заявок")
except LKError as e:
print(f" ОШИБКА: {e}")
print("[9/9] get_dialogues()...")
try:
dialogues = await client.get_dialogues()
print(f" OK: {len(dialogues)} диалогов")
except LKError as e:
print(f" ОШИБКА: {e}")
print("\nГотово.")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -0,0 +1,5 @@
# Пакет mospolytech-mcp: MCP-сервер с доступом к open_api и lk_api Политеха.
from .api import UniversityAPI
__all__ = ["UniversityAPI"]

View file

@ -0,0 +1,17 @@
# Минимальный .env-загрузчик, без зависимости на python-dotenv.
from __future__ import annotations
import os
from pathlib import Path
def load_dotenv(path: Path) -> None:
if not path.exists():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip())

View file

@ -0,0 +1,22 @@
# UniversityAPI: единая точка входа, объединяющая клиентов open_api и lk_api.
from __future__ import annotations
import httpx
from .lk_api import LKClient
from .open_api import OpenDataClient
__all__ = ["UniversityAPI"]
class UniversityAPI:
def __init__(self, *, lk_http_client: httpx.AsyncClient | None = None) -> None:
self.open = OpenDataClient()
self.lk = LKClient(http_client=lk_http_client)
async def __aenter__(self) -> "UniversityAPI":
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.lk.aclose()

View file

@ -0,0 +1,48 @@
# Кэш списка групп с TTL поверх БД, с fallback на просроченный кэш при сбое источника.
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy.ext.asyncio import async_sessionmaker
from .db import GroupsCache
from .open_api import OpenDataClient
DEFAULT_GROUPS_TTL = timedelta(minutes=15)
class GroupsCacheStore:
def __init__(
self,
open_client: OpenDataClient,
sessionmaker: async_sessionmaker,
*,
ttl: timedelta = DEFAULT_GROUPS_TTL,
) -> None:
self._open = open_client
self._sessionmaker = sessionmaker
self._ttl = ttl
async def get_groups(self) -> list[str]:
async with self._sessionmaker() as session:
row = await session.get(GroupsCache, 1)
now = datetime.now(UTC)
if row is not None and now - row.fetched_at < self._ttl:
return row.groups
try:
groups = await self._open.get_groups()
except Exception:
if row is not None:
return row.groups
raise
if row is None:
session.add(GroupsCache(id=1, groups=groups, fetched_at=now))
else:
row.groups = groups
row.fetched_at = now
await session.commit()
return groups

View file

@ -0,0 +1,7 @@
# Пакет db: доступ к PostgreSQL через SQLAlchemy, схема в alembic/.
from .base import Base
from .models import GroupsCache
from .session import make_engine, make_sessionmaker
__all__ = ["Base", "GroupsCache", "make_engine", "make_sessionmaker"]

View file

@ -0,0 +1,9 @@
# Общий declarative base для всех моделей — одна metadata для Alembic.
from __future__ import annotations
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View file

@ -0,0 +1,19 @@
# Модели БД (SQLAlchemy ORM).
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class GroupsCache(Base):
__tablename__ = "groups_cache"
id: Mapped[int] = mapped_column(primary_key=True)
groups: Mapped[list[str]] = mapped_column(JSONB)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))

View file

@ -0,0 +1,24 @@
# Подключение к БД: движок и фабрика сессий из DATABASE_URL.
from __future__ import annotations
import os
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
def _database_url() -> str:
url = os.environ.get("DATABASE_URL")
if not url:
raise RuntimeError(
"DATABASE_URL не задан — скопируйте .env.example в .env и заполните его"
)
return url
def make_engine(database_url: str | None = None) -> AsyncEngine:
return create_async_engine(database_url or _database_url())
def make_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, expire_on_commit=False)

View file

@ -0,0 +1,51 @@
# Пакет lk_api: авторизация и работа с личным кабинетом Московского Политеха.
from .client import DEFAULT_API_BASE_URL, DEFAULT_LK_BASE_URL, LKClient
from .errors import (
APIError,
InvalidCredentialsError,
LKError,
NoRefreshTokenError,
NotAuthenticatedError,
PasswordExpiredError,
)
from .models import (
Alert,
AppRequest,
Contract,
Dialogue,
DialogueMessage,
Lesson,
Notification,
Payments,
PerformanceRecord,
Schedule,
Tokens,
User,
)
from .schedule import ScheduleRange
__all__ = [
"LKClient",
"DEFAULT_LK_BASE_URL",
"DEFAULT_API_BASE_URL",
"Tokens",
"ScheduleRange",
"User",
"Schedule",
"Lesson",
"PerformanceRecord",
"Payments",
"Contract",
"Notification",
"Alert",
"AppRequest",
"Dialogue",
"DialogueMessage",
"LKError",
"InvalidCredentialsError",
"NotAuthenticatedError",
"NoRefreshTokenError",
"PasswordExpiredError",
"APIError",
]

View file

@ -0,0 +1,181 @@
# Ядро клиента ЛК: HTTP-транспорт, токены, логин/логаут, обновление JWT.
from __future__ import annotations
from typing import Any
import httpx
from .errors import (
APIError,
InvalidCredentialsError,
NoRefreshTokenError,
NotAuthenticatedError,
PasswordExpiredError,
)
from .models import Tokens
DEFAULT_LK_BASE_URL = "https://e.mospolytech.ru/old"
DEFAULT_API_BASE_URL = "https://api.mospolytech.ru"
class LKClientBase:
def __init__(
self,
*,
lk_base_url: str = DEFAULT_LK_BASE_URL,
api_base_url: str = DEFAULT_API_BASE_URL,
http_client: httpx.AsyncClient | None = None,
timeout: float = 30.0,
) -> None:
self._owns_http_client = http_client is None
self._http = http_client or httpx.AsyncClient(
timeout=timeout, follow_redirects=True
)
self._lk_base_url = lk_base_url.rstrip("/")
self._api_base_url = api_base_url.rstrip("/")
self.tokens = Tokens()
async def __aenter__(self) -> "LKClientBase":
return self
async def __aexit__(self, *exc_info: object) -> None:
await self.aclose()
async def aclose(self) -> None:
if self._owns_http_client:
await self._http.aclose()
@property
def is_authenticated(self) -> bool:
return bool(self.tokens.token)
async def login(self, login: str, password: str) -> Tokens:
resp = await self._http.post(
f"{self._lk_base_url}/lk_api.php",
data={"ulogin": login, "upassword": password},
)
if resp.status_code == 400:
raise InvalidCredentialsError()
if resp.status_code != 200:
raise APIError("login", resp.status_code, resp.text)
try:
data = resp.json()
except ValueError as exc:
raise APIError("login", resp.status_code, resp.text) from exc
token = data.get("token") or ""
if data.get("AD_pwd_expired") and not token:
domain = data.get("AD_domain") or "staff"
raise PasswordExpiredError(login=login, ad_domain=domain)
if not token:
raise APIError("login", resp.status_code, resp.text)
tokens = Tokens(
token=token,
jwt=data.get("jwt") or "",
jwt_refresh=data.get("jwt_refresh") or "",
guid=data.get("guid") or "",
)
await self._establish_php_session(login, password)
self.tokens = tokens
return tokens
async def _establish_php_session(self, login: str, password: str) -> None:
try:
await self._http.post(
self._lk_base_url,
files={
"ulogin": (None, login),
"upassword": (None, password),
"auth_action": (None, "userlogin"),
},
)
except httpx.HTTPError:
pass
def logout(self) -> None:
self.tokens = Tokens()
async def refresh_token(self) -> None:
refresh = self.tokens.jwt_refresh
if not refresh:
raise NoRefreshTokenError()
resp = await self._http.post(
f"{self._api_base_url}/auth/token/reissue",
json={"refresh_token": refresh},
)
if resp.status_code != 200:
raise APIError("token/reissue", resp.status_code, resp.text)
try:
data = resp.json()
except ValueError as exc:
raise APIError("token/reissue", resp.status_code, resp.text) from exc
access_token = data.get("access_token") or ""
if not access_token:
raise APIError("token/reissue", resp.status_code, resp.text)
self.tokens.jwt = access_token
self.tokens.jwt_refresh = data.get("refresh_token") or self.tokens.jwt_refresh
async def lk_get(self, query: str) -> Any:
return await self._lk_request("GET", query)
async def lk_post_form(
self, query: str, form: dict[str, str] | None = None
) -> Any:
return await self._lk_request("POST", query, data=form)
async def _lk_request(
self,
method: str,
query: str,
data: dict[str, str] | None = None,
) -> Any:
if not self.tokens.token:
raise NotAuthenticatedError()
url = f"{self._lk_base_url}/lk_api.php?"
if query:
url += query + "&"
url += "token=" + self.tokens.token
resp = await self._http.request(method, url, data=data)
if resp.status_code != 200:
raise APIError("lk_api", resp.status_code, resp.text)
raw = resp.text
if not raw:
return None
try:
return resp.json()
except ValueError as exc:
raise APIError("lk_api", resp.status_code, raw) from exc
async def api_request(
self,
method: str,
path: str,
*,
json_body: Any = None,
) -> httpx.Response:
if not self.tokens.jwt:
raise NotAuthenticatedError()
url = f"{self._api_base_url}{path}"
headers = {"Authorization": f"Bearer {self.tokens.jwt}"}
resp = await self._http.request(method, url, json=json_body, headers=headers)
if resp.status_code != 401:
return resp
await self.refresh_token()
headers = {"Authorization": f"Bearer {self.tokens.jwt}"}
return await self._http.request(method, url, json=json_body, headers=headers)

View file

@ -0,0 +1,27 @@
# Собранный клиент личного кабинета — публичная точка входа пакета.
from __future__ import annotations
from .base import DEFAULT_API_BASE_URL, DEFAULT_LK_BASE_URL, LKClientBase
from .messages import MessagesMixin
from .notifications import NotificationsMixin
from .payments import PaymentsMixin
from .performance import PerformanceMixin
from .requests_ import RequestsMixin
from .schedule import ScheduleMixin
from .user import UserMixin
__all__ = ["LKClient", "DEFAULT_LK_BASE_URL", "DEFAULT_API_BASE_URL"]
class LKClient(
LKClientBase,
UserMixin,
ScheduleMixin,
PerformanceMixin,
PaymentsMixin,
NotificationsMixin,
RequestsMixin,
MessagesMixin,
):
pass

View file

@ -0,0 +1,40 @@
# Исключения клиента личного кабинета.
from __future__ import annotations
class LKError(Exception):
pass
class InvalidCredentialsError(LKError):
def __init__(self) -> None:
super().__init__("неверный логин или пароль")
class NotAuthenticatedError(LKError):
def __init__(self) -> None:
super().__init__("клиент не авторизован — сначала вызовите login()")
class NoRefreshTokenError(LKError):
def __init__(self) -> None:
super().__init__("refresh-токен отсутствует")
class PasswordExpiredError(LKError):
def __init__(self, login: str, ad_domain: str) -> None:
self.login = login
self.ad_domain = ad_domain
super().__init__(
f"истёк пароль доменной учётной записи {login!r} (домен {ad_domain!r})"
)
class APIError(LKError):
def __init__(self, op: str, status_code: int, body: str) -> None:
self.op = op
self.status_code = status_code
self.body = body
shown = body if len(body) <= 500 else body[:500] + "…"
super().__init__(f"{op}: неожиданный статус {status_code}: {shown}")

View file

@ -0,0 +1,29 @@
# Диалоги и сообщения личного кабинета (ФЛК-006, ФЛК-007).
from __future__ import annotations
from typing import Any
from .models import Dialogue, DialogueMessage
class MessagesMixin:
async def get_dialogues(self) -> list[Dialogue]:
raw = await self.lk_get("getMsgDialogues") # type: ignore[attr-defined]
return [Dialogue.from_dict(d) for d in raw or []]
async def get_dialogue_messages(self, dialogue_id: str) -> list[DialogueMessage]:
raw = await self.lk_get( # type: ignore[attr-defined]
f"getMessagesInDialogue={dialogue_id}"
)
if isinstance(raw, list):
return [DialogueMessage.from_dict(m) for m in raw]
return []
async def get_group_chat(self) -> str:
raw = await self.lk_get("getGroupChat") # type: ignore[attr-defined]
return (raw or {}).get("link") or ""
async def send_message(self, dialogue_id: str, text: str) -> Any:
form = {"id": dialogue_id, "text": text}
return await self.lk_post_form("newMessage=1", form) # type: ignore[attr-defined]

View file

@ -0,0 +1,452 @@
# Модели данных личного кабинета (dataclasses + from_dict-парсинг lk_api.php).
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
def flex(value: Any) -> str:
if value is None:
return ""
return str(value)
@dataclass
class Tokens:
token: str = ""
jwt: str = ""
jwt_refresh: str = ""
guid: str = ""
@dataclass
class TeacherBrief:
id: int
name: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "TeacherBrief":
return cls(id=d.get("id") or 0, name=flex(d.get("name")))
@dataclass
class Lesson:
name: str
time_interval: str
place: str
rooms: list[str]
teachers: list[str]
date_interval: str
link: str
teachers_full: list[TeacherBrief]
raw: dict[str, Any] = field(default_factory=dict, repr=False)
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Lesson":
return cls(
name=flex(d.get("name")),
time_interval=flex(d.get("timeInterval")),
place=flex(d.get("place")),
rooms=list(d.get("rooms") or []),
teachers=list(d.get("teachers") or []),
date_interval=flex(d.get("dateInterval")),
link=flex(d.get("link")),
teachers_full=[TeacherBrief.from_dict(t) for t in d.get("teachersFull") or []],
raw=d,
)
@dataclass
class ScheduleDay:
lessons: list[Lesson]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "ScheduleDay":
return cls(lessons=[Lesson.from_dict(x) for x in d.get("lessons") or []])
Schedule = dict[str, ScheduleDay]
def schedule_from_dict(d: dict[str, Any]) -> Schedule:
return {date: ScheduleDay.from_dict(day) for date, day in d.items()}
@dataclass
class User:
id: int
name: str
surname: str
patronymic: str
status: str
user_status: str
course: str
avatar: str
is_token_valid: bool
faculty: str
group: str
specialty: str
specialization: str
code: str
education_form: str
finance: str
degree_level: str
degree_length: str
degree_length_std: str
enter_year: str
birthday: str
sex: str
email: str
phone: str
orders: list[str]
has_alerts: bool
last_access: str
pass_expired: bool
pass_expire_date: str | None
vacation_start: str | None
vacation_end: str | None
raw: dict[str, Any] = field(default_factory=dict, repr=False)
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "User":
return cls(
id=d.get("id") or 0,
name=flex(d.get("name")),
surname=flex(d.get("surname")),
patronymic=flex(d.get("patronymic")),
status=flex(d.get("status")),
user_status=flex(d.get("user_status")),
course=flex(d.get("course")),
avatar=flex(d.get("avatar")),
is_token_valid=bool(d.get("is_token_valid")),
faculty=flex(d.get("faculty")),
group=flex(d.get("group")),
specialty=flex(d.get("specialty")),
specialization=flex(d.get("specialization")),
code=flex(d.get("code")),
education_form=flex(d.get("educationForm")),
finance=flex(d.get("finance")),
degree_level=flex(d.get("degreeLevel")),
degree_length=flex(d.get("degreeLength")),
degree_length_std=flex(d.get("degreeLength_standart")),
enter_year=flex(d.get("enterYear")),
birthday=flex(d.get("birthday")),
sex=flex(d.get("sex")),
email=flex(d.get("email")),
phone=flex(d.get("phone")),
orders=list(d.get("orders") or []),
has_alerts=bool(d.get("hasAlerts")),
last_access=flex(d.get("lastaccess")),
pass_expired=bool(d.get("pass_expired")),
pass_expire_date=d.get("pass_expire_date"),
vacation_start=d.get("vacation_start"),
vacation_end=d.get("vacation_end"),
raw=d,
)
@dataclass
class PerformanceRecord:
id: str
bill_num: str
bill_type: str
doc_type: str
name: str
exam_date: str
exam_time: str
exam_type: str
grade: str
ticket_num: str
teacher: str
chair: str
course: str
year: str
semester: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "PerformanceRecord":
return cls(
id=flex(d.get("id")),
bill_num=flex(d.get("bill_num")),
bill_type=flex(d.get("bill_type")),
doc_type=flex(d.get("doc_type")),
name=flex(d.get("name")),
exam_date=flex(d.get("exam_date")),
exam_time=flex(d.get("exam_time")),
exam_type=flex(d.get("exam_type")),
grade=flex(d.get("grade")),
ticket_num=flex(d.get("ticket_num")),
teacher=flex(d.get("teacher")),
chair=flex(d.get("chair")),
course=flex(d.get("course")),
year=flex(d.get("year")),
semester=flex(d.get("semestr")),
)
@dataclass
class Payment:
date: str
value: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Payment":
return cls(date=flex(d.get("date")), value=flex(d.get("value")))
@dataclass
class Agreement:
id: str
status: str
sign_variant: str
sides: str
name: str
type: str
date: str
file: str
reason: str
code_sent: bool
can_sign: bool
signed_user: bool
signed_user_date: str
signed_user_time: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Agreement":
return cls(
id=flex(d.get("id")),
status=flex(d.get("status")),
sign_variant=flex(d.get("sign_variant")),
sides=flex(d.get("sides")),
name=flex(d.get("name")),
type=flex(d.get("type")),
date=flex(d.get("date")),
file=flex(d.get("file")),
reason=flex(d.get("reason")),
code_sent=bool(d.get("code_sent")),
can_sign=bool(d.get("can_sign")),
signed_user=bool(d.get("signed_user")),
signed_user_date=flex(d.get("signed_user_date")),
signed_user_time=flex(d.get("signed_user_time")),
)
@dataclass
class Contract:
id: str
number: str
name: str
type: str
level: str
status_1c: str
admission: str
contragent: str
student: str
sum: str
balance: str
balance_curr_date: str
last_payment_date: str
bill: str
bill_next: str
qr_current: str
qr_total: str
can_sign: bool
sign_text: str
sign_variant: str
signed_user: bool
signed_user_date: str
signed_user_time: str
start_date: str
end_date_plan: str
end_date_fact: str
create_date: str
file: str
payments: list[Payment]
agreements: list[Agreement]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Contract":
return cls(
id=flex(d.get("id")),
number=flex(d.get("number")),
name=flex(d.get("name")),
type=flex(d.get("type")),
level=flex(d.get("level")),
status_1c=flex(d.get("status1c")),
admission=flex(d.get("admission")),
contragent=flex(d.get("contragent")),
student=flex(d.get("student")),
sum=flex(d.get("sum")),
balance=flex(d.get("balance")),
balance_curr_date=flex(d.get("balance_currdate")),
last_payment_date=flex(d.get("lastPaymentDate")),
bill=flex(d.get("bill")),
bill_next=flex(d.get("bill_next")),
qr_current=flex(d.get("qr_current")),
qr_total=flex(d.get("qr_total")),
can_sign=bool(d.get("can_sign")),
sign_text=flex(d.get("sign_text")),
sign_variant=flex(d.get("sign_variant")),
signed_user=bool(d.get("signed_user")),
signed_user_date=flex(d.get("signed_user_date")),
signed_user_time=flex(d.get("signed_user_time")),
start_date=flex(d.get("startDate")),
end_date_plan=flex(d.get("endDatePlan")),
end_date_fact=flex(d.get("endDateFact")),
create_date=flex(d.get("createDate")),
file=flex(d.get("file")),
payments=[Payment.from_dict(p) for p in d.get("payments") or []],
agreements=[Agreement.from_dict(a) for a in d.get("agreements") or []],
)
@dataclass
class Payments:
education: list[Contract]
dormitory: list[Contract]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Payments":
return cls(
education=[Contract.from_dict(c) for c in d.get("education") or []],
dormitory=[Contract.from_dict(c) for c in d.get("dormitory") or []],
)
@dataclass
class Notification:
id: str
type: str
title: str
text: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Notification":
return cls(
id=flex(d.get("id")),
type=flex(d.get("type")),
title=flex(d.get("title")),
text=flex(d.get("text")),
)
@dataclass
class Alert:
id: str
title: str
content: str
date: str
time: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Alert":
return cls(
id=flex(d.get("id")),
title=flex(d.get("title")),
content=flex(d.get("content")),
date=flex(d.get("date")),
time=flex(d.get("time")),
)
@dataclass
class FileRef:
url: str
fname: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "FileRef":
return cls(url=flex(d.get("url")), fname=flex(d.get("fname")))
@dataclass
class AppRequest:
id: str
num: str
subject: str
description: str
response_div: str
response_contact: str
comment: str
can_delete: bool
status: str
raiting: str
status_update: str
files_output: list[FileRef]
created: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "AppRequest":
return cls(
id=flex(d.get("id")),
num=flex(d.get("num")),
subject=flex(d.get("subject")),
description=flex(d.get("description")),
response_div=flex(d.get("response_div")),
response_contact=flex(d.get("response_contact")),
comment=flex(d.get("comment")),
can_delete=bool(d.get("can_delete")),
status=flex(d.get("status")),
raiting=flex(d.get("raiting")),
status_update=flex(d.get("status_update")),
files_output=[FileRef.from_dict(f) for f in d.get("files_output") or []],
created=flex(d.get("created")),
)
@dataclass
class DialogueOpponent:
id: str
status: str
name: str
data: str
avatar: str
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "DialogueOpponent":
return cls(
id=flex(d.get("id")),
status=flex(d.get("status")),
name=flex(d.get("name")),
data=flex(d.get("data")),
avatar=flex(d.get("avatar")),
)
@dataclass
class DialogueMessage:
from_: str
html: str
text: str
datetime: str
readed: bool
readed_opponent: bool
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "DialogueMessage":
return cls(
from_=flex(d.get("from")),
html=flex(d.get("html")),
text=flex(d.get("text")),
datetime=flex(d.get("datetime")),
readed=bool(d.get("readed")),
readed_opponent=bool(d.get("readed_opponent")),
)
@dataclass
class Dialogue:
id: str
subject: str
opponent: DialogueOpponent
last_message: DialogueMessage
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "Dialogue":
return cls(
id=flex(d.get("id")),
subject=flex(d.get("subject")),
opponent=DialogueOpponent.from_dict(d.get("opponent") or {}),
last_message=DialogueMessage.from_dict(d.get("lastmessage") or {}),
)

View file

@ -0,0 +1,15 @@
# Уведомления и объявления личного кабинета (ФЛК-004).
from __future__ import annotations
from .models import Alert, Notification
class NotificationsMixin:
async def get_notifications(self) -> list[Notification]:
raw = await self.lk_get("getNotifications") # type: ignore[attr-defined]
return [Notification.from_dict(n) for n in raw or []]
async def get_alerts(self) -> list[Alert]:
raw = await self.lk_get("getAlerts") # type: ignore[attr-defined]
return [Alert.from_dict(a) for a in raw or []]

View file

@ -0,0 +1,12 @@
# Платежи и договоры личного кабинета (ФЛК-005).
from __future__ import annotations
from .models import Payments
class PaymentsMixin:
async def get_payments(self) -> Payments:
raw = await self.lk_get("getPayments") # type: ignore[attr-defined]
contracts = (raw or {}).get("contracts") or {}
return Payments.from_dict(contracts)

View file

@ -0,0 +1,14 @@
# Успеваемость (ФЛК-003).
from __future__ import annotations
from .models import PerformanceRecord
class PerformanceMixin:
async def get_academic_performance(self, semester: int) -> list[PerformanceRecord]:
raw = await self.lk_get( # type: ignore[attr-defined]
f"getAcademicPerformance&semestr={semester}"
)
records = (raw or {}).get("academicPerformance") or []
return [PerformanceRecord.from_dict(r) for r in records]

View file

@ -0,0 +1,11 @@
# Поданные заявки/обращения пользователя (ФЛК-005).
from __future__ import annotations
from .models import AppRequest
class RequestsMixin:
async def get_app_requests(self) -> list[AppRequest]:
raw = await self.lk_get("getAppRequests") # type: ignore[attr-defined]
return [AppRequest.from_dict(r) for r in raw or []]

View file

@ -0,0 +1,61 @@
# Расписание пользователя, группы и преподавателя через ЛК-сессию (ФЛК-002).
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import quote
from .models import Schedule, schedule_from_dict
@dataclass
class ScheduleRange:
mode: str | None = None
from_: str | None = None
to: str | None = None
def as_query(self) -> str:
parts = []
if self.mode:
parts.append(f"mode={quote(self.mode)}")
if self.from_:
parts.append(f"from={quote(self.from_)}")
if self.to:
parts.append(f"to={quote(self.to)}")
return "&".join(parts)
class ScheduleMixin:
async def get_my_schedule(self, r: ScheduleRange | None = None) -> Schedule:
query = "getSchedule"
if r is not None and (extra := r.as_query()):
query += "&" + extra
raw = await self.lk_get(query) # type: ignore[attr-defined]
return schedule_from_dict(raw or {})
async def get_session_schedule(self, r: ScheduleRange | None = None) -> Schedule:
query = "getSchedule&session=1"
if r is not None and (extra := r.as_query()):
query += "&" + extra
raw = await self.lk_get(query) # type: ignore[attr-defined]
return schedule_from_dict(raw or {})
async def get_group_schedule(
self, group: str, r: ScheduleRange | None = None
) -> Schedule:
query = f"getSchedule&group={quote(group)}"
if r is not None and (extra := r.as_query()):
query += "&" + extra
raw = await self.lk_get(query) # type: ignore[attr-defined]
return schedule_from_dict(raw or {})
async def get_teacher_schedule(
self, fio: str, *, session: bool = False, r: ScheduleRange | None = None
) -> Schedule:
query = f"getScheduleTeacher&fio={quote(fio)}"
if session:
query += "&session=1"
if r is not None and (extra := r.as_query()):
query += "&" + extra
raw = await self.lk_get(query) # type: ignore[attr-defined]
return schedule_from_dict(raw or {})

View file

@ -0,0 +1,17 @@
# Профиль пользователя личного кабинета.
from __future__ import annotations
from .errors import APIError
from .models import User
class UserMixin:
async def get_user(self) -> User:
raw = await self.lk_get("getUser") # type: ignore[attr-defined]
if not isinstance(raw, dict) or "user" not in raw:
raise APIError("getUser", 200, str(raw))
user_data = raw["user"]
if not isinstance(user_data, dict):
raise APIError("getUser", 200, str(raw))
return User.from_dict(user_data)

View file

@ -0,0 +1,5 @@
# Открытые данные университета — async-обёртка над вендоренной mospolytech_api.
from .client import OpenDataClient, Schedule
__all__ = ["OpenDataClient", "Schedule"]

View file

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2023 r4nd0lph-c
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -0,0 +1,25 @@
# Откуда это
`api.py` и `schedule.py` в этой папке — код библиотеки
[mospolytech_api](https://github.com/r4nd0lph-c/mospolytech_api)
(автор — r4nd0lph-c), скопированный сюда как есть, без правок. Так и
задумано автором библиотеки: она не публикуется на PyPI, а README прямо
говорит "download the repository and place it in your project" — то есть
это классический vendoring, а не наша реализация.
Почему не переписали, как MPU_LK_API: по ТЗ (ТПО-004) для открытого API
нужно использовать существующую готовую библиотеку, а не портировать её с
нуля — в отличие от ТПО-005, где для личного кабинета явно требуется
перенос логики на Python (см. `../../lk_api`).
Лицензия — Apache License 2.0, текст сохранён в `LICENSE.md`.
`hash_salt.txt` — плейсхолдер из оригинального репозитория
(`~YOUR-HASH-SALT-HERE~`), не настоящий секрет. Нужен только конструктору
`API()` (он читает файл при инициализации, даже если соль не используется
в вызове) и реально требуется лишь для `get_students()`. Функции, которые
нужны нам по ТЗ (`get_groups`, `get_schedule`) реального значения соли не
требуют — плейсхолдера достаточно, чтобы всё остальное работало.
Обновлять эти файлы вручную, только синхронизируя с апстримом при
необходимости.

View file

@ -0,0 +1,401 @@
# --------------------------------------------------------------------------------------------------------------------#
# ███╗ ███╗ ██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗ ██╗████████╗███████╗ ██████╗██╗ ██╗ █████╗ ██████╗ ██╗ #
# ████╗ ████║██╔═══██╗██╔════╝██╔══██╗██╔═══██╗██║ ╚██╗ ██╔╝╚══██╔══╝██╔════╝██╔════╝██║ ██║ ██╔══██╗██╔══██╗██║ #
# ██╔████╔██║██║ ██║███████╗██████╔╝██║ ██║██║ ╚████╔╝ ██║ █████╗ ██║ ███████║ ███████║██████╔╝██║ #
# ██║╚██╔╝██║██║ ██║╚════██║██╔═══╝ ██║ ██║██║ ╚██╔╝ ██║ ██╔══╝ ██║ ██╔══██║ ██╔══██║██╔═══╝ ██║ #
# ██║ ╚═╝ ██║╚██████╔╝███████║██║ ╚██████╔╝███████╗██║ ██║ ███████╗╚██████╗██║ ██║ ██║ ██║██║ ██║ #
# ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ #
# author: https://t.me/rand0lphc #
# ------------------------------------------------------------------------------------------------------------------- #
import json
from hashlib import md5
import requests
class API:
"""
DESCRIPTION
* API for working with services of Moscow Polytechnic University
* https://mospolytech.ru/en/
-----
ATTRIBUTES
* (instance) headers (dict): additional context about requests
-----
ARGS
* (optional) user_agent (str): string that lets servers identify application,
by default is __DEFAULT_USER_AGENT
* (optional) hash_salt_path (str): string that is required for some requests,
by default is __DEFAULT_HASH_SALT_PATH
-----
METHODS
* get_groups() -> list[str]
* get_students(groups: list = None) -> dict
* get_semester() -> dict
* get_session() -> dict
* get_schedule(group: str, is_session: bool = False) -> dict
"""
# attributes for class operations
__DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/86.0.4240.75 Safari/537.36"
)
__DEFAULT_HASH_SALT_PATH = "hash_salt.txt"
__URLS = {
"referer": "https://rasp.dmami.ru/",
"groups": "https://rasp.dmami.ru/groups-list.json",
"students": "https://e.mospolytech.ru/old/lk_api_mapp.php",
"semester": "https://rasp.dmami.ru/semester.json",
"session": "https://rasp.dmami.ru/session-file.json",
"schedule": "https://rasp.dmami.ru/site/group",
}
def __init__(
self,
user_agent: str = __DEFAULT_USER_AGENT,
hash_salt_path: str = __DEFAULT_HASH_SALT_PATH,
) -> None:
"""
DESCRIPTION
* initializes API object
-----
ARGS
* (optional) user_agent (str): string that lets servers identify application,
by default is __DEFAULT_USER_AGENT
* (optional) hash_salt_path (str): string that is required for some requests,
by default is __DEFAULT_HASH_SALT_PATH
-----
RETURNS
* there is no return
-----
ERRORS
* there are no custom errors
"""
# setting headers
self.headers = {"referer": self.__URLS["referer"], "user-agent": user_agent}
# setting hash salt
with open(hash_salt_path, "r", encoding="utf-8") as f:
self.__hash_salt = f.readline()
@staticmethod
def __check_status_code(code: int) -> None:
"""
DESCRIPTION
* checks correctness of response status code
-----
ARGS
* (required) code (int): response status code
-----
RETURNS
* there is no return
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# checking status code
if code != 200:
raise requests.ConnectionError(f"Expected status code 200, but got {code}.")
def __create_token(self, group: str) -> str:
"""
DESCRIPTION
* creates token (md5-str object) for given group and returns it
-----
ARGS
* (required) group (str): name of group
-----
RETURNS
* token (str): token (md5-str object) for given group
-----
ERRORS
* there are no custom errors
"""
# creating token (md5-hash object)
string = group + self.__hash_salt
token = md5(md5(string.encode()).hexdigest().encode())
# returning token (md5-str object) for given group
return token.hexdigest()
def __make_request(self, url: str) -> str:
"""
DESCRIPTION
* makes request to given url and returns content
-----
ARGS
* (required) url (str): url for request
-----
RETURNS
* content (str): decoded content of response
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# making request
r = requests.get(url=url, headers=self.headers)
# checking status code
self.__check_status_code(r.status_code)
# decoding content
content = r.content.decode("utf-8")
# returning decoded content of response
return content
@staticmethod
def __parse_grid(grid_source: dict) -> list[list]:
"""
DESCRIPTION
* modifies given grid into form comfortable for interactions and returns it
-----
ARGS
* (required) grid_source (dict): grid object contained in content of some requests
-----
RETURNS
* grid_modified (list[list]): modified grid
-----
ERRORS
* there are no custom errors
"""
# creating modified grid
grid_modified = []
for key_i in grid_source:
day = []
for key_j in grid_source[key_i]:
section = []
for obj in grid_source[key_i][key_j]:
# preparing link for event
link = obj["e_link"]
if link is None:
dirty_link = obj["auditories"][0]["title"]
if dirty_link[0:7] == "<a href":
link = dirty_link[9:].split('"')[0]
# preparing dates for event
if len(key_i) == 10:
dates = [".".join(d.split("-")[::-1]) for d in [key_i] * 2]
else:
dates = [
".".join(d.split("-")[::-1]) for d in [obj["df"], obj["dt"]]
]
# creating event
event = {
"title": obj["sbj"].strip(),
"type": obj["type"],
"teachers": [
" ".join(t.strip().split())
for t in obj["teacher"].split(",")
],
"location": obj["location"].strip(),
"rooms": [
r.strip().replace("_", "") for r in obj["shortRooms"]
],
"link": link,
"dates": dates,
}
# clearing event fields
event["teachers"] = list(
filter(lambda t: t != "", event["teachers"])
)
# appending
section.append(event)
day.append(section)
grid_modified.append(day)
# returning modified grid
return grid_modified
def get_groups(self) -> list[str]:
"""
DESCRIPTION
* gets sorted list of group names that are existing and returns it
-----
ARGS
* there are no args
-----
RETURNS
* groups (list[str]): sorted list of group names
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# making complex request
data = json.loads(self.__make_request(self.__URLS["groups"]))
# returning sorted list of group names
return sorted([name for name in data["groups"]])
def get_students(self, groups: list = None) -> dict:
"""
DESCRIPTION
* gets sorted dict of students for given groups and returns it
-----
ARGS
* (optional) groups (list[str]): sorted list of group names,
by default is None - search across all groups
-----
RETURNS
* students (dict): sorted dict of students for given groups
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# getting group names
if groups is None:
groups = self.get_groups()
# creating students
students = {}
for group in sorted(groups):
# creating token
token = self.__create_token(group)
# making complex request
url = (
self.__URLS["students"]
+ f"?group={group.replace(' ', '%20')}&token={token}"
)
batch = json.loads(self.__make_request(url))
batch = sorted(batch, key=lambda item: item["fio"])
# adding batch to dict of students
students[group] = [
{"guid": item["guid"], "student": item["fio"]} for item in batch
]
# returning sorted dict of students for given groups
return students
def get_semester(self) -> dict:
"""
DESCRIPTION
* gets sorted dictionary containing semester information and returns it
-----
ARGS
* there are no args
-----
RETURNS
* semester (dict): sorted dictionary containing semester information
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# making complex request
data = json.loads(self.__make_request(self.__URLS["semester"]))
# creating semester
semester = {}
for obj_key in data["contents"]:
obj = data["contents"][obj_key]
semester[obj_key] = {
"type": "evening" if obj["group"]["evening"] else "morning",
"dates": [
".".join(d.split("-")[::-1])
for d in [obj["group"]["dateFrom"], obj["group"]["dateTo"]]
],
"grid": self.__parse_grid(obj["grid"]),
}
# returning sorted dictionary containing semester information
return dict(sorted(semester.items()))
def get_session(self) -> dict:
"""
DESCRIPTION
* gets sorted dictionary containing session information and returns it
-----
ARGS
* there are no args
-----
RETURNS
* session (dict): sorted dictionary containing session information
-----
ERRORS
* ConnectionError(): if there is problem with connection
"""
# making complex request
data = json.loads(self.__make_request(self.__URLS["session"]))
# creating session
session = {}
for obj in data["contents"]:
session[obj["group"]["title"]] = {
"type": "evening" if obj["group"]["evening"] else "morning",
"dates": [
".".join(d.split("-")[::-1])
for d in [obj["group"]["dateFrom"], obj["group"]["dateTo"]]
],
"grid": self.__parse_grid(obj["grid"]),
}
# returning sorted dictionary containing session information
return dict(sorted(session.items()))
def get_schedule(self, group: str, is_session: bool = False) -> dict:
"""
DESCRIPTION
* gets dictionary containing schedule information and returns it
-----
ARGS
* (required) group (str): name of group
* (optional) is_session (bool): session flag,
by default is False - try to get general schedule, True - try to get session schedule
-----
RETURNS
* schedule (dict): dictionary containing schedule information
-----
ERRORS
* ConnectionError(): if there is problem with connection
* ValueError(): if there is problem with response content
"""
# making complex request
url = (
self.__URLS["schedule"]
+ f"?group={group.replace(' ', '%20')}&session={1 if is_session else 0}"
)
content = self.__make_request(url)
# checking correctness of response (1)
SCHEDULE_NOT_EXIST = "Еще не готово расписание для группы"
if content == SCHEDULE_NOT_EXIST:
raise ValueError(f"The schedule for the '{group}' group does not exist.")
# loading content to data dict
data = json.loads(content)
# checking correctness of response (2)
SCHEDULE_EMPTY = "Не нашлось расписание для группы"
if "message" in data:
if data["message"] == SCHEDULE_EMPTY:
raise ValueError(f"The schedule for the '{group}' group is empty.")
# creating schedule
schedule = {
"group": group,
"type": "evening" if data["group"]["evening"] else "morning",
"is_session": data["isSession"],
"dates": [
".".join(d.split("-")[::-1])
for d in [data["group"]["dateFrom"], data["group"]["dateTo"]]
],
"grid": self.__parse_grid(data["grid"]),
}
# returning dictionary containing schedule information
return schedule
if __name__ == "__main__":
pass

View file

@ -0,0 +1 @@
~YOUR-HASH-SALT-HERE~

View file

@ -0,0 +1,234 @@
# --------------------------------------------------------------------------------------------------------------------#
# ███╗ ███╗ ██████╗ ███████╗██████╗ ██████╗ ██╗ ██╗ ██╗████████╗███████╗ ██████╗██╗ ██╗ █████╗ ██████╗ ██╗ #
# ████╗ ████║██╔═══██╗██╔════╝██╔══██╗██╔═══██╗██║ ╚██╗ ██╔╝╚══██╔══╝██╔════╝██╔════╝██║ ██║ ██╔══██╗██╔══██╗██║ #
# ██╔████╔██║██║ ██║███████╗██████╔╝██║ ██║██║ ╚████╔╝ ██║ █████╗ ██║ ███████║ ███████║██████╔╝██║ #
# ██║╚██╔╝██║██║ ██║╚════██║██╔═══╝ ██║ ██║██║ ╚██╔╝ ██║ ██╔══╝ ██║ ██╔══██║ ██╔══██║██╔═══╝ ██║ #
# ██║ ╚═╝ ██║╚██████╔╝███████║██║ ╚██████╔╝███████╗██║ ██║ ███████╗╚██████╗██║ ██║ ██║ ██║██║ ██║ #
# ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ #
# author: https://t.me/rand0lphc #
# ------------------------------------------------------------------------------------------------------------------- #
from datetime import datetime, timedelta
class Schedule:
"""
DESCRIPTION
* class representing schedule of group
* contains information about schedule and manipulates it
-----
ATTRIBUTES
* (class) TIME_SECTIONS (dict): time sections for different forms of education
* (instance) group (str): name of group
* (instance) type (str): type of group
* (instance) is_session (bool): session flag
* (instance) dates (list[str]): range of available dates
* (instance) grid (list[list]): modified grid
-----
ARGS
* (required) schedule (dict): dict containing schedule information
-----
METHODS
* get_day(date: str) -> dict
* get_week(date: str, ignore_range_err: bool = False) -> dict
"""
# attribute for class operations
TIME_SECTIONS = {
"morning": [
["09:00", "10:30"],
["10:40", "12:10"],
["12:20", "13:50"],
["14:30", "16:00"],
["16:10", "17:40"],
["17:50", "19:20"],
["19:30", "21:00"],
],
"evening": [
["09:00", "10:30"],
["10:40", "12:10"],
["12:20", "13:50"],
["14:30", "16:00"],
["16:10", "17:40"],
["18:20", "19:40"],
["19:50", "21:10"],
],
}
def __init__(self, schedule: dict) -> None:
"""
DESCRIPTION
* initializes Schedule object
-----
ARGS
* (required) schedule (dict): dict containing schedule information
-----
RETURNS
* there is no return
-----
ERRORS
* there are no custom errors
"""
self.group = schedule["group"]
self.type = schedule["type"]
self.is_session = schedule["is_session"]
self.dates = schedule["dates"]
self.grid = schedule["grid"]
@staticmethod
def __d(date: str, frmt: str = "%d.%m.%Y") -> datetime:
"""
DESCRIPTION
* converts string to datetime object depending on given format
-----
ARGS
* (required) date (str): string with recorded date
* (optional) frmt (str): format of string with recorded date,
by default is "%d.%m.%Y"
-----
RETURNS
* date (datetime): datetime object
-----
ERRORS
* there are no custom errors
"""
# returning datetime object
return datetime.strptime(date, frmt)
@staticmethod
def __s(date: datetime, frmt: str = "%d.%m.%Y") -> str:
"""
DESCRIPTION
* converts datetime object to string depending on given format
-----
ARGS
* (required) date (datetime): datetime object
* (optional) frmt (str): format of string with recorded date,
by default is "%d.%m.%Y"
-----
RETURNS
* date (str): string with recorded date
-----
ERRORS
* there are no custom errors
"""
# returning string with recorded date
return date.strftime(frmt)
def get_day(self, date: str) -> dict:
"""
DESCRIPTION
* returns dictionary with information about study day
-----
ARGS
* (required) date (str): date of day (format: "%d.%m.%Y")
-----
RETURNS
* day (dict): dictionary with information about study day
-----
ERRORS
* ValueError(): if there is problem with range of available dates
"""
# checking correctness of date
if not (self.__d(self.dates[0]) <= self.__d(date) <= self.__d(self.dates[1])):
raise ValueError(
f"The specified date {date} is outside the range of available dates: \
[{self.dates[0]} - {self.dates[1]}]."
)
# getting raw day from grid
if len(self.grid) == 6:
# first case (per-week schedule)
w = self.__d(date).weekday()
if w < 6:
raw_day = self.grid[w]
else:
raw_day = [[] for _ in range(7)]
else:
# second case (per-day schedule)
d = abs((self.__d(date) - self.__d(self.dates[0])).days)
raw_day = self.grid[d]
# creating day
day = {
"group": self.group,
"type": self.type,
"is_session": self.is_session,
"date": date,
"day": [],
}
# filling day["day"]
for index, section in enumerate(raw_day):
event = {"time": self.TIME_SECTIONS[self.type][index], "subject": None}
for raw_sbj in section:
if (
self.__d(raw_sbj["dates"][0])
<= self.__d(date)
<= self.__d(raw_sbj["dates"][1])
):
event["subject"] = dict(raw_sbj)
del event["subject"]["dates"]
break
day["day"].append(event)
# returning dictionary with information about study day
return day
def get_week(self, date: str, ignore_range_err: bool = False) -> dict:
"""
DESCRIPTION
* returns dictionary with information about study week
-----
ARGS
* (required) date (str): date of day (format: "%d.%m.%Y")
* (optional) ignore_range_err (bool): error flag,
by default is False - considers range of available dates, True - ignores range of available dates
-----
RETURNS
* week (dict): dictionary with information about study week
-----
ERRORS
* ValueError(): if there is problem with range of available dates
"""
# converting date
date = self.__d(date)
# creating list with dates
weekframe = []
weekday = date.weekday()
for i in range(7):
weekframe.append(self.__s(date - timedelta(days=weekday - i)))
# creating week
week = {
"group": self.group,
"type": self.type,
"is_session": self.is_session,
"dates": [weekframe[0], weekframe[-1]],
"week": [],
}
# filling week["week"]
for date in weekframe:
try:
subjects = self.get_day(date)["day"]
except ValueError as e:
if ignore_range_err:
subjects = []
else:
raise e
week["week"].append({"date": date, "day": subjects})
# returning dictionary with information about study week
return week
if __name__ == "__main__":
pass

View file

@ -0,0 +1,34 @@
# Async-обёртка над синхронной вендоренной mospolytech_api (asyncio.to_thread).
from __future__ import annotations
import asyncio
from pathlib import Path
from ._vendor.api import API
from ._vendor.schedule import Schedule
_VENDOR_DIR = Path(__file__).resolve().parent / "_vendor"
_DEFAULT_HASH_SALT_PATH = _VENDOR_DIR / "hash_salt.txt"
__all__ = ["OpenDataClient", "Schedule"]
class OpenDataClient:
def __init__(self, *, hash_salt_path: str | Path = _DEFAULT_HASH_SALT_PATH) -> None:
self._api = API(hash_salt_path=str(hash_salt_path))
async def get_groups(self) -> list[str]:
return await asyncio.to_thread(self._api.get_groups)
async def get_schedule(self, group: str, *, is_session: bool = False) -> dict:
return await asyncio.to_thread(self._api.get_schedule, group, is_session)
async def get_students(self, groups: list[str] | None = None) -> dict:
return await asyncio.to_thread(self._api.get_students, groups)
async def get_semester(self) -> dict:
return await asyncio.to_thread(self._api.get_semester)
async def get_session(self) -> dict:
return await asyncio.to_thread(self._api.get_session)

View file

@ -0,0 +1,39 @@
# MCP-сервер mospolytech-mcp: инструменты поверх UniversityAPI (транспорт stdio).
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from mcp.server.mcpserver import MCPServer
from ._dotenv import load_dotenv
from .api import UniversityAPI
from .cache import GroupsCacheStore
from .db import make_engine, make_sessionmaker
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
mcp = MCPServer(name="mospolytech-mcp", version="0.1.0")
_uni = UniversityAPI()
_engine = make_engine()
_groups_cache = GroupsCacheStore(_uni.open, make_sessionmaker(_engine))
@mcp.tool()
async def list_groups() -> list[str]:
"""Список всех учебных групп Московского Политеха."""
return await _groups_cache.get_groups()
def main() -> None:
mcp.run(transport="stdio")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,186 @@
# Офлайн-тесты клиента личного кабинета (через httpx.MockTransport).
from __future__ import annotations
import json
import httpx
import pytest
from mospolytech_mcp.lk_api import (
APIError,
InvalidCredentialsError,
LKClient,
NotAuthenticatedError,
NoRefreshTokenError,
PasswordExpiredError,
)
def make_client(handler) -> LKClient:
transport = httpx.MockTransport(handler)
http_client = httpx.AsyncClient(transport=transport)
return LKClient(http_client=http_client)
async def test_login_success():
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/old/lk_api.php" and request.method == "POST":
return httpx.Response(
200,
json={
"token": "legacy-token",
"jwt": "jwt-access",
"jwt_refresh": "jwt-refresh",
"guid": "guid-123",
},
)
return httpx.Response(200, text="")
client = make_client(handler)
async with client:
tokens = await client.login("user@example.com", "secret")
assert tokens.token == "legacy-token"
assert tokens.jwt == "jwt-access"
assert tokens.jwt_refresh == "jwt-refresh"
assert tokens.guid == "guid-123"
assert client.is_authenticated is True
async def test_login_invalid_credentials():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(400, text="")
client = make_client(handler)
async with client:
with pytest.raises(InvalidCredentialsError):
await client.login("user@example.com", "wrong")
async def test_login_password_expired():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"token": "", "AD_pwd_expired": True, "AD_domain": "staff"},
)
client = make_client(handler)
async with client:
with pytest.raises(PasswordExpiredError) as exc_info:
await client.login("staff.member", "old-pass")
assert exc_info.value.ad_domain == "staff"
async def test_lk_get_requires_authentication():
client = make_client(lambda request: httpx.Response(200))
async with client:
with pytest.raises(NotAuthenticatedError):
await client.get_user()
async def test_get_user_parses_response():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.params["getUser"] == ""
assert request.url.params["token"] == "legacy-token"
return httpx.Response(
200,
json={
"user": {
"id": 42,
"name": "Иван",
"surname": "Иванов",
"course": 3,
"is_token_valid": True,
"hasAlerts": False,
}
},
)
client = make_client(handler)
client.tokens.token = "legacy-token"
async with client:
user = await client.get_user()
assert user.id == 42
assert user.name == "Иван"
assert user.course == "3"
assert user.is_token_valid is True
async def test_refresh_token_updates_jwt():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/auth/token/reissue"
body = json.loads(request.content)
assert body["refresh_token"] == "old-refresh"
return httpx.Response(
200, json={"access_token": "new-jwt", "refresh_token": "new-refresh"}
)
client = make_client(handler)
client.tokens.jwt_refresh = "old-refresh"
async with client:
await client.refresh_token()
assert client.tokens.jwt == "new-jwt"
assert client.tokens.jwt_refresh == "new-refresh"
async def test_refresh_token_without_refresh_token_raises():
client = make_client(lambda request: httpx.Response(200))
async with client:
with pytest.raises(NoRefreshTokenError):
await client.refresh_token()
async def test_get_my_schedule_parses_response():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"2026-09-14": {
"lessons": [
{
"name": "Матанализ",
"timeInterval": "09:00-10:30",
"place": "ауд. 101",
"rooms": ["101"],
"teachers": ["Петров П. П."],
"dateInterval": "",
"link": "",
"teachersFull": [{"id": 1, "name": "Петров П. П."}],
}
]
}
},
)
client = make_client(handler)
client.tokens.token = "legacy-token"
async with client:
schedule = await client.get_my_schedule()
day = schedule["2026-09-14"]
assert len(day.lessons) == 1
assert day.lessons[0].name == "Матанализ"
assert day.lessons[0].teachers_full[0].name == "Петров П. П."
async def test_lk_api_error_on_invalid_json():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, text="не json")
client = make_client(handler)
client.tokens.token = "legacy-token"
async with client:
with pytest.raises(APIError):
await client.get_notifications()
async def test_lk_api_error_on_bad_status():
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500, text="internal error")
client = make_client(handler)
client.tokens.token = "legacy-token"
async with client:
with pytest.raises(APIError) as exc_info:
await client.get_notifications()
assert exc_info.value.status_code == 500

View file

@ -0,0 +1,76 @@
# Офлайн-тесты обёртки над открытым API (open_api).
from __future__ import annotations
import json
import pytest
from mospolytech_mcp.open_api import OpenDataClient
from mospolytech_mcp.open_api._vendor import api as vendor_api_module
class _FakeResponse:
def __init__(self, status_code: int, content: bytes) -> None:
self.status_code = status_code
self.content = content
def test_client_initializes_with_bundled_hash_salt():
OpenDataClient()
async def test_get_groups_parses_response(monkeypatch):
def fake_get(url, headers=None):
assert url == vendor_api_module.API._API__URLS["groups"]
body = json.dumps({"groups": ["201-722", "201-721"]}).encode("utf-8")
return _FakeResponse(200, body)
monkeypatch.setattr(vendor_api_module.requests, "get", fake_get)
client = OpenDataClient()
groups = await client.get_groups()
assert groups == ["201-721", "201-722"]
async def test_get_schedule_parses_response(monkeypatch):
grid = {
"14.09.2026": {
"0": [
{
"sbj": "Матанализ",
"type": "лекция",
"teacher": "Петров П. П.",
"location": "ауд. 101",
"shortRooms": ["101"],
"e_link": None,
"auditories": [{"title": "ауд. 101"}],
}
]
}
}
payload = {
"group": {"evening": False, "dateFrom": "2026-09-14", "dateTo": "2026-09-14"},
"isSession": False,
"grid": grid,
}
def fake_get(url, headers=None):
return _FakeResponse(200, json.dumps(payload).encode("utf-8"))
monkeypatch.setattr(vendor_api_module.requests, "get", fake_get)
client = OpenDataClient()
schedule = await client.get_schedule("201-721", is_session=False)
assert schedule["group"] == "201-721"
assert schedule["type"] == "morning"
assert schedule["dates"] == ["14.09.2026", "14.09.2026"]
async def test_get_groups_raises_on_bad_status(monkeypatch):
def fake_get(url, headers=None):
return _FakeResponse(500, b"")
monkeypatch.setattr(vendor_api_module.requests, "get", fake_get)
client = OpenDataClient()
with pytest.raises(Exception):
await client.get_groups()