- Python 98.6%
- Mako 0.8%
- Dockerfile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Обмен имеет смысл только пока в LMS открыто окно записи на пары. Когда оно закрыто, пересесть нельзя ни самому, ни через куратора, и заказы лишь путают людей. Теперь биржу можно выключить одним флагом. Выключенная биржа отвечает 503 на всё, что меняет данные, и отдаёт причину публичной ручкой GET /api/v1/status — расширение спрашивает её первой, ещё до того как выяснять личность студента. Чтение остаётся: старые заказы видны и переживают выключение. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
| alembic | ||
| app | ||
| tests | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| alembic.ini | ||
| docker-compose.yml | ||
| Dockerfile | ||
| pyproject.toml | ||
| README.md | ||
| requirements-dev.txt | ||
| requirements.txt | ||
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):
- Расширение генерирует случайный
device_keyи кладёт его вbrowser.storage.local. POST /api/v1/registerпривязываетstudent_id(тот же UUID, что LMS отдаёт в/api/student-hub/students/me) к sha256 этого ключа.- Дальше каждый запрос идёт с заголовками
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