# Администраторский API

Эндпоинты управления инстансом VidConf — конференции, пользователи, команды, настройки. **Все эндпоинты требуют роль `admin` (403 иначе).**

## Авторизация

Все запросы должны включать JWT Bearer токен администратора в заголовке `Authorization`:
```bash
Authorization: Bearer <access_token>
```

Попытка обращения без роли `admin` → **403 Forbidden** с `detail="Forbidden"`.

## Эндпоинты

### Конференции

#### GET /api/v1/admin/conferences

**Список всех конференций инстанса с фильтрацией и пагинацией.**

**Query параметры:**
- `status` (опционально) — фильтр по статусу: `scheduled`, `active`, `ended`
- `q` (опционально) — текстовый поиск по названию/номеру/slug
- `limit` (опционально, default=50, max=200) — строк на странице
- `offset` (опционально, default=0) — смещение (пагинация)

**Response (200 OK):**
```json
{
  "items": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "number": "123456789",
      "slug": "abc123456",
      "title": "Встреча Q3",
      "status": "scheduled",
      "is_pinned": true,
      "is_closed": false,
      "scheduled_at": "2026-07-20T14:00:00Z",
      "duration_minutes": 60,
      "recurrence": { "type": "weekly", "weekdays": [0, 2, 4] },
      "summary_recipients": "all",
      "created_at": "2026-07-16T12:00:00Z",
      "owner_name": "Иван Иванов",
      "owner_email": "ivan@example.com"
    }
  ],
  "total": 42
}
```

**Ключевые поля:**
- `owner_name` / `owner_email` — имя и email владельца конференции (null если владельца нет)
- `summary_recipients` — текущий режим рассылки (null = дефолт инстанса)

---

#### PATCH /api/v1/admin/conferences/{conference_id}

**Изменить конференцию (реюз `ConferenceService.update`, те же правила валидации).**

**Path параметры:**
- `conference_id` (UUID)

**Request body:** (все поля опциональны)
```json
{
  "title": "Новое название",
  "scheduled_at": "2026-07-25T15:00:00Z",
  "duration_minutes": 90,
  "is_pinned": true,
  "recurrence": null,
  "is_closed": false,
  "password": null,
  "summary_recipients": "owner"
}
```

**Response (200 OK):** обновленная конференция (как GET /conferences)

**Коды ошибок:**
- `404` — conference_not_found
- `422` — invalid_conference_state (попытка добавить recurrence к неживой конференции и т.п.)

**Примечание:** админ проходит проверку владения как "владелец ИЛИ админ" — можно менять чужие конференции.

---

#### DELETE /api/v1/admin/conferences/{conference_id}

**Удалить конференцию (409 для активной).**

**Path параметры:**
- `conference_id` (UUID)

**Response (204 No Content)**

**Коды ошибок:**
- `404` — conference_not_found
- `409` — conference_active (активную конференцию удалить нельзя)

---

#### POST /api/v1/admin/conferences/{conference_id}/invitations

**Поставить в очередь ручную рассылку .ics-приглашений (202 Accepted, отправка идёт в Celery).**

**Path параметры:**
- `conference_id` (UUID)

**Request body:** (опционально)
```json
{
  "emails": ["alice@example.com", "bob@example.com"]
}
```

**Response (202 Accepted)** — без тела (задача поставлена в очередь)

**Логика:**
- Если `emails` опущена/null — получатели по умолчанию: владелец конференции + для закреплённых — участники прошлых сеансов
- Если `emails` передан — явный список адресов
- Каждое приглашение добавляется в `email_deliveries` с `kind='invitation'` (журнал без unique, дубликаты допускаются)

**Коды ошибок:**
- `404` — conference_not_found

---

### Пользователи

#### GET /api/v1/admin/users

**Список всех пользователей инстанса с пагинацией.**

**Query параметры:**
- `q` (опционально) — текстовый поиск по email/имени
- `limit` (опционально, default=50, max=200)
- `offset` (опционально, default=0)

**Response (200 OK):**
```json
{
  "items": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "email": "admin@example.com",
      "name_user": "Администратор",
      "role": "admin",
      "email_verified": true,
      "is_blocked": false,
      "team_id": null,
      "created_at": "2026-07-16T12:00:00Z"
    }
  ],
  "total": 15
}
```

---

#### POST /api/v1/admin/users

**Создать пользователя от имени администратора.**

Email сразу считается подтверждённым (письмо на верификацию не отправляется); роль по умолчанию — `user`. Дубль email → **409** `email_already_registered`. Несуществующая команда → **404** `team_not_found`.

**Request body:**
```json
{
  "name_user": "Иван Петров",
  "email": "ivan@example.com",
  "password": "securePassword123",
  "team_id": "550e8400-e29b-41d4-a716-446655440001"
}
```

