Биржа обмена парами для расширения CU LMS Enhancer
  • Python 98.6%
  • Mako 0.8%
  • Dockerfile 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Фёдор Жаков d5320d36c1 feat: рубильник биржи
Обмен имеет смысл только пока в LMS открыто окно записи на пары. Когда оно
закрыто, пересесть нельзя ни самому, ни через куратора, и заказы лишь путают
людей. Теперь биржу можно выключить одним флагом.

Выключенная биржа отвечает 503 на всё, что меняет данные, и отдаёт причину
публичной ручкой GET /api/v1/status — расширение спрашивает её первой, ещё до
того как выяснять личность студента. Чтение остаётся: старые заказы видны и
переживают выключение.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-04 18:52:41 +03:00
alembic feat: несколько устройств на студента вместо строгой привязки 2026-09-06 17:21:21 +03:00
app feat: рубильник биржи 2026-10-04 18:52:41 +03:00
tests feat: рубильник биржи 2026-10-04 18:52:41 +03:00
.dockerignore feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
.env.example feat: рубильник биржи 2026-10-04 18:52:41 +03:00
.gitignore feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
alembic.ini feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
docker-compose.yml feat: рубильник биржи 2026-10-04 18:52:41 +03:00
Dockerfile feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
pyproject.toml feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
README.md feat: рубильник биржи 2026-10-04 18:52:41 +03:00
requirements-dev.txt feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00
requirements.txt feat: биржа обмена парами для расширения CU LMS Enhancer 2026-09-06 16:11:00 +03:00

lms-swap-backend

Биржа обмена парами для расширения CU LMS Enhancer.

Студент видит в расписании слот, который ему подходит по времени, но забит («Нет мест»), и оставляет заказ: «отдаю свой слот, хочу вот этот». Сервис хранит такие заказы и постоянно ищет встречные — когда двое хотят ровно то, что отдаёт другой, обе стороны получают контакт друг друга.

Дальше местами их меняет куратор, вручную. Сами студенты этого сделать не могут: LMS умеет только «займи другой слот», а оба слота полные — ни один из двоих не сдвинется первым, потому что чужое место занято, пока его не освободили. Сервис не пересаживает никого, он лишь находит пару.

Как это работает

студент A: держит Сем-1 (Пт 14:30, мест нет), хочет Сем-3 (Сб 13:00, мест нет)
студент B: держит Сем-3 (Сб 13:00, мест нет), хочет Сем-1 (Пт 14:30, мест нет)
                          ↓
                 совпадение (2-цикл)
                          ↓
        обе стороны видят контакт друг друга, договариваются
                          ↓
              куратор меняет их местами вручную

Совпадением считается только взаимная пара внутри одной строки расписания — одинаковые course_id, event_type и event_row_number. Иначе обмен физически невозможен: LMS разрешает пересесть только между слотами одной строки.

Цепочки на трёх и более человек (A→B→C→A) пока не ищутся.

Запуск

cp .env.example .env
docker compose up --build

API поднимется на http://localhost:8000, интерактивные доки — на http://localhost:8000/docs. Миграции применяются автоматически при старте контейнера.

Значения по умолчанию в docker-compose.yml (swap/swap) годятся только для локального запуска — в проде пароль задаётся в .env, который в репозиторий не коммитится.

Деплой

Наружу сервис отдаёт обратный прокси, сам контейнер слушает только localhost (API_BIND). Блок для Caddy:

lms.swap.cu3rd.ru {
    reverse_proxy 127.0.0.1:8085
}

CORS выставляет само приложение, поэтому дублировать заголовки на стороне прокси не нужно.

Локально без докера:

python -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
DATABASE_URL=sqlite:///./swap.db .venv/bin/alembic upgrade head
DATABASE_URL=sqlite:///./swap.db .venv/bin/uvicorn app.main:app --reload

Тесты (гоняются на SQLite, база создаётся во временной папке):

.venv/bin/pytest

Авторизация

Токен сессии LMS достать нельзя — он лежит в HttpOnly-куках, и сервер не может переспросить у LMS, кто именно пришёл. Поэтому работает привязка при первом обращении (TOFU):

  1. Расширение генерирует случайный device_key и кладёт его в browser.storage.local.
  2. POST /api/v1/register привязывает student_id (тот же UUID, что LMS отдаёт в /api/student-hub/students/me) к sha256 этого ключа.
  3. Дальше каждый запрос идёт с заголовками Authorization: Bearer <device_key> и X-Student-Id: <uuid>.

Ключей у студента несколько — по одному на браузер. Chrome и Firefox не делят storage.local, поэтому второй браузер приходит с новым ключом, и регистрация его просто добавляет. Старые устройства продолжают работать, заказы у них общие. Сверх MAX_DEVICE_KEYS_PER_STUDENT самый старый ключ вытесняется, чтобы таблица не росла от переустановок расширения.

Отсюда честный размен: настоящий секрет здесь — сам student_id, потому что добавить себе устройство может любой, кто его знает. Перечислить чужие идентификаторы через API LMS нельзя — списочные ручки (/students, /students/me/groups, участники курса) отвечают 403/404, UUID виден только в собственном /students/me. Строгая привязка к одному устройству была бы крепче, но ломала биржу у каждого, кто открыл LMS во втором браузере или переустановил расширение.

Если понадобится настоящая проверка личности — код на @edu.centraluniversity.ru ложится поверх той же схемы: первую регистрацию оставить как есть, а добавление второго устройства закрыть письмом.

