Files
ss-tools/backend/src/models/translate.py
busya bb03929ca1 security: critical auth fixes, test migration to SQLite+FK, 44 test fixes
CRITICAL (CVE-class):
- C-4: Remove DEV_MODE fallback with hardcoded postgres:postgres credentials
- C-3: WebSocket endpoints now require JWT or API key token (?token=) auth
- C-1: Superset environment passwords encrypted via Fernet at rest in DB
- H-4: SESSION_SECRET_KEY separated from JWT AUTH_SECRET_KEY
- H-1: Log injection via %s-formatting instead of f-strings
- H-5: X-Trace-ID header validated as UUID4 to prevent trace poisoning

INFRASTRUCTURE:
- New src/core/encryption.py — EncryptionManager extracted from llm_provider
- Test DB: per-module sqlite:///:memory: with PRAGMA foreign_keys=ON
- testcontainers PostgreSQL via TEST_DB=postgres env var
- conftest temp-file global engine (no 10GB shared-cache leak)

TEST FIXES (44 pre-existing → 0):
- test_smoke_plugins: module-level sys.modules mock isolated to per-test fixture
- test_migration_engine: EXT:Python:uuid → uuid syntax fix
- test_dashboards_api: mock env attributes + correct patch target
- test_constants_audit_fixes: sync expected constant names with actual
- test_defensive_guards: patch.object instead of module-level Repo mock
- test_clean_release_cli: removed empty config.json directory
- FK violations: TaskRecord parents in log_persistence, Environment in
  mapping_service, ReleaseCandidate in candidate_manifest_services

ORTHOGONAL TESTS (18 new):
- test_security_orthogonal.py: bcrypt 72-byte limit, unicode, API key
  format invariants, Fernet robustness, encryption key lifecycle,
  log injection protection, JWT edge cases

MODEL FIXES:
- MetricSnapshot.job_id: nullable with SET NULL (was broken FK for
  aggregate prune snapshots, hidden by SQLite)

CLEANUP:
- Removed stale :memory:test_main/test_auth/test_tasks file databases
- Removed duplicate #endregion in encryption_key.py
2026-05-26 14:58:49 +03:00

401 lines
22 KiB
Python

