Files
vector/backend/models.py
T
Sadmin 5a2c9a0688 E2 (B21): аудит + админ-панель пользователей + гео-скоуп
- Модели audit_events (immutable) + audit_changes (field-level old→new);
  Alembic 008_e2_audit.
- backend/audit.py: audit_log() (событие + изменения), audit_retention_days
  (90 дней из security_settings), visible_unit_ids (рекурсивный CTE:
  своё подразделение + подчинённые; РЦУ РЧС/legacy → None = без фильтра),
  can_access_unit, _uuid_hex.
- backend/routers/admin_users.py (manage_users/view_audit):
  GET /admin/users (фильтры status/unit/q, скоуп по подразделению),
  POST (создание с must_change_password, роль, скоуп-проверка),
  PATCH /{id} (роль/юнит/статус/сброс пароля, field-level аудит),
  GET /units (справочник подразделений), GET /audit (ретеншн-фильтр).
  coordinator управляет только своим юнитом+подчинёнными и не трогает РЦУ.
- Грабли SQLite-тестов: UUID колонки хранят hex без дефисов — сравнения
  только в hex-пространстве (_uuid_hex); StaticPool обязателен.
- Тесты E2 (8): создание/блокировка/сброс пароля/скоуп координатора/
  cross-oblast 403/без права 403/аудит user_create.
210 passed, 5 skipped.
2026-09-09 21:23:47 +03:00

