mirror of
https://github.com/EDeev/mospolytech-mcp.git
synced 2026-10-07 20:49:52 +03:00
prototype
This commit is contained in:
parent
f8306ac574
commit
89813c4cd9
64 changed files with 5578 additions and 5 deletions
6
.env.example
Normal file
6
.env.example
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Учётные данные личного кабинета e.mospolytech.ru
|
||||
MPU_LOGIN=
|
||||
MPU_PASSWORD=
|
||||
|
||||
# Подключение к PostgreSQL
|
||||
DATABASE_URL=
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
venv/
|
||||
*.egg-info/
|
||||
src/*.egg-info/
|
||||
.pytest_cache/
|
||||
.env
|
||||
*.db
|
||||
138
README.md
138
README.md
|
|
@ -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
149
alembic.ini
Normal 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
1
alembic/README
Normal file
|
|
@ -0,0 +1 @@
|
|||
Generic single-database configuration with an async dbapi.
|
||||
71
alembic/env.py
Normal file
71
alembic/env.py
Normal 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
28
alembic/script.py.mako
Normal 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"}
|
||||
37
alembic/versions/d3ae49746cfb_create_groups_cache.py
Normal file
37
alembic/versions/d3ae49746cfb_create_groups_cache.py
Normal 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
23
docs/README.md
Normal 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 лабораторным, архитектура, риски |
|
||||
BIN
docs/official/lab01-tech-spec.pdf
Normal file
BIN
docs/official/lab01-tech-spec.pdf
Normal file
Binary file not shown.
2494
docs/sdo/gost_34.602-89.pdf
Normal file
2494
docs/sdo/gost_34.602-89.pdf
Normal file
File diff suppressed because it is too large
Load diff
BIN
docs/sdo/ieee_std_830-1998_ru.pdf
Normal file
BIN
docs/sdo/ieee_std_830-1998_ru.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/АПЖЦПС_Список_литературы.pdf
Normal file
BIN
docs/sdo/АПЖЦПС_Список_литературы.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Компоненты_CICD_пайплайны_и_их_взаимосвязь.pdf
Normal file
BIN
docs/sdo/Компоненты_CICD_пайплайны_и_их_взаимосвязь.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 1 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 1 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 1.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 1.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 2 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 2 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 2.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 2.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 3 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 3 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 3.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 3.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 4 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 4 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 4.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 4.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 5 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 5 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 5.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 5.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 6 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 6 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 6.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 6.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 7 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 7 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 7.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 7.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 8 Пример.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 8 Пример.pdf
Normal file
Binary file not shown.
BIN
docs/sdo/Лабораторная работа 8.pdf
Normal file
BIN
docs/sdo/Лабораторная работа 8.pdf
Normal file
Binary file not shown.
229
docs/working/plan.md
Normal file
229
docs/working/plan.md
Normal 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
34
pyproject.toml
Normal 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
101
scripts/check_lk_api.py
Normal 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()))
|
||||
5
src/mospolytech_mcp/__init__.py
Normal file
5
src/mospolytech_mcp/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Пакет mospolytech-mcp: MCP-сервер с доступом к open_api и lk_api Политеха.
|
||||
|
||||
from .api import UniversityAPI
|
||||
|
||||
__all__ = ["UniversityAPI"]
|
||||
17
src/mospolytech_mcp/_dotenv.py
Normal file
17
src/mospolytech_mcp/_dotenv.py
Normal 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())
|
||||
22
src/mospolytech_mcp/api.py
Normal file
22
src/mospolytech_mcp/api.py
Normal 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()
|
||||
48
src/mospolytech_mcp/cache.py
Normal file
48
src/mospolytech_mcp/cache.py
Normal 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
|
||||
7
src/mospolytech_mcp/db/__init__.py
Normal file
7
src/mospolytech_mcp/db/__init__.py
Normal 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"]
|
||||
9
src/mospolytech_mcp/db/base.py
Normal file
9
src/mospolytech_mcp/db/base.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Общий declarative base для всех моделей — одна metadata для Alembic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
19
src/mospolytech_mcp/db/models.py
Normal file
19
src/mospolytech_mcp/db/models.py
Normal 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))
|
||||
24
src/mospolytech_mcp/db/session.py
Normal file
24
src/mospolytech_mcp/db/session.py
Normal 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)
|
||||
51
src/mospolytech_mcp/lk_api/__init__.py
Normal file
51
src/mospolytech_mcp/lk_api/__init__.py
Normal 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",
|
||||
]
|
||||
181
src/mospolytech_mcp/lk_api/base.py
Normal file
181
src/mospolytech_mcp/lk_api/base.py
Normal 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)
|
||||
27
src/mospolytech_mcp/lk_api/client.py
Normal file
27
src/mospolytech_mcp/lk_api/client.py
Normal 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
|
||||
40
src/mospolytech_mcp/lk_api/errors.py
Normal file
40
src/mospolytech_mcp/lk_api/errors.py
Normal 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}")
|
||||
29
src/mospolytech_mcp/lk_api/messages.py
Normal file
29
src/mospolytech_mcp/lk_api/messages.py
Normal 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]
|
||||
452
src/mospolytech_mcp/lk_api/models.py
Normal file
452
src/mospolytech_mcp/lk_api/models.py
Normal 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 {}),
|
||||
)
|
||||
15
src/mospolytech_mcp/lk_api/notifications.py
Normal file
15
src/mospolytech_mcp/lk_api/notifications.py
Normal 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 []]
|
||||
12
src/mospolytech_mcp/lk_api/payments.py
Normal file
12
src/mospolytech_mcp/lk_api/payments.py
Normal 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)
|
||||
14
src/mospolytech_mcp/lk_api/performance.py
Normal file
14
src/mospolytech_mcp/lk_api/performance.py
Normal 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]
|
||||
11
src/mospolytech_mcp/lk_api/requests_.py
Normal file
11
src/mospolytech_mcp/lk_api/requests_.py
Normal 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 []]
|
||||
61
src/mospolytech_mcp/lk_api/schedule.py
Normal file
61
src/mospolytech_mcp/lk_api/schedule.py
Normal 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 {})
|
||||
17
src/mospolytech_mcp/lk_api/user.py
Normal file
17
src/mospolytech_mcp/lk_api/user.py
Normal 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)
|
||||
5
src/mospolytech_mcp/open_api/__init__.py
Normal file
5
src/mospolytech_mcp/open_api/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Открытые данные университета — async-обёртка над вендоренной mospolytech_api.
|
||||
|
||||
from .client import OpenDataClient, Schedule
|
||||
|
||||
__all__ = ["OpenDataClient", "Schedule"]
|
||||
201
src/mospolytech_mcp/open_api/_vendor/LICENSE.md
Normal file
201
src/mospolytech_mcp/open_api/_vendor/LICENSE.md
Normal 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.
|
||||
25
src/mospolytech_mcp/open_api/_vendor/NOTICE.md
Normal file
25
src/mospolytech_mcp/open_api/_vendor/NOTICE.md
Normal 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`) реального значения соли не
|
||||
требуют — плейсхолдера достаточно, чтобы всё остальное работало.
|
||||
|
||||
Обновлять эти файлы вручную, только синхронизируя с апстримом при
|
||||
необходимости.
|
||||
0
src/mospolytech_mcp/open_api/_vendor/__init__.py
Normal file
0
src/mospolytech_mcp/open_api/_vendor/__init__.py
Normal file
401
src/mospolytech_mcp/open_api/_vendor/api.py
Normal file
401
src/mospolytech_mcp/open_api/_vendor/api.py
Normal 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
|
||||
1
src/mospolytech_mcp/open_api/_vendor/hash_salt.txt
Normal file
1
src/mospolytech_mcp/open_api/_vendor/hash_salt.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
~YOUR-HASH-SALT-HERE~
|
||||
234
src/mospolytech_mcp/open_api/_vendor/schedule.py
Normal file
234
src/mospolytech_mcp/open_api/_vendor/schedule.py
Normal 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
|
||||
34
src/mospolytech_mcp/open_api/client.py
Normal file
34
src/mospolytech_mcp/open_api/client.py
Normal 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)
|
||||
39
src/mospolytech_mcp/server.py
Normal file
39
src/mospolytech_mcp/server.py
Normal 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()
|
||||
186
tests/test_lk_api_offline.py
Normal file
186
tests/test_lk_api_offline.py
Normal 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
|
||||
76
tests/test_open_api_offline.py
Normal file
76
tests/test_open_api_offline.py
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue