Files
mve-micro-docs/app/api/v1/incrementables.py

323 lines
10 KiB
Python

"""Incrementables parsing endpoints."""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Header, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from celery.result import AsyncResult
import hashlib
import logging
import uuid
from typing import Optional
from app.core.config import get_settings
from app.core.security import decode_access_token
from app.core.celery_app import celery_app
from app.tasks.parse_tasks import parse_pdf_task
from app.services.pdf_text import extract_text_from_pdf, PDFExtractionError
from app.services.parser import parse_incrementables, ParsingError
from app.schemas import (
ParseResponse, ParseAsyncResponse, TaskStatusResponse,
DocumentInfo, IncrementablesData, ExtractionInfo
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/incrementables", tags=["Incrementables"])
security = HTTPBearer()
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""
Dependency to validate JWT token and extract username.
"""
payload = decode_access_token(credentials.credentials)
username = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
return username
@router.post("/parse", response_model=ParseResponse)
async def parse_pdf(
file: UploadFile = File(..., description="PDF file to parse"),
document_ref: Optional[str] = Form(None, description="Optional document reference or folio"),
x_correlation_id: Optional[str] = Header(None),
current_user: str = Depends(get_current_user)
):
"""
Parse incrementables section from uploaded PDF.
- **file**: PDF file (required)
- **document_ref**: Optional document reference or folio
Returns parsed incrementables data with metadata.
**Authentication required**: Bearer token in Authorization header.
"""
settings = get_settings()
# Generate or use correlation ID
correlation_id = x_correlation_id or str(uuid.uuid4())
logger.info(
f"Parse request received",
extra={
"correlation_id": correlation_id,
"pdf_filename": file.filename,
"document_ref": document_ref,
"user": current_user
}
)
# Validate file type
if not file.filename.lower().endswith('.pdf'):
logger.warning(f"Invalid file type: {file.filename}", extra={"correlation_id": correlation_id})
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only PDF files are accepted"
)
# Validate content type
if file.content_type not in ["application/pdf", "application/x-pdf"]:
logger.warning(
f"Invalid content type: {file.content_type}",
extra={"correlation_id": correlation_id}
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid content type. Expected application/pdf, got {file.content_type}"
)
# Read file content
try:
pdf_bytes = await file.read()
except Exception as e:
logger.error(f"Failed to read file: {str(e)}", extra={"correlation_id": correlation_id})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to read uploaded file"
)
# Validate file size
file_size_mb = len(pdf_bytes) / (1024 * 1024)
if file_size_mb > settings.max_file_mb:
logger.warning(
f"File too large: {file_size_mb:.2f} MB",
extra={"correlation_id": correlation_id}
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum allowed size of {settings.max_file_mb} MB"
)
# Calculate SHA256 hash
file_hash = hashlib.sha256(pdf_bytes).hexdigest()
# Extract text from PDF
try:
text, page_count, extraction_method = extract_text_from_pdf(pdf_bytes)
logger.info(
f"Text extracted: {len(text)} characters, {page_count} pages",
extra={"correlation_id": correlation_id}
)
except PDFExtractionError as e:
logger.error(
f"PDF extraction failed: {str(e)}",
extra={"correlation_id": correlation_id}
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Failed to extract text from PDF: {str(e)}"
)
# Parse incrementables
try:
parsed_data = parse_incrementables(text)
logger.info(
f"Successfully parsed incrementables",
extra={
"correlation_id": correlation_id,
"currency": parsed_data["currency"],
"fletes": parsed_data["fletes"]
}
)
except ParsingError as e:
logger.error(
f"Parsing failed: {str(e)}",
extra={"correlation_id": correlation_id}
)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Failed to parse incrementables section: {str(e)}"
)
# Build response
response = ParseResponse(
correlation_id=correlation_id,
document=DocumentInfo(
filename=file.filename,
pages=page_count,
sha256=file_hash
),
incrementables=IncrementablesData(
currency=parsed_data["currency"],
fletes=parsed_data["fletes"],
seguros=parsed_data["seguros"],
almacenaje_consolidacion=parsed_data["almacenaje_consolidacion"],
regalias=parsed_data["regalias"]
),
extraction=ExtractionInfo(
method=extraction_method,
anchors_found=parsed_data["anchors_found"],
warnings=parsed_data["warnings"]
)
)
return response
@router.post("/parse/async", response_model=ParseAsyncResponse)
async def parse_pdf_async(
file: UploadFile = File(..., description="PDF file to parse"),
document_ref: Optional[str] = Form(None, description="Optional document reference or folio"),
x_correlation_id: Optional[str] = Header(None),
current_user: str = Depends(get_current_user)
):
"""
Parse incrementables section from uploaded PDF asynchronously using Celery.
- **file**: PDF file (required)
- **document_ref**: Optional document reference or folio
Returns task ID for checking status later.
**Authentication required**: Bearer token in Authorization header.
"""
settings = get_settings()
# Generate or use correlation ID
correlation_id = x_correlation_id or str(uuid.uuid4())
logger.info(
f"Async parse request received",
extra={
"correlation_id": correlation_id,
"pdf_filename": file.filename,
"document_ref": document_ref,
"user": current_user
}
)
# Validate file type
if not file.filename.lower().endswith('.pdf'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only PDF files are accepted"
)
# Validate content type
if file.content_type not in ["application/pdf", "application/x-pdf"]:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid content type. Expected application/pdf, got {file.content_type}"
)
# Read file content
try:
pdf_bytes = await file.read()
except Exception as e:
logger.error(f"Failed to read file: {str(e)}", extra={"correlation_id": correlation_id})
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to read uploaded file"
)
# Validate file size
file_size_mb = len(pdf_bytes) / (1024 * 1024)
if file_size_mb > settings.max_file_mb:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File size exceeds maximum allowed size of {settings.max_file_mb} MB"
)
# Convert bytes to hex for serialization
pdf_hex = pdf_bytes.hex()
# Queue task
task = parse_pdf_task.delay(pdf_hex, file.filename, document_ref)
logger.info(
f"Task queued: {task.id}",
extra={"correlation_id": correlation_id, "task_id": task.id}
)
return ParseAsyncResponse(
task_id=task.id,
correlation_id=correlation_id,
status="queued",
message="Task queued for processing. Use /v1/incrementables/status/{task_id} to check progress."
)
@router.get("/status/{task_id}", response_model=TaskStatusResponse)
async def get_task_status(
task_id: str,
current_user: str = Depends(get_current_user)
):
"""
Get status of an async parsing task.
- **task_id**: Task ID returned from /parse/async
Returns task status and result if completed.
**Authentication required**: Bearer token in Authorization header.
"""
task_result = AsyncResult(task_id, app=celery_app)
if task_result.state == "PENDING":
return TaskStatusResponse(
task_id=task_id,
status="pending",
result=None,
error=None
)
elif task_result.state == "STARTED":
return TaskStatusResponse(
task_id=task_id,
status="started",
result=None,
error=None
)
elif task_result.state == "SUCCESS":
result_data = task_result.result
if result_data.get("status") == "failed":
return TaskStatusResponse(
task_id=task_id,
status="failed",
result=None,
error=result_data.get("message", "Unknown error")
)
return TaskStatusResponse(
task_id=task_id,
status="completed",
result=result_data,
error=None
)
elif task_result.state == "FAILURE":
return TaskStatusResponse(
task_id=task_id,
status="failed",
result=None,
error=str(task_result.info)
)
else:
return TaskStatusResponse(
task_id=task_id,
status=task_result.state.lower(),
result=None,
error=None
)