Files
vector/backend/models.py
T
Sadmin a1de5794e2 fix: password_history.id → INTEGER monotonic (детерминированная обрезка)
Грабля: created_at в SQLite точен до секунды — 7 быстрых смен дают
равные метки, а secondary-сортировка по UUID-id случайна → «последние 5»
выбираются произвольно (флакущий тест + неверно хранимая история).
id теперь autoincrement (обрезка order_by id desc), UUID остаётся
как public_id. Миграция 010 обновлена (таблица только на CT108,
пересоздаётся миграцией заново — см. деплой).
2026-09-25 10:46:18 +03:00

422 lines
18 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())
# B23: политика паролей (expiry/history исполняется в routers/auth.py)
password_changed_at = Column(DateTime(timezone=True))
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(timezone=True), server_default=func.now())
# B17: идемпотентный ингест из КОНТУРа
contour_area_id = Column(String(64), index=True) # area_id на стороне КОНТУРа
team_label = Column(String(100)) # callsign цепи (КОНТУР)
source = Column(String(20), default="manual") # manual|contour
__table_args__ = (
sa.CheckConstraint("result IN ('clear','found','partial')", name="ck_areas_checked_result"),
sa.CheckConstraint("source IN ('manual','contour')", name="ck_areas_checked_source"),
)
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)
class PasswordHistoryEntry(Base):
"""B23: история паролей (bcrypt-хэши). Записывается при каждой смене,
проверяется против password_history_count последних записей.
id — monotonic-последовательность (не UUID): обрезка «последние N» должна
быть детерминированной, а created_at в SQLite имеет точность до секунды
(несколько смен внутри одной секунды → равные метки → случайный порядок).
"""
__tablename__ = "password_history"
id = Column(Integer, primary_key=True, autoincrement=True)
public_id = Column(UUID(as_uuid=True), default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
sa.Index("idx_password_history_user", "user_id", "created_at"),
)
# ==================== B21/E2: аудит действий ====================
class SearchOperation(Base):
"""B21/E3: поисковая операция — надстройка над карточкой (cases)."""
__tablename__ = "search_operations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String(255), nullable=False)
case_id = Column(UUID(as_uuid=True), ForeignKey("cases.id", ondelete="CASCADE"), nullable=False, unique=True)
status = Column(String(20), nullable=False, default="active")
unit_id = Column(UUID(as_uuid=True), ForeignKey("mchs_units.id", ondelete="SET NULL"))
contour_operation_id = Column(UUID(as_uuid=True))
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now())
closed_at = Column(DateTime(timezone=True))
__table_args__ = (
sa.CheckConstraint(
"status IN ('planned','active','paused','completed','archived')",
name="ck_search_operations_status",
),
sa.Index("idx_search_operations_status", "status"),
sa.Index("idx_search_operations_unit", "unit_id"),
)
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)