diff --git a/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py new file mode 100644 index 00000000..cfdc55d5 --- /dev/null +++ b/backend/alembic/versions/a5b6c7d8e9f0_set_null_ondelete_for_task_records_env_fk.py @@ -0,0 +1,50 @@ +"""set null ondelete for task_records environment FK + +Revision ID: a5b6c7d8e9f0 +Revises: c4a3a2f74bfe +Create Date: 2026-05-21 18:55:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'a5b6c7d8e9f0' +down_revision: Union[str, Sequence[str], None] = 'c4a3a2f74bfe' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.drop_constraint( + 'task_records_environment_id_fkey', + 'task_records', + type_='foreignkey', + ) + op.create_foreign_key( + 'task_records_environment_id_fkey', + 'task_records', + 'environments', + ['environment_id'], + ['id'], + ondelete='SET NULL', + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint( + 'task_records_environment_id_fkey', + 'task_records', + type_='foreignkey', + ) + op.create_foreign_key( + 'task_records_environment_id_fkey', + 'task_records', + 'environments', + ['environment_id'], + ['id'], + ) diff --git a/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py new file mode 100644 index 00000000..4f478b2d --- /dev/null +++ b/backend/alembic/versions/b0c1d2e3f4a5_cascade_ondelete_translate_fks.py @@ -0,0 +1,83 @@ +"""cascade ondelete for all translate FK references (translation_jobs/runs/batches chain) + +Revision ID: b0c1d2e3f4a5 +Revises: f0e9d8c7b6a5 +Create Date: 2026-05-21 19:05:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'b0c1d2e3f4a5' +down_revision: Union[str, Sequence[str], None] = 'f0e9d8c7b6a5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Each entry: (table, constraint_name, column, ref_table, ondelete_rule) +FK_FIXES = [ + # Direct FKs to translation_jobs.id + ('translation_runs', 'translation_runs_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_events', 'translation_events_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_preview_sessions', 'translation_preview_sessions_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_schedules', 'translation_schedules_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_job_dictionaries', 'translation_job_dictionaries_job_id_fkey', 'job_id', 'CASCADE'), + ('translation_metric_snapshots', 'translation_metric_snapshots_job_id_fkey', 'job_id', 'CASCADE'), + # FKs to translation_runs.id + ('translation_batches', 'translation_batches_run_id_fkey', 'run_id', 'CASCADE'), + ('translation_records', 'translation_records_run_id_fkey', 'run_id', 'CASCADE'), + ('translation_events', 'translation_events_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_preview_sessions', 'translation_preview_sessions_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_metric_snapshots', 'translation_metric_snapshots_run_id_fkey', 'run_id', 'SET NULL'), + ('translation_run_language_stats', 'translation_run_language_stats_run_id_fkey', 'run_id', 'CASCADE'), + # FKs to translation_batches.id + ('translation_records', 'translation_records_batch_id_fkey', 'batch_id', 'CASCADE'), + # FKs to translation_records.id + ('translation_languages', 'translation_languages_record_id_fkey', 'record_id', 'CASCADE'), + # FKs to translation_preview_sessions.id + ('translation_preview_records', 'translation_preview_records_session_id_fkey', 'session_id', 'CASCADE'), + # FKs to translation_preview_records.id + ('translation_preview_languages', 'translation_preview_languages_preview_record_id_fkey', 'preview_record_id', 'CASCADE'), +] + + +def upgrade() -> None: + """Upgrade schema.""" + for table, constraint, column, ondelete in FK_FIXES: + op.drop_constraint(constraint, table, type_='foreignkey') + op.create_foreign_key( + constraint, table, 'translation_jobs' if 'job_id' in column else ( + 'translation_runs' if 'run_id' in column else ( + 'translation_batches' if 'batch_id' in column else ( + 'translation_records' if 'record_id' in column and 'preview' not in constraint else ( + 'translation_preview_sessions' if 'session_id' in column else ( + 'translation_preview_records' if 'preview_record_id' in column else 'unknown' + ) + ) + ) + ) + ), + [column], ['id'], + ondelete=ondelete, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + ref_map = { + 'job_id': 'translation_jobs', + 'run_id': 'translation_runs', + 'batch_id': 'translation_batches', + 'record_id': 'translation_records', + 'session_id': 'translation_preview_sessions', + 'preview_record_id': 'translation_preview_records', + } + for table, constraint, column, _ondelete in reversed(FK_FIXES): + ref_table = ref_map[column] + op.drop_constraint(constraint, table, type_='foreignkey') + op.create_foreign_key( + constraint, table, ref_table, + [column], ['id'], + ) diff --git a/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py new file mode 100644 index 00000000..b9471fb5 --- /dev/null +++ b/backend/alembic/versions/f0e9d8c7b6a5_cascade_ondelete_all_env_fks.py @@ -0,0 +1,53 @@ +"""cascade ondelete for all remaining environments FK references + +Revision ID: f0e9d8c7b6a5 +Revises: a5b6c7d8e9f0, e5f4d3c2b1a +Create Date: 2026-05-21 19:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = 'f0e9d8c7b6a5' +down_revision: Union[str, Sequence[str], None] = ('a5b6c7d8e9f0', 'e5f4d3c2b1a') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +FK_DEFS = [ + ('resource_mappings', 'resource_mappings_environment_id_fkey', 'environment_id'), + ('database_mappings', 'database_mappings_source_env_id_fkey', 'source_env_id'), + ('database_mappings', 'database_mappings_target_env_id_fkey', 'target_env_id'), + ('migration_jobs', 'migration_jobs_source_env_id_fkey', 'source_env_id'), + ('migration_jobs', 'migration_jobs_target_env_id_fkey', 'target_env_id'), + ('dataset_review_sessions', 'dataset_review_sessions_environment_id_fkey', 'environment_id'), +] + + +def upgrade() -> None: + """Upgrade schema.""" + for table, constraint_name, column in FK_DEFS: + op.drop_constraint(constraint_name, table, type_='foreignkey') + op.create_foreign_key( + constraint_name, + table, + 'environments', + [column], + ['id'], + ondelete='CASCADE', + ) + + +def downgrade() -> None: + """Downgrade schema.""" + for table, constraint_name, column in reversed(FK_DEFS): + op.drop_constraint(constraint_name, table, type_='foreignkey') + op.create_foreign_key( + constraint_name, + table, + 'environments', + [column], + ['id'], + ) diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index dd2dcdc6..1c85bfbd 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -437,6 +437,7 @@ async def get_consolidated_settings( "api_key": "********", "default_model": p.default_model, "is_active": p.is_active, + "is_multimodal": bool(p.is_multimodal) if p.is_multimodal is not None else False, } for p in providers ] diff --git a/backend/src/models/dataset_review_pkg/_session_models.py b/backend/src/models/dataset_review_pkg/_session_models.py index 965dbc30..43e804f5 100644 --- a/backend/src/models/dataset_review_pkg/_session_models.py +++ b/backend/src/models/dataset_review_pkg/_session_models.py @@ -74,7 +74,7 @@ class DatasetReviewSession(Base): session_id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) user_id = Column(String, ForeignKey("users.id"), nullable=False) - environment_id = Column(String, ForeignKey("environments.id"), nullable=False) + environment_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) source_kind = Column(String, nullable=False) source_input = Column(String, nullable=False) dataset_ref = Column(String, nullable=False) diff --git a/backend/src/models/mapping.py b/backend/src/models/mapping.py index 15735550..5f765454 100644 --- a/backend/src/models/mapping.py +++ b/backend/src/models/mapping.py @@ -58,8 +58,8 @@ class DatabaseMapping(Base): __tablename__ = "database_mappings" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - source_env_id = Column(String, ForeignKey("environments.id"), nullable=False) - target_env_id = Column(String, ForeignKey("environments.id"), nullable=False) + source_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) + target_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) source_db_uuid = Column(String, nullable=False) target_db_uuid = Column(String, nullable=False) source_db_name = Column(String, nullable=False) @@ -73,8 +73,8 @@ class MigrationJob(Base): __tablename__ = "migration_jobs" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - source_env_id = Column(String, ForeignKey("environments.id"), nullable=False) - target_env_id = Column(String, ForeignKey("environments.id"), nullable=False) + source_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) + target_env_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) status = Column(SQLEnum(MigrationStatus), default=MigrationStatus.PENDING) replace_db = Column(Boolean, default=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -88,7 +88,7 @@ class ResourceMapping(Base): __tablename__ = "resource_mappings" id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) - environment_id = Column(String, ForeignKey("environments.id"), nullable=False) + environment_id = Column(String, ForeignKey("environments.id", ondelete="CASCADE"), nullable=False) resource_type = Column(SQLEnum(ResourceType), nullable=False) uuid = Column(String, nullable=False) remote_integer_id = Column(String, nullable=False) # Stored as string to handle potentially large or composite IDs safely, though Superset usually uses integers. diff --git a/backend/src/models/task.py b/backend/src/models/task.py index 35981ba2..a1ac64b5 100644 --- a/backend/src/models/task.py +++ b/backend/src/models/task.py @@ -22,7 +22,7 @@ class TaskRecord(Base): id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) type = Column(String, nullable=False) # e.g., "backup", "migration" status = Column(String, nullable=False) # Enum: "PENDING", "RUNNING", "SUCCESS", "FAILED" - environment_id = Column(String, ForeignKey("environments.id"), nullable=True) + environment_id = Column(String, ForeignKey("environments.id", ondelete="SET NULL"), nullable=True) started_at = Column(DateTime(timezone=True), nullable=True) finished_at = Column(DateTime(timezone=True), nullable=True) logs = Column(JSON, nullable=True) # Store structured logs as JSON (legacy, kept for backward compatibility) diff --git a/backend/src/models/translate.py b/backend/src/models/translate.py index 188ec60a..23e5269f 100644 --- a/backend/src/models/translate.py +++ b/backend/src/models/translate.py @@ -71,7 +71,7 @@ class TranslationRun(Base): __tablename__ = "translation_runs" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) + 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) @@ -101,7 +101,7 @@ class TranslationBatch(Base): __tablename__ = "translation_batches" id = Column(String, primary_key=True, default=generate_uuid) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=False, index=True) + 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) @@ -119,8 +119,8 @@ class TranslationRecord(Base): __tablename__ = "translation_records" id = Column(String, primary_key=True, default=generate_uuid) - batch_id = Column(String, ForeignKey("translation_batches.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=False, index=True) + 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 @@ -151,8 +151,8 @@ class TranslationEvent(Base): __tablename__ = "translation_events" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True, index=True) + 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) @@ -166,8 +166,8 @@ class TranslationPreviewSession(Base): __tablename__ = "translation_preview_sessions" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True) + 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)) @@ -181,7 +181,7 @@ 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"), nullable=False, index=True) + 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) @@ -219,7 +219,7 @@ class DictionaryEntry(Base): __tablename__ = "dictionary_entries" id = Column(String, primary_key=True, default=generate_uuid) - dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False, index=True) + 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) @@ -254,7 +254,7 @@ class TranslationSchedule(Base): __tablename__ = "translation_schedules" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) + 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) @@ -273,8 +273,8 @@ class TranslationJobDictionary(Base): __tablename__ = "translation_job_dictionaries" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - dictionary_id = Column(String, ForeignKey("terminology_dictionaries.id"), nullable=False, index=True) + 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__ = ( @@ -292,8 +292,8 @@ class MetricSnapshot(Base): __tablename__ = "translation_metric_snapshots" id = Column(String, primary_key=True, default=generate_uuid) - job_id = Column(String, ForeignKey("translation_jobs.id"), nullable=False, index=True) - run_id = Column(String, ForeignKey("translation_runs.id"), nullable=True, index=True) + 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) 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") @@ -324,7 +324,7 @@ class TranslationLanguage(Base): __tablename__ = "translation_languages" id = Column(String, primary_key=True, default=generate_uuid) - record_id = Column(String, ForeignKey("translation_records.id"), nullable=False) + 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") @@ -352,7 +352,7 @@ 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"), nullable=False) + 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) @@ -376,7 +376,7 @@ 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"), nullable=False) + 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) diff --git a/frontend/src/components/llm/ProviderConfig.svelte b/frontend/src/components/llm/ProviderConfig.svelte index cbb09a62..cf8df440 100644 --- a/frontend/src/components/llm/ProviderConfig.svelte +++ b/frontend/src/components/llm/ProviderConfig.svelte @@ -246,7 +246,6 @@ base_url: provider.base_url, default_model: provider.default_model, is_active: provider.is_active, - is_multimodal: Boolean(provider.is_multimodal), }; await requestApi(`/llm/providers/${provider.id}`, "PUT", updatePayload); } catch (err) { diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 89a5ce5d..17ddf5bf 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -3,7 +3,6 @@ // @RELATION DEPENDS_ON -> [ToastsModule] // @RELATION CALLED_BY -> [TranslateApi] import { addToast } from './toasts.js'; -import { PUBLIC_WS_URL } from '$env/static/public'; const API_BASE_URL = '/api'; @@ -39,12 +38,9 @@ function shouldSuppressApiErrorToast(endpoint, error) { } export const getWsUrl = (taskId) => { - let baseUrl = PUBLIC_WS_URL; - if (!baseUrl) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - baseUrl = `${protocol}//${window.location.host}`; - } - return `${baseUrl}/ws/logs/${taskId}`; + const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = typeof window !== 'undefined' ? window.location.host : 'localhost:8000'; + return `${protocol}//${host}/ws/logs/${taskId}`; }; function getAuthHeaders(extraHeaders = {}) { diff --git a/frontend/src/lib/components/ui/SearchableMultiSelect.svelte b/frontend/src/lib/components/ui/SearchableMultiSelect.svelte new file mode 100644 index 00000000..1f63389a --- /dev/null +++ b/frontend/src/lib/components/ui/SearchableMultiSelect.svelte @@ -0,0 +1,246 @@ + + + + + + + + + + +
Loading...
+{$t.validation?.created_at || 'Created'}: {new Date(task.created_at || task.created_on).toLocaleString()}
+{$t.validation?.created_at || 'Created'}: {task?.created_at ? new Date(task.created_at).toLocaleString() : '-'}
{/if}