All checks were successful
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Successful in 1m41s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m50s
Deploy / deploy (push) Successful in 1m22s
CI / backend-tests (push) Successful in 6m35s
CI / backend-tests (pull_request) Successful in 6m32s
Insight = аналитик пишет free-form intel про локацию/участок с пометкой «непублично» (is_confidential, §7.13/§8.9). CRUD под /api/v1/insights; created_by из X-Authenticated-User (не из тела — спуфинг автора невозможен). Зеркало custom_pois: raw-SQL service + Pydantic v2 schemas, sync def handlers (threadpool, off event loop). - data/sql/145_insight.sql: table insight (idempotent, geom GENERATED из lon/lat, индексы district/cad_num/is_confidential/created_by/GIST); аддитивно расширяет CHECK audit_log.action += insight_write (тот же constraint ck_audit_log_action, to_regclass-guarded, superset — не ломает #962-аудит; НЕ правит applied 144). - audit_middleware.classify_path: method-aware insight_write для POST/PUT/DELETE /api/v1/insights (GET-чтения не аудируются); обратно совместимо с parcels. - ACL: backend rbac_guard hard-блокирует только /api/v1/admin/*; pilot отсекается frontend RouteGuard + Caddy (как parcels/custom_pois). is_confidential — стораемая пометка без per-record backend-гейта (analyst видит, per #962-политике). - tests: 28 insight (CRUD+filters+required+#261 commit) + 5 audit. 86 зелёных, ruff. Part B (Location first-class, §8.2) — отдельный follow-up. Refs #948.
80 lines
3.2 KiB
Python
80 lines
3.2 KiB
Python
"""Pydantic schemas для insight (#948 Part A, ТЗ §7.13/§8.9).
|
||
|
||
InsightCreate — тело POST-запроса (title/body обязательны).
|
||
InsightUpdate — тело PUT-запроса (все поля опциональны — partial update).
|
||
InsightOut — ответ API (включает id, created_by, created_at, updated_at).
|
||
|
||
created_by НЕ принимается из тела — проставляется роутером из X-Authenticated-User.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
# Рекомендованные категории (мягкий enum, совпадает с CHECK ck_insight_category
|
||
# в data/sql/145_insight.sql). None допустим.
|
||
InsightCategory = Literal["competition", "permitting", "demand", "risk", "other"]
|
||
|
||
|
||
class InsightCreate(BaseModel):
|
||
title: str = Field(..., min_length=1, max_length=300, description="Заголовок заметки")
|
||
body: str = Field(..., min_length=1, max_length=20000, description="Тело заметки (intel)")
|
||
category: InsightCategory | None = Field(
|
||
None, description="Категория: competition|permitting|demand|risk|other"
|
||
)
|
||
is_confidential: bool = Field(
|
||
False, description="«Непублично» (§7.13): маркировка чувствительной заметки"
|
||
)
|
||
district: str | None = Field(
|
||
None,
|
||
max_length=200,
|
||
description="Location ref (район); Part B промоутит в Location FK",
|
||
)
|
||
cad_num: str | None = Field(
|
||
None, max_length=100, description="Опциональный кадастровый номер участка"
|
||
)
|
||
lon: float | None = Field(None, ge=-180.0, le=180.0, description="Долгота WGS-84 (map-pin)")
|
||
lat: float | None = Field(None, ge=-90.0, le=90.0, description="Широта WGS-84 (map-pin)")
|
||
source: str | None = Field(None, max_length=500, description="Источник intel")
|
||
|
||
|
||
class InsightUpdate(BaseModel):
|
||
"""Partial update (PUT) — переданные поля заменяются, отсутствующие не трогаются."""
|
||
|
||
title: str | None = Field(None, min_length=1, max_length=300)
|
||
body: str | None = Field(None, min_length=1, max_length=20000)
|
||
category: InsightCategory | None = None
|
||
is_confidential: bool | None = None
|
||
district: str | None = Field(None, max_length=200)
|
||
cad_num: str | None = Field(None, max_length=100)
|
||
lon: float | None = Field(None, ge=-180.0, le=180.0)
|
||
lat: float | None = Field(None, ge=-90.0, le=90.0)
|
||
source: str | None = Field(None, max_length=500)
|
||
|
||
|
||
class InsightOut(BaseModel):
|
||
id: int
|
||
created_by: str
|
||
title: str
|
||
body: str
|
||
category: str | None
|
||
is_confidential: bool
|
||
district: str | None
|
||
cad_num: str | None
|
||
lon: float | None
|
||
lat: float | None
|
||
source: str | None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
|
||
class InsightList(BaseModel):
|
||
"""Ответ list-эндпоинта: total + страница строк (как admin_leads)."""
|
||
|
||
total: int
|
||
limit: int
|
||
offset: int
|
||
rows: list[InsightOut]
|