**Поля:**
- `name_user` (str, 1..255) — отображаемое имя (обязательно)
- `email` (EmailStr) — адрес электронной почты (обязательно, уникален)
- `password` (str, ≥8) — пароль пользователя (политика как при регистрации)
- `team_id` (UUID или null) — опциональная привязка к команде (не обязана быть выбрана, в отличие от публичной регистрации)

**Response (201 Created):**
```json
{
  "id": "550e8400-e29b-41d4-a716-446655440002",
  "email": "ivan@example.com",
  "name_user": "Иван Петров",
  "role": "user",
  "is_blocked": false,
  "email_verified": true,
  "created_at": "2026-07-21T10:30:00Z",
  "team_id": "550e8400-e29b-41d4-a716-446655440001",
  "avatar_url": null,
  "team_name": "Backend"
}
```

**Коды ошибок:**
- `409` — email_already_registered (email уже зарегистрирован)
- `404` — team_not_found (передан несуществующий `team_id`)
- `422` — Unprocessable Entity (пароль короче 8 символов, валидация email и т.д.)

**Примечание:** В отличие от самостоятельной регистрации (`POST /auth/register`), пользователь не проходит верификацию email — роль назначается как `user`, а `email_verified` сразу `true`.

---

#### GET /api/v1/admin/users/{user_id}

**Карточка профиля пользователя — те же данные, что в пользовательском профиле + роль и статус блокировки.**

**Path параметры:**
- `user_id` (UUID)

**Response (200 OK):**
```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "email": "user@example.com",
  "name_user": "Иван Петров",
  "role": "user",
  "is_blocked": false,
  "team_id": "550e8400-e29b-41d4-a716-446655440001",
  "team_name": "Backend",
  "avatar_path": "avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
  "avatar_url": "/media/avatars/550e8400-e29b-41d4-a716-446655440000.jpg",
  "created_at": "2026-07-18T12:00:00Z"
}
```

**Поля:**
- `avatar_path` — путь к файлу аватара (относительно `MEDIA_ROOT`), `null` если нет
- `avatar_url` — готовый URL для отображения (`/media/avatars/...`)
- `team_name` — имя команды (null если не привязан)

**Коды ошибок:**
- `404` — user_not_found

---

#### PATCH /api/v1/admin/users/{user_id}

**Изменить роль, статус блокировки, ФИО, команду пользователя.**

**Path параметры:**
- `user_id` (UUID)

**Request body:** (все поля опциональны)
```json
{
  "role": "admin",
  "is_blocked": true,
  "name_user": "Новое имя",
  "team_id": "550e8400-e29b-41d4-a716-446655440000"
}
```

**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id})

**Коды ошибок:**
- `404` — user_not_found (пользователь) или team_not_found (несуществующий `team_id`)
- `409` — cannot_modify_self (попытка изменить свою `role`/`is_blocked`; своё `name_user` и `team_id` менять можно)

**Семантика:**
- `is_blocked=true` — пользователь получит 401 на любом защищённом эндпоинте (проверка в `api/deps.py::_user_from_token`), немедленно, без ожидания истечения access-токена
- `role` может быть `"admin"` или `"user"`
- `name_user` — новое ФИО пользователя
- `team_id` — явное `null` снимает привязку к команде; отсутствие поля в теле запроса команду не трогает (различается через `model_fields_set`); запрет самоизменения (409) на `team_id` **не распространяется**

---

#### POST /api/v1/admin/users/{user_id}/avatar

**Загрузить аватар пользователю.**

**Path параметры:**
- `user_id` (UUID)

**Request body:** `multipart/form-data`
- `file` — файл изображения (обязателен)

**Требования:**
- Формат: JPEG, PNG или WebP (проверяется по magic bytes)
- Размер: максимум 2 МБ

**Response (200 OK):** обновленный пользователь (как GET /admin/users/{id}, с новым `avatar_url`)

**Коды ошибок:**
- `404` — user_not_found
- `413` — avatar_too_large (файл больше 2 МБ)
- `415` — avatar_invalid_type (не JPEG/PNG/WebP)

**Примечание:** Старый аватар автоматически удаляется при загрузке нового.

---

### Команды

#### GET /api/v1/admin/teams

**Список всех команд, отсортированный по названию.**

**Response (200 OK):**
```json
{
  "items": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Backend",
      "created_at": "2026-07-18T12:00:00Z"
    }
  ],
  "total": 1
}
```

---

#### POST /api/v1/admin/teams

**Создать команду.**

**Request body:**
```json
{ "name": "Backend" }
```
`name` обрезается от пробелов (1..255 символов после обрезки).

**Response (201 Created):** созданная команда

