325 lines
9.9 KiB
Python
325 lines
9.9 KiB
Python
"""
|
|
Import/Export API router for Excel question migration.
|
|
|
|
Endpoints:
|
|
- POST /api/v1/import/preview: Preview Excel import without saving
|
|
- POST /api/v1/import/questions: Import questions from Excel to database
|
|
- GET /api/v1/export/questions: Export questions to Excel file
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
|
|
from fastapi.responses import FileResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import get_db
|
|
from app.services.excel_import import (
|
|
bulk_insert_items,
|
|
export_questions_to_excel,
|
|
parse_excel_import,
|
|
validate_excel_structure,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
|
|
|
|
|
|
def get_website_id_from_header(
|
|
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
|
|
) -> int:
|
|
"""
|
|
Extract and validate website_id from request header.
|
|
|
|
Args:
|
|
x_website_id: Website ID from header
|
|
|
|
Returns:
|
|
Validated website ID as integer
|
|
|
|
Raises:
|
|
HTTPException: If header is missing or invalid
|
|
"""
|
|
if x_website_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="X-Website-ID header is required",
|
|
)
|
|
try:
|
|
return int(x_website_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="X-Website-ID must be a valid integer",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/preview",
|
|
summary="Preview Excel import",
|
|
description="Parse Excel file and return preview without saving to database.",
|
|
)
|
|
async def preview_import(
|
|
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
|
website_id: int = Depends(get_website_id_from_header),
|
|
) -> dict:
|
|
"""
|
|
Preview Excel import without saving to database.
|
|
|
|
Args:
|
|
file: Excel file upload (.xlsx format)
|
|
website_id: Website ID from header
|
|
|
|
Returns:
|
|
Dict with:
|
|
- items_count: Number of items parsed
|
|
- preview: List of item previews
|
|
- validation_errors: List of validation errors if any
|
|
|
|
Raises:
|
|
HTTPException: If file format is invalid or parsing fails
|
|
"""
|
|
# Validate file format
|
|
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="File must be .xlsx format",
|
|
)
|
|
|
|
# Save uploaded file to temporary location
|
|
try:
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
|
content = await file.read()
|
|
temp_file.write(content)
|
|
temp_file_path = temp_file.name
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to save uploaded file: {str(e)}",
|
|
)
|
|
|
|
try:
|
|
# Validate Excel structure
|
|
validation = validate_excel_structure(temp_file_path)
|
|
if not validation["valid"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"error": "Invalid Excel structure",
|
|
"validation_errors": validation["errors"],
|
|
},
|
|
)
|
|
|
|
# Parse Excel (tryout_id is optional for preview)
|
|
tryout_id = "preview" # Use dummy tryout_id for preview
|
|
result = parse_excel_import(
|
|
temp_file_path,
|
|
website_id=website_id,
|
|
tryout_id=tryout_id
|
|
)
|
|
|
|
if result["validation_errors"]:
|
|
return {
|
|
"items_count": result["items_count"],
|
|
"preview": result["items"],
|
|
"validation_errors": result["validation_errors"],
|
|
"has_errors": True,
|
|
}
|
|
|
|
# Return limited preview (first 5 items)
|
|
preview_items = result["items"][:5]
|
|
|
|
return {
|
|
"items_count": result["items_count"],
|
|
"preview": preview_items,
|
|
"validation_errors": [],
|
|
"has_errors": False,
|
|
}
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
if os.path.exists(temp_file_path):
|
|
os.unlink(temp_file_path)
|
|
|
|
|
|
@router.post(
|
|
"/questions",
|
|
summary="Import questions from Excel",
|
|
description="Parse Excel file and import questions to database with 100% data integrity.",
|
|
)
|
|
async def import_questions(
|
|
file: UploadFile = File(..., description="Excel file (.xlsx)"),
|
|
website_id: int = Depends(get_website_id_from_header),
|
|
tryout_id: str = Form(..., description="Tryout identifier"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> dict:
|
|
"""
|
|
Import questions from Excel to database.
|
|
|
|
Validates file format, parses Excel content, checks for duplicates,
|
|
and performs bulk insert with rollback on error.
|
|
|
|
Args:
|
|
file: Excel file upload (.xlsx format)
|
|
website_id: Website ID from header
|
|
tryout_id: Tryout identifier
|
|
db: Async database session
|
|
|
|
Returns:
|
|
Dict with:
|
|
- imported: Number of items successfully imported
|
|
- duplicates: Number of duplicate items skipped
|
|
- errors: List of errors if any
|
|
|
|
Raises:
|
|
HTTPException: If file format is invalid, validation fails, or import fails
|
|
"""
|
|
# Validate file format
|
|
if not file.filename or not file.filename.lower().endswith('.xlsx'):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="File must be .xlsx format",
|
|
)
|
|
|
|
# Save uploaded file to temporary location
|
|
try:
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".xlsx") as temp_file:
|
|
content = await file.read()
|
|
temp_file.write(content)
|
|
temp_file_path = temp_file.name
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to save uploaded file: {str(e)}",
|
|
)
|
|
|
|
try:
|
|
# Validate Excel structure
|
|
validation = validate_excel_structure(temp_file_path)
|
|
if not validation["valid"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"error": "Invalid Excel structure",
|
|
"validation_errors": validation["errors"],
|
|
},
|
|
)
|
|
|
|
# Parse Excel
|
|
result = parse_excel_import(
|
|
temp_file_path,
|
|
website_id=website_id,
|
|
tryout_id=tryout_id
|
|
)
|
|
|
|
# Check for validation errors
|
|
if result["validation_errors"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={
|
|
"error": "Validation failed",
|
|
"validation_errors": result["validation_errors"],
|
|
},
|
|
)
|
|
|
|
# Check if items were parsed
|
|
if result["items_count"] == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="No items found in Excel file",
|
|
)
|
|
|
|
# Bulk insert items
|
|
insert_result = await bulk_insert_items(result["items"], db)
|
|
|
|
# Check for insertion errors
|
|
if insert_result["errors"]:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail={
|
|
"error": "Import failed",
|
|
"errors": insert_result["errors"],
|
|
},
|
|
)
|
|
|
|
# Check for conflicts (duplicates)
|
|
if insert_result["duplicate_count"] > 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={
|
|
"message": f"Import completed with {insert_result['duplicate_count']} duplicate(s) skipped",
|
|
"imported": insert_result["inserted_count"],
|
|
"duplicates": insert_result["duplicate_count"],
|
|
},
|
|
)
|
|
|
|
return {
|
|
"message": "Import successful",
|
|
"imported": insert_result["inserted_count"],
|
|
"duplicates": insert_result["duplicate_count"],
|
|
}
|
|
|
|
finally:
|
|
# Clean up temporary file
|
|
if os.path.exists(temp_file_path):
|
|
os.unlink(temp_file_path)
|
|
|
|
|
|
@router.get(
|
|
"/export/questions",
|
|
summary="Export questions to Excel",
|
|
description="Export questions for a tryout to Excel file in standardized format.",
|
|
)
|
|
async def export_questions(
|
|
tryout_id: str,
|
|
website_id: int = Depends(get_website_id_from_header),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> FileResponse:
|
|
"""
|
|
Export questions to Excel file.
|
|
|
|
Creates Excel file with standardized format:
|
|
- Row 2: KUNCI (answer key)
|
|
- Row 4: TK (p-values)
|
|
- Row 5: BOBOT (weights)
|
|
- Rows 6+: Question data
|
|
|
|
Args:
|
|
tryout_id: Tryout identifier
|
|
website_id: Website ID from header
|
|
db: Async database session
|
|
|
|
Returns:
|
|
FileResponse with Excel file
|
|
|
|
Raises:
|
|
HTTPException: If tryout has no questions or export fails
|
|
"""
|
|
try:
|
|
# Export questions to Excel
|
|
output_path = await export_questions_to_excel(
|
|
tryout_id=tryout_id,
|
|
website_id=website_id,
|
|
db=db
|
|
)
|
|
|
|
# Return file for download
|
|
filename = f"tryout_{tryout_id}_questions.xlsx"
|
|
return FileResponse(
|
|
path=output_path,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
filename=filename,
|
|
)
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=str(e),
|
|
)
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Export failed: {str(e)}",
|
|
)
|