From d7ec0cc9aa7ae5b4c0741379562bf8ef38b5ff77 Mon Sep 17 00:00:00 2001 From: emelinda Date: Mon, 13 Jul 2026 22:05:12 +0300 Subject: [PATCH] Add example `.env` file, configuration documentation, OpenAPI specification, and endpoint documentation for `rfi-backend` service. --- apps/rfi/.env.example | 57 ++ apps/rfi/CONFIGURATION.md | 211 +++++++ apps/rfi/ENDPOINTS.md | 100 +++ apps/rfi/openapi.yaml | 1218 +++++++++++++++++++++++++++++++++++++ 4 files changed, 1586 insertions(+) create mode 100644 apps/rfi/.env.example create mode 100644 apps/rfi/CONFIGURATION.md create mode 100644 apps/rfi/ENDPOINTS.md create mode 100644 apps/rfi/openapi.yaml diff --git a/apps/rfi/.env.example b/apps/rfi/.env.example new file mode 100644 index 0000000..4e9630f --- /dev/null +++ b/apps/rfi/.env.example @@ -0,0 +1,57 @@ +# PYTHONPATH (обязательно указывает на каталог с исходниками) +PYTHONPATH=src + +# Django +DJANGO_SECRET_KEY="django-insecure-a2o70(=hkv486m(3l-(6d52y5-1&98qokc06%43_uj_gmr^v-a" + +# Database (PostgreSQL) +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=postgres +DB_USER=postgres +DB_PASSWORD=postgres + +# Auth +# false — включён DefaultUserAuthentication (демо-пользователь для локальной разработки), +# true — Zitadel + JWT (проверка подписи RS512) +JWT_AUTH_ENABLE=false + +# Sarex backend (core/users, users_by_sa) — Basic-auth +SAREX_BACKEND_URL=https://stage.sarex.io +SAREX_BACKEND_AUTH=base64(username:password) +SAREX_BACKEND_TIMEOUT=30 + +# EAV (сервис атрибутов) +EAV_URL=http://eav-service.eav-stage + +# Gateway (resources) +GATEWAY_URL=https://stage-api.sarex.io/gateway + +# Resources API (IAM/resources) — используется в проде через Helm +# RESOURCES_API_HOST=http://iams.platform.svc.cluster.local:8080 + +# S3 (Yandex Object Storage) — используется в prod-настройках (config.settings.prod) +YC_S3_ACCESS_KEY_ID="" +YC_S3_SECRET_ACCESS_KEY="" +YC_S3_BUCKET_NAME="" +YC_S3_ENDPOINT_URL="" + +# Mailer service +MAILER_URL="http://mailer-service.mailer:8000" +MAILER_TIMEOUT=30 + +# Notifications +NOTIFICATIONS_ENABLE=True +NOTIFICATIONS_EMAIL_FROM=hello@sarex.io +NOTIFICATIONS_SERVICE_URL="https://stage.sarex.io/rfi" + +# RabbitMQ (брокер Celery) — дефолты заданы в settings/base.py +# RABBITMQ_USERNAME=mcc +# RABBITMQ_PASSWORD=mcc +# RABBITMQ_HOST=rabbitmq-service +# RABBITMQ_PORT=5672 +# RABBITMQ_VHOST=api + +# Celery +# True — уведомления отправляются асинхронно (apply_async), False — синхронно в процессе запроса +USE_ASYNC_FUNCTIONS=True diff --git a/apps/rfi/CONFIGURATION.md b/apps/rfi/CONFIGURATION.md new file mode 100644 index 0000000..9ab7102 --- /dev/null +++ b/apps/rfi/CONFIGURATION.md @@ -0,0 +1,211 @@ +# Конфигурация проекта rfi-backend + +Документ описывает все переменные окружения и способы конфигурирования сервиса. + +## Способы конфигурирования + +Сервис — это Django-приложение (Django 5.1, Django REST Framework). Настройки лежат в пакете `src/config/settings/`: + +- `base.py` — общие настройки (`DEBUG = True`), читает переменные через `os.getenv(...)`; +- `prod.py` — наследует `base.py` (`from .base import *`), выключает `DEBUG` и добавляет S3-хранилище (`django-storages` + `boto3`). Активируется через `DJANGO_SETTINGS_MODULE=config.settings.prod` (см. `docker/http/entrypoint.sh`). + +Часть настроек уведомлений вынесена в отдельные классы `pydantic-settings` (`src/notifications/config.py`): `MailerConfig` (`env_prefix="MAILER_"`), `SarexBackendConfig` (`env_prefix="SAREX_BACKEND_"`), `NotificationsConfig` (`env_prefix="NOTIFICATIONS_"`). Эти классы имеют значения по умолчанию и переопределяются переменными окружения с соответствующим префиксом. + +Отдельного конфиг-файла (yaml/toml) для приложения нет. Все настройки — переменные окружения процесса. + +Источники переменных по способам запуска: + +| Способ запуска | Откуда берутся переменные | +| --- | --- | +| Локально | `Makefile` через `SET_ENV`: `set -a; source .env; set +a`. Файл `.env` создаётся из `.env.template` вручную. `PYTHONPATH=src` обязателен | +| Docker (prod) | `docker/http/Dockerfile` + `entrypoint.sh`: миграции и `uwsgi` под `DJANGO_SETTINGS_MODULE=config.settings.prod`. Переменные пробрасываются рантаймом (Helm) | +| Kubernetes (Helm) | `.helm/values.yaml`: блоки `services.api.envs` / `services.api.secretEnvs` (и аналогичные для `services.celery`) universal-chart. Значения различаются по окружению (`_default`/`stage`/`preprod`/`production`) | +| CI/CD (GitLab) | `.gitlab-ci.yml`: `workflow.rules` переключают `STAND`/`NAMESPACE`, job `lint` гоняет ruff | + +Точки входа и процессы: + +| Процесс | Команда | Назначение | +| --- | --- | --- | +| HTTP API | `uv run src/manage.py runserver` (`make run`) / `uwsgi --ini uwsgi.ini` (prod) | Django REST API | +| Celery worker | `uv run celery -A config worker -l info` | Асинхронные задачи уведомлений (`notifications.tasks`) | +| Миграции | `uv run src/manage.py migrate` (`make migrate`) | Применение миграций (в контейнере — в `entrypoint.sh` перед стартом uwsgi) | + +Приложение по умолчанию слушает порт **8000** (uwsgi `http = 0.0.0.0:8000`; Django `runserver` — тоже 8000). Внутрикластерный Service слушает порт 80 → targetPort 8000. + +## Переменные приложения + +Дефолт `—` означает, что значение обязательно (читается `os.getenv` без дефолта; при отсутствии будет `None`, что приведёт к ошибке подключения/старта соответствующей подсистемы). + +### Django core + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `PYTHONPATH` | string | — | Должна быть `src` — корень пакета исходников | +| `DJANGO_SECRET_KEY` | string | — | Секретный ключ Django (`SECRET_KEY`) | +| `DJANGO_SETTINGS_MODULE` | string | `config.settings.base` | Модуль настроек. В контейнере — `config.settings.prod` | + +Значения `DEBUG`, `ALLOWED_HOSTS = ["*"]`, `LANGUAGE_CODE = ru-ru`, `TIME_ZONE = UTC` заданы в коде и не читаются из окружения. + +### Auth (`JWT_AUTH_ENABLE`) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `JWT_AUTH_ENABLE` | bool (`True`/`true`) | `false` | Режим аутентификации. `true` — включаются `ZitadelJWTStatelessUserAuthentication` и `JWTStatelessUserAuthentication`; `false` — `DefaultUserAuthentication` (демо-пользователь `DefaultUser`, только для локальной разработки) | + +Алгоритм JWT фиксирован в `SIMPLE_JWT` — `RS512`. Класс пользователя токена — `core.auth.User`. + +### Database (`DB_*`) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `DB_HOST` | string | — | Хост PostgreSQL | +| `DB_PORT` | int | — | Порт PostgreSQL | +| `DB_NAME` | string | — | Имя базы данных | +| `DB_USER` | string | — | Пользователь БД | +| `DB_PASSWORD` | string | — | Пароль пользователя БД | + +Движок фиксирован: `django.db.backends.postgresql`. + +### Sarex backend (`SAREX_BACKEND_*`) + +Используется в `core/services.py` (получение пользователей `core/users/`, `users_by_sa/`) и в `notifications` (`SarexBackendConfig`). Авторизация — Basic (`Authorization: Basic `). + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `SAREX_BACKEND_URL` | string | `https://stage.sarex.io` | Базовый URL sarex-backend | +| `SAREX_BACKEND_AUTH` | string (base64) | — | Basic-auth в виде `base64(username:password)` | +| `SAREX_BACKEND_TIMEOUT` | int | `30` | Таймаут запроса (используется `SarexBackendConfig`) | + +### Внешние сервисы (EAV, Gateway, Resources) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `EAV_URL` | string | — | Базовый URL EAV-сервиса атрибутов (`core/services.get_attributes`) | +| `GATEWAY_URL` | string | — | Базовый URL gateway (`resources/`) для получения имени/списка ресурсов | +| `RESOURCES_API_HOST` | string | — | Адрес IAM/resources API. Задаётся в Helm (в `.env.template` отсутствует) | + +### S3 / Object Storage (`YC_S3_*`) + +Читаются только в `config/settings/prod.py` (backend хранилища — `storages.backends.s3boto3.S3Boto3Storage`, ACL `public-read`). + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `YC_S3_ACCESS_KEY_ID` | string | — | Access key (`AWS_ACCESS_KEY_ID`) | +| `YC_S3_SECRET_ACCESS_KEY` | string | — | Secret key (`AWS_SECRET_ACCESS_KEY`) | +| `YC_S3_BUCKET_NAME` | string | — | Бакет (`AWS_STORAGE_BUCKET_NAME`) | +| `YC_S3_ENDPOINT_URL` | string | — | Эндпоинт S3 (`AWS_S3_ENDPOINT_URL`) | + +### Mailer (`MAILER_*`) + +Настройки клиента почтового сервиса (`MailerConfig`). + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `MAILER_URL` | string | `http://mailer-service.mailer:8000` | URL mailer-сервиса | +| `MAILER_TIMEOUT` | int | `30` | Таймаут запроса (сек) | + +### Notifications (`NOTIFICATIONS_*`) + +Настройки подсистемы уведомлений (`NotificationsConfig`). + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `NOTIFICATIONS_ENABLE` | bool | `True` | Включить отправку уведомлений | +| `NOTIFICATIONS_EMAIL_FROM` | string | `hello@sarex.io` | Адрес отправителя | +| `NOTIFICATIONS_SERVICE_URL` | string | `https://stage.sarex.io/rfi` | Внешний URL сервиса (для ссылок в письмах) | + +### RabbitMQ (`RABBITMQ_*`) + +Используются при сборке `CELERY_BROKER_URL` в `settings/base.py`. У всех заданы дефолты. + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `RABBITMQ_USERNAME` | string | `mcc` | Пользователь | +| `RABBITMQ_PASSWORD` | string | `mcc` | Пароль | +| `RABBITMQ_HOST` | string | `rabbitmq-service` | Хост | +| `RABBITMQ_PORT` | int | `5672` | Порт (задаётся в Helm через `envs`) | +| `RABBITMQ_VHOST` | string | `api` | Виртуальный хост | + +К итоговому URL добавляется `?heartbeat=30` (`BROKER_HEARTBEAT`). Прочие параметры брокера/Celery заданы в коде: `BROKER_POOL_LIMIT=10`, очередь `default`, `CELERY_TASK_SERIALIZER=json`, `CELERY_TIMEZONE=Europe/Moscow`, `CELERY_IMPORTS=("notifications.tasks",)`. + +### Celery-поведение (`USE_ASYNC_FUNCTIONS`) + +| Переменная | Тип | Значение по умолчанию | Назначение | +| --- | --- | --- | --- | +| `USE_ASYNC_FUNCTIONS` | bool | `True` | `True` — уведомления отправляются через `apply_async` (нужен работающий воркер и брокер); `False` — синхронно внутри запроса | + +## Переменные из Helm-чарта (`.helm/values.yaml`) + +Чарт — обёртка над `universal-chart` (`oci://cr.yandex/.../charts`, версия 0.1.7). Описаны два сервиса: `api` и `celery` (одинаковый образ `cr.yandex/crp3ccidau046kdj8g9q/rfi-backend`, разные команды и ресурсы). Значения выбираются по ключу `global.env` (`_default`/`stage`/`preprod`/`production`). + +Обычные значения (`services..envs`): `JWT_AUTH_ENABLE`, `SAREX_BACKEND_URL`, `EAV_URL`, `GATEWAY_URL`, `NOTIFICATIONS_ENABLE`, `NOTIFICATIONS_EMAIL_FROM`, `NOTIFICATIONS_SERVICE_URL`, `RABBITMQ_PORT`, `RABBITMQ_HOST`, `RESOURCES_API_HOST`. + +Значения из секретов (`services..secretEnvs`, монтируются как env через `secretKeyRef`): + +| Переменная | Секрет (`_default`) | Секрет (`production`) | Ключ | +| --- | --- | --- | --- | +| `DJANGO_SECRET_KEY` | `rfi-backend-api-django-secret` | `django-secret` | `django_secret_key` | +| `DB_HOST` | `rfi-backend-api-ya-pg-secret` | `ya-pg-secret` | `host` | +| `DB_PORT` | `rfi-backend-api-ya-pg-secret` | `ya-pg-secret` | `port` | +| `DB_NAME` | `rfi-backend-api-ya-pg-secret` | `ya-pg-secret` | `dbname` | +| `DB_USER` | `rfi-backend-api-ya-pg-secret` | `ya-pg-secret` | `user` | +| `DB_PASSWORD` | `rfi-backend-api-ya-pg-secret` | `ya-pg-secret` | `password` | +| `SAREX_BACKEND_AUTH` | `django-secret` | `django-secret` | `token` | +| `YC_S3_ACCESS_KEY_ID` | `rfi-backend-api-yc-s3-secret` | `yc-s3-secret` | `key_id` | +| `YC_S3_SECRET_ACCESS_KEY` | `rfi-backend-api-yc-s3-secret` | `yc-s3-secret` | `access_key` | +| `YC_S3_BUCKET_NAME` | `rfi-backend-api-yc-s3-secret` | `yc-s3-secret` | `storage_bucket_name` | +| `YC_S3_ENDPOINT_URL` | `rfi-backend-api-yc-s3-secret` | `yc-s3-secret` | `endpoint_url` | +| `RABBITMQ_VHOST` | `rfi-backend-api-rabbitmq-secret` | `rabbitmq-secret` | `vhost` | +| `RABBITMQ_USERNAME` | `rfi-backend-api-rabbitmq-secret` | `rabbitmq-secret` | `username` | +| `RABBITMQ_PASSWORD` | `rfi-backend-api-rabbitmq-secret` | `rabbitmq-secret` | `password` | + +Значения `envs` по окружениям (ключевые различия): + +| Переменная | stage | preprod | production | +| --- | --- | --- | --- | +| `SAREX_BACKEND_URL` | `https://stage.sarex.io` | `https://perprod.sarex.io` | `https://lk.sarex.io` | +| `EAV_URL` | `http://eav-service.eav-stage` | `http://eav-service.eav-preprod` | `http://eav-service.eav-prod` | +| `GATEWAY_URL` | `https://stage-api.sarex.io/gateway` | `https://api.perprod.sarex.io/gateway` | `https://api.sarex.io/gateway` | +| `NOTIFICATIONS_SERVICE_URL` | `https://stage.sarex.io/rfi` | `https://perprod.sarex.io/rfi` | `https://lk.sarex.io/rfi` | +| `RABBITMQ_HOST` | `rabbitmq.rabbitmq.svc.cluster.local` | `rabbitmq.rabbitmq.svc.cluster.local` | `default-rabbit-cluster.rabbitmq.svc` | +| `RESOURCES_API_HOST` | `http://iams.platform.svc.cluster.local:8080` | `http://sarex-resources-service.resources-preprod` | `http://iams.iam.svc.cluster.local:8080` | + +Прочие параметры чарта (не переменные приложения): `deployment.*` (имя, реплики — prod: api 2 / celery 2, ресурсы — api 1024Mi/1cpu, celery 512Mi/1cpu), `image.*`, `service.*` (ClusterIP, 80→8000), `imagePullSecrets: dockerhub`, `probes` (liveness/readiness выключены). + +## Переменные в CI (`.gitlab-ci.yml`) + +Пайплайн подключает общие шаблоны `generic/common-ci` (`common-security-scan.yaml`, `universal-pipeline.yaml`, ref `apps-business`). Общие переменные: `SERVICE_NAME=rfi-backend`, `DOCKERFILE_PATH=./docker/http/Dockerfile`, `CI_TRIGGER_SOURCE=app`. + +Переключение окружения (`workflow.rules`): + +| Условие | STAND | NAMESPACE | CHART_VERSION | +| --- | --- | --- | --- | +| ветка `stage` | `stage` | `proc` | `0.0.1-stage` | +| ветка `master` | `preprod` | `rfi-preprod` | `0.0.1-preprod` | +| тег (`CI_COMMIT_TAG`) | `production` | `rfi-prod` | `0.0.1-prod` | +| merge request | — | — | сборка образа отключена (`ENABLE_BUILD_IMAGE=false`) | + +Через `HELM_SET_ARGS` в чарт прокидываются образ (`services.api.image.name.`, `services.celery.image.name.`), `global.env`, а также метаданные коммита/джобы (`commitSha`, `gitlabUri`, `gitlabJobUrl`, `owner`). Job `lint` (образ `ghcr.io/astral-sh/uv`) прогоняет `ruff check` и `ruff format --check`. + +## Замечания и потенциальные проблемы + +- `PYTHONPATH=src` обязателен: без него не находятся пакеты (`config`, `core`, `request` и т.д.). Задан в `.env.template` и в `Dockerfile` (`ENV PYTHONPATH=src`). +- Приложение не загружает `.env` автоматически: Django-настройки читают `os.getenv`, а `.env` подхватывается только через `Makefile` (`set -a; source .env`). При ручном запуске переменные нужно экспортировать самому. +- `JWT_AUTH_ENABLE=false` включает `DefaultUser` с захардкоженными `id=356`, `company_ids=[1, 38]` и полным набором прав — это только для локальной отладки, в проде должно быть `true`. +- S3 (`YC_S3_*`) читается только в `config.settings.prod`. При запуске с `config.settings.base` (локально) хранилище S3 не используется — файлы (аватар RFI) сохраняются локально. +- В `.env.template` переменные `RABBITMQ_*` и `RESOURCES_API_HOST` отсутствуют — для Celery используются дефолты из кода (`mcc`/`rabbitmq-service`/`api`), в кластере они приходят из Helm (`envs` + `secretEnvs`). +- В values.yaml значения preprod для sarex/gateway записаны как `perprod` (`https://perprod.sarex.io`, `.../api.perprod.sarex.io/...`) — так в исходнике; при необходимости свериться с фактическими адресами. +- `USE_ASYNC_FUNCTIONS` через `os.getenv("USE_ASYNC_FUNCTIONS", True)` возвращает строку при заданной переменной; пустая/незаданная даёт `True`. Любая непустая строка (в т.ч. `"False"`) трактуется как истинное значение в условии `if settings.USE_ASYNC_FUNCTIONS`. + +## Минимальный набор для локального запуска + +С включённым `config.settings.base` и `JWT_AUTH_ENABLE=false` минимально необходимо: + +- `PYTHONPATH=src`, `DJANGO_SECRET_KEY` +- `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` (поднять PostgreSQL) +- `SAREX_BACKEND_URL`, `SAREX_BACKEND_AUTH` (для обращений к sarex-backend) +- `EAV_URL`, `GATEWAY_URL` (для атрибутов и ресурсов) +- `NOTIFICATIONS_ENABLE=False` — чтобы не требовался mailer/брокер; либо `True` + `MAILER_URL` и работающий RabbitMQ +- `USE_ASYNC_FUNCTIONS=False` — чтобы уведомления шли синхронно без Celery-воркера + +Порядок: `make migrate` → `make run` (API) и при необходимости `uv run celery -A config worker -l info` (воркер). Готовые значения-примеры — в `.env.example`. diff --git a/apps/rfi/ENDPOINTS.md b/apps/rfi/ENDPOINTS.md new file mode 100644 index 0000000..4bcf6b0 --- /dev/null +++ b/apps/rfi/ENDPOINTS.md @@ -0,0 +1,100 @@ +# Эндпоинты, с которыми взаимодействует rfi-frontend + +Документ описывает все HTTP-эндпоинты внешних сервисов, к которым обращается модуль (микрофронтенд `rfi-frontend`). + +## Как устроено взаимодействие + +Все запросы собраны в реестре `module/api/index.ts` и сгруппированы по объектам-«API»: `RfiAPI`, `AttachmentsAPI`, `CoreAPI`, `ResourcesAPI` и функция `getAttributes`. Каждый вызов идёт через единый `httpService` (`module/api/http-service.ts`), созданный фабрикой `createHttpService` из `@sarex-team/sdk-js` (поверх axios). + +Вызов задаётся объектом: + +- `service` — логическое имя сервиса (см. таблицу хостов ниже); +- `url` — путь запроса (дописывается к базовому хосту сервиса); +- `data` — тело запроса (для POST/PUT/PATCH); +- `axiosConfig` — доп. настройки axios, чаще всего `params` (query-параметры: `limit`, `offset`, `company_id` и т.п.); +- `showErrorNotification` — показывать ли уведомление об ошибке; +- `controller` — `AbortController` для отмены запроса. + +Методы `httpService`: `getRequest`, `postRequest`, `putRequest`, `patchRequest`, `deleteRequest`. Базовый хост подставляется по `service` из `module/api/hosts.ts` в зависимости от `BUILD_ENV`. + +## Базовые хосты по сервисам и окружениям + +Значения из `module/api/hosts.ts`. Итоговый URL = `<базовый хост сервиса>` + `url`. Использовано пять сервисов (остальные ключи в `hosts.ts` объявлены, но модулем не вызываются). + +| Сервис (`service`) | Назначение | `stage` | `prod` | +| --- | --- | --- | --- | +| `rfi` | Собственный backend RFI (этот сервис) | `https://stage-api.sarex.io/rfi/api/v1` | `https://api.sarex.io/rfi/api/v1` | +| `sarexApi` | Gateway/API Sarex (attachments, users v2) | `https://stage-api.sarex.io` | `https://api.sarex.io` | +| `sarex` | Локальный backend Sarex (`/api/core`, `/api/client`) | `https://stage.sarex.io` | `https://lk.sarex.io` | +| `gateway_api_v1` | Gateway API v1 (resources) | `https://stage-api.sarex.io/gateway/api/v1` | `https://api.sarex.io/gateway/api/v1` | +| `eav_api_v0` | EAV — сервис атрибутов/схем | `https://stage-api.sarex.io/eav/api/v0` | `https://api.sarex.io/eav/api/v0` | + +> Также определены окружения `local`, `preprod` и `contour`. В `local` сервис `sarex` проксируется на `/sarex-backend`; в `contour` используются относительные пути. Подключаемый удалённый модуль documentations описан отдельно в `module/api/module-hosts.ts` (Module Federation `remoteEntry.js`). + +## Эндпоинты по сервисам + +### `rfi` — Backend RFI (этот сервис) + +`RfiAPI` из `module/api/index.ts`. Пути указаны относительно базы `.../rfi/api/v1`. + +| Метод (`RfiAPI`) | HTTP | Путь | Назначение | +| --- | --- | --- | --- | +| `getRfi` | POST | `/rfi/filter/` | Список RFI по фильтру (query `limit`/`offset`, тело — фильтры) | +| `postRfi` | POST | `/rfi/` | Создать RFI | +| `putRfi` | PUT | `/rfi/{rfiId}/` | Полное обновление RFI | +| `patchRfi` | PATCH | `/rfi/{rfiId}/` | Частичное обновление RFI | +| `copyRfi` | POST | `/rfi/{rfiId}/copy/` | Скопировать RFI (тело `{ name }`) | +| `getRfiById` | GET | `/rfi/{id}/` | RFI по id | +| `deleteRfi` | DELETE | `/rfi/{id}/` | Удалить RFI (soft-delete) | +| `getHistory` | GET | `/rfi/history/` | История изменений по списку RFI (query-параметры фильтра) | +| `getStatusCount` | POST | `/rfi/status-count/` | Количество RFI по статусам (по `resource_id`) | +| `getPriorityCount` | POST | `/rfi/priority-count/` | Количество RFI по приоритетам (по `resource_id`) | +| `createRfiMessage` | POST | `/messages/` | Создать сообщение в RFI | +| `getMessagesByRfiId` | GET | `/messages/?request_id={id}` | Сообщения по id запроса | +| `patchRfiMessage` | PATCH | `/messages/{id}/` | Отметить сообщение решением (`is_solution`) | +| `getStatuses` | GET | `/statuses/` | Список статусов (query `company_id`, `limit`, `offset`) | +| `getStatusModels` | GET | `/status-models/` | Модели статусов компании (query `company_id`) | +| `getPriorities` | GET | `/priorities/` | Список приоритетов (query `company_id`, `limit`, `offset`) | +| `getPriorityModels` | GET | `/priority-models/` | Модели приоритетов компании (query `company_id`) | + +### `sarexApi` — Gateway/API Sarex + +`AttachmentsAPI` и `CoreAPI.getUsersV2`. Пути указаны относительно базы `https://(stage-)api.sarex.io`. + +| Метод | HTTP | Путь | Назначение | +| --- | --- | --- | --- | +| `AttachmentsAPI.uploadFiles` | POST | `/gateway/api/v1/attachments/` | Загрузить файлы (`multipart/form-data`) | +| `AttachmentsAPI.deleteFile` | DELETE | `/gateway/api/v1/attachments/{id}` | Удалить файл | +| `AttachmentsAPI.getFilesByRfiId` | GET | `/gateway/api/v1/attachments/?company_id={companyId}&instance_id={rfiId}&model_name={ATTACHMENTS_MODEL_NAME}` | Файлы, привязанные к RFI | +| `CoreAPI.getUsersV2` | GET | `/gateway/api/v2/users/` | Пользователи (query `company_id`, `permissions`, `resource_id`, `limit`, `offset`) | + +### `sarex` — Backend Sarex + +`CoreAPI`. Пути указаны относительно базы `https://stage.sarex.io` / `https://lk.sarex.io`. + +| Метод | HTTP | Путь | Назначение | +| --- | --- | --- | --- | +| `CoreAPI.getUsers` | GET | `/api/core/users/` | Пользователи (query `company`, `perm`, `resource_id`, `limit`, `offset`) | +| `CoreAPI.getDepartments` | GET | `/api/core/admin/departments/?company={companyId}&{query}` | Отделы компании | +| `CoreAPI.getPositions` | GET | `/api/core/admin/positions/?company={companyId}&{query}` | Должности компании | +| `CoreAPI.getSettings` | GET | `/api/client/settings/` | Клиентские настройки | + +### `gateway_api_v1` — Gateway API v1 + +`ResourcesAPI`. База уже включает `/gateway/api/v1`. + +| Метод | HTTP | Путь | Назначение | +| --- | --- | --- | --- | +| `ResourcesAPI.getResources` | GET | `/resources/` | Список ресурсов (query `company_id`) | + +### `eav_api_v0` — EAV (атрибуты) + +Функция `getAttributes`. База уже включает `/eav/api/v0`. + +| Метод | HTTP | Путь | Назначение | +| --- | --- | --- | --- | +| `getAttributes` | GET | `/schema/?model_name=flow&company_id={companyId}` | Схема атрибутов по компании | + +## Обработка ошибок + +Ошибки обрабатываются в `httpService` (`@sarex-team/sdk-js`). Тип ответа с ошибкой описан в `module/api/types.ts` (`ErrorResponse` — `{ response?.data?.detail }`). Часть запросов включает показ уведомления об ошибке флагом `showErrorNotification: true` (например `getSettings`, `AttachmentsAPI.*`). Права доступа (`core.can_*_RFI`) описаны в `module/api/permissions.ts` и проверяются на стороне backend RFI (`RFITokenBasedPermission`). diff --git a/apps/rfi/openapi.yaml b/apps/rfi/openapi.yaml new file mode 100644 index 0000000..185d7bd --- /dev/null +++ b/apps/rfi/openapi.yaml @@ -0,0 +1,1218 @@ +openapi: 3.0.3 + +info: + title: RFI project API + version: "0.0.1" + description: | + REST API сервиса **rfi-backend** (`proc/rfi-backend`) — управление + запросами RFI (Request For Information), сообщениями к ним, статусами и + приоритетами (и их моделями), а также журналом изменений. + + Сервис написан на Python (**Django 5.1 / Django REST Framework**). Роутинг + строится `DefaultRouter` (`src/config/api/v1/router.py`), в который + собираются роутеры приложений `request`, `message`, `status`, `priority`, + `change_history`. Все ресурсы доступны под префиксом `/api/v1/` + (`config/urls.py` → `config/api/urls.py` → `config/api/v1/urls.py`). + + Схема OpenAPI генерируется `drf-spectacular` и доступна по `/api/schema/`, + Swagger UI — по `/api/schema/swagger-ui/`, ReDoc — по `/api/schema/redoc/`. + Django-admin — по `/admin/`. + + ### Аутентификация + Все эндпоинты требуют аутентификации (`DEFAULT_PERMISSION_CLASSES = + IsAuthenticated`). Режим задаётся переменной `JWT_AUTH_ENABLE` + (`config/settings/base.py`): + + 1. **JWT включён** (`JWT_AUTH_ENABLE=true`) — активны + `ZitadelJWTStatelessUserAuthentication` и `JWTStatelessUserAuthentication`. + Основной токен передаётся заголовком `Authorization: Bearer ` + (алгоритм `RS512`). Если дополнительно передан заголовок + `Identity: Bearer `, полезная нагрузка берётся из него + (`urn:zitadel:iam:user:metadata`); подпись при этом сервисом не + проверяется (доверие обеспечивается сетевым слоем/Istio). + 2. **JWT выключен** (`JWT_AUTH_ENABLE=false`) — `DefaultUserAuthentication` + подставляет демо-пользователя `DefaultUser` (только для локальной + разработки). + + ### Авторизация (права) + Эндпоинты `rfi` и `messages` используют `RFITokenBasedPermission`: + проверяется принадлежность к компании (`company_id ∈ user.company_ids`) и + модульные права из токена (`core.can_view_RFI`, `core.can_create_RFI`, + `core.can_edit_RFI`, `core.can_delete_RFI`, `core.can_copy_RFI`). + Редактирование доступно автору или ответственному (service account), + либо администратору. + + ### Пагинация + Списочные ответы используют `LimitOffsetPagination` (`PAGE_SIZE = 100`). + Ответ оборачивается в объект `{ count, next, previous, results }`. + Параметры — `limit` и `offset`. + + ### Фильтрация + Списки RFI поддерживают фильтрацию как через query-параметры (GET), так и + через тело запроса (POST `/api/v1/rfi/filter/`) — за счёт + `GetAndPostDjangoFilterBackend`. Дополнительно `CompanyFilterBackend` + неявно ограничивает выборку компаниями пользователя, если `company_id` + не передан явно. + + ### Особенности + - Удаление объектов — «мягкое» (soft-delete через `deleted_at`), базовый + класс `core.models.BaseModel`. `DELETE` возвращает `204`. + - Создание/обновление RFI принимает `multipart/form-data` (есть поле-файл + `image`); ответ формируется сериализатором чтения (`RFIRetrieveSerializer`). + - При создании RFI и сообщений отправляются уведомления (Celery-задачи), + если включено (`USE_ASYNC_FUNCTIONS`, `NOTIFICATIONS_ENABLE`). + + contact: + name: rfi-backend + url: https://gitlab/proc/rfi-backend + +servers: + - url: https://api.sarex.io/rfi + description: Production (ingress, префикс /rfi) + - url: https://stage-api.sarex.io/rfi + description: Stage (ingress, префикс /rfi) + - url: http://rfi-backend.rfi-prod.svc.cluster.local + description: Внутрикластерный адрес (ClusterIP, порт 80 → 8000) + - url: http://localhost:8000 + description: Локальный запуск (uwsgi / runserver, порт 8000) + +tags: + - name: rfi + description: Запросы RFI — CRUD, копирование, фильтрация, счётчики, история + - name: messages + description: Сообщения в запросах RFI + - name: statuses + description: Статусы + - name: status-models + description: Модели статусов + - name: priorities + description: Приоритеты + - name: priority-models + description: Модели приоритетов + - name: change-history + description: Журнал изменений RFI (только чтение) + +security: + - bearerAuth: [] + +paths: + # ========================================================================== + # RFI + # ========================================================================== + /api/v1/rfi/: + get: + tags: [rfi] + summary: Список RFI + description: Постраничный список RFI с фильтрацией через query-параметры. + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: id, in: query, schema: { type: string }, description: "Один или несколько id (повторяющийся параметр)" } + - { name: resource_id, in: query, schema: { type: string }, description: "UUID ресурса (повторяющийся)" } + - { name: author_id, in: query, schema: { type: string } } + - { name: company_id, in: query, schema: { type: string } } + - { name: status_id, in: query, schema: { type: string } } + - { name: priority_id, in: query, schema: { type: string } } + - { name: responsible_users, in: query, schema: { type: string }, description: "UUID service account (overlap)" } + - { name: created_at_gte, in: query, schema: { type: string, format: date-time } } + - { name: created_at_lte, in: query, schema: { type: string, format: date-time } } + - { name: message_at_gte, in: query, schema: { type: string, format: date-time }, description: "Есть сообщение с created_at >= значения" } + - { name: message_at_lte, in: query, schema: { type: string, format: date-time } } + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/PaginatedRFIList" + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [rfi] + summary: Создать RFI + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: "#/components/schemas/RFIWrite" + application/json: + schema: + $ref: "#/components/schemas/RFIWrite" + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/RFI" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/rfi/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [rfi] + summary: Получить RFI по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/RFI" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [rfi] + summary: Полное обновление RFI + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: "#/components/schemas/RFIWrite" } + application/json: + schema: { $ref: "#/components/schemas/RFIWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/RFI" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [rfi] + summary: Частичное обновление RFI + requestBody: + required: true + content: + multipart/form-data: + schema: { $ref: "#/components/schemas/RFIWrite" } + application/json: + schema: { $ref: "#/components/schemas/RFIWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/RFI" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [rfi] + summary: Удалить RFI (soft-delete) + responses: + "204": { description: Удалено } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/rfi/filter/: + post: + tags: [rfi] + summary: Список RFI по фильтру (POST) + description: | + Аналог `GET /api/v1/rfi/`, но фильтры передаются в теле запроса. + Тело валидируется `RFIFilterRequestSerializer`. Пагинация — через + query-параметры `limit`/`offset`. + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/RFIFilterRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedRFIList" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/rfi/history/: + get: + tags: [rfi] + summary: История изменений по списку RFI + description: | + Журнал изменений для всех RFI, попадающих под те же фильтры, что и + список (`GET /api/v1/rfi/`). Отсортирован по `created_at` убыв. + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: resource_id, in: query, schema: { type: string } } + - { name: company_id, in: query, schema: { type: string } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedRFIChangeRecordList" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/rfi/{id}/history/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [rfi] + summary: История изменений одного RFI + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedRFIChangeRecordList" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/rfi/status-count/: + get: + tags: [rfi] + summary: Количество RFI по статусам + description: Для каждого `resource_id` возвращает его статусы и количество RFI. + parameters: + - { name: resource_id, in: query, schema: { type: string } } + - { name: company_id, in: query, schema: { type: string } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/RFIStatusCount" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [rfi] + summary: Количество RFI по статусам (POST-фильтр) + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/RFIFilterRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/RFIStatusCount" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/rfi/priority-count/: + get: + tags: [rfi] + summary: Количество RFI по приоритетам + parameters: + - { name: resource_id, in: query, schema: { type: string } } + - { name: company_id, in: query, schema: { type: string } } + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/RFIPriorityCount" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [rfi] + summary: Количество RFI по приоритетам (POST-фильтр) + requestBody: + required: false + content: + application/json: + schema: { $ref: "#/components/schemas/RFIFilterRequest" } + responses: + "200": + description: OK + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/RFIPriorityCount" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/rfi/{id}/copy/: + parameters: + - $ref: "#/components/parameters/PathId" + post: + tags: [rfi] + summary: Копировать RFI + description: Создаёт копию RFI с новым именем. + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CopyName" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/RFI" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Messages + # ========================================================================== + /api/v1/messages/: + get: + tags: [messages] + summary: Список сообщений + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: id, in: query, schema: { type: string } } + - { name: author_id, in: query, schema: { type: string } } + - { name: request_id, in: query, schema: { type: string } } + - { name: reply_to_id, in: query, schema: { type: string } } + - { name: company_id, in: query, schema: { type: string } } + - { name: resource_id, in: query, schema: { type: string, format: uuid } } + - { name: created_at_gte, in: query, schema: { type: string, format: date-time } } + - { name: created_at_lte, in: query, schema: { type: string, format: date-time } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedMessageList" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [messages] + summary: Создать сообщение + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/messages/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [messages] + summary: Сообщение по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Message" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [messages] + summary: Обновить сообщение + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [messages] + summary: Частичное обновление сообщения (напр. отметить решением) + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/MessageWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [messages] + summary: Удалить сообщение (soft-delete) + responses: + "204": { description: Удалено } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Statuses + # ========================================================================== + /api/v1/statuses/: + get: + tags: [statuses] + summary: Список статусов + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: company_id, in: query, schema: { type: string } } + - { name: status_model, in: query, schema: { type: integer }, description: "id модели статусов (повторяющийся)" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedStatusList" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [statuses] + summary: Создать статус + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/statuses/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [statuses] + summary: Статус по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Status" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [statuses] + summary: Обновить статус + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + "400": { $ref: "#/components/responses/ValidationError" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [statuses] + summary: Частичное обновление статуса + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/StatusCreate" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [statuses] + summary: Удалить статус (soft-delete) + responses: + "204": { description: Удалено } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Status models + # ========================================================================== + /api/v1/status-models/: + get: + tags: [status-models] + summary: Список моделей статусов + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: company_id, in: query, schema: { type: integer } } + - { name: resource_id, in: query, schema: { type: string, format: uuid } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedStatusModelList" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [status-models] + summary: Создать модель статусов + description: | + При создании первой модели статусов для компании автоматически + создаётся набор статусов по умолчанию (`DEFAULT_STATUSES`). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/status-models/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [status-models] + summary: Модель статусов по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelDetail" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [status-models] + summary: Обновить модель статусов + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [status-models] + summary: Частичное обновление модели статусов + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/StatusModelWrite" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [status-models] + summary: Удалить модель статусов (soft-delete) + responses: + "204": { description: Удалено } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Priorities + # ========================================================================== + /api/v1/priorities/: + get: + tags: [priorities] + summary: Список приоритетов + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: company_id, in: query, schema: { type: string } } + - { name: priority_model, in: query, schema: { type: integer }, description: "id модели приоритетов (повторяющийся)" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedPriorityList" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [priorities] + summary: Создать приоритет + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/priorities/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [priorities] + summary: Приоритет по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/Priority" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [priorities] + summary: Обновить приоритет + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [priorities] + summary: Частичное обновление приоритета + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityWrite" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [priorities] + summary: Удалить приоритет (soft-delete) + responses: + "204": { description: Удалено } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Priority models + # ========================================================================== + /api/v1/priority-models/: + get: + tags: [priority-models] + summary: Список моделей приоритетов + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: company_id, in: query, schema: { type: integer } } + - { name: resource_id, in: query, schema: { type: string, format: uuid } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedPriorityModelList" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + tags: [priority-models] + summary: Создать модель приоритетов + description: | + При создании первой модели приоритетов для компании автоматически + создаётся набор приоритетов по умолчанию (`DEFAULT_PRIORITIES`). + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + responses: + "201": + description: Создано + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + "400": { $ref: "#/components/responses/ValidationError" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/priority-models/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [priority-models] + summary: Модель приоритетов по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModel" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [priority-models] + summary: Обновить модель приоритетов + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + tags: [priority-models] + summary: Частичное обновление модели приоритетов + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PriorityModelWrite" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [priority-models] + summary: Удалить модель приоритетов (soft-delete) + responses: + "204": { description: Удалено } + "404": { $ref: "#/components/responses/NotFound" } + + # ========================================================================== + # Change history (read-only) + # ========================================================================== + /api/v1/change-history/: + get: + tags: [change-history] + summary: Журнал изменений RFI + parameters: + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + - { name: request_id, in: query, schema: { type: integer } } + - { name: resource_id, in: query, schema: { type: string, format: uuid } } + - { name: company_id, in: query, schema: { type: string } } + - { name: created_at_gte, in: query, schema: { type: string, format: date-time } } + - { name: created_at_lte, in: query, schema: { type: string, format: date-time } } + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/PaginatedRFIChangeRecordList" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/change-history/{id}/: + parameters: + - $ref: "#/components/parameters/PathId" + get: + tags: [change-history] + summary: Запись журнала изменений по id + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/RFIChangeRecord" } + "404": { $ref: "#/components/responses/NotFound" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + `Authorization: Bearer ` (RS512). Опционально — + `Identity: Bearer ` для режима Zitadel. + + parameters: + Limit: + name: limit + in: query + required: false + schema: { type: integer, default: 100 } + description: Количество элементов на странице (LimitOffsetPagination). + Offset: + name: offset + in: query + required: false + schema: { type: integer } + description: Смещение от начала выборки. + PathId: + name: id + in: path + required: true + schema: { type: integer } + + responses: + ValidationError: + description: Ошибка валидации (DRF) + content: + application/json: + schema: + type: object + additionalProperties: true + example: { field_name: ["Обязательное поле."] } + Forbidden: + description: Недостаточно прав / не аутентифицирован + content: + application/json: + schema: { $ref: "#/components/schemas/Detail" } + NotFound: + description: Не найдено + content: + application/json: + schema: { $ref: "#/components/schemas/Detail" } + + schemas: + Detail: + type: object + properties: + detail: { type: string } + + Link: + type: object + properties: + name: { type: string, maxLength: 255 } + link: { type: string, format: uri } + required: [name, link] + + RFI: + description: Чтение RFI (RFIListSerializer / RFIRetrieveSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string, maxLength: 255 } + author_id: { type: integer } + description: { type: string, nullable: true } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + responsible_users: + type: array + items: { type: string, format: uuid } + completion_date: { type: string, format: date-time, nullable: true } + image: { type: string, nullable: true, description: "URL изображения (по умолчанию rfi-default.png)" } + attributes: { type: object, additionalProperties: true } + links: + type: array + items: { $ref: "#/components/schemas/Link" } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + status: { type: integer, description: "id статуса (status_id)" } + priority: { type: integer, description: "id приоритета (priority_id)" } + + RFIWrite: + description: Запись RFI (RFIWriteSerializer). + type: object + properties: + name: { type: string, maxLength: 255 } + author_id: { type: integer } + description: { type: string, nullable: true } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + responsible_users: + type: array + items: { type: string, format: uuid } + completion_date: { type: string, format: date-time, nullable: true } + image: { type: string, format: binary, nullable: true } + attributes: { type: object, additionalProperties: true, default: {} } + links: + type: array + items: { $ref: "#/components/schemas/Link" } + status_id: { type: integer, description: "PK статуса" } + priority_id: { type: integer, description: "PK приоритета" } + required: [name, author_id, company_id, responsible_users, status_id, priority_id] + + RFIFilterRequest: + description: Тело фильтра для POST /rfi/filter/, /rfi/status-count/, /rfi/priority-count/. + type: object + properties: + id: { type: array, items: { type: string } } + resource_id: { type: array, items: { type: string } } + author_id: { type: array, items: { type: string } } + company_id: { type: integer } + status_id: { type: array, items: { type: string } } + priority_id: { type: array, items: { type: string } } + responsible_users: { type: array, items: { type: string } } + created_at_gte: { type: string, format: date-time } + created_at_lte: { type: string, format: date-time } + message_at_gte: { type: string, format: date-time } + message_at_lte: { type: string, format: date-time } + + CopyName: + type: object + properties: + name: { type: string, maxLength: 255 } + required: [name] + + Message: + description: Чтение сообщения (MessageReadSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + author_id: { type: integer } + text: { type: string } + is_solution: { type: boolean } + request: { type: integer, description: "id запроса (request_id)" } + reply_to: { type: integer, nullable: true, description: "id родительского сообщения" } + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + + MessageWrite: + description: Запись сообщения (MessageWriteSerializer). + type: object + properties: + author_id: { type: integer } + text: { type: string } + is_solution: { type: boolean } + request_id: { type: integer } + reply_to_id: { type: integer, nullable: true } + required: [author_id, text, request_id] + + Status: + description: Чтение статуса (StatusReadSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string, maxLength: 512 } + label: { type: string, maxLength: 512 } + color: { type: string, description: "HEX-цвет", example: "#FFFFFF" } + status_model: { type: integer, description: "id модели статусов" } + + StatusShort: + type: object + properties: + id: { type: integer } + name: { type: string } + label: { type: string } + color: { type: string } + + StatusCreate: + description: Создание/обновление статуса (StatusCreateSerializer). + type: object + properties: + name: { type: string, maxLength: 512 } + label: { type: string, maxLength: 512 } + color: { type: string, example: "#FFFFFF" } + status_model: { type: integer, description: "PK модели статусов" } + required: [name, label, status_model] + + StatusModelList: + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string, maxLength: 512 } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + statuses: + type: array + items: { $ref: "#/components/schemas/StatusShort" } + + StatusModelDetail: + allOf: + - $ref: "#/components/schemas/StatusModelList" + - type: object + properties: + created_at: { type: string, format: date-time, readOnly: true } + updated_at: { type: string, format: date-time, readOnly: true } + + StatusModelWrite: + type: object + properties: + name: { type: string, maxLength: 512 } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + required: [name, company_id] + + Priority: + description: Чтение приоритета (PriorityReadSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string, maxLength: 512 } + label: { type: string, maxLength: 512 } + color: { type: string, example: "#FFFFFF" } + priority_model: { type: integer, description: "id модели приоритетов" } + + PriorityShort: + type: object + properties: + id: { type: integer } + name: { type: string } + label: { type: string } + color: { type: string } + + PriorityWrite: + description: Создание/обновление приоритета (PriorityWriteSerializer). + type: object + properties: + name: { type: string, maxLength: 512 } + label: { type: string, maxLength: 512 } + color: { type: string, example: "#FFFFFF" } + priority_model_id: { type: integer, description: "PK модели приоритетов (write-only)" } + required: [name, label, priority_model_id] + + PriorityModel: + description: Чтение модели приоритетов (PriorityModelReadSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + name: { type: string, maxLength: 512 } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + priorities: + type: array + items: { $ref: "#/components/schemas/PriorityShort" } + + PriorityModelWrite: + type: object + properties: + name: { type: string, maxLength: 512 } + company_id: { type: integer } + resource_id: { type: string, format: uuid, nullable: true } + required: [name, company_id] + + CountItem: + type: object + properties: + id: { type: integer } + name: { type: string } + label: { type: string } + color: { type: string } + count: { type: integer } + + RFIStatusCount: + type: object + properties: + resource_id: { type: string, format: uuid } + statuses: + type: array + items: { $ref: "#/components/schemas/CountItem" } + + RFIPriorityCount: + type: object + properties: + resource_id: { type: string, format: uuid } + priorities: + type: array + items: { $ref: "#/components/schemas/CountItem" } + + RFIChangeRecord: + description: Запись журнала изменений (RFIChangeRecordSerializer). + type: object + properties: + id: { type: integer, readOnly: true } + created_at: { type: string, format: date-time, readOnly: true } + request_id: { type: integer, readOnly: true } + created_by: { type: integer, description: "ID пользователя, совершившего изменение" } + field_name: { type: string, maxLength: 128 } + attribute_name: { type: string, maxLength: 256, nullable: true } + was: { type: string, description: "Старое значение (raw)" } + became: { type: string, description: "Новое значение (raw)" } + was_text: { type: string, description: "Человекочитаемое старое значение" } + became_text: { type: string, description: "Человекочитаемое новое значение" } + + # ---- Пагинированные обёртки (LimitOffsetPagination) ---- + PaginatedBase: + type: object + properties: + count: { type: integer } + next: { type: string, format: uri, nullable: true } + previous: { type: string, format: uri, nullable: true } + + PaginatedRFIList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/RFI" } + + PaginatedMessageList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/Message" } + + PaginatedStatusList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/Status" } + + PaginatedStatusModelList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/StatusModelList" } + + PaginatedPriorityList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/Priority" } + + PaginatedPriorityModelList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/PriorityModel" } + + PaginatedRFIChangeRecordList: + allOf: + - $ref: "#/components/schemas/PaginatedBase" + - type: object + properties: + results: + type: array + items: { $ref: "#/components/schemas/RFIChangeRecord" }