fix: show real file validation stats instead of hardcoded mock data
StepUpload was showing hardcoded "42 Total Lines, 8 Display Formats" for every file upload. Now: - Added POST /jobs/validate-source endpoint that parses xlsx in a temp file and returns real stats (line count, display formats, columns found, warnings) without creating any DB records - Frontend calls validateSource() when user selects a file - Shows spinner during validation, real results after - Blocks "Next" if validation fails - Removed all mock validation data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
84f37a4649
commit
b11b2df0e2
3 changed files with 177 additions and 41 deletions
|
|
@ -108,6 +108,82 @@ async def get_job(
|
|||
return _enrich_job_response(job)
|
||||
|
||||
|
||||
@router.post("/validate-source")
|
||||
async def validate_source(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""Validate a source xlsx file without saving. Returns line count, columns, etc."""
|
||||
import tempfile, shutil
|
||||
from app.pipeline.modules.source_file_parser import parse_source_file, _resolve_headers, HEADER_ALIASES
|
||||
|
||||
if not file.filename or not file.filename.endswith(".xlsx"):
|
||||
raise HTTPException(status_code=400, detail="Only .xlsx files are accepted")
|
||||
|
||||
# Write to temp file for parsing
|
||||
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||
shutil.copyfileobj(file.file, tmp)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(tmp_path, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
if ws is None:
|
||||
raise HTTPException(status_code=400, detail="Workbook has no active sheet")
|
||||
|
||||
# Read headers
|
||||
first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
||||
if first_row is None:
|
||||
raise HTTPException(status_code=400, detail="File is empty")
|
||||
|
||||
raw_headers = [str(cell).strip() if cell else "" for cell in first_row]
|
||||
col_map = _resolve_headers(raw_headers)
|
||||
wb.close()
|
||||
|
||||
# Parse lines
|
||||
parsed_lines = parse_source_file(tmp_path)
|
||||
|
||||
# Build column check results
|
||||
columns = []
|
||||
for field, aliases in HEADER_ALIASES.items():
|
||||
columns.append({
|
||||
"name": field,
|
||||
"found": field in col_map,
|
||||
"required": field == "en_gb",
|
||||
})
|
||||
|
||||
display_format_count = sum(1 for l in parsed_lines if l.get("is_display_format"))
|
||||
char_limit_missing = sum(1 for l in parsed_lines if not l.get("char_limit"))
|
||||
|
||||
warnings = []
|
||||
if char_limit_missing > 0:
|
||||
warnings.append(f"{char_limit_missing} rows have empty char_limit values")
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"total_lines": len(parsed_lines),
|
||||
"display_format_count": display_format_count,
|
||||
"columns": columns,
|
||||
"errors": [],
|
||||
"warnings": warnings,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return {
|
||||
"valid": False,
|
||||
"total_lines": 0,
|
||||
"display_format_count": 0,
|
||||
"columns": [],
|
||||
"errors": [str(e)],
|
||||
"warnings": [],
|
||||
}
|
||||
finally:
|
||||
import os
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@router.put("/{job_id}/source")
|
||||
async def upload_source(
|
||||
job_id: UUID,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { JobFormData } from "@/app/jobs/new/page";
|
||||
import type { ValidationResult } from "@/lib/types";
|
||||
import { validateSource } from "@/lib/api";
|
||||
import {
|
||||
Upload,
|
||||
FileSpreadsheet,
|
||||
|
|
@ -15,6 +16,7 @@ import {
|
|||
XCircle,
|
||||
AlertTriangle,
|
||||
File,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -25,25 +27,30 @@ interface StepUploadProps {
|
|||
onNext: () => void;
|
||||
}
|
||||
|
||||
// Mock validation result
|
||||
const mockValidation = {
|
||||
valid: true,
|
||||
total_lines: 42,
|
||||
display_format_count: 8,
|
||||
columns: [
|
||||
{ name: "copy_type", found: true, required: true },
|
||||
{ name: "source_text", found: true, required: true },
|
||||
{ name: "char_limit", found: true, required: false },
|
||||
{ name: "display_format", found: true, required: false },
|
||||
{ name: "notes", found: true, required: false },
|
||||
],
|
||||
errors: [],
|
||||
warnings: ["2 rows have empty char_limit values"],
|
||||
};
|
||||
|
||||
export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) {
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [validated, setValidated] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validation, setValidation] = useState<ValidationResult | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
const handleValidate = useCallback(
|
||||
async (file: File) => {
|
||||
onChange({ source_file: file });
|
||||
setValidating(true);
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
|
||||
try {
|
||||
const result = await validateSource(file);
|
||||
setValidation(result);
|
||||
} catch {
|
||||
setValidationError("Failed to validate file. Please try again.");
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
);
|
||||
|
||||
const handleSourceDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
|
|
@ -51,22 +58,20 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
setDragActive(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && (file.name.endsWith(".xlsx") || file.name.endsWith(".xls"))) {
|
||||
onChange({ source_file: file });
|
||||
setValidated(true);
|
||||
handleValidate(file);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
[handleValidate]
|
||||
);
|
||||
|
||||
const handleSourceSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onChange({ source_file: file });
|
||||
setValidated(true);
|
||||
handleValidate(file);
|
||||
}
|
||||
},
|
||||
[onChange]
|
||||
[handleValidate]
|
||||
);
|
||||
|
||||
const handleSupplementarySelect = useCallback(
|
||||
|
|
@ -85,6 +90,12 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
});
|
||||
};
|
||||
|
||||
const clearSource = () => {
|
||||
onChange({ source_file: null });
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Source file upload */}
|
||||
|
|
@ -141,33 +152,54 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
onChange({ source_file: null });
|
||||
setValidated(false);
|
||||
}}
|
||||
onClick={clearSource}
|
||||
className="text-gray-400 hover:text-amazon-error"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Validating spinner */}
|
||||
{validating && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Validating file...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation error */}
|
||||
{validationError && (
|
||||
<div className="flex items-center gap-2 text-sm text-amazon-error">
|
||||
<XCircle className="h-4 w-4" />
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation results */}
|
||||
{validated && (
|
||||
{validation && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amazon-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Validation passed
|
||||
</div>
|
||||
{validation.valid ? (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amazon-success">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Validation passed
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-amazon-error">
|
||||
<XCircle className="h-4 w-4" />
|
||||
Validation failed
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-amazon-text">
|
||||
{mockValidation.total_lines}
|
||||
{validation.total_lines}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Total Lines</p>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-amazon-text">
|
||||
{mockValidation.display_format_count}
|
||||
{validation.display_format_count}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Display Formats
|
||||
|
|
@ -175,8 +207,8 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-lg text-center">
|
||||
<p className="text-2xl font-bold text-amazon-text">
|
||||
{mockValidation.columns.filter((c) => c.found).length}/
|
||||
{mockValidation.columns.length}
|
||||
{validation.columns.filter((c) => c.found).length}/
|
||||
{validation.columns.length}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Columns Found</p>
|
||||
</div>
|
||||
|
|
@ -184,7 +216,7 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
|
||||
{/* Column checks */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mockValidation.columns.map((col) => (
|
||||
{validation.columns.map((col) => (
|
||||
<div
|
||||
key={col.name}
|
||||
className="flex items-center gap-1.5 text-xs"
|
||||
|
|
@ -204,12 +236,24 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{validation.errors.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-xs text-amazon-error bg-red-50 p-2 rounded">
|
||||
<XCircle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
{validation.errors.map((e, i) => (
|
||||
<p key={i}>{e}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{mockValidation.warnings.length > 0 && (
|
||||
{validation.warnings.length > 0 && (
|
||||
<div className="flex items-start gap-2 text-xs text-amber-600 bg-amber-50 p-2 rounded">
|
||||
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
{mockValidation.warnings.map((w, i) => (
|
||||
{validation.warnings.map((w, i) => (
|
||||
<p key={i}>{w}</p>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -302,7 +346,10 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)
|
|||
<Button variant="outline" onClick={onBack}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!data.source_file}>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!data.source_file || validating || (validation !== null && !validation.valid)}
|
||||
>
|
||||
Next: Review
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -240,6 +240,19 @@ export async function uploadSource(
|
|||
return response.data;
|
||||
}
|
||||
|
||||
export async function validateSource(
|
||||
file: File
|
||||
): Promise<ValidationResult> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await api.post<ValidationResult>(
|
||||
"/jobs/validate-source",
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadSupplementary(
|
||||
jobId: string,
|
||||
file: File
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue