5a2c9a0688
- Модели 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.
369 lines
15 KiB
Python
369 lines
15 KiB
Python
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)
|