service updates

This commit is contained in:
John Lancaster
2026-06-27 21:48:10 -05:00
parent e6f12fa993
commit f0b359edf8
3 changed files with 115 additions and 96 deletions
+32 -94
View File
@@ -3,8 +3,8 @@ from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from uuid import UUID
from uuid import uuid4
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import selectinload
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -17,11 +17,10 @@ from ..models import Document
from ..models import Job
from ..models import JobStatus
from .base import ServiceBase
from .store import store_file
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class DocumentError(AppError):
"""Raised when document operations fail."""
@@ -35,6 +34,10 @@ class UploadError(DocumentError):
"""Raised when uploaded content cannot be persisted safely."""
class DocumentAlreadyExistsError(DocumentError):
"""Raised when a document with the same filename already exists in the database."""
@dataclass(frozen=True)
class UploadJobResult:
"""Summary of created upload records."""
@@ -48,11 +51,22 @@ class UploadJobResult:
class DocumentService(ServiceBase):
"""Thin service class for managing documents in the database."""
#
# CRUD Operations
#
async def create_document(self, document: Document) -> Document:
"""Create a new document in the database."""
async with self.session_factory() as session:
session.add(document)
await session.commit()
try:
await session.commit()
except IntegrityError as exc:
raise DocumentAlreadyExistsError(
f"Document with id {document.id} already exists",
category=ErrorCategory.VALIDATION,
suggestion="Rename the file and try again.",
) from exc
await session.refresh(document)
return document
@@ -95,73 +109,22 @@ class DocumentService(ServiceBase):
await session.delete(document)
await session.commit()
# Query Operations
async def query_documents(self, *, filename: str | None = None) -> Sequence[Document]:
"""Query documents from the database based on provided filters."""
async with self.session_factory() as session:
query = select(Document)
if filename is not None:
query = query.where(Document.filename == filename)
return (await session.exec(query)).all()
result = await session.exec(query)
return result.all()
async def list_documents(self) -> Sequence[Document]:
"""List all documents in the database."""
async with self.session_factory() as session:
return (await session.exec(select(Document))).all()
async def upload_file(
self,
*,
filename: str,
file_bytes: bytes,
) -> UploadJobResult:
"""Persist an uploaded file and create document/job records."""
stored_path = store_file(filename=filename, file_bytes=file_bytes, settings=self.settings)
try:
document, job = await _create_upload_records(
session=self.session_factory(),
original_filename=filename,
stored_path=stored_path,
)
except Exception as exc:
_best_effort_delete(stored_path)
raise UploadError(
"Failed to create upload database records",
category=ErrorCategory.INFRA_TRANSIENT,
suggestion="Retry upload. If this keeps happening, verify database availability.",
retriable=True,
) from exc
logger.info("Created upload job document_id=%s job_id=%s", document.id, job.id)
return UploadJobResult(
document_id=document.id,
job_id=job.id,
stored_path=stored_path,
original_filename=Path(filename).name,
)
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
logger.info("Stored uploaded file: %s", stored_path)
return stored_path
result = await session.exec(select(Document))
return result.all()
async def create_upload_job(
@@ -169,10 +132,15 @@ async def create_upload_job(
filename: str,
file_bytes: bytes,
session: AsyncSession,
settings: Settings,
settings: Settings | None = None,
) -> UploadJobResult:
"""Create upload-backed document and queued job records."""
stored_path = store_file(filename=filename, file_bytes=file_bytes, settings=settings)
runtime_settings = settings or get_settings()
stored_path = store_file(
filename=filename,
file_bytes=file_bytes,
settings=runtime_settings,
)
try:
document, job = await _create_upload_records(
session=session,
@@ -205,7 +173,7 @@ async def _create_upload_records(
) -> tuple[Document, Job]:
document = Document(
filename=Path(original_filename).name,
file_path=stored_path,
file_path=str(stored_path),
)
session.add(document)
await session.flush()
@@ -221,36 +189,6 @@ async def _create_upload_records(
return document, job
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
)
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}"
def _best_effort_delete(path: Path) -> None:
try:
if path.exists():
+13 -2
View File
@@ -13,6 +13,10 @@ from .base import ServiceBase
class JobService(ServiceBase):
"""Thin service class for managing jobs in the database."""
#
# CRUD Operations
#
async def create_job(self, job: Job, session: AsyncSession | None = None) -> Job:
"""Create a new job in the database."""
async with self._session_scope(session) as _session:
@@ -52,6 +56,8 @@ class JobService(ServiceBase):
await _session.delete(job)
await _session.commit()
# Query Operations
async def query_jobs(
self,
*,
@@ -67,10 +73,15 @@ class JobService(ServiceBase):
query = query.where(Job.document.filename == filename)
return (await session.exec(query)).all()
async def list_jobs(self, session: AsyncSession | None = None) -> Sequence[Job]:
async def list_jobs(self, session: AsyncSession | None = None, *, load_docs: bool = False) -> Sequence[Job]:
"""List all jobs in the database."""
async with self._session_scope(session) as _session:
return (await _session.exec(select(Job))).all()
query = select(Job)
if load_docs:
query = query.options(selectinload(Job.document)) # pyright: ignore[reportArgumentType]
return (await _session.exec(query)).all()
# Other Operations
async def mark_job_status(
self,
+70
View File
@@ -0,0 +1,70 @@
import logging
from pathlib import Path
from uuid import uuid4
from ..config import Settings
from ..config import get_settings
from ..errors import AppError
from ..errors import ErrorCategory
logger = logging.getLogger(__name__)
SUPPORTED_UPLOAD_EXTENSIONS = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".pdf"}
class UploadError(AppError):
"""Raised when uploaded content cannot be persisted safely."""
def store_file(*, filename: str, file_bytes: bytes, settings: Settings | None = None) -> Path:
"""Persist an uploaded file to the configured upload directory."""
runtime_settings = settings or get_settings()
_validate_upload(filename=filename, file_bytes=file_bytes)
upload_dir = runtime_settings.upload_dir
upload_dir.mkdir(parents=True, exist_ok=True)
stored_name = _build_stored_filename(filename)
stored_path = upload_dir / stored_name
try:
stored_path.write_bytes(file_bytes)
except OSError as exc:
raise UploadError(
"Failed to persist upload file",
category=ErrorCategory.INFRA_PERSISTENT,
suggestion="Check upload directory permissions and available disk space, then retry.",
) from exc
logger.info("Stored uploaded file: %s", stored_path)
return stored_path
def _validate_upload(*, filename: str, file_bytes: bytes) -> None:
if not file_bytes:
raise UploadError(
"Upload payload is empty",
category=ErrorCategory.VALIDATION,
suggestion="Select a non-empty file and try again.",
)
safe_name = Path(filename).name
if not safe_name:
raise UploadError(
"Upload filename is required",
category=ErrorCategory.VALIDATION,
suggestion="Choose a file with a valid filename and retry.",
)
suffix = Path(safe_name).suffix.lower()
if suffix not in SUPPORTED_UPLOAD_EXTENSIONS:
raise UploadError(
f"Unsupported upload extension: {suffix}",
category=ErrorCategory.USER_INPUT,
suggestion="Upload JPG, JPEG, PNG, TIFF, or PDF files only.",
)
def _build_stored_filename(filename: str) -> str:
safe_name = Path(filename).name
return f"{uuid4()}_{safe_name}"