feat(pipelines): add custom processing pipeline engine

- Add Pipeline and PipelineStep models with user-specific ownership
- Add pipeline_id FK column to FileRecord
- Migration 017_add_pipelines (batch mode for SQLite FK compat)
- Pipeline CRUD API at /api/pipelines with step management endpoints
- Reorder steps PUT endpoint placed before parameterised {step_id} routes
- POST /api/files/{id}/assign-pipeline for per-file pipeline assignment
- Admin-only POST /api/pipelines/admin/system for system-level pipelines
- Management UI at /pipelines (Jinja2 + Alpine.js + Tailwind)
- Pipelines link added to desktop and mobile navigation
- 41 new tests in tests/test_api_pipelines.py (all passing)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-07 10:32:00 +00:00
parent 2c12af95fc
commit 89e0c2fb50
11 changed files with 2369 additions and 0 deletions
+72
View File
@@ -1559,3 +1559,75 @@ def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), fi
"updated_count": updated,
"owner_id": owner_id,
}
# ---------------------------------------------------------------------------
# Pipeline assignment
# ---------------------------------------------------------------------------
@router.post("/files/{file_id}/assign-pipeline")
@require_login
def assign_pipeline_to_file(
request: Request,
file_id: int,
db: DbSession,
pipeline_id: int | None = None,
):
"""Assign (or remove) a processing pipeline from a file.
Path Parameters:
file_id: The file to update.
Query / Body Parameters:
pipeline_id: The pipeline to assign. Pass ``null`` or omit to clear the
assignment (the system default will be used for future processing).
Returns:
A summary dict with the file_id and updated pipeline_id.
Raises:
HTTPException 404: If the file or pipeline does not exist / is not
accessible to the current user.
"""
from app.auth import get_current_user
from app.models import Pipeline
user = get_current_user(request)
# Derive user identity the same way the pipelines API does (_get_user_id)
if user:
user_id: str = user.get("preferred_username") or user.get("email") or user.get("id") or "anonymous"
else:
user_id = "anonymous"
is_admin_user = bool(user and user.get("is_admin"))
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
# Non-admins may only update files they own (or unowned files in single-user mode)
owner_id = get_current_owner_id(request)
if not is_admin_user and file_record.owner_id is not None and file_record.owner_id != owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if pipeline_id is not None:
pipeline = db.query(Pipeline).filter(Pipeline.id == pipeline_id).first()
if not pipeline:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
# Check access: users can only assign their own pipelines or system pipelines (owner_id=None)
if not is_admin_user and pipeline.owner_id is not None and pipeline.owner_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pipeline not found")
file_record.pipeline_id = pipeline_id
try:
db.commit()
db.refresh(file_record)
except Exception as exc:
db.rollback()
logger.exception(f"Failed to assign pipeline to file id={file_id}: {exc}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to assign pipeline")
logger.info(f"Pipeline {pipeline_id!r} assigned to file id={file_id}")
return {"file_id": file_id, "pipeline_id": file_record.pipeline_id}