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, 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) class PasswordHistoryEntry(Base): """B23: история паролей (bcrypt-хэши). Записывается при каждой смене, проверяется против password_history_count последних записей.""" __tablename__ = "password_history" 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) 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)