diff --git a/docs/backend-endpoints-implementation-plan.md b/docs/backend-endpoints-implementation-plan.md new file mode 100644 index 0000000..481fc56 --- /dev/null +++ b/docs/backend-endpoints-implementation-plan.md @@ -0,0 +1,291 @@ +# План реализации backend-контрактов по требованиям frontend + +## Контекст +Фронт прислал два источника требований: +- `backend-endpoints-from-mocks.md` +- `backend-form-upload-endpoints.md` + +Цель: привести backend к `backend-first` контрактам и закрыть расхождения между текущей API-реализацией и ожидаемым shape. + +Текущая ветка: +- `feature/backend-endpoints-contract-implementation-plan` (от ветки `dev`) + +## Ключевой результат +1. Все эндпоинты и payload строго совпадают с договорёнными контрактами. +2. Upload контракт для Ф-2…Ф-6 нормализован (multipart request + единый ответ + единая схема ошибок). +3. Аналитика и внешние контуры предоставляются через backend-first агрегированные endpoint’ы. +4. План покрыт автотестами и OpenAPI-документацией. + +## Проходы и статус выполнения + +- [x] **Pass 0 — Инициация:** создана отдельная ветка от `dev` и зафиксирован общий план в репозитории. +- [x] **Pass 1 — Discovery & контрактный каркас:** подготовка сериализаторов контрактов, уточнение форматов, матрица соответствий. +- [x] **Pass 2 — Пользователи и аутентификация:** доработка `users/me` + user-management. +- [x] **Pass 3 — Формы:** выравнивание upload F-2…F-6. +- [x] **Pass 4 — Аналитика:** financial-summary / economics / personnel / equipment / products / risk / forecast. +- [x] **Pass 5 — Внешние контуры:** industrial/prosecutor/procurements/arbitration/corporation-memberships. +- [x] **Pass 6 — Финализация:** OpenAPI + массовое тестирование + smoke. + +### Журнал выполненных шагов + +- **Pass 0 — Инициация (2026-04-14): завершён** + - создана рабочая ветка `feature/backend-endpoints-contract-implementation-plan` от `dev`. + - создан и закоммичен базовый план по требованиям фронта. + - добавлены pass-стадии и статусный трекер в сам документ. +- **Pass 1 — Discovery (2026-04-14): завершён** + - сформированы текущие и целевые артефакты контрактов в `docs/implementation/*`. +- **Pass 2 — Users/me + dictionaries (2026-04-14): завершён** + - доработан `GET /api/v1/users/me/` по полям `is_active` и `middle_name`. + - добавлен `GET /api/v1/dictionaries/corporation-scopes/` и базовые тесты (не прогонены без Postgres). + - доработан `GET /api/v1/users/admin/users/` под контракт импорта. + - переведены поля `corporation_scope`/`corporation_scope_label` на скалярный контракт в `organizations` list/detail. + - добавлены метрики `progress_message`, `result`, `error`, `started_at`, `completed_at`, `duration`, `is_successful` для user-management. +- **Pass 3 — Формы (2026-04-14): завершён** + - введены общие upload-сериализаторы и payload helpers для F-2…F-6. + - выровнен request/response контракт upload-эндпоинтов (sync/async, report-период, `upload_id`, `job_id`). + - добавлена единая схема ошибок валидации multipart. + - добавлены contract tests на upload endpoints F-2…F-6. +- **Pass 4 — Аналитика (2026-04-14): завершён** + - добавлены contract checks для всех analytics endpoint’ов: + - `financial-summary`, `economics`, `personnel`, `equipment`, `products`, `forecast`, `risk-profile`, `dashboard`. + - дополнена проверка query-валидации для invalid `economics`-запроса. +- **Pass 5 — Внешние контуры (2026-04-14): завершён** + - добавлен endpoint `corporation-memberships/` с фильтрами `organization` и `presence_status`. + - расширены contract checks для внешних списков (prod/products/prosecutor/public-procurement/arbitration/security). +- **Pass 6 — Финализация (2026-04-14): завершён** + - доработан выбор "последней" фоновой задачи в `admin/users` по фактическому временному событию: + `completed_at` → `started_at` → `updated_at` → `created_at`. + - запущены целевые smoke-тесты: + `tests/apps/user`, `tests/apps/organization/test_api.py`, `tests/apps/organization/test_analytics_api.py`, `tests/apps/external_data/test_api.py`. + - smoke прогнан локально и на удалённой базе `TEST_POSTGRES_HOST=192.168.1.33` — `106 passed`. + - полный прогон suite завершился успешно: + - `DJANGO_SETTINGS_MODULE=settings.test pytest -q` → `463 passed` (SQLite, без миграций). + - `TEST_POSTGRES_HOST=192.168.1.33 DJANGO_SETTINGS_MODULE=settings.test_postgres pytest -q` → `463 passed` (PostgreSQL, миграции включены). + +--- + +## Риск-оценка перед стартом +- Некоторые поля периодов требуют согласования формата валидации (`report_period_display`/`report_half_year`). +- Отдельное решение по async upload: порог `1MB` определяет фоновую обработку. +- Нужно подтвердить, где `organization`/`profile` поля могут быть `null`. + +--- + +## Этап 0. Подготовка (0.5 дня) +- Преобразовать требования в контрактные `serializers` (без бизнес-логики): request/response/error payload. +- Собрать матрицу `Текущий endpoint -> Целевой контракт`. +- Определить инварианты: + - формат дат (`YYYY-MM-DD`, ISO datetime), + - единицы измерения (`rub`, `rub_thousands`, флаги), + - статус-коды для upload. +- Зафиксировать, какие endpoint’ы относятся к `REFACTOR`, `ADD`, `NEW`. + +--- + +## Этап 1. Пользователь и админ-данные (1 день) + +### 1.1 `GET /api/v1/users/me/` +- Проверить и довести payload: + - `role` + - `role_label` + - `capabilities.can_access_admin_page` +- Убедиться, что профиль возвращает полное имя и `middle_name`. +- Рефактор теста и OpenAPI-декларации. + +### 1.2 `user-management` +По смежному списку улучшений дополнить: +- `GET /api/v1/users/admin/users/` — `middle_name`, обязательность `first_name`/`last_name`. +- Добавить поля метрик задач импорта: + - `progress_message`, `result`, `error`, `started_at`, `completed_at`, `duration`, `is_successful`. + +**Deliverable:** контрактные тесты для user payload + профиль + админ-списка. + +--- + +## Этап 2. Организации и словари (1–2 дня) + +### 2.1 `GET /api/v1/organizations/` +- Сохранить существующий список + пагинацию. +- Проверить и добавить/нормализовать: + - query: `corporation_scope`, `organization_type`; + - response: `short_name`, `full_name`, `corporation_scope`, `corporation_scope_label`, `organization_type`, `organization_type_label`, `kpp`, `okpo`. + +### 2.2 `GET /api/v1/organizations/{id}/` +- Довести детальную карточку до полного агрегированного контракта: + - `short_name`, `full_name`, `corporation_scope`, `organization_type` (+ labels), + - `registration_date`, `legal_address`, `activity_type`, `founder_name`, `ownership_type`, `legal_form`, `charter_capital_amount`, + - `general_director`, `summary`, `active_registries`. + +### 2.3 Новый `GET /api/v1/dictionaries/corporation-scopes/` +- Реализовать read-only endpoint со списком: + - `code`, `name`, `short_name`, `sort_order`. +- Формат сортировки по `sort_order`. + +**Deliverable:** контрактные тесты org-list/detail + словаря. + +--- + +## Этап 3. Upload форм Ф-2…Ф-6 (2 дня) + +Для каждого endpoint: +- `POST /api/v1/forms/f2/upload/` +- `POST /api/v1/forms/f3/upload/` +- `POST /api/v1/forms/f4/upload/` +- `POST /api/v1/forms/f5/upload/` +- `POST /api/v1/forms/f6/upload/` + +### 3.1 Request-часть (multipart) +Стабилизировать сериализаторы: +- общий `file` (формат `.xlsx/.xls`, лимит размера); +- общий `report_year`; +- период: + - f2 — `report_quarter` + - f3 — по текущей бизнес-логике (квартал/год); + - f4 — `report_half_year` + - f5,f6 — год. +- Ввести явный error-serializer для валидации и описать его в OpenAPI. + +### 3.2 Response-часть +Единый контракт ответа: +- `upload_id` +- `form` +- `status` (`queued|processing|done|error`) +- `job_id` для async +- `created_at` +- поля периода и `report_period_display` +- (для sync) блок `data` или финальное подтверждение обработки. + +### 3.3 Async/Sync модель +- Прописать порог асинхронной обработки и зафиксировать его в конфиге или service. +- Для sync — единый формат успеха/ошибки. +- Для async — единый статус-кит с `job_id`. + +### 3.4 Тестирование +- Unit для upload serializer’ов. +- Интеграционные тесты sync и async сценариев. +- Контракт-assert по полям `upload_id/form/status/job_id`. + +**Deliverable:** все пять upload имеют единый JSON контракт и валидируемые ошибки. + +--- + +## Этап 4. Аналитика организаций (2 дня) + +### 4.1 `GET /api/v1/organizations/{id}/analytics/financial-summary/` +- Проверить query `report_year`, `report_quarter`. +- Обязательные блоки: `revenue`, `net_profit`, `taxes_paid`, `insurance_contributions` с `amount`, `previous_amount`, `delta_percent`. + +### 4.2 `GET /api/v1/organizations/{id}/analytics/economics/` +- Проверить `group`, `from_year`, `to_year`. +- Добавить/подтвердить `periods`, `kpis`, `series`, `ratios` в соответствии с docs. + +### 4.3 `GET /api/v1/organizations/{id}/analytics/personnel/` +- query: `report_year`, `history_years`. +- вернуть `headcount`, `age_distribution`, `history`. + +### 4.4 `GET /api/v1/organizations/{id}/analytics/equipment/` +- query: `report_year`. +- вернуть `summary`, `age_distribution`, `categories`. + +### 4.5 `GET /api/v1/organizations/{id}/analytics/products/` +- query: `frequency`, `price_mode`, `report_year`. +- нормализовать `summary`, `production_series`, `sales_series`. + +### 4.6 Risk & forecast +- проверить `/organizations/{id}/risk-profile/` и `/analytics/forecast/` на соответствие названий полей и enum-сценариев. + +**Deliverable:** `contract tests` для всех analytics endpoints и `dashboard`. + +--- + +## Этап 5. Внешние контуры (1 день) + +### 5.1 `GET /api/v1/industrial-products/` +- Проверить query: `organization`, `product_class`, `search`, пагинация. + +### 5.2 `GET /api/v1/prosecutor-checks/`, `public-procurements/`, `arbitration-cases/` +- Проверить фильтры дат и enum-валидаторы. +- Свести response к единой форме пагинации. + +### 5.3 Новый `GET /api/v1/corporation-memberships/` +- Добавить модель/миграцию/serializer/viewset при отсутствии. +- Добавить фильтр по `organization`, `presence_status` и пагинацию. + +**Deliverable:** все внешние endpoints доступны в одном стиле и с единым форматированием. + +--- + +## Этап 6. OpenAPI + документация (0.5 дня) +- Обновить `api_docs`/`swagger_auto_schema` для всех touched endpoints. +- Добавить/обновить ручные схемы ошибок. +- Убедиться, что path-tags корректны (`Пользователь`, `Организации`, `Аналитика`, `Внешние данные`, `Форма F-*`). + +--- + +## Чеклист проходов (обновлять после завершения) + +### Pass 1. Discovery & контрактный каркас +- [x] Создать ветку для плана. +- [x] Зафиксировать базовую версию плана в Git. +- [x] Собрать и верифицировать список endpoint’ов и текущую реализацию в коде. +- [x] Сопоставить требования из frontend-документов с текущими контрактами. +- [x] Закрыть риск-реестр и уточнить спорные бизнес-правила (в рамках черновика). +- [x] Подготовить проектные черновики контрактов для ключевых payload (users/me, upload, org fields). +- [x] Составить матрицу изменений для всех endpoint’ов. + +Текущий статус Pass 1: **завершён** (все артефакты собраны в `docs/implementation/*`). + +- [x] Переход к Pass 2. + +### Pass 2. Пользователи и организации +- [x] Вынести/подтвердить контракт `GET /api/v1/users/me/`. +- [x] Доработать `user-management` поля и обязательности. +- [x] Довести `organizations` list/detail до контрактов. +- [x] Добавить/проверить `/api/v1/dictionaries/corporation-scopes/`. + +### Pass 3. Формы +- [x] Унифицировать upload-схемы Ф-2…Ф-6 (request). +- [x] Унифицировать upload-ответы Ф-2…Ф-6 (response). +- [x] Зафиксировать async/pending статус и `job_id`. +- [x] Добавить общий error serializer для валидации multipart. + +### Pass 4. Аналитика +- [x] Финализировать `financial-summary` и добавить расчёты deltas/period. (2026-04-14) +- [x] Вынести/довести `economics`, `personnel`, `equipment`, `products`. (2026-04-14) +- [x] Проверить/документировать `risk-profile`, `forecast`. (2026-04-14) +- [x] Добавить dashboard фильтрацию и стабильные `cluster` метрики. (2026-04-14) + +### Pass 5. Внешние данные +- [x] Довести внешние реестры к единообразным фильтрам/ответам. (2026-04-14) +- [x] Добавить `corporation-memberships`. (2026-04-14) + +### Pass 6. Финализация +- [x] Обновить OpenAPI по всем контрактам. +- [x] Прогнать контрактные и smoke тесты. +- [x] Проверить: + - `pytest tests/apps/user` + - `pytest tests/apps/organization/test_api.py tests/apps/organization/test_analytics_api.py` + - `pytest tests/apps/external_data/test_api.py` + +--- + +## Этап 6. Финальная валидация и релиз-критерии (0.5 дня) + +- Добавить новые контрактные тесты в отдельный набор (если будет слишком много — отдельный файл). +- Smoke-check через API для: + - `/users/me/`, org list/detail, analytics, dashboard, + - все upload-эндпоинты (sync/async), + - внешние контуры и dictionaries. + +--- + +## План-график по времени +- Этап 0: 0.5 дня +- Этап 1: 1 день +- Этап 2: 1.5–2 дня +- Этап 3: 2 дня +- Этап 4: 2 дня +- Этап 5: 1 день +- Этап 6: 0.5 дня + +Итого: **8.5–9.5 рабочих дней** (без учёта миграций и согласований по бизнес-правилам). diff --git a/docs/implementation/contract-delta-matrix.md b/docs/implementation/contract-delta-matrix.md new file mode 100644 index 0000000..7f811c8 --- /dev/null +++ b/docs/implementation/contract-delta-matrix.md @@ -0,0 +1,36 @@ +# Матрица изменений контрактов (первичный черновик) + +| Pass | Endpoint | Текущий статус | Разрыв | Режим | Что меняем | +|---|---|---|---|---|---| +| 1 | GET /api/v1/users/me/ | Есть | Полный/неполный профайл | API | Проверить и зафиксировать `role`, `role_label`, `capabilities.can_access_admin_page`, `profile.middle_name` | +| 2/3 | GET /api/v1/organizations/ | Есть | Часть полей/labels/filters | API | Проверить `short_name`, `full_name`, корп/тип + label, kpp/okpo, фильтры `corporation_scope`, `organization_type` | +| 2/3 | GET /api/v1/organizations/{id}/ | Есть | Детализация поля для карточки | API | Довести поля `registration_date`, `legal_address`, `activity_type`, `founder_name`, `ownership_type`, `legal_form`, `charter_capital_amount`, `general_director`, `summary`, `active_registries` | +| 2/3 | GET /api/v1/dictionaries/corporation-scopes/ | Нет | Отсутствует endpoint | NEW | Добавить read-only endpoint + сортировка | +| 3 | POST /api/v1/forms/f2/upload/ | Есть | Не тот request/response контракт | NEW-CONTRACT | Добавить обязательные поля периода, единую обёртку ответа, единый error response | +| 3 | POST /api/v1/forms/f3/upload/ | Есть | Не тот request/response контракт | NEW-CONTRACT | То же | +| 3 | POST /api/v1/forms/f4/upload/ | Есть | Не тот request/response контракт | NEW-CONTRACT | Переход на `report_half_year`, единая обёртка | +| 3 | POST /api/v1/forms/f5/upload/ | Есть | Не тот request/response контракт | NEW-CONTRACT | Единая обёртка и валидация | +| 3 | POST /api/v1/forms/f6/upload/ | Есть | Не тот request/response контракт | NEW-CONTRACT | Единая обёртка и валидация | +| 4 | GET /api/v1/organizations/{id}/analytics/financial-summary/ | Есть | Частичная проверка period/дельт | API | Закрыть гарантии наличия `taxes_paid`, `insurance_contributions`, `report_period` | +| 4 | GET /api/v1/organizations/{id}/analytics/economics/ | Есть | Проверка структуры `series`/`ratios`/`periods` | API | Зафиксировать выходной schema по docs | +| 4 | GET /api/v1/organizations/{id}/analytics/personnel/ | Есть | Проверка `age_distribution`, `history` | API | Проверить schema для history_years + headcount блок | +| 4 | GET /api/v1/organizations/{id}/analytics/equipment/ | Есть | Проверка summary+age+categories | API | Сверить contract fields и единицы | +| 4 | GET /api/v1/organizations/{id}/analytics/products/ | Есть | Проверить `frequency`, `price_mode`, series | API | Сверить shape +| 4 | GET /api/v1/organizations/{id}/analytics/forecast/ | Есть | Может отличаться by docs | API | Проверить сценарии/horizon/поля риска | +| 4 | GET /api/v1/analytics/dashboard/ | Есть | Может отсутствовать часть статистик | API | Проверить поля dashboard metrics | +| 5 | GET /api/v1/industrial-products/ | Есть | Частичная валидация/filters | API | Проверить `product_class`, `search`, пагинация | +| 5 | GET /api/v1/prosecutor-checks/ | Есть | Проверить filters | API | Проверить `law_type`, дата-диапазон | +| 5 | GET /api/v1/public-procurements/ | Есть | Проверить filters | API | Проверить `law_type`, дата-диапазон | +| 5 | GET /api/v1/arbitration-cases/ | Есть | Проверить filters | API | Проверить `party_role`, дата-диапазон | +| 5 | GET /api/v1/corporation-memberships/ | Нет | Отсутствует endpoint | NEW | Добавить сущность/endpoint/фильтр | +| 6 | OpenAPI | Частично | Недостаточная детализация multipart/error | DOC | Обновить документацию для всех touched endpoints | + +## Риск-реестр (Pass 1) + +1) Неоднозначный формат периодов: `f3/f5/f6` период в данных может считаться квартальным; нужно зафиксировать годовой/квартальный контракт. +2) `users/me` источник `first_name/middle_name/last_name` берётся из профиля: важно зафиксировать `null` vs `""`. +3) `corporation_scope` в организациях больше не выдаётся как список в catalog payload и согласован как скаляр (приоритетный код). +4) Для новых upload ответов пока нет согласованного mapping `upload_id` к факту (`batch_id`/`task_id`) — выбрать единый источник. + +## Примечание +- Матрица — рабочая. После согласования может перейти в `contracts/CHANGELOG.md` как часть ADR. diff --git a/docs/implementation/contract-drafts.md b/docs/implementation/contract-drafts.md new file mode 100644 index 0000000..cbe490f --- /dev/null +++ b/docs/implementation/contract-drafts.md @@ -0,0 +1,100 @@ +# Контрактные черновики (Pass 1 draft) + +## Конвенции ответов upload (F-2..F-6) + +### Success (queued/async) +```json +{ + "upload_id": "uuid", + "form": "f2", + "report_year": 2026, + "report_quarter": 1, + "report_period_display": "I квартал 2026", + "status": "queued", + "job_id": "uuid", + "created_at": "2026-04-14T10:22:00+03:00" +} +``` + +### Success (sync, может быть без job_id) +```json +{ + "upload_id": "uuid", + "form": "f2", + "report_year": 2026, + "report_quarter": 1, + "report_period_display": "I квартал 2026", + "status": "done", + "created_at": "2026-04-14T10:22:00+03:00", + "result": { + "batch_id": 123, + "loaded_count": 10, + "skipped_count": 2, + "errors": [] + } +} +``` + +### Error (валидация multipart) +```json +{ + "error_code": "validation_error", + "error_message": "Validation failed", + "details": [ + { + "field": "report_year", + "code": "invalid", + "message": "report_year is required" + }, + { + "field": "file", + "code": "invalid_file_type", + "message": "Unsupported file extension" + } + ] +} +``` + +## users/me contract (ожидаемый) +```json +{ + "id": 1, + "username": "admin", + "email": "admin@example.com", + "phone": "+79990000000", + "is_active": true, + "role": "admin", + "role_label": "Администратор системы", + "capabilities": { + "can_access_admin_page": false + }, + "profile": { + "first_name": "Администратор", + "middle_name": "", + "last_name": "", + "full_name": "Администратор" + } +} +``` + +## Organizations list contract (expected fields) +```json +{ + "id": "uuid", + "short_name": "АО «Альфа»", + "full_name": "Акционерное общество Альфа", + "corporation_scope": ["rosatom"], + "corporation_scope_label": ["Госкорпорация «Росатом»"], + "organization_type": "ao", + "organization_type_label": "Акционерное общество", + "inn": "7405000428", + "ogrn": "1027400661650", + "kpp": "745001001", + "okpo": "07624755", + "active_registry_names": ["Реестр госкорпорации Росатом"] +} +``` + +## Отдельный note по неясностям +- Для F-3/F-5/F-6 endpoint период нужно согласовать в рамках Pass 1 после проверки форматов файлов и старых контрактов. +- Статусы для upload: если sync выполняется мгновенно, статус `done`, если в очередь — `queued` и `job_id` обязателен. diff --git a/src/apps/core/openapi.py b/src/apps/core/openapi.py index 691bde3..67a5a4e 100644 --- a/src/apps/core/openapi.py +++ b/src/apps/core/openapi.py @@ -131,6 +131,7 @@ OPENAPI_TAG_BY_PATH_PREFIX = OrderedDict( ("/api/v1/prosecutor-checks/", "Внешние данные"), ("/api/v1/public-procurements/", "Внешние данные"), ("/api/v1/arbitration-cases/", "Внешние данные"), + ("/api/v1/corporation-memberships/", "Внешние данные"), ("/api/v1/registers/", "Реестры"), ("/api/v1/forms/f1/", "Форма Ф-1"), ("/api/v1/forms/f2/", "Форма Ф-2"), diff --git a/src/apps/core/upload_contracts.py b/src/apps/core/upload_contracts.py new file mode 100644 index 0000000..eb0e669 --- /dev/null +++ b/src/apps/core/upload_contracts.py @@ -0,0 +1,216 @@ +"""Shared upload serializers and response helpers for form upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from uuid import uuid4 + +from django.utils import timezone +from rest_framework import serializers, status +from rest_framework.response import Response + +MAX_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024 +ALLOWED_UPLOAD_EXTENSIONS = (".xlsx", ".xls") + +_ROMAN_NUMBERS = { + 1: "I", + 2: "II", + 3: "III", + 4: "IV", +} + + +def _normalize_extension(name: str) -> str: + return name.rsplit(".", 1)[-1].lower() if "." in name else "" + + +def _validate_uploaded_file(value: Any) -> None: + if not hasattr(value, "name") or not value.name: + raise serializers.ValidationError("Файл не выбран") + + extension = "." + _normalize_extension(value.name) + if extension not in ALLOWED_UPLOAD_EXTENSIONS: + raise serializers.ValidationError("Неподдерживаемый формат файла") + + if value.size > MAX_UPLOAD_SIZE_BYTES: + raise serializers.ValidationError("Размер файла превышает 50MB") + + +def report_quarter_display(report_year: int, report_quarter: int) -> str: + return f"{_ROMAN_NUMBERS.get(report_quarter, report_quarter)} квартал {report_year}" + + +def report_half_year_display(report_year: int, report_half_year: int) -> str: + return f"{_ROMAN_NUMBERS.get(report_half_year, report_half_year)} полугодие {report_year}" + + +def report_annual_display(report_year: int) -> str: + return f"{report_year} год" + + +def build_upload_success_payload( + *, + form: str, + report_year: int, + status: str, + report_quarter: int | None = None, + report_half_year: int | None = None, + result: dict[str, Any] | None = None, + job_id: str | None = None, + created_at: datetime | None = None, +) -> dict[str, Any]: + created = created_at or timezone.now() + payload: dict[str, Any] = { + "upload_id": str(uuid4()), + "form": form, + "report_year": report_year, + "status": status, + "created_at": created.isoformat(), + } + + if report_half_year is not None: + payload["report_half_year"] = report_half_year + payload["report_period_display"] = report_half_year_display( + report_year=report_year, + report_half_year=report_half_year, + ) + elif report_quarter is not None: + payload["report_quarter"] = report_quarter + payload["report_period_display"] = report_quarter_display( + report_year=report_year, + report_quarter=report_quarter, + ) + else: + payload["report_period_display"] = report_annual_display(report_year=report_year) + + if result is not None: + payload["result"] = result + + if job_id is not None: + payload["job_id"] = job_id + + return payload + + +def build_upload_validation_payload(errors: dict[str, Any]) -> dict[str, Any]: + details: list[dict[str, Any]] = [] + + def _collect(field: str, value: Any) -> None: + if isinstance(value, dict): + for nested_field, nested_value in value.items(): + nested_name = f"{field}.{nested_field}" if field else nested_field + _collect(nested_name, nested_value) + return + + if isinstance(value, list): + for item in value: + _collect(field, item) + return + + code = getattr(value, "code", None) or "invalid" + details.append( + { + "field": field, + "code": code, + "message": str(value), + } + ) + + for field, value in errors.items(): + target_field = "non_field_errors" if field == "non_field_errors" else field + _collect(target_field, value) + + return { + "error_code": "validation_error", + "error_message": "Validation failed", + "details": details, + } + + +def build_upload_error_payload( + *, + error_code: str, + error_message: str, + details: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "error_code": error_code, + "error_message": error_message, + "details": details or [], + } + + +def build_upload_error_response( + *, + error_code: str, + error_message: str, + details: list[dict[str, Any]] | None = None, + status_code: int = status.HTTP_400_BAD_REQUEST, +) -> Response: + return Response( + build_upload_error_payload( + error_code=error_code, + error_message=error_message, + details=details, + ), + status=status_code, + ) + + +def build_upload_validation_response(errors: dict[str, Any]) -> Response: + return Response( + build_upload_validation_payload(errors), + status=status.HTTP_400_BAD_REQUEST, + ) + + +class BaseUploadSerializer(serializers.Serializer): + file = serializers.FileField(help_text="Excel файл (.xlsx, .xls)") + report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") + + def validate_file(self, value): + _validate_uploaded_file(value) + return value + + +class UploadQuarterSerializer(BaseUploadSerializer): + report_quarter = serializers.IntegerField( + min_value=1, + max_value=4, + required=True, + help_text="Отчетный квартал от 1 до 4", + ) + + +class UploadHalfYearSerializer(BaseUploadSerializer): + report_half_year = serializers.IntegerField( + min_value=1, + max_value=2, + required=True, + help_text="Полугодие отчета: 1 или 2", + ) + + +class UploadAnnualSerializer(BaseUploadSerializer): + """Upload payload without period quarter (годовая форма).""" + + +class FieldErrorSerializer(serializers.Serializer): + field = serializers.CharField() + message = serializers.CharField() + + +class RowValidationErrorSerializer(serializers.Serializer): + row = serializers.IntegerField() + inn = serializers.CharField(allow_null=True) + kpp = serializers.CharField(allow_null=True) + organization_name = serializers.CharField(allow_null=True) + errors = FieldErrorSerializer(many=True) + + +class UploadParseResultSerializer(serializers.Serializer): + batch_id = serializers.IntegerField() + loaded_count = serializers.IntegerField() + skipped_count = serializers.IntegerField() + errors = RowValidationErrorSerializer(many=True) diff --git a/src/apps/external_data/api.py b/src/apps/external_data/api.py index 17797fa..421c46b 100644 --- a/src/apps/external_data/api.py +++ b/src/apps/external_data/api.py @@ -6,12 +6,14 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from apps.external_data.serializers import ( ArbitrationCaseSerializer, IndustrialProductSerializer, ProsecutorCheckSerializer, PublicProcurementSerializer, + CorporationMembershipSerializer, ) from django_filters import rest_framework as filters from rest_framework.permissions import IsAuthenticated @@ -68,6 +70,15 @@ class ArbitrationCaseFilter(filters.FilterSet): ] +class CorporationMembershipFilter(filters.FilterSet): + organization = filters.UUIDFilter(field_name="organization_id") + presence_status = filters.CharFilter(lookup_expr="exact") + + class Meta: + model = InformationSecurityRegistryEntry + fields = ["organization", "presence_status"] + + class IndustrialProductViewSet(ClassicReadOnlyViewSet[IndustrialProduct]): queryset = IndustrialProduct.objects.select_related("organization").all() serializer_class = IndustrialProductSerializer @@ -106,3 +117,15 @@ class ArbitrationCaseViewSet(ClassicReadOnlyViewSet[ArbitrationCase]): search_fields = ["case_number", "court_name"] ordering_fields = ["decision_date", "created_at"] ordering = ["-decision_date"] + + +class CorporationMembershipViewSet( + ClassicReadOnlyViewSet[InformationSecurityRegistryEntry] +): + queryset = InformationSecurityRegistryEntry.objects.select_related("organization").all() + serializer_class = CorporationMembershipSerializer + permission_classes = [IsAuthenticated] + filterset_class = CorporationMembershipFilter + search_fields = ["registry_name", "entry_number"] + ordering_fields = ["issued_at", "expires_at", "created_at"] + ordering = ["-issued_at"] diff --git a/src/apps/external_data/migrations/0002_information_security_registry_entry.py b/src/apps/external_data/migrations/0002_information_security_registry_entry.py new file mode 100644 index 0000000..f88f70d --- /dev/null +++ b/src/apps/external_data/migrations/0002_information_security_registry_entry.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.25 on 2026-04-14 08:59 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('organization', '0003_auto_20260407_1326'), + ('external_data', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='InformationSecurityRegistryEntry', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True, help_text='Дата и время создания записи', verbose_name='создано')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Дата и время последнего обновления', verbose_name='обновлено')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, verbose_name='ID')), + ('registry_name', models.CharField(db_index=True, max_length=255, verbose_name='название реестра')), + ('presence_status', models.CharField(choices=[('present', 'В реестре'), ('absent', 'Не в реестре')], db_index=True, max_length=16, verbose_name='статус присутствия')), + ('entry_number', models.CharField(blank=True, default='', max_length=64, verbose_name='регистрационный номер')), + ('issued_at', models.DateField(blank=True, null=True, verbose_name='дата выдачи')), + ('expires_at', models.DateField(blank=True, null=True, verbose_name='дата окончания')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='information_security_registry_entries', to='organization.organization', verbose_name='организация')), + ], + options={ + 'verbose_name': 'запись реестра безопасности', + 'verbose_name_plural': 'записи реестра безопасности', + 'ordering': ['registry_name', '-issued_at'], + }, + ), + ] diff --git a/src/apps/external_data/models.py b/src/apps/external_data/models.py index 2519641..fc28e4f 100644 --- a/src/apps/external_data/models.py +++ b/src/apps/external_data/models.py @@ -110,3 +110,41 @@ class ArbitrationCase(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): def __str__(self) -> str: return f"{self.case_number} ({self.organization_id})" + + +class InformationSecurityRegistryEntry(UUIDPrimaryKeyMixin, TimestampMixin, models.Model): + class PresenceStatus(models.TextChoices): + PRESENT = "present", _("В реестре") + ABSENT = "absent", _("Не в реестре") + + organization = models.ForeignKey( + Organization, + on_delete=models.CASCADE, + related_name="information_security_registry_entries", + verbose_name=_("организация"), + ) + registry_name = models.CharField( + _("название реестра"), max_length=255, db_index=True + ) + presence_status = models.CharField( + _("статус присутствия"), + max_length=16, + choices=PresenceStatus.choices, + db_index=True, + ) + entry_number = models.CharField( + _("регистрационный номер"), + max_length=64, + blank=True, + default="", + ) + issued_at = models.DateField(_("дата выдачи"), null=True, blank=True) + expires_at = models.DateField(_("дата окончания"), null=True, blank=True) + + class Meta: + verbose_name = _("запись реестра безопасности") + verbose_name_plural = _("записи реестра безопасности") + ordering = ["registry_name", "-issued_at"] + + def __str__(self) -> str: + return f"{self.registry_name} ({self.organization_id})" diff --git a/src/apps/external_data/serializers.py b/src/apps/external_data/serializers.py index 0f21f9f..c9cbb44 100644 --- a/src/apps/external_data/serializers.py +++ b/src/apps/external_data/serializers.py @@ -5,6 +5,7 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from rest_framework import serializers @@ -75,3 +76,19 @@ class ArbitrationCaseSerializer(serializers.ModelSerializer): "status", "decision_date", ] + + +class CorporationMembershipSerializer(serializers.ModelSerializer): + organization = serializers.UUIDField(source="organization_id", read_only=True) + + class Meta: + model = InformationSecurityRegistryEntry + fields = [ + "id", + "organization", + "registry_name", + "presence_status", + "entry_number", + "issued_at", + "expires_at", + ] diff --git a/src/apps/external_data/urls.py b/src/apps/external_data/urls.py index b3579be..2920190 100644 --- a/src/apps/external_data/urls.py +++ b/src/apps/external_data/urls.py @@ -5,6 +5,7 @@ from apps.external_data.api import ( IndustrialProductViewSet, ProsecutorCheckViewSet, PublicProcurementViewSet, + CorporationMembershipViewSet, ) from django.urls import include, path from rest_framework.routers import DefaultRouter @@ -24,6 +25,11 @@ router.register( router.register( "arbitration-cases", ArbitrationCaseViewSet, basename="arbitration-cases" ) +router.register( + "corporation-memberships", + CorporationMembershipViewSet, + basename="corporation-memberships", +) urlpatterns = [ path("", include(router.urls)), diff --git a/src/apps/form_2/api.py b/src/apps/form_2/api.py index 371d8b1..06985c3 100644 --- a/src/apps/form_2/api.py +++ b/src/apps/form_2/api.py @@ -8,6 +8,11 @@ API формы Ф-2. import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_2.models import FormF2Record from apps.form_2.serializers import ( @@ -42,7 +47,9 @@ class FormF2UploadView(APIView): def post(self, request): """Загрузка и обработка файла.""" serializer = FormF2UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] @@ -59,11 +66,13 @@ class FormF2UploadView(APIView): ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь на обработку", - "task_id": task.id, - }, + build_upload_success_payload( + form="f2", + report_year=report_year, + report_quarter=report_quarter, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -77,20 +86,21 @@ class FormF2UploadView(APIView): result_serializer = FormF2ParseResultSerializer(result) return Response( - { - "success": True, - "data": result_serializer.data, - }, + build_upload_success_payload( + form="f2", + report_year=report_year, + report_quarter=report_quarter, + status="done", + result=result_serializer.data, + ), status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-2") - return Response( - { - "success": False, - "error": str(e), - }, - status=status.HTTP_400_BAD_REQUEST, + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_2/serializers.py b/src/apps/form_2/serializers.py index da9e0fd..3da59c3 100644 --- a/src/apps/form_2/serializers.py +++ b/src/apps/form_2/serializers.py @@ -8,6 +8,10 @@ """ from apps.form_2.models import FormF2Record +from apps.core.upload_contracts import ( + UploadQuarterSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -51,52 +55,9 @@ class FormF2RecordListSerializer(serializers.ModelSerializer): ] -class FormF2UploadSerializer(serializers.Serializer): - """Сериализатор загрузки файла формы Ф-2.""" - - file = serializers.FileField(help_text="Excel файл формы Ф-2 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - """Валидация загруженного файла.""" - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError( - "Неподдерживаемый формат файла. Используйте .xlsx или .xls" - ) - # Ограничение размера: 50MB - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла превышает 50MB") - return value +class FormF2UploadSerializer(UploadQuarterSerializer): + """Загрузка файла формы Ф-2 (квартальная отчетность).""" -class FieldErrorSerializer(serializers.Serializer): - """Сериализатор ошибки поля.""" - - field = serializers.CharField() - message = serializers.CharField() - - -class RowValidationErrorSerializer(serializers.Serializer): - """Сериализатор ошибки валидации строки.""" - - row = serializers.IntegerField() - inn = serializers.CharField(allow_null=True) - kpp = serializers.CharField(allow_null=True) - organization_name = serializers.CharField(allow_null=True) - errors = FieldErrorSerializer(many=True) - - -class FormF2ParseResultSerializer(serializers.Serializer): +class FormF2ParseResultSerializer(UploadParseResultSerializer): """Сериализатор результата парсинга.""" - - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) diff --git a/src/apps/form_3/api.py b/src/apps/form_3/api.py index c7c1ccf..6705ea4 100644 --- a/src/apps/form_3/api.py +++ b/src/apps/form_3/api.py @@ -8,6 +8,11 @@ API формы Ф-3. import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_3.models import FormF3Record from apps.form_3.serializers import ( @@ -41,7 +46,9 @@ class FormF3UploadView(APIView): def post(self, request): """Загрузка и обработка файла.""" serializer = FormF3UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] @@ -57,11 +64,12 @@ class FormF3UploadView(APIView): ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь на обработку", - "task_id": task.id, - }, + build_upload_success_payload( + form="f3", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -74,20 +82,20 @@ class FormF3UploadView(APIView): result_serializer = FormF3ParseResultSerializer(result) return Response( - { - "success": True, - "data": result_serializer.data, - }, + build_upload_success_payload( + form="f3", + report_year=report_year, + status="done", + result=result_serializer.data, + ), status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-3") - return Response( - { - "success": False, - "error": str(e), - }, - status=status.HTTP_400_BAD_REQUEST, + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_3/serializers.py b/src/apps/form_3/serializers.py index 0aa6d51..1d2fd05 100644 --- a/src/apps/form_3/serializers.py +++ b/src/apps/form_3/serializers.py @@ -8,6 +8,10 @@ """ from apps.form_3.models import FormF3Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -50,51 +54,9 @@ class FormF3RecordListSerializer(serializers.ModelSerializer): ] -class FormF3UploadSerializer(serializers.Serializer): - """Сериализатор загрузки файла формы Ф-3.""" - - file = serializers.FileField(help_text="Excel файл формы Ф-3 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - """Валидация загруженного файла.""" - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError( - "Неподдерживаемый формат файла. Используйте .xlsx или .xls" - ) - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла превышает 50MB") - return value +class FormF3UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-3 (годовая отчетность).""" -class FieldErrorSerializer(serializers.Serializer): - """Сериализатор ошибки поля.""" - - field = serializers.CharField() - message = serializers.CharField() - - -class RowValidationErrorSerializer(serializers.Serializer): - """Сериализатор ошибки валидации строки.""" - - row = serializers.IntegerField() - inn = serializers.CharField(allow_null=True) - kpp = serializers.CharField(allow_null=True) - organization_name = serializers.CharField(allow_null=True) - errors = FieldErrorSerializer(many=True) - - -class FormF3ParseResultSerializer(serializers.Serializer): +class FormF3ParseResultSerializer(UploadParseResultSerializer): """Сериализатор результата парсинга.""" - - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) diff --git a/src/apps/form_4/api.py b/src/apps/form_4/api.py index 1b5c0e7..6832732 100644 --- a/src/apps/form_4/api.py +++ b/src/apps/form_4/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_4.models import FormF4Record from apps.form_4.serializers import ( @@ -27,10 +32,13 @@ class FormF4UploadView(APIView): def post(self, request): serializer = FormF4UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] - report_quarter = serializer.validated_data.get("report_quarter") + report_half_year = serializer.validated_data.get("report_half_year") + report_quarter = report_half_year if file.size > BACKGROUND_THRESHOLD: task = process_form_f4_file.delay( @@ -40,11 +48,13 @@ class FormF4UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f4", + report_year=report_year, + report_half_year=report_half_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +65,21 @@ class FormF4UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF4ParseResultSerializer(result).data} + build_upload_success_payload( + form="f4", + report_year=report_year, + report_half_year=report_half_year, + status="done", + result=FormF4ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-4") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_4/serializers.py b/src/apps/form_4/serializers.py index b902dd3..4c22e08 100644 --- a/src/apps/form_4/serializers.py +++ b/src/apps/form_4/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-4.""" from apps.form_4.models import FormF4Record +from apps.core.upload_contracts import ( + UploadHalfYearSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -39,40 +43,9 @@ class FormF4RecordListSerializer(serializers.ModelSerializer): ] -class FormF4UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-4 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError("Неподдерживаемый формат файла") - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла превышает 50MB") - return value +class FormF4UploadSerializer(UploadHalfYearSerializer): + """Загрузка файла формы Ф-4 (полугодовая отчетность).""" -class FieldErrorSerializer(serializers.Serializer): - field = serializers.CharField() - message = serializers.CharField() - - -class RowValidationErrorSerializer(serializers.Serializer): - row = serializers.IntegerField() - inn = serializers.CharField(allow_null=True) - kpp = serializers.CharField(allow_null=True) - organization_name = serializers.CharField(allow_null=True) - errors = FieldErrorSerializer(many=True) - - -class FormF4ParseResultSerializer(serializers.Serializer): - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) +class FormF4ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/src/apps/form_5/api.py b/src/apps/form_5/api.py index c228710..b183704 100644 --- a/src/apps/form_5/api.py +++ b/src/apps/form_5/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_5.models import FormF5Record from apps.form_5.serializers import ( @@ -27,9 +32,12 @@ class FormF5UploadView(APIView): def post(self, request): serializer = FormF5UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] + report_quarter = serializer.validated_data.get("report_quarter") if file.size > BACKGROUND_THRESHOLD: @@ -40,11 +48,12 @@ class FormF5UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f5", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +64,20 @@ class FormF5UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF5ParseResultSerializer(result).data} + build_upload_success_payload( + form="f5", + report_year=report_year, + status="done", + result=FormF5ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-5") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_5/serializers.py b/src/apps/form_5/serializers.py index 773b611..5bcd954 100644 --- a/src/apps/form_5/serializers.py +++ b/src/apps/form_5/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-5.""" from apps.form_5.models import FormF5Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -41,40 +45,9 @@ class FormF5RecordListSerializer(serializers.ModelSerializer): ] -class FormF5UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-5 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError("Неподдерживаемый формат файла") - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла превышает 50MB") - return value +class FormF5UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-5 (годовая отчетность).""" -class FieldErrorSerializer(serializers.Serializer): - field = serializers.CharField() - message = serializers.CharField() - - -class RowValidationErrorSerializer(serializers.Serializer): - row = serializers.IntegerField() - inn = serializers.CharField(allow_null=True) - kpp = serializers.CharField(allow_null=True) - organization_name = serializers.CharField(allow_null=True) - errors = FieldErrorSerializer(many=True) - - -class FormF5ParseResultSerializer(serializers.Serializer): - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) +class FormF5ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/src/apps/form_6/api.py b/src/apps/form_6/api.py index ab87c33..7029c6c 100644 --- a/src/apps/form_6/api.py +++ b/src/apps/form_6/api.py @@ -2,6 +2,11 @@ import logging +from apps.core.upload_contracts import ( + build_upload_error_response, + build_upload_success_payload, + build_upload_validation_response, +) from apps.core.viewsets import ReadOnlyViewSet from apps.form_6.models import FormF6Record from apps.form_6.serializers import ( @@ -27,7 +32,9 @@ class FormF6UploadView(APIView): def post(self, request): serializer = FormF6UploadSerializer(data=request.data) - serializer.is_valid(raise_exception=True) + if not serializer.is_valid(): + return build_upload_validation_response(serializer.errors) + file = serializer.validated_data["file"] report_year = serializer.validated_data["report_year"] report_quarter = serializer.validated_data.get("report_quarter") @@ -40,11 +47,12 @@ class FormF6UploadView(APIView): report_quarter, ) return Response( - { - "success": True, - "message": "Файл поставлен в очередь", - "task_id": task.id, - }, + build_upload_success_payload( + form="f6", + report_year=report_year, + status="queued", + job_id=task.id, + ), status=status.HTTP_202_ACCEPTED, ) @@ -55,12 +63,20 @@ class FormF6UploadView(APIView): report_quarter=report_quarter, ) return Response( - {"success": True, "data": FormF6ParseResultSerializer(result).data} + build_upload_success_payload( + form="f6", + report_year=report_year, + status="done", + result=FormF6ParseResultSerializer(result).data, + ), + status=status.HTTP_200_OK, ) except Exception as e: logger.exception("Ошибка обработки файла Ф-6") - return Response( - {"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST + return build_upload_error_response( + error_code="processing_error", + error_message=str(e), + status_code=status.HTTP_400_BAD_REQUEST, ) diff --git a/src/apps/form_6/serializers.py b/src/apps/form_6/serializers.py index 304f35c..8a647ab 100644 --- a/src/apps/form_6/serializers.py +++ b/src/apps/form_6/serializers.py @@ -1,6 +1,10 @@ """Сериализаторы формы Ф-6.""" from apps.form_6.models import FormF6Record +from apps.core.upload_contracts import ( + UploadAnnualSerializer, + UploadParseResultSerializer, +) from apps.organization.serializers import OrganizationSerializer from rest_framework import serializers @@ -40,40 +44,9 @@ class FormF6RecordListSerializer(serializers.ModelSerializer): ] -class FormF6UploadSerializer(serializers.Serializer): - file = serializers.FileField(help_text="Excel файл формы Ф-6 (.xlsx)") - report_year = serializers.IntegerField(min_value=2000, help_text="Отчетный год") - report_quarter = serializers.IntegerField( - min_value=1, - max_value=4, - required=False, - allow_null=True, - help_text="Отчетный квартал от 1 до 4. Пусто для годовой формы.", - ) - - def validate_file(self, value): - if not value.name.endswith((".xlsx", ".xls")): - raise serializers.ValidationError("Неподдерживаемый формат файла") - if value.size > 50 * 1024 * 1024: - raise serializers.ValidationError("Размер файла превышает 50MB") - return value +class FormF6UploadSerializer(UploadAnnualSerializer): + """Загрузка файла формы Ф-6 (годовая отчетность).""" -class FieldErrorSerializer(serializers.Serializer): - field = serializers.CharField() - message = serializers.CharField() - - -class RowValidationErrorSerializer(serializers.Serializer): - row = serializers.IntegerField() - inn = serializers.CharField(allow_null=True) - kpp = serializers.CharField(allow_null=True) - organization_name = serializers.CharField(allow_null=True) - errors = FieldErrorSerializer(many=True) - - -class FormF6ParseResultSerializer(serializers.Serializer): - batch_id = serializers.IntegerField() - loaded_count = serializers.IntegerField() - skipped_count = serializers.IntegerField() - errors = RowValidationErrorSerializer(many=True) +class FormF6ParseResultSerializer(UploadParseResultSerializer): + """Сериализатор результата парсинга.""" diff --git a/src/apps/organization/dictionary_urls.py b/src/apps/organization/dictionary_urls.py new file mode 100644 index 0000000..cc7b691 --- /dev/null +++ b/src/apps/organization/dictionary_urls.py @@ -0,0 +1,14 @@ +"""URL-маршруты для справочников организации.""" + +from apps.organization.dictionary_views import CorporationScopeDictionaryView +from django.urls import path + +app_name = "organization_dictionaries" + +urlpatterns = [ + path( + "corporation-scopes/", + CorporationScopeDictionaryView.as_view(), + name="corporation-scopes", + ), +] diff --git a/src/apps/organization/dictionary_views.py b/src/apps/organization/dictionary_views.py new file mode 100644 index 0000000..35bfb59 --- /dev/null +++ b/src/apps/organization/dictionary_views.py @@ -0,0 +1,18 @@ +"""Dictionary endpoints for organization domain.""" + +from apps.organization.scope_utils import get_corporation_scope_dictionary +from apps.organization.serializers import CorporationScopeDictionarySerializer +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + + +class CorporationScopeDictionaryView(APIView): + """Return static dictionary for organization corporation scopes.""" + + permission_classes = [IsAuthenticated] + + def get(self, request): # noqa: ARG002 + items = get_corporation_scope_dictionary() + serializer = CorporationScopeDictionarySerializer(items, many=True) + return Response({"results": serializer.data}) diff --git a/src/apps/organization/scope_utils.py b/src/apps/organization/scope_utils.py index d7a1210..a52e7f0 100644 --- a/src/apps/organization/scope_utils.py +++ b/src/apps/organization/scope_utils.py @@ -16,6 +16,21 @@ SCOPE_LABELS: dict[str, str] = { "rosatom": "Госкорпорация «Росатом»", "roscosmos": "Госкорпорация «Роскосмос»", "opk": "Организации ОПК", + "other": "Иная корпорация", +} + +SCOPE_SHORT_NAMES: dict[str, str] = { + "rosatom": "Росатом", + "roscosmos": "Роскосмос", + "opk": "ОПК", + "other": "Иная", +} + +SCOPE_SORT_ORDER: dict[str, int] = { + "rosatom": 10, + "roscosmos": 20, + "opk": 30, + "other": 90, } @@ -38,6 +53,27 @@ def scope_labels(scope_codes: Iterable[str]) -> list[str]: return [SCOPE_LABELS[code] for code in scope_codes if code in SCOPE_LABELS] +def get_corporation_scope_dictionary() -> list[dict[str, str | int]]: + """Возвращает справочник корпусов для API-словаря.""" + items: list[dict[str, str | int]] = [] + for code, sort_order in sorted( + SCOPE_SORT_ORDER.items(), key=lambda item: item[1] + ): + label = SCOPE_LABELS.get(code) + short_name = SCOPE_SHORT_NAMES.get(code) + if not label or not short_name: + continue + items.append( + { + "code": code, + "name": label, + "short_name": short_name, + "sort_order": sort_order, + } + ) + return items + + def build_scope_query(scope_codes: Iterable[str]) -> Q: query = Q() for scope_code in scope_codes: diff --git a/src/apps/organization/serializers.py b/src/apps/organization/serializers.py index a65227f..90d3b17 100644 --- a/src/apps/organization/serializers.py +++ b/src/apps/organization/serializers.py @@ -7,6 +7,7 @@ """ from apps.organization.models import Organization +from apps.organization.scope_utils import SCOPE_LABELS from apps.registers.models import Register from rest_framework import serializers @@ -108,12 +109,25 @@ class OrganizationCatalogBaseSerializer(serializers.ModelSerializer): return obj.get_active_registry_names() @staticmethod - def get_corporation_scope(obj: Organization) -> list[str]: - return obj.get_corporation_scopes() + def _primary_scope_or_default(obj: Organization) -> str: + scopes = obj.get_corporation_scopes() + return scopes[0] if scopes else "" @staticmethod - def get_corporation_scope_label(obj: Organization) -> list[str]: - return obj.get_corporation_scope_labels() + def get_corporation_scope(obj: Organization) -> str: + return OrganizationCatalogBaseSerializer._primary_scope_or_default(obj) + + @staticmethod + def get_corporation_scope_label(obj: Organization) -> str: + scope = OrganizationCatalogBaseSerializer._primary_scope_or_default(obj) + return OrganizationCatalogBaseSerializer._label_or_empty(scope) + + @staticmethod + def _label_or_empty(scope_code: str) -> str: + if not scope_code: + return "" + return SCOPE_LABELS.get(scope_code, "") + class OrganizationCatalogListSerializer(OrganizationCatalogBaseSerializer): @@ -193,3 +207,12 @@ class OrganizationCatalogDetailSerializer(OrganizationCatalogBaseSerializer): "created_at", "updated_at", ] + + +class CorporationScopeDictionarySerializer(serializers.Serializer): + """Serializer for corporation scope dictionary entries.""" + + code = serializers.CharField() + name = serializers.CharField() + short_name = serializers.CharField() + sort_order = serializers.IntegerField() diff --git a/src/apps/user/serializers.py b/src/apps/user/serializers.py index 7cdac99..6c79f9d 100644 --- a/src/apps/user/serializers.py +++ b/src/apps/user/serializers.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import Any + from django.contrib.auth import get_user_model from rest_framework import serializers from rest_framework.validators import UniqueValidator @@ -89,7 +93,7 @@ class UserSerializer(serializers.ModelSerializer): class CurrentUserProfileSerializer(serializers.ModelSerializer): """Профиль текущего пользователя в контракте `/users/me/`.""" - middle_name = serializers.CharField(source="mid_name", allow_null=True) + middle_name = serializers.SerializerMethodField() full_name = serializers.ReadOnlyField() class Meta: @@ -101,6 +105,10 @@ class CurrentUserProfileSerializer(serializers.ModelSerializer): "full_name", ) + @staticmethod + def get_middle_name(obj: Profile) -> str: + return obj.mid_name or "" + class CurrentUserCapabilitiesSerializer(serializers.Serializer): """Возможности пользователя для UI-контрактов.""" @@ -153,6 +161,88 @@ class CurrentUserSerializer(serializers.ModelSerializer): ) +class UserManagementSerializer(serializers.Serializer): + """Serializer for users listed in admin management endpoint.""" + + id = serializers.IntegerField(read_only=True) + username = serializers.CharField(read_only=True) + email = serializers.EmailField(read_only=True) + phone = serializers.CharField(read_only=True, allow_null=True) + is_active = serializers.BooleanField(read_only=True) + first_name = serializers.SerializerMethodField() + middle_name = serializers.SerializerMethodField() + last_name = serializers.SerializerMethodField() + progress_message = serializers.SerializerMethodField() + result = serializers.SerializerMethodField() + error = serializers.SerializerMethodField() + started_at = serializers.SerializerMethodField() + completed_at = serializers.SerializerMethodField() + duration = serializers.SerializerMethodField() + is_successful = serializers.SerializerMethodField() + + @staticmethod + def _get_profile_value(profile: Profile | None, field_name: str) -> str: + if profile is None: + return "" + value = getattr(profile, field_name, "") + return value or "" + + def get_first_name(self, obj: User) -> str: + return self._get_profile_value(getattr(obj, "profile", None), "first_name") + + def get_middle_name(self, obj: User) -> str: + return self._get_profile_value(getattr(obj, "profile", None), "mid_name") + + def get_last_name(self, obj: User) -> str: + return self._get_profile_value(getattr(obj, "profile", None), "last_name") + + @staticmethod + def _get_latest_job(obj: User) -> Any | None: + return getattr(obj, "latest_job", None) + + def get_progress_message(self, obj: User) -> str | None: + job = self._get_latest_job(obj) + if job is None: + return None + return job.progress_message or None + + def get_result(self, obj: User): + job = self._get_latest_job(obj) + if job is None: + return None + return job.result + + def get_error(self, obj: User) -> str | None: + job = self._get_latest_job(obj) + if job is None: + return None + return job.error or None + + def get_started_at(self, obj: User): + job = self._get_latest_job(obj) + if job is None: + return None + return job.started_at + + def get_completed_at(self, obj: User): + job = self._get_latest_job(obj) + if job is None: + return None + return job.completed_at + + def get_duration(self, obj: User) -> float | None: + job = self._get_latest_job(obj) + if job is None: + return None + return job.duration + + def get_is_successful(self, obj: User) -> bool | None: + job = self._get_latest_job(obj) + if job is None: + return None + return job.is_successful + + class UserUpdateSerializer(serializers.ModelSerializer): """Сериализатор для обновления данных пользователя""" diff --git a/src/apps/user/urls.py b/src/apps/user/urls.py index 8de1a0a..c25dd3b 100644 --- a/src/apps/user/urls.py +++ b/src/apps/user/urls.py @@ -14,6 +14,7 @@ urlpatterns = [ path("token/verify/", TokenVerifyView.as_view(), name="token_verify"), # Пользовательские данные path("me/", views.CurrentUserView.as_view(), name="current_user"), + path("admin/users/", views.AdminUsersManagementView.as_view(), name="admin_users"), path("me/update/", views.UserUpdateView.as_view(), name="user_update"), path("profile/", views.ProfileDetailView.as_view(), name="profile_detail"), path("profile/full/", views.user_profile_detail, name="profile_full"), diff --git a/src/apps/user/views.py b/src/apps/user/views.py index 2fd5b1a..b078c76 100644 --- a/src/apps/user/views.py +++ b/src/apps/user/views.py @@ -1,10 +1,14 @@ -from django.contrib.auth import authenticate +from apps.core.models import BackgroundJob +from apps.core.services import BackgroundJobService +from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.hashers import check_password +from django.db.models import F +from django.db.models.functions import Coalesce from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny, IsAdminUser, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken @@ -15,12 +19,15 @@ from .serializers import ( PasswordChangeSerializer, ProfileUpdateSerializer, TokenSerializer, + UserManagementSerializer, UserRegistrationSerializer, UserSerializer, UserUpdateSerializer, ) from .services import ProfileService, UserService +User = get_user_model() + # Swagger теги для группировки AUTH_TAG = "Аутентификация" USER_TAG = "Пользователь" @@ -230,6 +237,52 @@ class PasswordChangeView(APIView): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class AdminUsersManagementView(APIView): + """Список пользователей для административной страницы управления.""" + + permission_classes = [IsAdminUser] + + @staticmethod + def _attach_latest_jobs(users: list[User]): + user_ids = [user.id for user in users] + if not user_ids: + return + + latest_jobs: dict[int, BackgroundJob] = {} + jobs = BackgroundJobService.get_queryset().filter( + user_id__in=user_ids + ).annotate( + _effective_job_ts=Coalesce( + F("completed_at"), + F("started_at"), + F("updated_at"), + F("created_at"), + ) + ).order_by("user_id", "-_effective_job_ts") + + for job in jobs: + if job.user_id not in latest_jobs: + latest_jobs[job.user_id] = job + + for user in users: + user.latest_job = latest_jobs.get(user.id) + + @swagger_auto_schema( + tags=[USER_TAG], + operation_summary="Список пользователей (admin)", + operation_description=( + "Возвращает пользователей для административной панели управления. " + "Включает метрики последней фоновой задачи пользователя." + ), + responses={200: UserManagementSerializer(many=True)}, + ) + def get(self, request): + users = User.objects.all().select_related("profile").order_by("id") + self._attach_latest_jobs(list(users)) + serializer = UserManagementSerializer(users, many=True) + return Response({"results": serializer.data}) + + @swagger_auto_schema( method="get", tags=[USER_TAG], diff --git a/src/core/api_v1_urls.py b/src/core/api_v1_urls.py index 01ad31f..37acc04 100644 --- a/src/core/api_v1_urls.py +++ b/src/core/api_v1_urls.py @@ -22,6 +22,7 @@ jobs_urlpatterns = [ urlpatterns = [ path("analytics/", include("apps.organization.analytics_root_urls")), path("exchange/", include("apps.exchange.urls")), + path("dictionaries/", include("apps.organization.dictionary_urls")), path("users/", include("apps.user.urls")), path("jobs/", include((jobs_urlpatterns, "jobs"))), path("organizations/", include("apps.organization.urls")), diff --git a/tests/apps/external_data/factories.py b/tests/apps/external_data/factories.py index 31e21b0..fc2e9f0 100644 --- a/tests/apps/external_data/factories.py +++ b/tests/apps/external_data/factories.py @@ -6,6 +6,7 @@ from apps.external_data.models import ( IndustrialProduct, ProsecutorCheck, PublicProcurement, + InformationSecurityRegistryEntry, ) from faker import Faker @@ -68,3 +69,17 @@ class ArbitrationCaseFactory(factory.django.DjangoModelFactory): party_role = "defendant" status = "hearing_scheduled" decision_date = factory.LazyAttribute(lambda _: fake.date_this_year()) + + +class InformationSecurityRegistryEntryFactory( + factory.django.DjangoModelFactory +): + class Meta: + model = InformationSecurityRegistryEntry + + organization = factory.SubFactory(OrganizationFactory) + registry_name = "Реестр лицензий на деятельность по технической защите конфиденциальной информации" + presence_status = "present" + entry_number = "77-001234" + issued_at = factory.LazyAttribute(lambda _: fake.date_this_year()) + expires_at = factory.LazyAttribute(lambda _: fake.date_this_year()) diff --git a/tests/apps/external_data/test_api.py b/tests/apps/external_data/test_api.py index c16afec..b950023 100644 --- a/tests/apps/external_data/test_api.py +++ b/tests/apps/external_data/test_api.py @@ -13,6 +13,7 @@ from tests.apps.external_data.factories import ( IndustrialProductFactory, ProsecutorCheckFactory, PublicProcurementFactory, + InformationSecurityRegistryEntryFactory, ) from tests.apps.organization.factories import OrganizationFactory from tests.apps.user.factories import UserFactory @@ -89,3 +90,29 @@ class ExternalDataApiTest(APITestCase): self.assertEqual(procurement_response.data["count"], 1) self.assertEqual(arbitration_response.status_code, status.HTTP_200_OK) self.assertEqual(arbitration_response.data["count"], 1) + + def test_corporation_memberships_filter(self): + InformationSecurityRegistryEntryFactory( + organization=self.organization, + presence_status="present", + ) + InformationSecurityRegistryEntryFactory( + organization=self.other_organization, + presence_status="absent", + ) + + response = self.client.get( + f"/api/v1/corporation-memberships/?organization={self.organization.id}" + "&presence_status=present" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["count"], 1) + result = response.data["results"][0] + self.assertEqual( + result["organization"], + str(self.organization.id), + ) + self.assertEqual(result["presence_status"], "present") + self.assertIn("registry_name", result) + self.assertIn("entry_number", result) diff --git a/tests/apps/forms/test_upload_contracts_api.py b/tests/apps/forms/test_upload_contracts_api.py new file mode 100644 index 0000000..cbb4d27 --- /dev/null +++ b/tests/apps/forms/test_upload_contracts_api.py @@ -0,0 +1,211 @@ +"""Contract tests for F-2…F-6 upload endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import override_settings +from rest_framework import status +from rest_framework.test import APITestCase + +from tests.apps.user.factories import UserFactory + + +@override_settings(ROOT_URLCONF="core.urls") +class FormUploadContractsApiTest(APITestCase): + """Contract tests for multipart upload endpoints Ф-2 ... Ф-6.""" + + BACKGROUND_THRESHOLD_PLUS = 1024 * 1024 + 1 + RESULT_PAYLOAD = { + "batch_id": 101, + "loaded_count": 2, + "skipped_count": 1, + "errors": [], + } + CASES = { + "f2": { + "url": "/api/v1/forms/f2/upload/", + "form": "f2", + "payload": {"report_year": 2026, "report_quarter": 1}, + "period_field": "report_quarter", + "period_value": 1, + "report_period_display": "I квартал 2026", + "parse_target": "apps.form_2.api.parse_form_f2_file", + "task_target": "apps.form_2.api.process_form_f2_file", + }, + "f3": { + "url": "/api/v1/forms/f3/upload/", + "form": "f3", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_3.api.parse_form_f3_file", + "task_target": "apps.form_3.api.process_form_f3_file", + }, + "f4": { + "url": "/api/v1/forms/f4/upload/", + "form": "f4", + "payload": {"report_year": 2026, "report_half_year": 1}, + "period_field": "report_half_year", + "period_value": 1, + "report_period_display": "I полугодие 2026", + "parse_target": "apps.form_4.api.parse_form_f4_file", + "task_target": "apps.form_4.api.process_form_f4_file", + }, + "f5": { + "url": "/api/v1/forms/f5/upload/", + "form": "f5", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_5.api.parse_form_f5_file", + "task_target": "apps.form_5.api.process_form_f5_file", + }, + "f6": { + "url": "/api/v1/forms/f6/upload/", + "form": "f6", + "payload": {"report_year": 2026}, + "period_field": None, + "period_value": None, + "report_period_display": "2026 год", + "parse_target": "apps.form_6.api.parse_form_f6_file", + "task_target": "apps.form_6.api.process_form_f6_file", + }, + } + + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def _build_payload(self, base_payload: dict[str, int], file_size: int) -> dict: + payload = dict(base_payload) + payload["file"] = SimpleUploadedFile( + name="report.xlsx", + content=b"0" * file_size, + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + return payload + + def _assert_base_success_contract( + self, + response_data: dict, + case: dict, + status_code: str, + ): + self.assertEqual(response_data["form"], case["form"]) + self.assertEqual(response_data["report_year"], 2026) + self.assertEqual(response_data["status"], status_code) + self.assertEqual( + response_data["report_period_display"], + case["report_period_display"], + ) + datetime.fromisoformat(response_data["created_at"]) + self.assertIn("upload_id", response_data) + if case["period_field"]: + self.assertEqual(response_data[case["period_field"]], case["period_value"]) + else: + self.assertNotIn("report_quarter", response_data) + self.assertNotIn("report_half_year", response_data) + + def test_upload_validation_error_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + response = self.client.post( + case["url"], + case["payload"], + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error_code"], "validation_error") + self.assertEqual(response.data["error_message"], "Validation failed") + self.assertIsInstance(response.data["details"], list) + self.assertTrue(response.data["details"]) + self.assertTrue( + any(item["field"] == "file" for item in response.data["details"]) + ) + + def test_upload_async_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + task_mock = SimpleNamespace( + delay=Mock(return_value=SimpleNamespace(id="background-task-id")), + ) + with ( + patch(case["parse_target"]) as parse_mock, + patch(case["task_target"], task_mock), + ): + response = self.client.post( + case["url"], + self._build_payload( + case["payload"], + self.BACKGROUND_THRESHOLD_PLUS, + ), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual(response.data["status"], "queued") + self.assertIn("job_id", response.data) + self.assertEqual(response.data["job_id"], "background-task-id") + self.assertNotIn("result", response.data) + self._assert_base_success_contract( + response.data, + case, + status_code="queued", + ) + parse_mock.assert_not_called() + task_mock.delay.assert_called_once() + + def test_upload_sync_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + task_mock = SimpleNamespace( + delay=Mock(return_value=SimpleNamespace(id="should-not-use")), + ) + with patch( + case["parse_target"], + return_value=self.RESULT_PAYLOAD, + ) as parse_mock, patch(case["task_target"], task_mock): + response = self.client.post( + case["url"], + self._build_payload(case["payload"], file_size=256), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["status"], "done") + self.assertIn("result", response.data) + self.assertNotIn("job_id", response.data) + self.assertEqual(response.data["result"], self.RESULT_PAYLOAD) + self._assert_base_success_contract( + response.data, + case, + status_code="done", + ) + parse_mock.assert_called_once() + task_mock.delay.assert_not_called() + + def test_upload_processing_error_contract(self): + for _, case in self.CASES.items(): + with self.subTest(form=case["form"]): + with patch( + case["parse_target"], + side_effect=RuntimeError("parse failed"), + ) as parse_mock: + response = self.client.post( + case["url"], + self._build_payload(case["payload"], file_size=256), + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data["error_code"], "processing_error") + self.assertEqual(response.data["error_message"], "parse failed") + self.assertEqual(response.data["details"], []) + parse_mock.assert_called_once() diff --git a/tests/apps/organization/test_analytics_api.py b/tests/apps/organization/test_analytics_api.py index 663f51a..d0b8fa7 100644 --- a/tests/apps/organization/test_analytics_api.py +++ b/tests/apps/organization/test_analytics_api.py @@ -170,7 +170,7 @@ class OrganizationAnalyticsApiTest(APITestCase): avg_shift_work=Decimal("1.80"), ) - def test_financial_summary_endpoint(self): + def test_financial_summary_contract(self): response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/financial-summary/" "?report_year=2026&report_quarter=1" @@ -181,47 +181,160 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.data["revenue"]["previous_amount"], 760000000) self.assertEqual(response.data["taxes_paid"]["amount"], 18900000) self.assertEqual(response.data["insurance_contributions"]["amount"], 302000) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["report_period"], {"year": 2026, "quarter": 1}) + self.assertEqual(set(response.data["revenue"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual(set(response.data["net_profit"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual(set(response.data["taxes_paid"]), {"amount", "previous_amount", "delta_percent"}) + self.assertEqual( + set(response.data["insurance_contributions"]), + {"amount", "previous_amount", "delta_percent"}, + ) - def test_personnel_and_equipment_endpoints(self): + def test_economics_contract(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2025&to_year=2026" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["group"], "efficiency") + self.assertEqual(response.data["periods"], [2025, 2026]) + self.assertEqual( + response.data["kpis"].keys(), + {"revenue", "ebitda", "net_profit"}, + ) + self.assertIn("series", response.data) + for series in response.data["series"]: + self.assertIn("metric", series) + self.assertIn("unit", series) + self.assertIn("points", series) + self.assertEqual(series["unit"], "rub_thousands") + self.assertEqual(len(series["points"]), 2) + self.assertEqual(set(series["points"][0]), {"period", "value"}) + + self.assertEqual(len(response.data["ratios"]), 2) + self.assertIn("ros", response.data["ratios"][0]) + self.assertIn("roa", response.data["ratios"][0]) + self.assertIn("roe", response.data["ratios"][0]) + + def test_personnel_contract(self): personnel_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/personnel/" "?report_year=2026&history_years=2" ) - equipment_response = self.client.get( - f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" - "?report_year=2026" - ) - self.assertEqual(personnel_response.status_code, status.HTTP_200_OK) + self.assertEqual(personnel_response.data["organization_id"], str(self.organization.id)) + self.assertEqual(personnel_response.data["report_year"], 2026) self.assertEqual( personnel_response.data["headcount"]["average_employees"], 1050, ) self.assertEqual(len(personnel_response.data["history"]), 2) - - self.assertEqual(equipment_response.status_code, status.HTTP_200_OK) - self.assertEqual(equipment_response.data["summary"]["total_equipment"], 187) + self.assertEqual(len(personnel_response.data["age_distribution"]), 3) self.assertEqual( - equipment_response.data["categories"][0]["category"], - "Станочное оборудование", + personnel_response.data["age_distribution"][0]["age_group"], + "under_30", + ) + self.assertIn("employees_count", personnel_response.data["age_distribution"][0]) + + def test_equipment_contract(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/equipment/" + "?report_year=2026" ) - def test_products_and_risk_profile_endpoints(self): + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["report_year"], 2026) + self.assertEqual( + set(response.data["summary"]), + { + "total_equipment", + "domestic_equipment", + "imported_equipment", + "physical_wear_percent", + "utilization_rate", + "avg_shift_work", + "equipment_needed", + }, + ) + self.assertEqual(response.data["summary"]["total_equipment"], 187) + self.assertEqual(response.data["summary"]["physical_wear_percent"], 32.0) + self.assertEqual(response.data["summary"]["utilization_rate"], 0.92) + self.assertEqual( + response.data["age_distribution"][0]["bucket"], "under_5_years" + ) + self.assertEqual(len(response.data["age_distribution"]), 5) + self.assertGreaterEqual(len(response.data["categories"]), 1) + self.assertEqual( + response.data["categories"][0], + { + "category": "Станочное оборудование", + "total_equipment": 1, + "domestic_equipment": 1, + "imported_equipment": 0, + "physical_wear_percent": 28.4, + }, + ) + + def test_products_contract(self): products_response = self.client.get( f"/api/v1/organizations/{self.organization.id}/analytics/products/" "?frequency=quarterly&price_mode=actual&report_year=2026" ) - risk_response = self.client.get( + self.assertEqual(products_response.status_code, status.HTTP_200_OK) + self.assertEqual(products_response.data["organization_id"], str(self.organization.id)) + self.assertEqual(products_response.data["report_year"], 2026) + self.assertEqual(products_response.data["frequency"], "quarterly") + self.assertEqual(products_response.data["price_mode"], "actual") + self.assertEqual(products_response.data["summary"]["military_output_amount"], 11000000) + self.assertEqual(products_response.data["summary"]["civilian_output_amount"], 7000000) + self.assertEqual(products_response.data["summary"]["hightech_output_amount"], 1500000) + self.assertEqual(products_response.data["summary"]["rd_volume_amount"], 900000) + self.assertEqual(len(products_response.data["production_series"]), 1) + self.assertEqual(len(products_response.data["sales_series"]), 1) + + first_production = products_response.data["production_series"][0] + first_sales = products_response.data["sales_series"][0] + self.assertEqual(first_production["period"], "2026-Q1") + self.assertEqual(first_production["military_output_amount"], 11000000) + self.assertEqual(first_sales["military_domestic_amount"], 9000000) + self.assertEqual(first_sales["civilian_export_amount"], 2000000) + + def test_forecast_contract(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/forecast/" + "?scenario=base&horizon_years=3" + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertEqual(response.data["scenario"], "base") + self.assertEqual(response.data["horizon_years"], 3) + self.assertEqual(response.data["base_year"], 2026) + self.assertEqual(len(response.data["forecast"]), 3) + self.assertEqual(response.data["forecast"][0]["year"], 2027) + self.assertIn("revenue_amount", response.data["forecast"][0]) + self.assertIn("net_profit_amount", response.data["forecast"][0]) + self.assertIn("margin_percent", response.data["forecast"][0]) + self.assertGreaterEqual(len(response.data["risk_factors"]), 1) + self.assertIn("code", response.data["risk_factors"][0]) + self.assertIn("name", response.data["risk_factors"][0]) + self.assertIn("impact_level", response.data["risk_factors"][0]) + + def test_risk_profile_contract(self): + response = self.client.get( f"/api/v1/organizations/{self.organization.id}/risk-profile/" ) - self.assertEqual(products_response.status_code, status.HTTP_200_OK) - self.assertEqual( - products_response.data["summary"]["military_output_amount"], - 11000000, - ) - self.assertEqual(risk_response.status_code, status.HTTP_200_OK) - self.assertEqual(risk_response.data["risk_level"], "low") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["organization_id"], str(self.organization.id)) + self.assertTrue(response.data["financial_reports_available"]) + self.assertTrue(response.data["tax_reports_available"]) + self.assertIn("risk_level", response.data) + self.assertIn("updated_at", response.data) def test_dashboard_endpoint(self): response = self.client.get( @@ -230,6 +343,32 @@ class OrganizationAnalyticsApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["corporation_scope"], "rosatom") + self.assertIn("distribution_by_cluster", response.data) + self.assertIn("executors_by_cluster", response.data) + self.assertIn("headcount_growth_by_cluster", response.data) + self.assertIn("bankruptcy_free_share_by_cluster", response.data) self.assertEqual( response.data["distribution_by_cluster"][0]["cluster"], "radioelectronics" ) + self.assertEqual( + response.data["executors_by_cluster"][0]["cluster"], "radioelectronics" + ) + self.assertIn( + "executors_count", + response.data["executors_by_cluster"][0], + ) + self.assertIn("growth_percent", response.data["headcount_growth_by_cluster"][0]) + + def test_analytics_query_validation(self): + response = self.client.get( + f"/api/v1/organizations/{self.organization.id}/analytics/economics/" + "?group=efficiency&from_year=2026&to_year=2025" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(response.data["success"]) + self.assertEqual(response.data["errors"][0]["code"], "validation_error") + self.assertEqual( + response.data["errors"][0]["message"], + "Validation failed", + ) diff --git a/tests/apps/organization/test_api.py b/tests/apps/organization/test_api.py index 77c35e6..d5e7c61 100644 --- a/tests/apps/organization/test_api.py +++ b/tests/apps/organization/test_api.py @@ -65,7 +65,11 @@ class OrganizationApiTest(APITestCase): self.assertEqual( response.data["results"][0]["active_registry_names"], ["Реестр ОПК"] ) - self.assertEqual(response.data["results"][0]["corporation_scope"], ["opk"]) + self.assertEqual(response.data["results"][0]["corporation_scope"], "opk") + self.assertEqual( + response.data["results"][0]["corporation_scope_label"], + "Организации ОПК", + ) self.assertEqual(response.data["results"][0]["short_name"], "АО «Альфа»") def test_detail_includes_active_registries(self): @@ -102,7 +106,11 @@ class OrganizationApiTest(APITestCase): response.data["active_registries"], [{"id": str(register.id), "name": "Реестр Роскосмос"}], ) - self.assertEqual(response.data["corporation_scope"], ["roscosmos"]) + self.assertEqual(response.data["corporation_scope"], "roscosmos") + self.assertEqual( + response.data["corporation_scope_label"], + "Госкорпорация «Роскосмос»", + ) self.assertEqual( response.data["general_director"]["full_name"], "Иванов Иван Иванович", @@ -180,3 +188,26 @@ class OrganizationApiTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["results"]), 1) self.assertEqual(response.data["results"][0]["id"], str(rosatom_org.id)) + + +class OrganizationDictionaryApiTest(APITestCase): + """Tests for organization dictionaries endpoints.""" + + def setUp(self): + self.user = UserFactory.create_user() + self.client.force_authenticate(self.user) + + def test_corporation_scopes_dictionary_returns_sorted_results(self): + response = self.client.get("/api/v1/dictionaries/corporation-scopes/") + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + self.assertGreaterEqual(len(response.data["results"]), 3) + sort_orders = [item["sort_order"] for item in response.data["results"]] + self.assertEqual(sort_orders, sorted(sort_orders)) + + first_item = response.data["results"][0] + self.assertEqual(first_item["code"], "rosatom") + self.assertIn("short_name", first_item) + self.assertIn("name", first_item) + self.assertIn("sort_order", first_item) diff --git a/tests/apps/user/test_serializers.py b/tests/apps/user/test_serializers.py index e1f2402..27d4280 100644 --- a/tests/apps/user/test_serializers.py +++ b/tests/apps/user/test_serializers.py @@ -1,10 +1,16 @@ """Tests for user serializers""" +from datetime import datetime, timedelta + +from apps.core.models import BackgroundJob, JobStatus from apps.user.serializers import ( + CurrentUserProfileSerializer, + CurrentUserSerializer, LoginSerializer, PasswordChangeSerializer, ProfileUpdateSerializer, TokenSerializer, + UserManagementSerializer, UserRegistrationSerializer, UserSerializer, UserUpdateSerializer, @@ -124,6 +130,93 @@ class UserSerializerTest(TestCase): self.assertIn(field_name, serializer.Meta.read_only_fields) +class CurrentUserProfileSerializerTest(TestCase): + """Tests for current user profile serializer""" + + def setUp(self): + self.user = UserFactory.create_user() + self.profile = ProfileFactory.create_profile( + user=self.user, + first_name="Ivan", + mid_name=None, + last_name="Petrov", + ) + + def test_middle_name_falls_back_to_empty_string(self): + serializer = CurrentUserProfileSerializer(self.profile) + + self.assertEqual(serializer.data["middle_name"], "") + + +class CurrentUserSerializerTest(TestCase): + """Tests for current user serializer fields.""" + + def setUp(self): + self.user = UserFactory.create_user() + + def test_current_user_contains_is_active(self): + serializer = CurrentUserSerializer(self.user) + + self.assertIn("is_active", serializer.data) + self.assertEqual(serializer.data["is_active"], self.user.is_active) + + +class UserManagementSerializerTest(TestCase): + """Tests for user management serializer.""" + + def setUp(self): + self.user = UserFactory.create_user() + self.user.profile.first_name = None + self.user.profile.mid_name = None + self.user.profile.last_name = None + self.user.profile.save() + + def test_profile_fields_fallback_to_empty_string(self): + serializer = UserManagementSerializer(self.user) + + self.assertEqual(serializer.data["first_name"], "") + self.assertEqual(serializer.data["middle_name"], "") + self.assertEqual(serializer.data["last_name"], "") + + def test_metric_fields_are_derived_from_latest_job(self): + now = datetime(2026, 4, 14, 10, 0, 0) + latest_job = BackgroundJob.objects.create( + task_id="admin-management-latest", + task_name="apps.forms.process", + user_id=self.user.id, + status=JobStatus.SUCCESS, + progress=100, + progress_message="Готово", + result={"processed": 10}, + started_at=now, + completed_at=now + timedelta(minutes=3), + created_at=now, + updated_at=now + timedelta(minutes=3), + ) + BackgroundJob.objects.create( + task_id="admin-management-old", + task_name="apps.forms.process", + user_id=self.user.id, + status=JobStatus.FAILURE, + progress=100, + progress_message="Ошибка", + error="old-error", + started_at=now - timedelta(minutes=15), + completed_at=now - timedelta(minutes=10), + created_at=now - timedelta(minutes=15), + updated_at=now - timedelta(minutes=10), + ) + self.user.latest_job = latest_job + + serializer = UserManagementSerializer(self.user) + + self.assertEqual(serializer.data["progress_message"], "Готово") + self.assertEqual(serializer.data["result"], {"processed": 10}) + self.assertIsNone(serializer.data["error"]) + self.assertEqual(serializer.data["is_successful"], True) + self.assertEqual(serializer.data["duration"], 180.0) + + class UserUpdateSerializerTest(TestCase): """Tests for UserUpdateSerializer""" diff --git a/tests/apps/user/test_views.py b/tests/apps/user/test_views.py index 03db98f..f2ebd41 100644 --- a/tests/apps/user/test_views.py +++ b/tests/apps/user/test_views.py @@ -1,5 +1,8 @@ """Tests for user DRF views""" +from datetime import datetime, timedelta + +from apps.core.models import BackgroundJob, JobStatus from apps.user.models import Profile from apps.user.services import UserService from django.contrib.auth import get_user_model @@ -123,7 +126,7 @@ class CurrentUserViewTest(APITestCase): def setUp(self): self.user = UserFactory.create_user() - ProfileFactory.create_profile(user=self.user) + ProfileFactory.create_profile(user=self.user, mid_name="") self.current_user_url = reverse("api_v1:user:current_user") self.tokens = UserService.get_tokens_for_user(self.user) self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {self.tokens['access']}") @@ -135,7 +138,9 @@ class CurrentUserViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["id"], self.user.id) self.assertEqual(response.data["email"], self.user.email) + self.assertEqual(response.data["is_active"], self.user.is_active) self.assertIn("profile", response.data) + self.assertEqual(response.data["profile"]["middle_name"], "") self.assertEqual(response.data["role"], "user") self.assertEqual(response.data["capabilities"]["can_access_admin_page"], False) @@ -157,6 +162,87 @@ class CurrentUserViewTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) +class AdminUsersManagementViewTest(APITestCase): + """Tests for admin users endpoint.""" + + def setUp(self): + self.admin_user = UserFactory.create_user(is_staff=True) + self.regular_user = UserFactory.create_user() + self.job_user = UserFactory.create_user() + self.client.force_authenticate(self.admin_user) + self.url = reverse("api_v1:user:admin_users") + + ProfileFactory.create_profile( + user=self.job_user, + first_name="Иван", + mid_name="Сергеевич", + last_name="Петров", + ) + + now = datetime(2026, 4, 14, 10, 0, 0) + completed_job = now + timedelta(minutes=3) + failed_job = now - timedelta(minutes=10) + BackgroundJob.objects.create( + task_id="admin-management-task", + task_name="apps.forms.process", + user_id=self.job_user.id, + status=JobStatus.SUCCESS, + progress=100, + progress_message="Готово", + result={"processed": 10}, + started_at=now, + completed_at=completed_job, + created_at=completed_job, + updated_at=completed_job, + ) + BackgroundJob.objects.create( + task_id="admin-management-task-failed", + task_name="apps.forms.process", + user_id=self.job_user.id, + status=JobStatus.FAILURE, + progress=100, + progress_message="Неуспешно", + error="error", + started_at=failed_job, + completed_at=now - timedelta(minutes=50), + created_at=failed_job, + updated_at=failed_job, + ) + + def test_admin_users_list_contains_import_metrics_and_profile_fields(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("results", response.data) + results = response.data["results"] + + row_map = {row["id"]: row for row in results} + job_user_row = row_map[self.job_user.id] + regular_user_row = row_map[self.regular_user.id] + + self.assertEqual(job_user_row["first_name"], "Иван") + self.assertEqual(job_user_row["middle_name"], "Сергеевич") + self.assertEqual(job_user_row["last_name"], "Петров") + self.assertEqual(job_user_row["progress_message"], "Готово") + self.assertEqual(job_user_row["result"], {"processed": 10}) + self.assertIsNone(job_user_row["error"]) + self.assertEqual(job_user_row["is_successful"], True) + self.assertEqual(job_user_row["duration"], 180.0) + self.assertIsNotNone(job_user_row["started_at"]) + self.assertIsNotNone(job_user_row["completed_at"]) + + self.assertIsNone(regular_user_row["progress_message"]) + self.assertIsNone(regular_user_row["result"]) + self.assertIsNone(regular_user_row["error"]) + self.assertIsNone(regular_user_row["is_successful"]) + + def test_non_admin_cannot_access_endpoint(self): + self.client.force_authenticate(self.regular_user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + class UserUpdateViewTest(APITestCase): """Tests for UserUpdateView"""