gendesign/backend/app/schemas/insight.py
Light1YT 6400ebde24
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
feat(insights): manual-entry Insight entity CRUD + §19 audit (#948 part A)
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.
2026-06-08 12:41:39 +05:00

80 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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]