712 lines
27 KiB
Python
712 lines
27 KiB
Python
import os
|
|
import io
|
|
import csv
|
|
import uuid
|
|
import shutil
|
|
import zipfile
|
|
import threading
|
|
import datetime
|
|
from typing import List, Dict, Any, Optional
|
|
from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, status
|
|
from pydantic import BaseModel
|
|
from fastapi.responses import Response, StreamingResponse
|
|
from sqlalchemy.orm import Session
|
|
from app.core.database.db_session import get_db, SessionLocal
|
|
import openpyxl
|
|
from openpyxl.styles import Font, PatternFill, Alignment
|
|
from openpyxl.utils import get_column_letter
|
|
|
|
from app.models.MigrationModel import (
|
|
MigrationBatch, MigrationJob, MigrationJobCheckpoint, MigrationMediaItem, MigrationError, MediaGroup, MediaAsset,
|
|
MigrationSnapshot, BatchStatusEnum, JobStatusEnum, PhaseEnum, ImportModeEnum, BatchTypeEnum, MediaItemStatusEnum, RetryStatusEnum
|
|
)
|
|
from app.models.ProductModel import Product, ProductVariant, ProductImage, VariantAttribute, VariantImage
|
|
from app.models.BrandModel import Brand
|
|
from app.models.CategoryModel import Category
|
|
from app.models.DeviceCatalogModel import DeviceSeries, DeviceModel
|
|
from app.services.migration_engine.file_parsers import DataFileParser
|
|
from app.services.migration_engine.column_mapper import ColumnMapper
|
|
from app.services.migration_engine.storage_manager import StorageManager
|
|
from app.services.migration_engine.migration_worker import MigrationWorker
|
|
|
|
router = APIRouter(prefix="/api/v1/migration", tags=["Data Migration Engine"])
|
|
|
|
from pathlib import Path
|
|
project_root = Path(__file__).resolve().parents[4]
|
|
UPLOAD_DIR = str(project_root / "uploads" / "migrations")
|
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Background Worker Daemon Loop
|
|
# -----------------------------------------------------------------------------
|
|
def worker_daemon_loop():
|
|
worker = MigrationWorker()
|
|
while True:
|
|
try:
|
|
db = SessionLocal()
|
|
try:
|
|
claimed = worker.claim_next_job(db)
|
|
if claimed:
|
|
job_id, lease_ver = claimed
|
|
worker.process_job(job_id, lease_ver)
|
|
finally:
|
|
db.close()
|
|
except Exception as e:
|
|
print(f"[WorkerDaemon] Error in worker loop: {e}")
|
|
time_to_sleep = 2.0
|
|
import time
|
|
time.sleep(time_to_sleep)
|
|
|
|
_worker_thread = threading.Thread(target=worker_daemon_loop, daemon=True)
|
|
_worker_thread.start()
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Endpoints
|
|
# -----------------------------------------------------------------------------
|
|
|
|
@router.get("/template/excel")
|
|
def download_excel_template(db: Session = Depends(get_db)):
|
|
"""
|
|
Generates dynamic multi-sheet Excel spreadsheet (.xlsx) with styled headers and sample demo data.
|
|
"""
|
|
wb = openpyxl.Workbook()
|
|
|
|
header_fill = PatternFill(start_color="1F2937", end_color="1F2937", fill_type="solid")
|
|
header_font = Font(name="Calibri", size=11, bold=True, color="FFFFFF")
|
|
|
|
sub_header_fill = PatternFill(start_color="374151", end_color="374151", fill_type="solid")
|
|
sub_header_font = Font(name="Calibri", size=10, bold=True, color="F3F4F6")
|
|
|
|
# 1. Products_and_Variants
|
|
ws1 = wb.active
|
|
ws1.title = "Products_and_Variants"
|
|
ws1.views.sheetView[0].showGridLines = True
|
|
|
|
headers_products = [
|
|
"sku", "parent_name", "name", "brand", "parent_category", "category",
|
|
"is_parent_feature", "device_type", "device_series", "device_model",
|
|
"price", "cost_price", "stock", "color", "storage", "ram", "material",
|
|
"warranty_months", "media_key", "barcode", "is_active", "description"
|
|
]
|
|
ws1.append(headers_products)
|
|
|
|
products_data = [
|
|
[
|
|
"APP-IP15P-CLR-1P", "Apple iPhone 15 Pro Tempered Glass Screen Protector",
|
|
"Clear Glass - 1 Pack", "Apple", "Mobile Accessories", "Screen Guards",
|
|
"TRUE", "Mobile", "iPhone 15 Series", "iPhone 15 Pro", 499.00, 180.00,
|
|
150, "Clear", "N/A", "N/A", "9H Tempered Glass", 6,
|
|
"apple_iphone_15_pro_screenguard", "8901234567890", "TRUE",
|
|
"Ultra-clear 9H hardness tempered glass screen guard with anti-scratch coating."
|
|
],
|
|
[
|
|
"SAM-S24U-ARM-BLK", "Samsung Galaxy S24 Ultra Heavy Duty Armor Case",
|
|
"Matte Black Shield", "Samsung", "Mobile Accessories", "Back Covers",
|
|
"TRUE", "Mobile", "Galaxy S Series", "Galaxy S24 Ultra", 999.00, 380.00,
|
|
75, "Matte Black", "N/A", "N/A", "TPU + Polycarbonate", 12,
|
|
"samsung_s24_ultra_case", "8901234567892", "TRUE",
|
|
"Military-grade dual layer shockproof armor cover with magnetic kickstand."
|
|
]
|
|
]
|
|
for row in products_data:
|
|
ws1.append(row)
|
|
|
|
for col_num, header in enumerate(headers_products, start=1):
|
|
cell = ws1.cell(row=1, column=col_num)
|
|
cell.fill = header_fill
|
|
cell.font = header_font
|
|
cell.alignment = Alignment(horizontal="center", vertical="center")
|
|
|
|
output = io.BytesIO()
|
|
wb.save(output)
|
|
output.seek(0)
|
|
|
|
return Response(
|
|
content=output.getvalue(),
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={"Content-Disposition": "attachment; filename=iFixKart_Bulk_Import_Demo_Template.xlsx"}
|
|
)
|
|
|
|
@router.get("/template/zip")
|
|
def download_zip_template():
|
|
"""
|
|
Generates sample Media ZIP archive template.
|
|
"""
|
|
output = io.BytesIO()
|
|
sample_image_bytes = (
|
|
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
|
|
b'\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0'
|
|
b'\x00\x00\x03\x01\x01\x00\x18\xdd\x8d\xb0\x00\x00\x00\x00IEND\xaeB`\x82'
|
|
)
|
|
|
|
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("README.txt", "iFixKart Media ZIP Template")
|
|
zf.writestr("apple_iphone_15_pro_screenguard/1_front.png", sample_image_bytes)
|
|
zf.writestr("samsung_s24_ultra_case/1_main.png", sample_image_bytes)
|
|
|
|
output.seek(0)
|
|
return Response(
|
|
content=output.getvalue(),
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": "attachment; filename=iFixKart_Media_ZIP_Demo_Template.zip"}
|
|
)
|
|
|
|
from app.services.migration_engine.validation_engine import ValidationEngine
|
|
|
|
@router.post("/preview")
|
|
@router.get("/preview")
|
|
def preview_migration_data(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
|
"""
|
|
Dynamic preview endpoint returning mapped file columns and sample rows.
|
|
"""
|
|
job = None
|
|
if job_id:
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
|
|
|
if not job:
|
|
return {"status": "success", "valid": True, "preview_items": []}
|
|
|
|
job_dir = StorageManager.get_job_dir(job.id)
|
|
dataset_dir = os.path.join(job_dir, "dataset")
|
|
if not os.path.exists(dataset_dir):
|
|
return {"status": "success", "valid": True, "preview_items": []}
|
|
|
|
files = os.listdir(dataset_dir)
|
|
if not files:
|
|
return {"status": "success", "valid": True, "preview_items": []}
|
|
|
|
filepath = os.path.join(dataset_dir, files[0])
|
|
fmt = os.path.splitext(filepath)[1].lower().replace(".", "").upper()
|
|
headers = DataFileParser.get_headers(filepath, fmt)
|
|
column_maps = ColumnMapper.suggest_mappings(headers)
|
|
|
|
preview_items = []
|
|
for idx, (row_num, row_dict) in enumerate(DataFileParser.stream_rows(filepath, fmt)):
|
|
if idx >= 10:
|
|
break
|
|
mapped = ColumnMapper.apply_mapping(row_dict, column_maps)
|
|
preview_items.append(mapped)
|
|
|
|
return {
|
|
"status": "success",
|
|
"job_id": job.id,
|
|
"valid": True,
|
|
"headers": headers,
|
|
"column_maps": column_maps,
|
|
"preview_items": preview_items
|
|
}
|
|
|
|
@router.get("/media-groups/preview")
|
|
@router.post("/media-groups/preview")
|
|
def get_media_groups_preview(db: Session = Depends(get_db)):
|
|
"""
|
|
Returns indexed MediaGroup records with their assets for the Media Library tab.
|
|
"""
|
|
groups = db.query(MediaGroup).order_by(MediaGroup.created_at.desc()).all()
|
|
res = []
|
|
for g in groups:
|
|
assets = []
|
|
for a in g.media_assets:
|
|
assets.append({
|
|
"id": a.id,
|
|
"original_filename": a.original_filename,
|
|
"cdn_url": a.cdn_url,
|
|
"thumbnail_url": a.thumbnail_url,
|
|
"file_size_bytes": a.file_size_bytes,
|
|
"mime_type": a.mime_type
|
|
})
|
|
res.append({
|
|
"id": g.id,
|
|
"media_key": g.media_key,
|
|
"brand_name": g.brand_name,
|
|
"model_name": g.model_name,
|
|
"variant_tag": g.variant_tag,
|
|
"assets_count": len(assets),
|
|
"media_assets": assets
|
|
})
|
|
|
|
return {
|
|
"status": "success",
|
|
"total_count": len(res),
|
|
"media_groups": res
|
|
}
|
|
|
|
@router.post("/dry-run")
|
|
@router.get("/dry-run")
|
|
@router.post("/dry_run")
|
|
@router.get("/dry_run")
|
|
def dry_run_migration_data(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
|
"""
|
|
Dynamic dry-run endpoint executing real ValidationEngine checks against uploaded file rows.
|
|
"""
|
|
job = None
|
|
if job_id:
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
|
|
|
if not job:
|
|
return {
|
|
"status": "success",
|
|
"message": "No active migration job found to validate.",
|
|
"valid": True,
|
|
"total_rows": 0,
|
|
"valid_rows": 0,
|
|
"invalid_rows": 0,
|
|
"errors": [],
|
|
"warnings": []
|
|
}
|
|
|
|
job_dir = StorageManager.get_job_dir(job.id)
|
|
dataset_dir = os.path.join(job_dir, "dataset")
|
|
if not os.path.exists(dataset_dir) or not os.listdir(dataset_dir):
|
|
return {
|
|
"status": "success",
|
|
"message": f"Job {job.id} initialized. Dataset file pending.",
|
|
"valid": True,
|
|
"total_rows": 0,
|
|
"valid_rows": 0,
|
|
"invalid_rows": 0,
|
|
"errors": [],
|
|
"warnings": []
|
|
}
|
|
|
|
filepath = os.path.join(dataset_dir, os.listdir(dataset_dir)[0])
|
|
fmt = os.path.splitext(filepath)[1].lower().replace(".", "").upper()
|
|
headers = DataFileParser.get_headers(filepath, fmt)
|
|
column_maps = ColumnMapper.suggest_mappings(headers)
|
|
|
|
mapped_rows = []
|
|
for row_num, row_dict in DataFileParser.stream_rows(filepath, fmt):
|
|
mapped = ColumnMapper.apply_mapping(row_dict, column_maps)
|
|
mapped_rows.append((row_num, mapped))
|
|
|
|
total_rows = len(mapped_rows)
|
|
errors, warnings = ValidationEngine.validate_batch(mapped_rows, db, import_mode="UPSERT")
|
|
|
|
invalid_rows_count = len(set(e["row_number"] for e in errors))
|
|
valid_rows_count = max(0, total_rows - invalid_rows_count)
|
|
|
|
job.total_records = total_rows
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"job_id": job.id,
|
|
"message": f"Dynamic Dry-Run Validation complete for {total_rows} rows.",
|
|
"valid": invalid_rows_count == 0,
|
|
"total_rows": total_rows,
|
|
"valid_rows": valid_rows_count,
|
|
"invalid_rows": invalid_rows_count,
|
|
"errors": errors[:100],
|
|
"warnings": warnings[:100],
|
|
"headers": headers,
|
|
"column_maps": column_maps
|
|
}
|
|
|
|
@router.post("/execute")
|
|
@router.get("/execute")
|
|
def execute_migration_batch(job_id: Optional[str] = None, db: Session = Depends(get_db)):
|
|
"""
|
|
Dynamic execution endpoint enqueuing job for persistent MigrationWorker processing.
|
|
"""
|
|
job = None
|
|
if job_id:
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
job = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).first()
|
|
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="No migration job found to execute.")
|
|
|
|
# Only mark as QUEUED if the job is not already RUNNING or COMPLETED
|
|
if job.status not in (JobStatusEnum.RUNNING, JobStatusEnum.COMPLETED):
|
|
job.status = JobStatusEnum.QUEUED
|
|
if job.current_phase == PhaseEnum.UPLOAD:
|
|
job.current_phase = PhaseEnum.VALIDATE
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"job_id": job.id,
|
|
"batch_id": job.batch_id,
|
|
"job_status": job.status,
|
|
"message": f"Migration Job {job.id} enqueued. Background worker thread will execute pipeline phases."
|
|
}
|
|
|
|
|
|
|
|
@router.post("/upload")
|
|
async def upload_migration_file(
|
|
file: Optional[UploadFile] = File(None),
|
|
media_file: Optional[UploadFile] = File(None),
|
|
batch_type: str = Form("PRODUCTS"),
|
|
import_mode: str = Form("UPSERT"),
|
|
user_id: str = Form("admin-user-01"),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
64KB Streamed Upload Endpoint. Streams dataset spreadsheet and/or ZIP archive directly to disk,
|
|
creates MigrationBatch & MigrationJob, and returns immediate job_id in QUEUED status.
|
|
"""
|
|
if not file and not media_file:
|
|
raise HTTPException(status_code=400, detail="Please upload a dataset file (.xlsx/.csv) or media ZIP archive (.zip).")
|
|
|
|
batch_id = str(uuid.uuid4())
|
|
job_id = str(uuid.uuid4())
|
|
job_dir = StorageManager.get_job_dir(job_id)
|
|
|
|
batch = MigrationBatch(
|
|
id=batch_id,
|
|
batch_type=BatchTypeEnum(batch_type) if batch_type in BatchTypeEnum.__members__ else BatchTypeEnum.PRODUCTS,
|
|
user_id=user_id,
|
|
import_mode=ImportModeEnum(import_mode) if import_mode in ImportModeEnum.__members__ else ImportModeEnum.UPSERT,
|
|
status=BatchStatusEnum.PENDING
|
|
)
|
|
db.add(batch)
|
|
|
|
file_name = ""
|
|
file_format = "CSV"
|
|
|
|
if file:
|
|
file_name = file.filename
|
|
ext = os.path.splitext(file.filename)[1].lower().replace(".", "")
|
|
file_format = ext.upper()
|
|
dataset_dest = os.path.join(job_dir, "dataset", file.filename)
|
|
await StorageManager.save_upload_stream_async(file, dataset_dest)
|
|
|
|
if media_file:
|
|
archive_dest = os.path.join(job_dir, "archives", media_file.filename)
|
|
await StorageManager.save_upload_stream_async(media_file, archive_dest)
|
|
if not file_name:
|
|
file_name = media_file.filename
|
|
file_format = "ZIP"
|
|
|
|
job = MigrationJob(
|
|
id=job_id,
|
|
batch_id=batch_id,
|
|
job_type=batch_type,
|
|
is_dry_run=False,
|
|
file_name=file_name,
|
|
file_format=file_format,
|
|
status=JobStatusEnum.QUEUED,
|
|
current_phase=PhaseEnum.UPLOAD
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
|
|
return {
|
|
"status": "success",
|
|
"job_id": job.id,
|
|
"batch_id": batch.id,
|
|
"job_status": job.status,
|
|
"current_phase": job.current_phase,
|
|
"message": f"File uploaded safely to disk. Migration Job {job.id} queued for background processing."
|
|
}
|
|
|
|
@router.get("/batches")
|
|
@router.get("/jobs")
|
|
@router.get("/history")
|
|
def list_migration_jobs(db: Session = Depends(get_db)):
|
|
"""
|
|
List all historical migration jobs for the admin dashboard.
|
|
"""
|
|
jobs = db.query(MigrationJob).order_by(MigrationJob.started_at.desc(), MigrationJob.id.desc()).all()
|
|
res = []
|
|
for j in jobs:
|
|
dur_secs = 0
|
|
if j.started_at:
|
|
end_t = j.completed_at or j.finished_at or datetime.datetime.utcnow()
|
|
dur_secs = max(0, int((end_t - j.started_at).total_seconds()))
|
|
|
|
dur_mins = dur_secs // 60
|
|
dur_s = dur_secs % 60
|
|
duration_fmt = f"{dur_mins}m {dur_s}s" if dur_mins > 0 else f"{dur_s}s"
|
|
|
|
res.append({
|
|
"id": j.id,
|
|
"job_id": j.id,
|
|
"batch_id": j.batch_id,
|
|
"file_name": j.file_name,
|
|
"file_format": j.file_format,
|
|
"status": j.status,
|
|
"current_phase": j.current_phase,
|
|
"current_batch": j.current_batch,
|
|
"total_batches": j.total_batches,
|
|
"processed_records": j.processed_records,
|
|
"total_records": j.total_records,
|
|
"successful_records": j.successful_records,
|
|
"failed_records": j.failed_records,
|
|
"worker_id": j.worker_id,
|
|
"heartbeat_at": j.heartbeat_at.isoformat() if j.heartbeat_at else None,
|
|
"started_at": j.started_at.strftime("%Y-%m-%d %H:%M:%S") if j.started_at else "Pending",
|
|
"completed_at": j.completed_at.strftime("%Y-%m-%d %H:%M:%S") if j.completed_at else None,
|
|
"duration_seconds": dur_secs,
|
|
"duration_formatted": duration_fmt,
|
|
"error_message": j.error_message
|
|
})
|
|
return {"status": "success", "batches": res, "jobs": res}
|
|
|
|
class BulkDeleteMediaRequest(BaseModel):
|
|
ids: list[str]
|
|
|
|
@router.delete("/media-groups/{group_id}")
|
|
def delete_single_media_group(group_id: str, db: Session = Depends(get_db)):
|
|
db.query(MigrationMediaItem).filter(MigrationMediaItem.id == group_id).delete()
|
|
db.commit()
|
|
return {"status": "success", "message": f"Media item {group_id} deleted."}
|
|
|
|
@router.post("/media-groups/bulk-delete")
|
|
def bulk_delete_media_groups(payload: BulkDeleteMediaRequest, db: Session = Depends(get_db)):
|
|
if payload.ids:
|
|
db.query(MigrationMediaItem).filter(MigrationMediaItem.id.in_(payload.ids)).delete(synchronize_session=False)
|
|
db.commit()
|
|
return {"status": "success", "message": f"Deleted {len(payload.ids)} media items."}
|
|
|
|
@router.post("/purge-all")
|
|
@router.post("/purge_all")
|
|
def purge_all_migration_data(db: Session = Depends(get_db)):
|
|
from sqlalchemy import text
|
|
try:
|
|
db.execute(text("DELETE FROM migration_snapshots"))
|
|
db.execute(text("DELETE FROM migration_errors"))
|
|
db.execute(text("DELETE FROM migration_job_checkpoints"))
|
|
db.execute(text("DELETE FROM migration_media_items"))
|
|
db.execute(text("DELETE FROM migration_jobs"))
|
|
db.execute(text("DELETE FROM migration_batches"))
|
|
db.commit()
|
|
except Exception as e:
|
|
db.rollback()
|
|
print(f"Purge warning: {e}")
|
|
return {"status": "success", "message": "All migration history and media groups purged."}
|
|
|
|
@router.get("/jobs/{job_id}/status")
|
|
def get_job_telemetry(job_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Fetches real-time telemetry metrics for a migration job.
|
|
"""
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
|
|
|
checkpoints = db.query(MigrationJobCheckpoint).filter(MigrationJobCheckpoint.job_id == job_id).all()
|
|
phase_checkpoints = {}
|
|
for c in checkpoints:
|
|
phase_checkpoints[c.phase] = {
|
|
"last_successful_batch": c.last_successful_batch,
|
|
"total_batches": c.total_batches,
|
|
"processed_records": c.processed_records,
|
|
"failed_records": c.failed_records
|
|
}
|
|
|
|
pct = 0.0
|
|
if job.total_records > 0:
|
|
pct = round((job.processed_records / job.total_records) * 100.0, 2)
|
|
|
|
elapsed_seconds = 0
|
|
if job.started_at:
|
|
end_time = job.completed_at or job.finished_at or datetime.datetime.utcnow()
|
|
elapsed_seconds = max(0, int((end_time - job.started_at).total_seconds()))
|
|
|
|
processing_rate = 0.0
|
|
estimated_seconds_remaining = None
|
|
if job.status == JobStatusEnum.QUEUED:
|
|
eta_formatted = "Queued in Line"
|
|
elif job.status == JobStatusEnum.RUNNING and job.processed_records == 0:
|
|
eta_formatted = "Starting Batch Processing..."
|
|
elif job.status == JobStatusEnum.COMPLETED:
|
|
eta_formatted = "0s (Completed)"
|
|
else:
|
|
eta_formatted = "Calculating..."
|
|
|
|
if elapsed_seconds > 0 and job.processed_records > 0:
|
|
processing_rate = round(job.processed_records / elapsed_seconds, 2)
|
|
remaining_records = max(0, job.total_records - job.processed_records)
|
|
if remaining_records > 0 and processing_rate > 0:
|
|
estimated_seconds_remaining = int(remaining_records / processing_rate)
|
|
mins = estimated_seconds_remaining // 60
|
|
secs = estimated_seconds_remaining % 60
|
|
eta_formatted = f"{mins}m {secs}s" if mins > 0 else f"{secs}s"
|
|
elif remaining_records == 0 and job.status == JobStatusEnum.COMPLETED:
|
|
eta_formatted = "0s (Completed)"
|
|
|
|
elapsed_mins = elapsed_seconds // 60
|
|
elapsed_secs = elapsed_seconds % 60
|
|
elapsed_formatted = f"{elapsed_mins}m {elapsed_secs}s" if elapsed_mins > 0 else f"{elapsed_secs}s"
|
|
|
|
return {
|
|
"status": "success",
|
|
"job_id": job.id,
|
|
"batch_id": job.batch_id,
|
|
"job_status": job.status,
|
|
"current_phase": job.current_phase,
|
|
"worker_id": job.worker_id,
|
|
"heartbeat_at": job.heartbeat_at.isoformat() if job.heartbeat_at else None,
|
|
"lease_version": job.lease_version,
|
|
"current_batch": job.current_batch,
|
|
"total_batches": job.total_batches,
|
|
"last_successful_batch": job.last_successful_batch,
|
|
"processed_records": job.processed_records,
|
|
"total_records": job.total_records,
|
|
"successful_records": job.successful_records,
|
|
"failed_records": job.failed_records,
|
|
"expected_products": job.expected_products,
|
|
"expected_variants": job.expected_variants,
|
|
"expected_media_items": job.expected_media_items,
|
|
"progress_percentage": pct,
|
|
"elapsed_seconds": elapsed_seconds,
|
|
"elapsed_formatted": elapsed_formatted,
|
|
"processing_rate": processing_rate,
|
|
"estimated_seconds_remaining": estimated_seconds_remaining,
|
|
"eta_formatted": eta_formatted,
|
|
"phase_checkpoints": phase_checkpoints,
|
|
"error_message": job.error_message
|
|
}
|
|
|
|
@router.get("/jobs/{job_id}/stream")
|
|
def stream_job_telemetry(job_id: str):
|
|
"""
|
|
Streams real-time Server-Sent Events (SSE) telemetry data for a migration job.
|
|
"""
|
|
def event_generator():
|
|
import json, time
|
|
max_duration = 3600 # Max 1 hour stream safeguard
|
|
start_stream = time.time()
|
|
|
|
while time.time() - start_stream < max_duration:
|
|
local_db = SessionLocal()
|
|
try:
|
|
telemetry = get_job_telemetry(job_id=job_id, db=local_db)
|
|
data_str = json.dumps(telemetry, default=str)
|
|
yield f"data: {data_str}\n\n"
|
|
|
|
job_status = telemetry.get("job_status")
|
|
if job_status in [JobStatusEnum.COMPLETED, JobStatusEnum.FAILED, JobStatusEnum.CANCELLED, "COMPLETED", "FAILED", "CANCELLED"]:
|
|
break
|
|
except Exception as e:
|
|
err_payload = json.dumps({"status": "error", "error_message": str(e)})
|
|
yield f"data: {err_payload}\n\n"
|
|
break
|
|
finally:
|
|
local_db.close()
|
|
|
|
time.sleep(1.0)
|
|
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|
|
|
@router.post("/jobs/{job_id}/cancel")
|
|
def cancel_job(job_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Flags job status as CANCELLING. Worker finishes active batch transaction before halting cleanly.
|
|
"""
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
|
|
|
if job.status in (JobStatusEnum.COMPLETED, JobStatusEnum.CANCELLED, JobStatusEnum.FAILED):
|
|
return {"status": "info", "message": f"Job is already in terminal status {job.status}"}
|
|
|
|
job.status = JobStatusEnum.CANCELLING
|
|
job.cancel_requested_at = datetime.datetime.utcnow()
|
|
db.commit()
|
|
|
|
return {"status": "success", "message": f"Cancellation requested for job {job_id}. Worker will halt cleanly after current batch finishes."}
|
|
|
|
@router.post("/jobs/{job_id}/resume")
|
|
def resume_job(job_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Resumes an interrupted, cancelled, or failed job from its last successful phase checkpoint.
|
|
"""
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
|
|
|
if job.status == JobStatusEnum.RUNNING:
|
|
return {"status": "info", "message": "Job is already running."}
|
|
|
|
job.status = JobStatusEnum.QUEUED
|
|
job.cancel_requested_at = None
|
|
job.error_message = None
|
|
db.commit()
|
|
|
|
return {"status": "success", "message": f"Job {job_id} re-queued and will resume from phase {job.current_phase} batch {job.last_successful_batch}."}
|
|
|
|
@router.get("/jobs/{job_id}/errors/export")
|
|
def export_job_errors(job_id: str, db: Session = Depends(get_db)):
|
|
"""
|
|
Exports CSV failure report: Row Number, SKU, Product Name, File Name, Phase, Error Type, Error Message, Retry Status.
|
|
"""
|
|
job = db.query(MigrationJob).filter(MigrationJob.id == job_id).first()
|
|
if not job:
|
|
raise HTTPException(status_code=404, detail="Migration job not found")
|
|
|
|
errors = db.query(MigrationError).filter(MigrationError.job_id == job_id).order_by(MigrationError.row_number.asc()).all()
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow([
|
|
"Row Number", "SKU", "Product Name", "File Name", "Entity Type",
|
|
"Phase", "Error Type", "Severity", "Error Message", "Suggested Fix", "Retry Status"
|
|
])
|
|
|
|
for e in errors:
|
|
writer.writerow([
|
|
e.row_number, e.sku or "", e.product_name or "", e.file_name or "", e.entity_type or "",
|
|
e.phase, e.error_type, e.severity, e.error_message, e.suggested_fix or "", e.retry_status
|
|
])
|
|
|
|
output.seek(0)
|
|
return StreamingResponse(
|
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": f"attachment; filename=Migration_Errors_Job_{job_id[:8]}.csv"}
|
|
)
|
|
|
|
@router.get("/jobs/{job_id}/media")
|
|
def list_job_media(
|
|
job_id: str,
|
|
page: int = 1,
|
|
limit: int = 50,
|
|
search: Optional[str] = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Server-side paginated media gallery for admin console to view 30,000+ indexed media assets without DOM lag.
|
|
"""
|
|
query = db.query(MigrationMediaItem).filter(MigrationMediaItem.job_id == job_id)
|
|
if search:
|
|
query = query.filter(
|
|
(MigrationMediaItem.file_name.ilike(f"%{search}%")) |
|
|
(MigrationMediaItem.media_key.ilike(f"%{search}%")) |
|
|
(MigrationMediaItem.sha256.ilike(f"%{search}%"))
|
|
)
|
|
|
|
total_count = query.count()
|
|
items = query.offset((page - 1) * limit).limit(limit).all()
|
|
|
|
res = []
|
|
for item in items:
|
|
res.append({
|
|
"id": item.id,
|
|
"batch_number": item.batch_number,
|
|
"file_name": item.file_name,
|
|
"archive_name": item.archive_name,
|
|
"zip_entry_path": item.zip_entry_path,
|
|
"media_key": item.media_key,
|
|
"sha256": item.sha256,
|
|
"storage_path": item.storage_path,
|
|
"cdn_url": f"/uploads/migrations/{job_id}/media/{item.sha256[:2]}/{item.sha256}.jpg" if item.sha256 else None,
|
|
"status": item.status,
|
|
"error": item.error
|
|
})
|
|
|
|
return {
|
|
"status": "success",
|
|
"total_count": total_count,
|
|
"page": page,
|
|
"limit": limit,
|
|
"total_pages": (total_count // limit) + (1 if total_count % limit > 0 else 0),
|
|
"items": res
|
|
}
|