fix(translate): исправление INSERT + выгрузка CSV для неуспешных запусков

**Исправления ошибок:**
- superset_executor: убран несуществующий fallback endpoint /api/v1/sql_lab/execute/
  (правильный URL /api/v1/sqllab/execute/ подтверждён браузерным запросом Superset)
  Ошибка 500 (permission denied) больше не маскируется 404
- orchestrator_run_completion: run.status=FAILED при провале insert
  (раньше всегда ставился COMPLETED). timeout тоже treated as failure
- test_orchestrator: assertion обновлён COMPLETED→FAILED

**Новая функция — выгрузка CSV:**
- GET /api/translate/runs/{run_id}/failed.csv — возвращает CSV с успешно
  переведёнными, но не вставленными записями (status=SUCCESS)
- Колонки: record_id, source_*, target_sql, source_data (JSON), языки, error_message
- Метаданные запуска (run_status, insert_status, run_error) в конце файла
- Кнопки скачивания в 3 местах фронта:
  - История запусков (при status=FAILED)
  - TranslationRunProgress (insert_failed)
  - TranslationRunResult (failed/partial/insert_failed)
- downloadFailedCsv() через fetchApiBlob + Blob URL
This commit is contained in:
2026-06-01 22:38:52 +03:00
parent 40e364d9f6
commit 28b28338c5
9 changed files with 243 additions and 43 deletions

View File

@@ -222,4 +222,137 @@ async def download_skipped_csv(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion download_skipped_csv
# ============================================================
# Download Failed CSV (uninserted records from failed runs)
# ============================================================
# #region download_failed_csv [C:4] [TYPE Function]
# @BRIEF Download a CSV of successfully translated but uninserted records from a FAILED run.
# @PRE User has translate.job.view permission. Run must have insert_status=failed or status=FAILED.
# @POST Returns CSV file of records that were translated but not inserted into target DB.
# @RATIONALE When Superset SQL Lab INSERT fails (e.g., permission denied), users need to
# recover the translated data. This CSV lets them manually import or retry.
# @REJECTED Exporting ALL records from a failed run was rejected — only SUCCESS records
# (those with valid translations) are useful for recovery.
@router.get("/runs/{run_id}/failed.csv")
async def download_failed_csv(
run_id: str,
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "VIEW")),
db: Session = Depends(get_db),
):
"""Download successfully translated but uninserted records as CSV (for failed runs)."""
logger.reason(f"download_failed_csv — Run: {run_id}, User: {current_user.username}",
extra={"src": "translate_routes"})
try:
import csv
import io
import json
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import joinedload
from ....models.translate import TranslationRecord, TranslationRun
run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
if not run:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run not found")
# Export only SUCCESS records — these are the ones that were translated
# but could not be inserted into the target database.
records = (
db.query(TranslationRecord)
.options(joinedload(TranslationRecord.languages))
.filter(
TranslationRecord.run_id == run_id,
TranslationRecord.status == "SUCCESS",
)
.order_by(TranslationRecord.created_at)
.all()
)
if not records:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No successful records found for this run",
)
# Collect all language codes across records for CSV header
language_codes: list[str] = []
seen_codes: set[str] = set()
for rec in records:
for lang in (rec.languages or []):
if lang.language_code and lang.language_code not in seen_codes:
language_codes.append(lang.language_code)
seen_codes.add(lang.language_code)
output = io.StringIO()
writer = csv.writer(output)
# Header: identity + source + target + per-language + metadata
header = [
"record_id", "source_object_type", "source_object_id",
"source_object_name", "source_sql", "target_sql",
"source_data",
]
header.extend(language_codes)
header.append("error_message")
writer.writerow(header)
for rec in records:
# Build per-language values map
lang_values: dict[str, str] = {}
for lang in (rec.languages or []):
if lang.language_code:
lang_values[lang.language_code] = lang.final_value or lang.translated_value or ""
# Flatten source_data JSON to string for CSV compatibility
source_data_str = ""
if rec.source_data:
try:
source_data_str = json.dumps(rec.source_data, ensure_ascii=False)
except (TypeError, ValueError):
source_data_str = str(rec.source_data)
row = [
rec.id,
rec.source_object_type,
rec.source_object_id,
rec.source_object_name,
rec.source_sql,
rec.target_sql,
source_data_str,
]
row.extend(lang_values.get(code, "") for code in language_codes)
row.append(rec.error_message or "")
writer.writerow(row)
# Append run-level failure metadata as a comment row
writer.writerow([])
writer.writerow(["# Run failure metadata:"])
writer.writerow(["run_id", run.id])
writer.writerow(["run_status", run.status])
writer.writerow(["insert_status", run.insert_status or ""])
writer.writerow(["run_error", run.error_message or ""])
writer.writerow(["total_records", run.total_records or 0])
writer.writerow(["successful_records", run.successful_records or 0])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=failed_{run_id[:8]}.csv"
},
)
except HTTPException:
raise
except Exception as e:
logger.explore("download_failed_csv failed",
extra={"src": "translate_routes", "error": str(e)})
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# #endregion download_failed_csv
# #endregion TranslateRunListRoutesModule