# #region TranslateModels [C:3] [TYPE Module] [SEMANTICS sqlalchemy, translate, model, schema, dashboard, llm]
# @BRIEF SQLAlchemy ORM models for LLM-based SQL/dashboard translation across dialects.
# @LAYER Domain
# @RELATION INHERITS -> [MappingModels]
from datetime import UTC, datetime
import uuid
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import relationship
from .mapping import Base
def generate_uuid():
return str(uuid.uuid4())
# #region TranslationJob [TYPE Class]
# @BRIEF A translation job representing a multi-dialect conversion task with column mappings, LLM config, and dictionary attachments.
# @RATIONALE Snapshot isolation — in-progress runs use config snapshot; config edits affect future runs only.
# @REJECTED Invalidating in-progress runs on config edit would break scheduled run continuity.
class TranslationJob(Base):
__tablename__ = "translation_jobs"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
source_dialect = Column(String, nullable=False)
target_dialect = Column(String, nullable=False)
database_dialect = Column(String, nullable=True, comment="Detected dialect from Superset connection at save time")
status = Column(String, nullable=False, default="DRAFT") # DRAFT, READY, RUNNING, COMPLETED, FAILED, CANCELLED
# Datasource & target table configuration
source_datasource_id = Column(String, nullable=True, comment="Superset datasource ID")
source_table = Column(String, nullable=True, comment="Source table name resolved from datasource")
target_schema = Column(String, nullable=True, comment="Target table schema")
target_table = Column(String, nullable=True, comment="Target table name")
# Column mapping
source_key_cols = Column(JSON, nullable=True, comment="Source key column names for composite key")
target_key_cols = Column(JSON, nullable=True, comment="Target key column names for composite key")
translation_column = Column(String, nullable=True, comment="Source column whose values will be translated")
target_column = Column(String, nullable=True, comment="Target column for translated output (defaults to translation_column)")
target_language_column = Column(String, nullable=True, comment="Target column for language code (e.g. 'ru', 'en')")
target_source_column = Column(String, nullable=True, comment="Target column for source/original text")
target_source_language_column = Column(String, nullable=True, comment="Target column for detected source language (BCP-47)")
context_columns = Column(JSON, nullable=True, comment="Context column names included in LLM prompt")
# LLM & processing settings
# source_language removed — deprecated, auto-detected per row by LLM; use TranslationLanguage.source_language_detected instead
target_languages = Column(JSON, nullable=True, comment="List of BCP-47 target language codes (multi-language support)")
provider_id = Column(String, nullable=True, comment="LLM provider ID")
batch_size = Column(Integer, nullable=False, default=50, comment="Records per batch")
disable_reasoning = Column(Boolean, default=False, comment="If true, pass reasoning_effort:none to LLM to save output tokens")
upsert_strategy = Column(String, nullable=False, default="MERGE", comment="MERGE, INSERT, UPDATE")
# Environment association
environment_id = Column(String, nullable=True, comment="Superset environment ID for datasource access")
target_database_id = Column(String, nullable=True, comment="Superset database ID for SQL Lab insert target")
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
# #endregion TranslationJob
# #region TranslationRun [TYPE Class]
# @BRIEF Represents a single execution of a translation job, capturing status, timing, and Superset execution metadata.
class TranslationRun(Base):
__tablename__ = "translation_runs"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
status = Column(String, nullable=False, default="PENDING") # PENDING, RUNNING, COMPLETED, FAILED, CANCELLED
trigger_type = Column(String, nullable=True, comment="manual, scheduled, retry, baseline_expired")
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
total_records = Column(Integer, default=0)
successful_records = Column(Integer, default=0)
failed_records = Column(Integer, default=0)
skipped_records = Column(Integer, default=0)
insert_status = Column(String, nullable=True, comment="Status of the Superset insert/update operation")
superset_execution_id = Column(String, nullable=True, comment="Superset execution/task ID")
superset_execution_log = Column(JSON, nullable=True, comment="Superset execution log output")
config_snapshot = Column(JSON, nullable=True, comment="Snapshot of job config at run creation time")
key_hash = Column(String, nullable=True, comment="Hash of source key fields for dedup")
config_hash = Column(String, nullable=True, comment="Hash of translation configuration state")
dict_snapshot_hash = Column(String, nullable=True, comment="Hash of dictionary state at run time")
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
language_stats = relationship("TranslationRunLanguageStats", back_populates="run")
# #endregion TranslationRun
# #region TranslationBatch [TYPE Class]
# @BRIEF Groups translation records within a run into manageable batches with timing and record counts.
class TranslationBatch(Base):
__tablename__ = "translation_batches"
id = Column(String, primary_key=True, default=generate_uuid)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False, index=True)
batch_index = Column(Integer, nullable=False)
status = Column(String, nullable=False, default="PENDING")
total_records = Column(Integer, default=0)
successful_records = Column(Integer, default=0)
failed_records = Column(Integer, default=0)
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
# #endregion TranslationBatch
# #region TranslationRecord [TYPE Class]
# @BRIEF Individual translation result for a single SQL statement or dashboard element, tracking source, target, and error state.
class TranslationRecord(Base):
__tablename__ = "translation_records"
id = Column(String, primary_key=True, default=generate_uuid)
batch_id = Column(String, ForeignKey("translation_batches.id", ondelete="CASCADE"), nullable=False, index=True)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False, index=True)
source_sql = Column(Text, nullable=True)
target_sql = Column(Text, nullable=True)
source_object_type = Column(String, nullable=True) # query, dashboard, chart, dataset
source_object_id = Column(String, nullable=True)
source_object_name = Column(String, nullable=True)
source_data = Column(JSON, nullable=True, comment="Original source row key columns for upsert matching")
status = Column(String, nullable=False, default="PENDING") # PENDING, SUCCESS, FAILED, SKIPPED
error_message = Column(Text, nullable=True)
token_count_input = Column(Integer, nullable=True)
token_count_output = Column(Integer, nullable=True)
translation_duration_ms = Column(Integer, nullable=True)
source_hash = Column(String, nullable=True,
comment="SHA256(source_text+context+dict_snapshot_hash+config_hash) for cache dedup — excludes key/identifier columns")
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
languages = relationship("TranslationLanguage", back_populates="record")
__table_args__ = (
Index("ix_translation_records_run_status", "run_id", "status"),
Index("ix_translation_records_source_hash_status", "source_hash", "status"),
)
# #endregion TranslationRecord
# #region TranslationEvent [TYPE Class]
# @BRIEF Audit/event log for translation operations, with optional run_id for context.
class TranslationEvent(Base):
__tablename__ = "translation_events"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True, index=True)
event_type = Column(String, nullable=False) # JOB_CREATED, JOB_STARTED, JOB_COMPLETED, JOB_FAILED, etc.
event_data = Column(JSON, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
# #endregion TranslationEvent
# #region TranslationPreviewSession [TYPE Class]
# @BRIEF A preview session allowing users to review proposed translations before applying them.
class TranslationPreviewSession(Base):
__tablename__ = "translation_preview_sessions"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True)
status = Column(String, nullable=False, default="ACTIVE") # ACTIVE, APPLIED, DISCARDED, EXPIRED
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
expires_at = Column(DateTime, nullable=True)
# #endregion TranslationPreviewSession
# #region TranslationPreviewRecord [TYPE Class]
# @BRIEF Individual preview entry within a preview session, showing original and translated content side by side.
class TranslationPreviewRecord(Base):
__tablename__ = "translation_preview_records"
id = Column(String, primary_key=True, default=generate_uuid)
session_id = Column(String, ForeignKey("translation_preview_sessions.id", ondelete="CASCADE"), nullable=False, index=True)
source_sql = Column(Text, nullable=True)
target_sql = Column(Text, nullable=True)
source_object_type = Column(String, nullable=True)
source_object_id = Column(String, nullable=True)
source_object_name = Column(String, nullable=True)
source_data = Column(JSON, nullable=True, comment="Original source row key columns for upsert matching")
status = Column(String, nullable=False, default="PENDING") # PENDING, APPROVED, REJECTED
feedback = Column(Text, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
languages = relationship("TranslationPreviewLanguage", back_populates="preview_record")
# #endregion TranslationPreviewRecord
# #region TerminologyDictionary [TYPE Class]
# @BRIEF A named collection of terminology mappings used during translation to ensure consistent term translation.
class TerminologyDictionary(Base):
__tablename__ = "terminology_dictionaries"
id = Column(String, primary_key=True, default=generate_uuid)
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
source_dialect = Column(String, nullable=False)
target_dialect = Column(String, nullable=False)
is_active = Column(Boolean, default=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
# #endregion TerminologyDictionary
# #region DictionaryEntry [TYPE Class]
# @BRIEF A single term mapping entry with case-insensitive unique constraint on source_term_normalized and origin tracking.
class DictionaryEntry(Base):
__tablename__ = "dictionary_entries"
id = Column(String, primary_key=True, default=generate_uuid)
dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id", ondelete="CASCADE"), nullable=False, index=True)
source_term = Column(String, nullable=False)
source_term_normalized = Column(String, nullable=False)
target_term = Column(String, nullable=False)
source_language = Column(String, nullable=False, comment="BCP-47 source language code")
target_language = Column(String, nullable=False, comment="BCP-47 target language code")
context_notes = Column(Text, nullable=True)
context_data = Column(JSON, nullable=True, comment="Structured context for term usage")
usage_notes = Column(Text, nullable=True, comment="Usage guidance for the term mapping")
has_context = Column(Boolean, default=False, comment="Whether context_data is populated")
context_source = Column(String, nullable=True, comment="auto|auto_with_edits|manual|bulk")
origin_source_language = Column(String, nullable=True, comment="Original source language of the term")
origin_run_id = Column(String, nullable=True, comment="Run ID from which this correction originated")
origin_row_key = Column(String, nullable=True, comment="Row key within the run that triggered this correction")
origin_user_id = Column(String, nullable=True, comment="User who submitted the correction")
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
__table_args__ = (
UniqueConstraint(
"dictionary_id", "source_term_normalized", "source_language", "target_language",
name="uq_dict_source_term_lang"
),
Index("idx_dict_entry_lang", "source_language", "target_language"),
Index("idx_dict_has_context", "has_context"),
)
# #endregion DictionaryEntry
# #region TranslationSchedule [TYPE Class]
# @BRIEF Defines a cron-based schedule for recurring translation jobs.
class TranslationSchedule(Base):
__tablename__ = "translation_schedules"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
cron_expression = Column(String, nullable=False)
timezone = Column(String, nullable=False, default="UTC")
is_active = Column(Boolean, default=True)
last_run_at = Column(DateTime, nullable=True)
next_run_at = Column(DateTime, nullable=True)
execution_mode = Column(String, nullable=False, default="full", comment="full, new_key_only")
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
# #endregion TranslationSchedule
# #region TranslationJobDictionary [TYPE Class]
# @BRIEF Many-to-many association between translation jobs and terminology dictionaries.
class TranslationJobDictionary(Base):
__tablename__ = "translation_job_dictionaries"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="CASCADE"), nullable=False, index=True)
dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
__table_args__ = (
UniqueConstraint(
"job_id", "dictionary_id",
name="uq_job_dictionary"
),
)
# #endregion TranslationJobDictionary
# #region MetricSnapshot [TYPE Class]
# @BRIEF Captures translation performance metrics at a point in time with config/content/dictionary hash keys for aggregation.
# @RATIONALE job_id is nullable with SET NULL because prune_expired creates aggregate snapshots
# (job_id=None) that span all jobs — enforced FK in PostgreSQL rejects virtual IDs like
# '_prune_aggregate_' (see [SEC:PG-FK]).
class MetricSnapshot(Base):
__tablename__ = "translation_metric_snapshots"
id = Column(String, primary_key=True, default=generate_uuid)
job_id = Column(String, ForeignKey("translation_jobs.id", ondelete="SET NULL"), nullable=True, index=True)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="SET NULL"), nullable=True, index=True)
key_hash = Column(String, nullable=False, comment="Hash of dimension key fields for aggregation")
config_hash = Column(String, nullable=True, comment="Hash of translation configuration state")
dict_snapshot_hash = Column(String, nullable=True, comment="Hash of dictionary state at capture time")
covers_events_before = Column(DateTime, nullable=True, comment="Indicates snapshot covers events before this timestamp")
total_jobs = Column(Integer, default=0)
total_runs = Column(Integer, default=0)
total_records = Column(Integer, default=0)
successful_records = Column(Integer, default=0)
failed_records = Column(Integer, default=0)
skipped_records = Column(Integer, default=0)
avg_duration_ms = Column(Integer, nullable=True)
p50_duration_ms = Column(Integer, nullable=True)
p95_duration_ms = Column(Integer, nullable=True)
p99_duration_ms = Column(Integer, nullable=True)
per_language_metrics = Column(JSON, nullable=True, comment="Per-language cumulative metrics: {lang: {cumulative_tokens, cumulative_cost, runs}}")
snapshot_date = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
__table_args__ = (
Index("ix_metric_snapshots_job_date", "job_id", "snapshot_date"),
)
# #endregion MetricSnapshot
# #region TranslationLanguage [C:1] [TYPE Class]
# @BRIEF Per-language translation result for a single record, supporting multi-language output.
class TranslationLanguage(Base):
__tablename__ = "translation_languages"
id = Column(String, primary_key=True, default=generate_uuid)
record_id = Column(String, ForeignKey("translation_records.id", ondelete="CASCADE"), nullable=False)
language_code = Column(String, nullable=False, comment="BCP-47 language code")
source_language_detected = Column(String, nullable=True, comment="BCP-47 or 'und' for undetermined")
translated_value = Column(Text, nullable=True, comment="LLM-generated translation")
user_edit = Column(Text, nullable=True, comment="User-edited translation")
final_value = Column(Text, nullable=True, comment="Final resolved value (translated or user edit)")
status = Column(String, default="pending", comment="pending|translated|approved|edited|rejected|failed|skipped")
error_message = Column(Text, nullable=True)
needs_review = Column(Boolean, default=False, comment="Flagged because source language could not be determined")
language_overridden = Column(Boolean, default=False, comment="Source language was manually overridden by user")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
record = relationship("TranslationRecord", back_populates="languages")
__table_args__ = (
UniqueConstraint("record_id", "language_code", name="uq_record_language"),
Index("idx_tl_language", "language_code"),
Index("idx_tl_record_lang", "record_id", "language_code"),
)
# #endregion TranslationLanguage
# #region TranslationPreviewLanguage [C:1] [TYPE Class]
# @BRIEF Per-language preview entry within a preview session.
class TranslationPreviewLanguage(Base):
__tablename__ = "translation_preview_languages"
id = Column(String, primary_key=True, default=generate_uuid)
preview_record_id = Column(String, ForeignKey("translation_preview_records.id", ondelete="CASCADE"), nullable=False)
language_code = Column(String, nullable=False, comment="BCP-47 language code")
source_language_detected = Column(String, nullable=True, comment="BCP-47 or 'und'")
translated_value = Column(Text, nullable=True)
user_edit = Column(Text, nullable=True)
final_value = Column(Text, nullable=True)
status = Column(String, default="pending", comment="pending|approved|edited|rejected")
needs_review = Column(Boolean, default=False, comment="Flagged because source language could not be determined")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
preview_record = relationship("TranslationPreviewRecord", back_populates="languages")
__table_args__ = (
UniqueConstraint("preview_record_id", "language_code", name="uq_preview_record_language"),
)
# #endregion TranslationPreviewLanguage
# #region TranslationRunLanguageStats [C:1] [TYPE Class]
# @BRIEF Per-language statistics for a translation run (row counts, tokens, cost).
class TranslationRunLanguageStats(Base):
__tablename__ = "translation_run_language_stats"
id = Column(String, primary_key=True, default=generate_uuid)
run_id = Column(String, ForeignKey("translation_runs.id", ondelete="CASCADE"), nullable=False)
language_code = Column(String, nullable=False, comment="BCP-47 language code")
total_rows = Column(Integer, default=0)
translated_rows = Column(Integer, default=0)
failed_rows = Column(Integer, default=0)
skipped_rows = Column(Integer, default=0)
token_count = Column(Integer, default=0)
estimated_cost = Column(Float, default=0.0)
run = relationship("TranslationRun", back_populates="language_stats")
__table_args__ = (
UniqueConstraint("run_id", "language_code", name="uq_run_language"),
Index("idx_rls_run", "run_id"),
)
# #endregion TranslationRunLanguageStats
# #endregion TranslateModels