**Коды ошибок:**
- `409` — team_name_taken (команда с таким названием уже существует)

---

#### PATCH /api/v1/admin/teams/{team_id}

**Переименовать команду.**

**Path параметры:**
- `team_id` (UUID)

**Request body:**
```json
{ "name": "Backend & Platform" }
```

**Response (200 OK):** обновлённая команда

**Коды ошибок:**
- `404` — team_not_found
- `409` — team_name_taken

---

#### DELETE /api/v1/admin/teams/{team_id}

**Удалить команду.**

**Path параметры:**
- `team_id` (UUID)

**Response (204 No Content)**

У пользователей, состоявших в удалённой команде, `team_id` автоматически обнуляется (`ON DELETE SET NULL`) — сами пользователи не удаляются.

**Коды ошибок:**
- `404` — team_not_found

---

### Настройки инстанса

#### GET /api/v1/admin/settings

**Текущие эффективные настройки инстанса (собрань из БД + дефолты).**

**Response (200 OK):**
```json
{
  "chat_enabled": false,
  "transcription_enabled": true,
  "ai_level": "min",
  "ai_levels": [
    {"level": "min", "available": true, "reason": null},
    {"level": "medium", "available": false, "reason": "Недостаточно памяти"},
    {"level": "max", "available": false, "reason": "Нет GPU"}
  ],
  "summary_recipients": "all",
  "display_timezone": "Europe/Moscow",
  "registration_team_choice": false,
  "registration_email_domain_enabled": false,
  "registration_email_domain": null,
  "transcription_queue_served": true
}
```

**Поля:**
- `chat_enabled` — включен ли чат в конференциях
- `transcription_enabled` — включены ли транскрибация и суммаризация (единый переключатель)
- `ai_level` — текущий уровень AI (`min`, `medium`, `max`)
- `ai_levels` — доступность всех уровней с причинами (например, `medium` может быть недоступен при недостатке памяти, `max` требует GPU)
- `summary_recipients` — режим рассылки саммри по умолчанию (`all` или `owner`)
- `display_timezone` — таймзона отображения времени в письмах и .ics (IANA, например `Europe/Moscow`)
- `registration_team_choice` — разрешён ли выбор команды на публичной форме регистрации (дефолт `false`); см. `GET /api/v1/auth/registration-options`
- `registration_email_domain_enabled` — включена ли верификация регистрирующихся по домену email (дефолт `false`)
- `registration_email_domain` — эталонный домен email (нормализован: без ведущего `@`, в нижнем регистре); `null`, пока верификация не настроена
- `transcription_queue_served` — `true`, если хотя бы один Celery-воркер `transcriber` активно обслуживает очередь транскрибации; `false` = предупреждение в админке (см. ниже)

---

#### PUT /api/v1/admin/settings

**Частично обновить настройки инстанса (недоступный уровень AI/неверная таймзона → 400).**

**Request body:** (все поля опциональны, PATCH-семантика)
```json
{
  "chat_enabled": true,
  "transcription_enabled": false,
  "ai_level": "min",
  "summary_recipients": "owner",
  "display_timezone": "Europe/London",
  "registration_team_choice": true,
  "registration_email_domain_enabled": true,
  "registration_email_domain": "example.com"
}
```

**Response (200 OK):** обновленные настройки (как GET /settings)

**Коды ошибок:**
- `400` — invalid AI level (попытка установить недоступный уровень, например `medium` без нужного железа), неверная таймзона (не IANA) или некорректная настройка верификации домена email (включение без домена либо домен не проходит валидацию паттерном)

**Логика:**
- `chat_enabled`: пишет `enabled` в хранилище настроек (`instance_settings.chat`)
- `transcription_enabled`: пишет `enabled` сразу в обе секции (`transcriber`, `summarizer`) — единый переключатель; **guard:** если установлено `true`, но `transcription_queue_served=false` в ответе `GET /settings`, админка отображает предупреждение «Очередь транскрибации не обслуживается ни одним Celery-воркером» (воркеры не запущены, обслуживают другую очередь или недоступны)
- `ai_level`: проверяется `detect_ai_levels()` на доступность перед сохранением
- `display_timezone`: валидируется как IANA (через `ZoneInfo`)
- `summary_recipients`: может быть `"all"` или `"owner"` (дефолт для новых конференций)
- `registration_team_choice`: включает/выключает выбор команды на публичной форме регистрации (`instance_settings.registration_team_choice`); влияет на `GET /api/v1/auth/registration-options` и допустимость `team_id` в `POST /api/v1/auth/register`
- `registration_email_domain_enabled` / `registration_email_domain`: включают верификацию регистрирующихся по домену email (`instance_settings.registration_email_domain`); включение (`enabled=true`) при пустом/отсутствующем домене → `400`; домен нормализуется (`strip`, без ведущей `@`, нижний регистр) и валидируется простым паттерном доменного имени, иначе `400`; влияет на `GET /api/v1/auth/registration-options` (`email_domain`) и `POST /api/v1/auth/register` (`400 invalid_email_domain` при несовпадении домена)

