2222b7ad5b
- backend/password_policy.py: validate_new_password (длина, сложность буквы+цифры, сверка с текущим и историей), record_password_change (хранит ПРЕДЫДУШИЙ хэш, обрезка до password_history_count), password_expired + enforce_expiry (пометка must_change_password) - users.password_changed_at + таблица password_history (010_b23_policy) - change-password и админ-сброс теперь под полной политикой (сложность и переиспользование вместо голой длины); get_current_user проверяет срок действия каждого пароля при запросе - backend/purge_logs.py: реальный DELETE просроченных audit_events (+changes) и auth_events; POST /admin/users/maintenance/purge-logs (manage_security) + CLI python -m backend.purge_logs для cron - тесты: +19 (258 passed) — валидация/история/срок, purge/идемпотентность/403
411 lines
17 KiB
Python
411 lines
17 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())
|
||
# 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)
|