369 lines
15 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.
import sqlalchemy as sa
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ARRAY, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import func
import uuid
try:
from backend.database import Base
except ImportError: # pragma: no cover - compatibility for direct module runs
from database import Base
class User(Base):
"""User model for authentication."""
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
username = Column(String(100), unique=True, nullable=False)
email = Column(String(255), unique=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255))
role = Column(String(20), nullable=False, default="operator")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
last_login = Column(DateTime)
# B21/E1 additions
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
position = Column(String(255))
status = Column(String(20), default="active") # active|locked|disabled
failed_login_count = Column(Integer, default=0)
locked_until = Column(DateTime(timezone=True))
must_change_password = Column(Boolean, default=False)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
updated_at = Column(DateTime, server_default=func.now())
class Case(Base):
"""Unified case model - combines search case and result."""
__tablename__ = "cases"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
created_at = Column(DateTime, server_default=func.now())
status = Column(String(20), default="active")
child_name = Column(String(255))
age_years = Column(Integer, nullable=False)
gender = Column(String(1))
height_build = Column(Text)
clothes_upper = Column(Text)
clothes_lower = Column(Text)
shoes = Column(Text)
clothes_description = Column(Text)
special_marks = Column(Text)
phone_status = Column(String(20))
has_diagnosis = Column(Boolean, default=False)
diagnosis_type = Column(ARRAY(String))
fitness_level = Column(String(20))
has_transport = Column(String(20), default="none")
cant_swim = Column(Boolean, default=False)
psychotype = Column(String(50))
psychotype_answers = Column(JSONB)
loss_reason = Column(String(100))
loss_time = Column(DateTime)
elapsed_hours = Column(Float)
last_seen_direction = Column(String(10))
last_seen_reliability = Column(String(20))
last_seen_description = Column(Text)
behavior_description = Column(Text)
familiar_places = Column(Text)
lost_before = Column(String(20))
season = Column(String(20))
temperature_c = Column(Float)
precipitation = Column(String(20))
visibility = Column(String(20))
wind = Column(String(20))
terrain = Column(ARRAY(String))
tnp_lat = Column(Float)
tnp_lon = Column(Float)
tnp_address = Column(Text)
teams_count = Column(Integer)
team_size = Column(Integer)
has_dog = Column(Boolean, default=False)
extra_resources = Column(ARRAY(String))
found_alive = Column(Boolean)
found_distance_km = Column(Float)
found_direction = Column(String(10))
found_location_type = Column(String(50))
found_lat = Column(Float)
found_lon = Column(Float)
search_duration_hours = Column(Float)
who_found = Column(String(50))
confidence_avg = Column(Float)
raw_text = Column(Text)
analysis_log = Column(JSONB)
class AnalysisLog(Base):
"""Analysis log for audit and debugging."""
__tablename__ = "analysis_log"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
analysis_type = Column(String(50), nullable=False)
input_data = Column(JSONB)
output_data = Column(JSONB)
execution_time = Column(Float)
status = Column(String(20), default="success")
error_message = Column(Text)
created_at = Column(DateTime, server_default=func.now())
class RawDocument(Base):
"""Raw document storage for parsed reports."""
__tablename__ = "raw_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
filename = Column(String(255), nullable=False)
raw_text = Column(Text)
extracted_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
# ==================== B15: слоистая модель данных (B13-принцип 2) ====================
# Слой 4 (результат): search_models — версионированные поисковые модели (фундамент B18)
# Слой 3 (оперативные): field_observations / search_teams / areas_checked / found_events
# — схема+модели сейчас, ингестия в B17
# Слой 1 (исходные): reference_priors — версионированный справочник априорных
# (заглушка под B12/ISRID и экспертные значения; данные вносятся позже)
class SearchModelRecord(Base):
"""Слой 4: версия поисковой модели для случая (per-case версионирование)."""
__tablename__ = "search_models"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
version = Column(Integer, nullable=False)
input_snapshot = Column(JSONB)
model_json = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.UniqueConstraint("case_id", "version", name="uq_search_models_case_version"),
)
class SearchTeam(Base):
"""Слой 3: поисковая группа (ингестия — B17)."""
__tablename__ = "search_teams"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
members = Column(Integer)
role = Column(String(50))
created_at = Column(DateTime, server_default=func.now())
class FieldObservation(Base):
"""Слой 3: наблюдение в поле (position/observation/track/clue/witness).
Геометрия — JSONB GeoJSON (B16 добавит PostGIS-колонки)."""
__tablename__ = "field_observations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
obs_type = Column(String(20), nullable=False)
geom = Column(JSONB)
observed_at = Column(DateTime(timezone=True))
confidence = Column(Float)
description = Column(Text)
raw = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.CheckConstraint(
"obs_type IN ('position','observation','track','clue','witness')",
name="ck_field_observations_obs_type",
),
)
class AreaChecked(Base):
"""Слой 3: проверенный участок (результат работы группы)."""
__tablename__ = "areas_checked"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
geom = Column(JSONB)
checked_at = Column(DateTime(timezone=True))
result = Column(String(20), default="partial")
coverage_pct = Column(Float)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
)
class FoundEvent(Base):
"""Слой 3: событие находки пропавшего."""
__tablename__ = "found_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False)
geom = Column(JSONB)
occurred_at = Column(DateTime(timezone=True))
condition = Column(String(50))
team_id = Column(UUID(as_uuid=True), ForeignKey("search_teams.id", ondelete="SET NULL"))
description = Column(Text)
created_at = Column(DateTime, server_default=func.now())
class ReferencePrior(Base):
"""Слой 1: версионированный справочник априорных значений
(экспертные нормы сейчас, ISRID — после B12). Данные НЕ вносятся (B15)."""
__tablename__ = "reference_priors"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source = Column(String(100), nullable=False)
category = Column(String(100), nullable=False)
value = Column(Float, nullable=False)
units = Column(String(50))
provenance = Column(Text)
version = Column(Integer, nullable=False, default=1)
created_at = Column(DateTime, server_default=func.now())
# ==================== B21/E1: RBAC-ядро (по образцу админки РВС) ====================
# Иерархия: РЦУ РЧС (rcu) → ОУМЧС (oblast) → Г(Р)ОЧС (gor_rayon)
class MchsUnit(Base):
"""Подразделение МЧС: РЦУ РЧС, ОУМЧС, Г(Р)ОЧС."""
__tablename__ = "mchs_units"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False, unique=True)
region = Column(String(100)) # область (для фильтров)
kind = Column(String(20), nullable=False) # rcu | oblast | gor_rayon
parent_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.CheckConstraint("kind IN ('rcu','oblast','gor_rayon')", name="ck_mchs_units_kind"),
)
class Role(Base):
"""Роль (is_system — сидовые, удаление запрещено)."""
__tablename__ = "roles"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(50), nullable=False, unique=True)
description = Column(String(255))
is_system = Column(Boolean, default=False)
created_at = Column(DateTime, server_default=func.now())
class Permission(Base):
"""Атомарное право."""
__tablename__ = "permissions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
code = Column(String(50), nullable=False, unique=True)
description = Column(String(255))
class RolePermission(Base):
"""M2M роль ↔ право."""
__tablename__ = "role_permissions"
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
permission_id = Column(UUID(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True)
class UserRole(Base):
"""M2M пользователь ↔ роль (с кем назначена)."""
__tablename__ = "user_roles"
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True)
role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
assigned_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
assigned_at = Column(DateTime, server_default=func.now())
class UserSession(Base):
"""Server-side сессия: JWT несёт session_id, отзыв возможен на стороне БД."""
__tablename__ = "sessions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token_hash = Column(String(64), nullable=False, unique=True) # sha256 jti
ip_address = Column(String(45))
user_agent = Column(String(255))
created_at = Column(DateTime, server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
last_seen_at = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True)
class AuthEvent(Base):
"""Журнал аутентификации: вход/выход/блокировка."""
__tablename__ = "auth_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
username = Column(String(100)) # даже если user_id неизвестен
event_type = Column(String(30), nullable=False) # login|logout|login_failed|lockout|password_change
ip_address = Column(String(64))
user_agent = Column(String(255))
details = Column(JSONB)
created_at = Column(DateTime, server_default=func.now())
__table_args__ = (
sa.Index("idx_auth_events_created", "created_at"),
)
class SecuritySetting(Base):
"""Параметры безопасности (key/value)."""
__tablename__ = "security_settings"
key = Column(String(50), primary_key=True)
value = Column(String(255), nullable=False)
# ==================== B21/E2: аудит действий ====================
class AuditEvent(Base):
"""Аудит действий (immutable): кто, когда, что. CREATE+READ only."""
__tablename__ = "audit_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
username = Column(String(100))
event_type = Column(String(40), nullable=False) # user_create|user_update|user_lock|case_create|...
object_type = Column(String(40)) # user|case|operation|...
object_id = Column(String(64))
ip_address = Column(String(64))
user_agent = Column(String(255))
details = Column(JSONB)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
sa.Index("idx_audit_events_created", "created_at"),
)
class AuditChange(Base):
"""Field-level изменения: old → new (привязаны к AuditEvent)."""
__tablename__ = "audit_changes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), ForeignKey("audit_events.id", ondelete="CASCADE"), nullable=False)
field_name = Column(String(100), nullable=False)
old_value = Column(Text)
new_value = Column(Text)