openapi: 3.0.3 info: title: Sarex Resources API version: "1.0.0" description: | REST API сервиса **sarex-resources** (`platform/sarex-resources`) — управление ресурсами/проектами, их типами (иерархия), локациями, а также разрешениями сервисных аккаунтов на ресурсы и на компании. Сервис написан на Python (**Django 4.1 + Django REST Framework**, GeoDjango/PostGIS). Роутинг задаётся в `server/config/urls.py`: - `resource-management/` — Django-admin; - `api/v1/` — основной API (`apps.resource.urls`); - `api/v2/` — расширенный API ресурсов (`apps.resource.urls_v2`). Списочные и CRUD-эндпоинты строятся `rest_framework.routers.DefaultRouter` поверh `ModelViewSet`; часть операций — отдельные `APIView`. ### Аутентификация На уровне приложения аутентификация и permission-классы DRF **отключены** (`DEFAULT_AUTHENTICATION_CLASSES = []`, permission-классы не заданы — эффективно `AllowAny`). Ограничение доступа обеспечивается сетевым слоем (istio/ingress, внутрикластерный доступ к `sarex-resources-service`). ### Пагинация Используется `rest_framework.pagination.LimitOffsetPagination` с очень большим `PAGE_SIZE` (100000). Списочные ответы оборачиваются в `{ count, next, previous, results }`; постранично управляется параметрами `limit` и `offset`. ### Идентификаторы ресурсов У ресурса есть внутренний `id` (int, в API почти не используется), публичный `public_id`/`id` (UUID — основной идентификатор в URL detail-эндпоинтов v1/v2) и устаревший `target_id` (`_target_id`, int) для обратной совместимости. Удаление — «мягкое» (`deleted=True`), объекты с `deleted=True` из выборок исключаются. ### Замечания (расхождения кода) - Detail-роуты `resource` в v1/v2 ищут объект по `public_id` (UUID), а не по первичному ключу; путь при этом выглядит как `/api/v1/resource/{public_id}/`. - Ряд аналитических эндпоинтов (`users-grouped-by-resource`, `users-with-resources`, `resources-grouped-by-sa`) возвращают «сырые» структуры (`dict`/списки), не обёрнутые в пагинацию. - `bulk_delete/resource_permission/` и `permissions-bulk/` выполняют **жёсткое** удаление разрешений (`.delete()`), в отличие от «мягкого» удаления сущностей. - `RetrieveResourceByTargetIdAPIView` при отсутствии ресурса возвращает `404` без тела. contact: name: sarex-resources url: https://gitlab/platform/sarex-resources servers: - url: https://api.sarex.io/resource-management description: Production admin (istio VirtualService, prefix /resource-management) - url: http://sarex-resources-service.resources-prod description: Внутрикластерный адрес (ClusterIP), production namespace - url: http://sarex-resources-service.resources-stage description: Внутрикластерный адрес (ClusterIP), stage namespace - url: http://localhost:8888 description: Локальный запуск (docker-compose, 8888 → 8000) tags: - name: resources description: Ресурсы (проекты) - name: resources_v2 description: Расширенные ресурсы (api/v2) - name: resource_types description: Типы ресурсов (иерархия) - name: locations description: Локации (гео-данные) - name: resource_permissions description: Разрешения сервисных аккаунтов на ресурсы - name: company_permissions description: Разрешения сервисных аккаунтов на компании - name: analytics description: Служебные/аналитические эндпоинты (группировки, интеграция с Sarex) paths: /api/v1/resource/: get: tags: [resources] summary: Список ресурсов parameters: - { name: tenant_id, in: query, schema: { type: string }, description: "ID компании; поддерживает список через запятую" } - { name: type, in: query, schema: { type: integer } } - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; фильтрация по поддереву (ltree)" } - { name: show_in_overview, in: query, schema: { type: boolean } } - { name: id, in: query, schema: { type: string }, description: "public_id (UUID); список через запятую" } - { name: target_id, in: query, schema: { type: string }, description: "устаревший target_id; список через запятую" } - { name: service_accounts, in: query, schema: { type: string }, description: "UUID сервисных аккаунтов через запятую (доступные ресурсы)" } - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/PaginatedResourceList" post: tags: [resources] summary: Создать ресурс requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ResourceWrite" } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/ResourceWrite" } /api/v1/resource/{public_id}/: parameters: - { name: public_id, in: path, required: true, schema: { type: string, format: uuid } } get: tags: [resources] summary: Получить ресурс по public_id responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourceRetrieve" } "404": { description: Не найден } delete: tags: [resources] summary: Мягкое удаление ресурса (deleted=true) responses: "204": { description: No Content } /api/v1/resource_type/: get: tags: [resource_types] summary: Список типов ресурсов parameters: - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; фильтрация по поддереву" } - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PaginatedResourceTypeList" } post: tags: [resource_types] summary: Создать тип ресурса requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ResourceTypeWrite" } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/ResourceTypeWrite" } /api/v1/resource_type/filters/: get: tags: [resource_types] summary: "Значения фильтра типов ресурсов (потомки родителя)" parameters: - { name: parent, in: query, schema: { type: integer }, description: "ID родителя; вернёт его потомков" } responses: "200": description: OK content: application/json: schema: type: object properties: parent: type: array items: type: object properties: id: { type: integer } name: { type: string } /api/v1/resource_type/{id}/: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [resource_types] summary: Получить тип ресурса responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourceTypeRetrieve" } delete: tags: [resource_types] summary: Мягкое удаление типа ресурса responses: "204": { description: No Content } /api/v1/location/: get: tags: [locations] summary: Список локаций parameters: - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PaginatedLocationList" } post: tags: [locations] summary: Создать локацию requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/LocationWrite" } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/LocationWrite" } /api/v1/location/{id}/: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [locations] summary: Получить локацию responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/LocationRetrieve" } delete: tags: [locations] summary: Мягкое удаление локации responses: "204": { description: No Content } /api/v1/resource_permission/: get: tags: [resource_permissions] summary: Список разрешений на ресурсы parameters: - { name: public_resource_id, in: query, schema: { type: string }, description: "UUID через запятую" } - { name: resource_id, in: query, schema: { type: string }, description: "внутренние id через запятую" } - { name: service_account, in: query, schema: { type: string }, description: "UUID через запятую" } - { name: tenant_id, in: query, schema: { type: string }, description: "ID компаний через запятую (по resource.tenant_id)" } - { name: company_id, in: query, schema: { type: string }, description: "синоним tenant_id" } - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PaginatedResourcePermissionList" } post: tags: [resource_permissions] summary: "Создать разрешение(я) на ресурс" description: "Принимает один объект или массив (bulk). Поле resource определяется по public_resource_id." requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/ResourcePermissionWrite" - type: array items: { $ref: "#/components/schemas/ResourcePermissionWrite" } responses: "201": description: Created content: application/json: schema: oneOf: - $ref: "#/components/schemas/ResourcePermissionWrite" - type: array items: { $ref: "#/components/schemas/ResourcePermissionWrite" } /api/v1/resource_permission/{id}/: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [resource_permissions] summary: Получить разрешение на ресурс responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourcePermissionRetrieve" } delete: tags: [resource_permissions] summary: Мягкое удаление разрешения responses: "204": { description: No Content } /api/v1/company_resource_permission/: get: tags: [company_permissions] summary: Список разрешений на компании parameters: - { name: service_account, in: query, schema: { type: string }, description: "UUID через запятую" } - { name: tenant_id, in: query, schema: { type: string }, description: "ID компаний через запятую" } - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PaginatedCompanyPermissionList" } post: tags: [company_permissions] summary: "Создать разрешение(я) на компанию" description: "Принимает один объект или массив (bulk_create)." requestBody: required: true content: application/json: schema: oneOf: - $ref: "#/components/schemas/CompanyPermission" - type: array items: { $ref: "#/components/schemas/CompanyPermission" } responses: "201": description: Created /api/v1/company_resource_permission/{id}/: parameters: - { name: id, in: path, required: true, schema: { type: integer } } get: tags: [company_permissions] summary: Получить разрешение на компанию responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/CompanyPermission" } delete: tags: [company_permissions] summary: Мягкое удаление разрешения на компанию responses: "204": { description: No Content } /api/v1/targets/{target_id}/resource/: get: tags: [resources] summary: Получить ресурс по устаревшему target_id parameters: - { name: target_id, in: path, required: true, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourceRetrieve" } "404": { description: Не найден } /api/v1/bulk_delete/resource_permission/: post: tags: [resource_permissions] summary: Массовое (жёсткое) удаление разрешений на ресурсы requestBody: required: true content: application/json: schema: type: object properties: id: type: array items: { type: integer } required: [id] responses: "200": { description: OK } "400": { description: "id не передан или содержит не-int" } /api/v1/service-accounts/: get: tags: [analytics] summary: Сервисные аккаунты, имеющие доступ к ресурсу description: "Нужно указать ровно один из resource_id (UUID) или target_id (int)." parameters: - { name: resource_id, in: query, schema: { type: string, format: uuid } } - { name: target_id, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: type: object properties: count: { type: integer } results: type: array items: { type: string, format: uuid } "400": { description: "не указан либо указаны оба параметра / ошибка парсинга" } "404": { description: Ресурс не найден } /api/v1/users-grouped-by-resource/: get: tags: [analytics] summary: "Пользователи, сгруппированные по ресурсам" description: "Обращается к ядру Sarex за списком пользователей. Возвращает map resource_id → [user_id]." responses: "200": description: OK content: application/json: schema: type: object additionalProperties: type: array items: { type: integer } /api/v1/resources-grouped-by-sa/: get: tags: [analytics] summary: "Ресурсы, сгруппированные по сервисным аккаунтам" description: "Учитывает прямые (read) и компанейские разрешения. Возвращает map service_account → [public_resource_id]." responses: "200": description: OK content: application/json: schema: type: object additionalProperties: type: array items: { type: string, format: uuid } /api/v1/users-with-resources/: post: tags: [analytics] summary: "Доступные ресурсы для списка пользователей в рамках компании" requestBody: required: true content: application/json: schema: type: object properties: id: type: array items: { type: integer } tenant_id: { type: integer } required: [id, tenant_id] responses: "200": description: OK content: application/json: schema: type: object properties: results: type: array items: type: object properties: id: { type: integer } unrestricted_access: { type: boolean } resources: type: array items: type: object properties: id: { type: string, format: uuid } name: { type: string } "400": { description: "не передан id или tenant_id / некорректный tenant_id" } /api/v1/permissions-bulk/: patch: tags: [resource_permissions] summary: "Синхронизация (bulk) разрешений на ресурсы и компании" description: | Приводит разрешения к переданному состоянию: создаёт недостающие, удаляет лишние (жёстко). `permissions` — map service_account → [resource_id], `unrestricted_permissions` — map service_account → bool (доступ ко всей компании). requestBody: required: true content: application/json: schema: type: object properties: tenant_id: { type: integer } permissions: type: object additionalProperties: type: array items: { type: string, format: uuid } unrestricted_permissions: type: object additionalProperties: { type: boolean } required: [tenant_id, permissions] responses: "200": { description: OK } /api/v2/resource/: get: tags: [resources_v2] summary: Список расширенных ресурсов parameters: - { name: tenant_id, in: query, schema: { type: string }, description: "список через запятую" } - { name: type, in: query, schema: { type: integer } } - { name: parent, in: query, schema: { type: integer } } - { name: show_in_overview, in: query, schema: { type: boolean } } - { name: id, in: query, schema: { type: string }, description: "public_id (UUID) через запятую" } - { name: target_id, in: query, schema: { type: integer } } - { name: service_accounts, in: query, schema: { type: string } } - { name: limit, in: query, schema: { type: integer } } - { name: offset, in: query, schema: { type: integer } } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/PaginatedResourceExpandedList" } post: tags: [resources_v2] summary: Создать расширенный ресурс requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ResourceExpandedWrite" } responses: "201": description: Created content: application/json: schema: { $ref: "#/components/schemas/ResourceExpandedWrite" } /api/v2/resource/{public_id}/: parameters: - { name: public_id, in: path, required: true, schema: { type: string, format: uuid } } - { name: tenant_id, in: query, schema: { type: string }, description: "фильтр по компаниям (через запятую) при retrieve" } get: tags: [resources_v2] summary: Получить расширенный ресурс по public_id responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourceExpandedRetrieve" } "404": { description: "there is no resource with provided id" } patch: tags: [resources_v2] summary: Частичное обновление расширенного ресурса requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ResourceExpandedPartialUpdate" } responses: "200": description: OK content: application/json: schema: { $ref: "#/components/schemas/ResourceExpandedPartialUpdate" } delete: tags: [resources_v2] summary: Мягкое удаление расширенного ресурса responses: "204": { description: No Content } components: schemas: PermissionType: type: integer description: "0=read, 1=write, 2=delete, 3=admin" enum: [0, 1, 2, 3] ResourceRetrieve: type: object properties: id: { type: string, format: uuid, description: "public_id" } name: { type: string } type: { type: integer, nullable: true } tenant_id: { type: integer } location: { type: integer, nullable: true } parent_id: { type: integer, nullable: true } created_by: { type: integer } target_id: { type: integer, nullable: true } code: { type: string } ResourceWrite: type: object properties: id: { type: string, format: uuid, nullable: true, readOnly: true } name: { type: string } type: { type: integer, nullable: true } tenant_id: { type: integer } location: { type: integer, nullable: true } parent_id: { type: integer, nullable: true } created_by: { type: integer } target_id: { type: integer, nullable: true } code: { type: string } created_at: { type: string, format: date-time, readOnly: true } updated_at: { type: string, format: date-time, readOnly: true } required: [name, tenant_id, target_id] PaginatedResourceList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/ResourceRetrieve" } ResourceTypeRetrieve: type: object properties: id: { type: integer } name: { type: string } parent_id: { type: integer, nullable: true } created_by: { type: integer } ResourceTypeWrite: type: object properties: id: { type: integer, readOnly: true } name: { type: string } parent_id: { type: integer, nullable: true } created_by: { type: integer } created_at: { type: string, format: date-time, readOnly: true } updated_at: { type: string, format: date-time, readOnly: true } required: [name] PaginatedResourceTypeList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/ResourceTypeRetrieve" } LocationRetrieve: type: object properties: id: { type: integer } name: { type: string } coordinate_system: { type: integer } geometry: { type: object, description: "GeoJSON-геометрия" } geography: { type: object, description: "GeoJSON-геометрия (geography)" } latitude: { type: string, format: decimal } longitude: { type: string, format: decimal } created_by: { type: integer } LocationWrite: allOf: - $ref: "#/components/schemas/LocationRetrieve" - type: object properties: created_at: { type: string, format: date-time, readOnly: true } updated_at: { type: string, format: date-time, readOnly: true } required: [name, coordinate_system, geometry, geography, latitude, longitude] PaginatedLocationList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/LocationRetrieve" } ResourcePermissionRetrieve: type: object properties: id: { type: integer } service_account: { type: string, format: uuid } public_resource_id: { type: string, format: uuid } type: { $ref: "#/components/schemas/PermissionType" } created_by: { type: integer } ResourcePermissionWrite: type: object properties: id: { type: integer, readOnly: true } service_account: { type: string, format: uuid } public_resource_id: { type: string, format: uuid } type: { $ref: "#/components/schemas/PermissionType" } created_by: { type: integer } resource: { type: integer, description: "заполняется сервером по public_resource_id" } created_at: { type: string, format: date-time, readOnly: true } updated_at: { type: string, format: date-time, readOnly: true } required: [service_account, public_resource_id] PaginatedResourcePermissionList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/ResourcePermissionRetrieve" } CompanyPermission: type: object properties: id: { type: integer, readOnly: true } service_account: { type: string, format: uuid } tenant_id: { type: integer } created_by: { type: integer } created_at: { type: string, format: date-time, readOnly: true } updated_at: { type: string, format: date-time, readOnly: true } required: [service_account, tenant_id] PaginatedCompanyPermissionList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/CompanyPermission" } ResourcePhoto: type: object properties: id: { type: integer } author_id: { type: integer } created_at: { type: string, format: date-time } image: { type: string, format: uri } ResourceExpandedRead: type: object properties: id: { type: string, format: uuid } name: { type: string } type: { type: integer, nullable: true } tenant_id: { type: integer } location: { type: integer, nullable: true } parent_id: { type: integer, nullable: true } created_by: { type: integer } target_id: { type: integer, nullable: true } code: { type: string } description: { type: string } location_verbose: { type: string } latitude: { type: number, format: float } longitude: { type: number, format: float } photos: type: array items: { $ref: "#/components/schemas/ResourcePhoto" } attributes: { type: object } planning_widget_id: { type: integer, nullable: true } work_schedule_project_id: { type: integer, nullable: true } show_in_overview: { type: boolean } widgets: { type: object } meta: { type: object } ResourceExpandedRetrieve: allOf: - $ref: "#/components/schemas/ResourceExpandedRead" PaginatedResourceExpandedList: type: object properties: count: { type: integer } next: { type: string, nullable: true } previous: { type: string, nullable: true } results: type: array items: { $ref: "#/components/schemas/ResourceExpandedRead" } ResourceExpandedWrite: type: object properties: id: { type: string, format: uuid, nullable: true, readOnly: true } name: { type: string } type: { type: integer, nullable: true } tenant_id: { type: integer } parent_id: { type: integer, nullable: true } created_by: { type: integer } target_id: { type: integer, nullable: true } description: { type: string } location_verbose: { type: string } latitude: { type: number, format: float } longitude: { type: number, format: float } attributes: { type: object } planning_widget_id: { type: integer, nullable: true } work_schedule_project_id: { type: integer, nullable: true } code: { type: string } show_in_overview: { type: boolean } widgets: { type: object } meta: { type: object } required: [name, tenant_id, target_id] ResourceExpandedPartialUpdate: type: object properties: id: { type: string, format: uuid, readOnly: true } name: { type: string } type: { type: integer, nullable: true } tenant_id: { type: integer, readOnly: true } location: { type: integer, nullable: true } parent_id: { type: integer, nullable: true } created_by: { type: integer, readOnly: true } target_id: { type: integer, readOnly: true } code: { type: string } description: { type: string } location_verbose: { type: string } latitude: { type: number, format: float } longitude: { type: number, format: float } attributes: { type: object } planning_widget_id: { type: integer, nullable: true } show_in_overview: { type: boolean } widgets: { type: object } meta: { type: object }