**Примечание:** Изменение настроек **не требует рестарта** ни backend'а, ни воркеров Celery (они читают конфиг на старте каждой задачи).

### Предупреждение об очереди транскрибации

При включённой транскрибации (`transcription_enabled=true`) админка проверяет
доступность очереди через поле `transcription_queue_served` в ответе
`GET /api/v1/admin/settings`:

- **`transcription_queue_served=true`:** хотя бы один Celery-воркер `transcriber`
  активен и обслуживает очередь — новые конференции будут обработаны
- **`transcription_queue_served=false`:** ни один воркер не подключён к очереди —
  **все** сеансы конференций по завершении зависнут в статусе `transcribing`
  (задачи накопятся в Redis, никто их не возьмёт)

**Решение:** запустить хотя бы один воркер транскрибации:
```bash
docker compose up -d transcriber
# или
cd workers && celery -A transcriber worker -q
```

После запуска воркера поле `transcription_queue_served` обновится на `true`
(проверяется при каждом запросе к `/api/v1/admin/settings`).

---

## Примеры запросов

### Получить все конференции с фильтром по статусу

```bash
curl -X GET \
  'http://localhost:8000/api/v1/admin/conferences?status=scheduled&limit=20&offset=0' \
  -H "Authorization: Bearer $TOKEN"
```

### Изменить режим рассылки саммри конференции

```bash
curl -X PATCH \
  'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"summary_recipients": "owner"}'
```

### Поставить ручную рассылку приглашений

```bash
curl -X POST \
  'http://localhost:8000/api/v1/admin/conferences/550e8400-e29b-41d4-a716-446655440000/invitations' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["alice@example.com", "bob@example.com"]}'
```

### Создать пользователя

```bash
curl -X POST \
  'http://localhost:8000/api/v1/admin/users' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name_user": "Иван Петров",
    "email": "ivan@example.com",
    "password": "securePassword123",
    "team_id": "550e8400-e29b-41d4-a716-446655440001"
  }'
```

### Заблокировать пользователя

```bash
curl -X PATCH \
  'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_blocked": true}'
```

### Назначить пользователю команду

```bash
curl -X PATCH \
  'http://localhost:8000/api/v1/admin/users/550e8400-e29b-41d4-a716-446655440000' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"team_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}'
```

### Изменить настройки инстанса

```bash
curl -X PUT \
  'http://localhost:8000/api/v1/admin/settings' \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "chat_enabled": true,
    "ai_level": "min",
    "display_timezone": "Europe/London",
    "summary_recipients": "all"
  }'
```

---

## Безопасность и ограничения

- **Только для админов:** все эндпоинты блокируют 403 для роли `user`
- **Самоизменение роли/блокировки запрещено:** PATCH собственных `role`/`is_blocked` → 409 (`cannot_modify_self`); свою команду (`team_id`) администратор менять может
- **Секреты SMTP не видны:** почтовые реквизиты хранятся только в `.env`, в API не попадают
- **Блокировка немедленна:** `is_blocked=true` действует на любом защищённом эндпоинте без ожидания истечения токена
- **Рассылка в очереди:** отправка писем идёт асинхронно через Celery (202, не 200), ошибки логируются в воркере

---

## Примечания

1. **Изменение расписания конференции** (PATCH `scheduled_at`/`duration_minutes`/`recurrence`/`title`) инкрементирует `ics_sequence` и ставит в очередь `send_invitations` — календарные клиенты получат обновление по UID события.

2. **Переопределение рассылки саммри** (`summary_recipients` в конференции) сильнее дефолта инстанса — если в конференции установлено, оно используется; иначе берётся настройка из `instance_settings.summary_recipients`.

3. **AI-уровни:** три уровня качества `min`/`medium`/`max` (Qwen3.5-4B/9B/35B-A3B, faster-whisper small/medium/large-v3). Доступность уровня определяется детектом железа (HW_CPUS/HW_RAM_MB/HW_GPU_NAME/HW_VRAM_MB в `.env`). Недоступный уровень → 400 Bad Request с объяснением. ADR-004: [docs/architecture/adr/004-ai-tier-matrix.md](../architecture/adr/004-ai-tier-matrix.md)

---

## Ссылки

- [Конфигурация инстанса](../architecture/README.md#Компоненты) — общее описание системы
- [Схема БД](../db/schema.md) — таблицы `instance_settings`, `email_deliveries`
- [API конференций](./conferences.md) — пользовательский API (без админа)
