Changes committed

This commit is contained in:
2026-06-01 21:49:53 +05:30
parent 8537c653c1
commit 3163bb213e
387 changed files with 21940 additions and 107 deletions

View File

View File

@@ -0,0 +1,15 @@
from __future__ import annotations
from fastapi import APIRouter
from app.api.v1.auth import router as auth_router
from app.api.v1.documents import router as documents_router
from app.api.v1.health import router as health_router
from app.api.v1.templates import router as templates_router
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(health_router)
api_v1_router.include_router(auth_router)
api_v1_router.include_router(documents_router)
api_v1_router.include_router(templates_router)

View File

View File

@@ -0,0 +1,246 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
InvalidTokenError,
)
from app.repositories.user_repository import RefreshTokenRepository, UserRepository
from app.schemas.auth import (
ChangePasswordRequest,
LoginRequest,
RefreshTokenRequest,
RegisterRequest,
TokenResponse,
)
from app.schemas.common import SuccessResponse
from app.schemas.user import UserResponse
router = APIRouter(prefix="/auth", tags=["Authentication"])
@router.post(
"/register",
response_model=UserResponse,
status_code=status.HTTP_201_CREATED,
summary="Register User",
description="Register a new user account.",
)
def register(
payload: RegisterRequest,
db: Session = Depends(get_db),
) -> UserResponse:
"""Register a new user."""
user_repo = UserRepository(db)
# Check for existing user
if user_repo.get_by_username(payload.username):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Username '{payload.username}' is already taken",
)
if user_repo.get_by_email(payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Email '{payload.email}' is already registered",
)
hashed = hash_password(payload.password)
user = user_repo.create_user(
username=payload.username,
email=payload.email,
hashed_password=hashed,
full_name=payload.full_name,
role_names=["user"],
)
db.commit()
db.refresh(user)
return UserResponse.model_validate(user)
@router.post(
"/login",
response_model=TokenResponse,
summary="Login",
description="Authenticate with username and password to obtain JWT tokens.",
)
def login(
payload: LoginRequest,
db: Session = Depends(get_db),
) -> TokenResponse:
"""Authenticate user and return JWT tokens."""
user_repo = UserRepository(db)
refresh_repo = RefreshTokenRepository(db)
user = user_repo.get_by_username(payload.username)
if not user or not verify_password(payload.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is deactivated",
)
# Generate tokens
access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
# Store refresh token
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
refresh_repo.create_token(
user_id=user.id,
token=refresh_token_str,
expires_at=expires_at,
)
# Update last login
user_repo.update_last_login(user)
db.commit()
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
expires_in=settings.jwt_access_token_expire_minutes * 60,
)
@router.post(
"/refresh",
response_model=TokenResponse,
summary="Refresh Token",
description="Obtain a new access token using a valid refresh token.",
)
def refresh_token(
payload: RefreshTokenRequest,
db: Session = Depends(get_db),
) -> TokenResponse:
"""Refresh access token using a refresh token."""
refresh_repo = RefreshTokenRepository(db)
user_repo = UserRepository(db)
# Validate the refresh token
try:
token_payload = decode_token(payload.refresh_token)
except InvalidTokenError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
)
if token_payload.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token type",
)
# Check if token exists in database and is not revoked
stored_token = refresh_repo.get_by_token(payload.refresh_token)
if not stored_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token not found or revoked",
)
user = user_repo.get_by_id(token_payload["sub"])
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or deactivated",
)
# Revoke old refresh token
refresh_repo.revoke_token(payload.refresh_token)
# Generate new tokens
new_access_token = create_access_token(data={"sub": str(user.id), "username": user.username})
new_refresh_token = create_refresh_token(data={"sub": str(user.id)})
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
refresh_repo.create_token(
user_id=user.id,
token=new_refresh_token,
expires_at=expires_at,
)
db.commit()
return TokenResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
expires_in=settings.jwt_access_token_expire_minutes * 60,
)
@router.post(
"/logout",
response_model=SuccessResponse,
summary="Logout",
description="Revoke the current refresh token.",
)
def logout(
payload: RefreshTokenRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
) -> SuccessResponse:
"""Logout by revoking the refresh token."""
refresh_repo = RefreshTokenRepository(db)
refresh_repo.revoke_token(payload.refresh_token)
db.commit()
return SuccessResponse(message="Successfully logged out")
@router.post(
"/change-password",
response_model=SuccessResponse,
summary="Change Password",
description="Change the current user's password.",
)
def change_password(
payload: ChangePasswordRequest,
current_user: CurrentUser,
db: Session = Depends(get_db),
) -> SuccessResponse:
"""Change user password."""
if not verify_password(payload.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
current_user.hashed_password = hash_password(payload.new_password)
# Revoke all refresh tokens for security
refresh_repo = RefreshTokenRepository(db)
refresh_repo.revoke_all_user_tokens(current_user.id)
db.commit()
return SuccessResponse(message="Password changed successfully")
@router.get(
"/me",
response_model=UserResponse,
summary="Get Current User",
description="Get the currently authenticated user's profile.",
)
def get_me(current_user: CurrentUser) -> UserResponse:
"""Get current authenticated user profile."""
return UserResponse.model_validate(current_user)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.exceptions import FileSizeError, UnsupportedFileTypeError
from app.core.logging_config import get_logger
from app.models.document import Document
from app.repositories.document_repository import DocumentRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.document import (
DocumentListResponse,
DocumentResponse,
DocumentUploadResponse,
TemplateMatchRequest,
TemplateMatchResponse,
)
from app.storage.provider import LocalStorageProvider, get_storage_provider
logger = get_logger(__name__)
router = APIRouter(prefix="/documents", tags=["Documents"])
ALLOWED_CONTENT_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/tiff": "tiff",
"application/pdf": "pdf",
}
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tiff", ".tif", ".pdf"}
def _validate_file(file: UploadFile) -> str:
"""Validate uploaded file type and size. Returns the content type."""
if not file.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required",
)
# Check extension
from pathlib import Path
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise UnsupportedFileTypeError(ext)
# Determine content type
content_type = file.content_type or ""
if content_type not in ALLOWED_CONTENT_TYPES:
# Try to infer from extension
ext_to_ct = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".tiff": "image/tiff",
".tif": "image/tiff",
".pdf": "application/pdf",
}
content_type = ext_to_ct.get(ext, "")
if not content_type:
raise UnsupportedFileTypeError(file.content_type or "unknown")
return content_type
@router.post(
"/upload",
response_model=DocumentUploadResponse,
status_code=status.HTTP_201_CREATED,
summary="Upload Document",
description="Upload a document (JPG, JPEG, PNG, TIFF, or PDF) for processing.",
)
async def upload_document(
file: UploadFile = File(..., description="Document file to upload"),
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> DocumentUploadResponse:
"""Upload a document for processing."""
content_type = _validate_file(file)
# Read file data
file_data = await file.read()
# Check file size
if len(file_data) > settings.storage_max_file_size_bytes:
raise FileSizeError(settings.storage_max_file_size_mb)
# Store file
storage = get_storage_provider()
checksum = storage.compute_checksum(file_data)
safe_filename = file.filename or "unknown"
stored_filename = storage.generate_filename(safe_filename)
storage_path = storage.save_file(file_data, "documents", stored_filename)
# Create document record
doc_repo = DocumentRepository(db)
document = Document(
filename=stored_filename,
original_filename=safe_filename,
content_type=content_type,
file_size=len(file_data),
checksum=checksum,
storage_path=storage_path,
status="pending",
uploaded_by=current_user.id if current_user else None,
)
doc_repo.create(document)
db.commit()
db.refresh(document)
logger.info(
"document_uploaded",
document_id=str(document.id),
filename=safe_filename,
size=len(file_data),
content_type=content_type,
)
# Trigger async processing via Celery
try:
from app.tasks.document_tasks import process_document_task
process_document_task.delay(str(document.id))
except Exception as e:
logger.warning("celery_dispatch_failed", error=str(e), document_id=str(document.id))
return DocumentUploadResponse.model_validate(document)
@router.get(
"/{document_id}",
response_model=DocumentResponse,
summary="Get Document",
description="Retrieve a document by its ID with all extracted content.",
)
def get_document(
document_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> DocumentResponse:
"""Get a document by ID."""
doc_repo = DocumentRepository(db)
document = doc_repo.get_with_pages(document_id)
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Document '{document_id}' not found",
)
return DocumentResponse.model_validate(document)
@router.get(
"",
response_model=PaginatedResponse[DocumentListResponse],
summary="List Documents",
description="List all documents with pagination.",
)
def list_documents(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
status_filter: str | None = Query(default=None, alias="status"),
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> PaginatedResponse[DocumentListResponse]:
"""List documents with pagination and optional status filter."""
doc_repo = DocumentRepository(db)
offset = (page - 1) * page_size
filters = {}
if status_filter:
filters["status"] = status_filter
documents = doc_repo.get_all(
offset=offset,
limit=page_size,
filters=filters,
order_by="created_at",
order_desc=True,
)
total = doc_repo.count(filters=filters)
items = [DocumentListResponse.model_validate(doc) for doc in documents]
return PaginatedResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/{document_id}/template",
response_model=list[TemplateMatchResponse],
summary="Get Document Template Matches",
description="Get template matching results for a document.",
)
def get_document_template_matches(
document_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> list[TemplateMatchResponse]:
"""Get template matches for a document."""
from app.repositories.document_repository import TemplateMatchRepository
doc_repo = DocumentRepository(db)
document = doc_repo.get_by_id(document_id)
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Document '{document_id}' not found",
)
match_repo = TemplateMatchRepository(db)
matches = match_repo.get_document_matches(document_id)
results = []
for match in matches:
resp = TemplateMatchResponse(
id=match.id,
document_id=match.document_id,
format_id=match.format_id,
confidence_score=match.confidence_score,
match_details=match.match_details,
selected=match.selected,
template_name=match.template.name if match.template else None,
created_at=match.created_at,
)
results.append(resp)
return results
@router.delete(
"/{document_id}",
response_model=SuccessResponse,
summary="Delete Document",
description="Delete a document and its associated data.",
)
def delete_document(
document_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> SuccessResponse:
"""Delete a document."""
doc_repo = DocumentRepository(db)
document = doc_repo.get_by_id(document_id)
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Document '{document_id}' not found",
)
# Delete stored file
try:
storage = get_storage_provider()
storage.delete_file(document.storage_path)
except Exception as e:
logger.warning("file_delete_failed", error=str(e), path=document.storage_path)
doc_repo.delete(document)
db.commit()
return SuccessResponse(message=f"Document '{document_id}' deleted successfully")

View File

@@ -0,0 +1,51 @@
from __future__ import annotations
from datetime import datetime
import redis
from fastapi import APIRouter, status
from app.core.config import settings
from app.core.database import check_database_connection
from app.schemas.common import HealthResponse
router = APIRouter(tags=["Health"])
@router.get(
"/health",
response_model=HealthResponse,
status_code=status.HTTP_200_OK,
summary="Health Check",
description="Check the health status of the application and its dependencies.",
)
async def health_check() -> HealthResponse:
"""Perform health check on all system components."""
# Check database
db_status = "healthy" if check_database_connection() else "unhealthy"
# Check Redis
redis_status = "healthy"
try:
r = redis.Redis(
host=settings.redis_host,
port=settings.redis_port,
db=settings.redis_db,
password=settings.redis_password or None,
socket_timeout=3,
)
r.ping()
r.close()
except Exception:
redis_status = "unhealthy"
overall_status = "healthy" if db_status == "healthy" and redis_status == "healthy" else "degraded"
return HealthResponse(
status=overall_status,
version=settings.app_version,
environment=settings.app_env,
database=db_status,
redis=redis_status,
timestamp=datetime.utcnow(),
)

View File

@@ -0,0 +1,223 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import CurrentUser
from app.core.logging_config import get_logger
from app.repositories.document_repository import DocumentRepository, TemplateMatchRepository
from app.repositories.template_repository import TemplateRepository
from app.schemas.common import PaginatedResponse, SuccessResponse
from app.schemas.document import TemplateMatchRequest, TemplateMatchResponse
from app.schemas.template import (
TemplateListResponse,
TemplateRenderRequest,
TemplateRenderResponse,
TemplateResponse,
)
logger = get_logger(__name__)
router = APIRouter(prefix="/templates", tags=["Templates"])
@router.get(
"",
response_model=PaginatedResponse[TemplateListResponse],
summary="List Templates",
description="List all active templates with pagination.",
)
def list_templates(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> PaginatedResponse[TemplateListResponse]:
"""List all active templates."""
template_repo = TemplateRepository(db)
offset = (page - 1) * page_size
templates = template_repo.get_active_templates(offset=offset, limit=page_size)
total = template_repo.count_active()
items = [TemplateListResponse.model_validate(t) for t in templates]
return PaginatedResponse.create(
items=items,
total=total,
page=page,
page_size=page_size,
)
@router.get(
"/{template_id}",
response_model=TemplateResponse,
summary="Get Template",
description="Retrieve a template by ID with all its components.",
)
def get_template(
template_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> TemplateResponse:
"""Get a template by ID."""
template_repo = TemplateRepository(db)
template = template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template '{template_id}' not found",
)
return TemplateResponse.model_validate(template)
@router.delete(
"/{template_id}",
response_model=SuccessResponse,
summary="Delete Template",
description="Soft-delete a template by deactivating it.",
)
def delete_template(
template_id: uuid.UUID,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> SuccessResponse:
"""Soft-delete a template."""
template_repo = TemplateRepository(db)
template = template_repo.get_by_id(template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template '{template_id}' not found",
)
template_repo.deactivate_template(template_id)
db.commit()
return SuccessResponse(message=f"Template '{template_id}' deactivated successfully")
@router.post(
"/match",
response_model=list[TemplateMatchResponse],
summary="Match Document to Templates",
description="Match a document against existing templates and return ranked results.",
)
def match_template(
payload: TemplateMatchRequest,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> list[TemplateMatchResponse]:
"""Match a document against existing templates."""
doc_repo = DocumentRepository(db)
document = doc_repo.get_by_id(payload.document_id)
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Document '{payload.document_id}' not found",
)
if document.status != "completed":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Document must be in 'completed' status. Current status: '{document.status}'",
)
# Perform template matching
from app.services.matching_service import MatchingService
matching_service = MatchingService(db)
matches = matching_service.match_document(
document_id=payload.document_id,
min_confidence=payload.min_confidence,
max_results=payload.max_results,
)
db.commit()
results = []
for match in matches:
resp = TemplateMatchResponse(
id=match.id,
document_id=match.document_id,
format_id=match.format_id,
confidence_score=match.confidence_score,
match_details=match.match_details,
selected=match.selected,
template_name=match.template.name if match.template else None,
created_at=match.created_at,
)
results.append(resp)
return results
@router.post(
"/render",
response_model=TemplateRenderResponse,
summary="Render Template to PDF",
description="Generate a PDF from a stored template with supplied data.",
)
def render_template(
payload: TemplateRenderRequest,
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> TemplateRenderResponse:
"""Render a template to PDF."""
template_repo = TemplateRepository(db)
template = template_repo.get_by_id(payload.template_id)
if not template:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Template '{payload.template_id}' not found",
)
if not template.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Template is deactivated",
)
from app.services.reconstruction_service import ReconstructionService
reconstruction_service = ReconstructionService(db)
result = reconstruction_service.render_template(
template=template,
data=payload.data,
output_filename=payload.output_filename,
images=payload.images,
)
return result
@router.get(
"/{template_id}/download",
summary="Download Rendered PDF",
description="Download a previously rendered PDF.",
)
def download_rendered_pdf(
template_id: uuid.UUID,
filename: str = Query(..., description="Filename of the rendered PDF"),
current_user: CurrentUser = None,
db: Session = Depends(get_db),
) -> FileResponse:
"""Download a rendered PDF."""
from app.storage.provider import get_storage_provider
storage = get_storage_provider()
storage_path = f"rendered/{filename}"
if not storage.file_exists(storage_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Rendered PDF '{filename}' not found",
)
absolute_path = storage.get_absolute_path(storage_path)
return FileResponse(
path=absolute_path,
media_type="application/pdf",
filename=filename,
)