Срез 1: справочники (API), импорт из ЕЦП CRM, policy видимости, администрирование #6

Merged
aep merged 51 commits from feat/5-slice-1-directories into main 2026-10-05 10:00:05 +03:00
6 changed files with 62 additions and 6 deletions
Showing only changes of commit 7f891ddb48 - Show all commits

View File

@ -318,6 +318,11 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
| `MAX_IMPORT_ROWS` | 100 000 строк листа/записей csv, **включая пустые** | 422 `UNSUPPORTED_FILE` |
| `MAX_UNCOMPRESSED_BYTES` | 200 МиБ суммарно по частям xlsx (zip), проверяется до разбора | 422 `UNSUPPORTED_FILE` |
**Требование к ingress / reverse proxy (M-1).** FastAPI/Starlette разбирают multipart-тело **до** аутентификации: файл
сбрасывается во временный файл на диске ещё до ответов 401/403 и до проверки `IMPORT_MAX_BYTES`. Поэтому лимит тела
запроса должен ставить ingress: `client_max_body_size` (или аналог) не меньше 20 МиБ — только для пути
`/api/v1/directories/import`, а для остальных путей — небольшой (например, 1 МиБ).
Остальные причины `UNSUPPORTED_FILE`: расширение не `xlsx`/`csv`, повреждённый xlsx, csv не в UTF-8.
## Администрирование и экран /admin
@ -327,6 +332,10 @@ curl -s -X POST localhost:8000/api/v1/directories/import -H "Authorization: Bear
пользователей (создание, смена ролей и активности) и вкладка «Матрица прав» (роль × право). Без права экран показывает
«Нет доступа» и запросов к `/admin/*` не делает (настоящую защиту обеспечивает сервер).
Форма создания пользователя берёт список филиалов из `GET /directories/regional-offices` (право `DIRECTORY_VIEW`).
Матрица прав на этом же экране позволяет снять `DIRECTORY_VIEW` с роли администратора — тогда форма получит пустой
список / «недостаточно прав»; держите `DIRECTORY_VIEW` у роли администратора.
**Как запустить.**
```bash
@ -355,6 +364,10 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
недостающие связи «роль × право» из `api/seed/core/role_permissions.yaml`: право, которое администратор снял с роли
в матрице, следующий запуск seed выдаст снова (`granted_by` — пусто); права, добавленные администратором сверх seed,
seed не отзывает. На стенде с правками матрицы seed повторно не запускайте (follow-up к M-8 Среза 0).
- **Профиль `/me` не перечитывается** после того, как администратор правит собственные роли или права: перезагрузите
страницу или войдите заново.
- **Имя и филиал ГК одним PATCH** пишут одно событие аудита `ASSIGNMENT_CHANGE` с обоими полями в `before`/`after`
(решение №35): выборка журнала по `GROUP_CHANGE` такое переименование не найдёт.
- **Один выбор роли — один запрос.** Мульти-выбор ролей в строке таблицы отправляет по одному `PUT …/roles` на каждое
нажатие, поэтому каждое нажатие — отдельное событие `audit_event(USER_ROLE_CHANGE)`.
- **Показываются первые 200 пользователей** (`limit=200`, максимум страницы); постраничного просмотра в UI пока нет —
@ -367,7 +380,7 @@ cd web && npm install && npm run start # фронтенд (rsbuild dev), о
Доменные ошибки (`src/common/exceptions.py`, обработчик — `src/core/exception_handlers.py`) отдаются объектом:
```json
{"detail": {"code": "INN_EXISTS", "message": "ЮЛ с таким ИНН уже существует", "legal_entity_id": "…"}}
{"detail": {"code": "ALIAS_TAKEN", "message": "Такой алиас уже закреплён за другим ЮЛ", "legal_entity_id": "…"}}
```
`code` — машинный код (по нему работает клиент), `message` — текст по-русски, дополнительные поля зависят от ошибки
@ -397,7 +410,7 @@ email без учёта регистра проверяет только сер
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
| `MISSING_COLUMNS` / `UNSUPPORTED_FILE` | 422 | файл импорта целиком (`missing` — перечень колонок) |
| `CRM_ID_EXISTS` | 409 | ГК или ЮЛ с таким `crm_id` уже есть |
| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (`legal_entity_id`, если видно) |
| `INN_EXISTS` | 409 | ЮЛ с таким ИНН уже есть (без дополнительных полей) |
| `LEGAL_ENTITY_CLOSED` | 409 | перевод закрытого ЮЛ |
| `SAME_COMPANY_GROUP` | 409 | перевод в ту же ГК |
| `ALREADY_CLOSED` | 409 | ЮЛ уже закрыто |

View File

@ -38,6 +38,8 @@ class RegionalOfficeOut(RegionalOfficeBrief):
class CompanyGroupCreate(BaseModel):
"""Создание ГК: `code` и `valid_from` назначает сервис."""
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)
crm_id: str | None = None
base_regional_office_id: uuid.UUID | None = None
@ -138,6 +140,8 @@ def _strip_required_name(value: str | None) -> str | None:
class LegalEntityCreate(BaseModel):
"""Создание ЮЛ/ИП: `inn` проверяется по `entity_type` в сервисе (`INVALID_INN`); без ГК — монозаёмщик."""
model_config = ConfigDict(extra="forbid")
inn: str
entity_type: EntityType
name: str = Field(min_length=1, max_length=NAME_MAX_LENGTH)

View File

@ -88,13 +88,19 @@ def check_size(size: int, limit: int) -> None:
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail=f"файл больше {limit // MEGABYTE} МБ")
_DROPPED_CATEGORIES = frozenset({"Cc", "Cf"})
def safe_filename(filename: str | None) -> str:
"""Имя файла без пути (клиент может прислать `C:\\…\\x.xlsx` или `../x.csv`): для формата, аудита и ключа S3.
Управляющие символы (в т. ч. NUL — его не принимает PostgreSQL) отбрасываются; длина ограничена
Управляющие (Cc, в т. ч. NUL — его не принимает PostgreSQL) и форматирующие (Cf: bidi-переопределения U+202E,
нулевой ширины U+200B) символы отбрасываются до обрезки длины; длина ограничена
`MAX_FILENAME_LENGTH` с сохранением расширения (T10.4), чтобы имя не давало 500 и ключ S3 не выходил за 1024 байта.
"""
cleaned = "".join(char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) != "Cc")
cleaned = "".join(
char for char in (filename or "").replace("\\", "/") if unicodedata.category(char) not in _DROPPED_CATEGORIES
)
name = PurePosixPath(cleaned).name.strip()
if name in {"", ".", ".."}:
return DEFAULT_FILENAME

View File

@ -12,7 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.audit_ex import AuditEvent
from src.models.directories import CompanyGroupHistory
from src.models.directories import CompanyGroup, CompanyGroupHistory
from src.models.enums import AuditAction, AuditSection
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_legal_entity
@ -281,3 +281,15 @@ async def test_validation_and_unknown_group(auth_client: AuthClientFactory) -> N
missing = await client.patch(f"{GROUPS}/{uuid.uuid4()}", json={"name": "X"})
assert missing.status_code == 404 # noqa: PLR2004
assert missing.json()["detail"]["message"] == "ГК не найдена"
@pytest.mark.integration
async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""M-8: `extra="forbid"` — служебное поле (`code`) в теле создания даёт 422 (массив ошибок), ГК не создаётся."""
client = await auth_client("ka_go@armka.local")
before = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one()
response = await client.post(GROUPS, json={"name": "ГК Лишнее поле", "code": "GK-999999"})
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert isinstance(response.json()["detail"], list)
count = (await seeded.execute(select(func.count()).select_from(CompanyGroup))).scalar_one()
assert count == before

View File

@ -552,12 +552,20 @@ async def test_dry_run_with_update_leaves_history_and_audit_untouched(
("..", "import"),
(None, "import"),
("тест.csv", "тест.csv"),
("evil\u202efdp.csv", "evilfdp.csv"),
("a\u200bb\u200d.xlsx", "ab.xlsx"),
],
)
def test_safe_filename_drops_control_characters(raw: str | None, expected: str) -> None:
def test_safe_filename_drops_control_and_format_characters(raw: str | None, expected: str) -> None:
assert safe_filename(raw) == expected
def test_safe_filename_drops_format_characters_before_length_cap() -> None:
# невидимые символы не занимают место в лимите: после их удаления имя ровно в пределе и не обрезается
padded = "x" * (MAX_FILENAME_LENGTH - len(".csv")) + "\u200b" * 50 + ".csv"
assert safe_filename(padded) == "x" * (MAX_FILENAME_LENGTH - len(".csv")) + ".csv"
def test_safe_filename_caps_length_keeping_extension() -> None:
long_name = safe_filename("я" * 1500 + ".xlsx")
assert len(long_name) == MAX_FILENAME_LENGTH

View File

@ -563,3 +563,16 @@ async def test_branch_patch_with_unchanged_foreign_office_is_not_rejected(
to_foreign = await client.patch(f"{ENTITIES}/{entity.id}", json={"regional_office_id": None})
assert to_foreign.status_code == HTTP_OK # явный null у филиала — свой филиал (это изменение)
assert to_foreign.json()["regional_office_id"] == str(rostov.id)
@pytest.mark.integration
async def test_create_rejects_unknown_fields(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""M-8: `extra="forbid"` — неизвестное поле (`close_date`) при создании даёт 422 (массив), ЮЛ не создаётся."""
client = await auth_client("ka_go@armka.local")
before = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one()
body = {"inn": "3528052718", "entity_type": "ЮЛ", "name": "ООО Лишнее", "close_date": "2026-01-01"}
response = await client.post(ENTITIES, json=body)
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert isinstance(response.json()["detail"], list)
count = (await seeded.execute(select(func.count()).select_from(LegalEntity))).scalar_one()
assert count == before