Персональные данные

/api/student-hub/students/me в LMS отдаёт много лишнего — ИНН, СНИЛС, телефон, дату и место рождения. Сюда ничего из этого не попадает: сервис хранит только student_id и один контакт, который студент выбрал сам (студпочта, телеграм из личного кабинета или произвольная строка).

Чужой контакт отдаётся только после совпадения. Пока заказ висит открытым, о нём не знает никто, кроме автора.

Эндпоинты

Все, кроме /health и /register, требуют заголовков авторизации.

Метод Путь Что делает
GET /api/v1/health Проверка живости
GET /api/v1/status Включена ли биржа; без авторизации
POST /api/v1/register Привязать student_id к ключу устройства, задать контакт
GET /api/v1/me Текущий студент и его контакт
GET /api/v1/me/devices Сколько браузеров сейчас привязано
PUT /api/v1/me/contact Сменить способ связи
GET /api/v1/orders Свои заказы вместе с найденными совпадениями
POST /api/v1/orders Создать заказ (сразу пытается найти встречный)
DELETE /api/v1/orders/{id} Отменить заказ; если он был сосватан — освобождает вторую сторону
GET /api/v1/demand?wanted_event_id= Сколько людей готовы отдать указанные слоты
POST /api/v1/matches/{id}/close Убрать совпадение у вызвавшего; вторая сторона своё ещё видит

Пример: создать заказ

POST /api/v1/orders
Authorization: Bearer <device_key>
X-Student-Id: 11111111-2222-4333-8444-555555555555
Content-Type: application/json

{
  "course_id": 1245,
  "course_name": "Машинное обучение (Machine Learning)",
  "event_type": "seminar",
  "event_row_number": 1,
  "offered_event_id": "08df0286-568a-4c48-4c46-0d000105ac16",
  "offered_label": "Пятница, 14:30 - 15:50, Хорец Даниил",
  "wanted_event_id": "08df0286-553e-0875-4c46-0d000105ac10",
  "wanted_label": "Пятница, 10:00 - 11:20, Хорец Даниил"
}

Если встречный заказ уже лежит в базе, ответ придёт сразу со status: matched и заполненным match, где будет контакт второй стороны.

Матчинг

Матчинг запускается дважды:

  • синхронно при создании каждого заказа — чтобы совпадение нашлось в том же ответе, без ожидания;
  • фоновым потоком раз в MATCH_INTERVAL_SECONDS — чтобы подобрать пары, ставшие возможными позже: после отмен, отклонённых совпадений и упавших транзакций.

Гонок нет без явных блокировок: переход заказа open → matched делается одним UPDATE ... WHERE status = 'open', и если параллельный процесс успел раньше, затронутых строк окажется меньше двух — транзакция откатывается, пара пропускается.

Однажды сведённая пара повторно не сватается: перед созданием совпадения матчер проверяет, не сводились ли эти двое раньше.

Закрытие совпадения одностороннее. Каждый нажимает «ОК» у себя, и его заказ уходит в completed — из выдачи он пропадает, чтобы в интерфейсе не висела карточка, с которой уже нечего делать. Вторая сторона своё совпадение видит, пока не нажмёт сама. Договорились студенты или нет, сервису знать незачем: местами их всё равно меняет куратор.

Рубильник

Обмен имеет смысл только пока в LMS открыто окно записи на пары: если пересесть нельзя, заказы бессмысленны и только путают людей. Поэтому биржу можно выключить целиком:

# в .env
SWAP_ENABLED=false
docker compose up -d

Что происходит при выключении:

  • GET /api/v1/status отдаёт {"enabled": false, "message": "…"}. Ручка публичная — расширение спрашивает её первой, ещё до того как выяснять, кто за браузером: у выключенной биржи нет вопроса «кто ты».
  • Всё, что меняет данные — регистрация, смена контакта, создание и отмена заказов, закрытие совпадений — отвечает 503 с тем же текстом.
  • Чтение остаётся: /orders, /me, /demand работают, старые заказы никуда не деваются и видны после включения.

Текст для студента правится через SWAP_DISABLED_MESSAGE; пустое значение означает «взять текст по умолчанию из config.py».

Расширение опрашивает /status раз в 20 секунд, поэтому включение подхватывается само, без перезагрузки страницы.

Настройки

Переменная По умолчанию Смысл
DATABASE_URL postgresql+psycopg://swap:swap@db:5432/swap Строка подключения SQLAlchemy
MATCH_INTERVAL_SECONDS 30 Период фонового прохода матчера
MAX_OPEN_ORDERS_PER_STUDENT 20 Лимит открытых заказов на студента
MAX_DEVICE_KEYS_PER_STUDENT 10 Сколько браузеров помнит один студент
SWAP_ENABLED true Рубильник биржи
SWAP_DISABLED_MESSAGE текст в config.py Что показать студенту, пока она выключена
MAX_CONTACT_LENGTH 200 Предел длины произвольного контакта
LOG_LEVEL INFO Уровень логов

Структура

app/
├── main.py         # FastAPI, CORS, фоновой матчер
├── config.py       # настройки из окружения
├── db.py           # engine и сессии
├── models.py       # students, device_keys, orders, matches
├── schemas.py      # pydantic-схемы
├── auth.py         # авторизация по ключу устройства
├── switch.py       # рубильник биржи
├── matching.py     # поиск взаимных пар
├── serializers.py  # сборка ответов с точки зрения студента
└── routers/        # students, orders, matches
alembic/            # миграции
tests/              # pytest, на SQLite