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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user