Merge pull request #580 from christianlouis/copilot/add-conditional-routing

feat: add conditional routing based on document type and metadata
This commit is contained in:
Christian Krakau-Louis
2026-03-13 08:54:20 +01:00
committed by GitHub
10 changed files with 1801 additions and 0 deletions
+2
View File
@@ -34,6 +34,7 @@ from app.api.plans import router as plans_router
from app.api.process import router as process_router
from app.api.profile import router as profile_router
from app.api.queue import router as queue_router
from app.api.routing_rules import router as routing_rules_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scheduled_jobs import router as scheduled_jobs_router
from app.api.search import router as search_router
@@ -85,6 +86,7 @@ router.include_router(onboarding_router)
router.include_router(billing_router)
router.include_router(pipelines_router)
router.include_router(profile_router)
router.include_router(routing_rules_router)
router.include_router(imap_accounts_router)
router.include_router(imap_profiles_router)
router.include_router(integrations_router)
+468
View File
@@ -0,0 +1,468 @@
"""Routing rules API endpoints.
Provides full CRUD for pipeline routing rules that conditionally assign
documents to pipelines based on document properties (file type, category,
metadata fields, size, etc.).
Rules are evaluated in ascending ``position`` order. The first rule whose
condition matches wins and routes the document to the specified target
pipeline. If no rule matches, the caller falls back to the owner's (or
system) default pipeline.
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.models import Pipeline, PipelineRoutingRule
from app.utils.routing_engine import (
BUILTIN_FIELDS,
VALID_OPERATORS,
_evaluate_condition,
_resolve_field,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/routing-rules", tags=["routing-rules"])
DbSession = Annotated[Session, Depends(get_db)]
MAX_RULES_PER_OWNER = 100
MAX_NAME_LENGTH = 255
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(request: Request) -> str:
"""Return the authenticated user identifier."""
user = getattr(request.state, "user", None)
if user:
if isinstance(user, dict):
return user.get("sub", user.get("email", "anonymous"))
return getattr(user, "sub", getattr(user, "email", "anonymous"))
return "anonymous"
def _is_admin(request: Request) -> bool:
"""Return ``True`` when the current user has admin privileges."""
user = getattr(request.state, "user", None)
if not user:
return False
groups = user.get("groups", []) if isinstance(user, dict) else getattr(user, "groups", [])
return "admin" in groups
def _can_access_rule(rule: PipelineRoutingRule, user_id: str, admin: bool) -> bool:
"""Check whether the user is allowed to read this rule."""
if admin:
return True
return rule.owner_id == user_id
def _can_write_rule(rule: PipelineRoutingRule, user_id: str, admin: bool) -> bool:
"""Check whether the user is allowed to modify this rule."""
if rule.owner_id is None:
return admin
return rule.owner_id == user_id
def _validate_field(field: str) -> None:
"""Raise 422 if the field name is invalid."""
if field in BUILTIN_FIELDS:
return
if field.startswith("metadata.") and len(field) > len("metadata."):
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Invalid field '{field}'. "
f"Valid built-in fields: {sorted(BUILTIN_FIELDS)}. "
"For AI metadata, use 'metadata.<key>'."
),
)
def _validate_operator(operator: str) -> None:
"""Raise 422 if the operator is not recognised."""
if operator not in VALID_OPERATORS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid operator '{operator}'. Valid operators: {sorted(VALID_OPERATORS)}",
)
def _serialize_rule(rule: PipelineRoutingRule) -> dict[str, Any]:
"""Serialize a routing rule to a JSON-compatible dict."""
return {
"id": rule.id,
"owner_id": rule.owner_id,
"name": rule.name,
"position": rule.position,
"field": rule.field,
"operator": rule.operator,
"value": rule.value,
"target_pipeline_id": rule.target_pipeline_id,
"is_active": rule.is_active,
"created_at": rule.created_at.isoformat() if rule.created_at else None,
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
}
# ---------------------------------------------------------------------------
# Pydantic request models
# ---------------------------------------------------------------------------
class RoutingRuleCreate(BaseModel):
"""Request body for creating a routing rule."""
name: str = Field(..., min_length=1, max_length=MAX_NAME_LENGTH)
field: str = Field(..., min_length=1, max_length=255)
operator: str = Field(..., min_length=1, max_length=50)
value: str = Field(..., max_length=1024)
target_pipeline_id: int
position: int | None = None
is_active: bool = True
class RoutingRuleUpdate(BaseModel):
"""Request body for updating a routing rule."""
name: str | None = Field(None, min_length=1, max_length=MAX_NAME_LENGTH)
field: str | None = Field(None, min_length=1, max_length=255)
operator: str | None = Field(None, min_length=1, max_length=50)
value: str | None = Field(None, max_length=1024)
target_pipeline_id: int | None = None
position: int | None = None
is_active: bool | None = None
class RoutingRuleEvaluateRequest(BaseModel):
"""Request body for dry-run rule evaluation."""
file_type: str | None = None
filename: str | None = None
size: int | None = None
document_type: str | None = None
metadata: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("")
@require_login
def list_routing_rules(request: Request, db: DbSession) -> list[dict[str, Any]]:
"""List all routing rules accessible by the current user.
Returns the user's own rules plus any system-wide rules (``owner_id=NULL``).
Rules are sorted by position.
"""
user_id = _get_user_id(request)
rules = (
db.query(PipelineRoutingRule)
.filter((PipelineRoutingRule.owner_id == user_id) | (PipelineRoutingRule.owner_id.is_(None)))
.order_by(
PipelineRoutingRule.owner_id.is_(None).asc(),
PipelineRoutingRule.position.asc(),
)
.all()
)
return [_serialize_rule(r) for r in rules]
@router.post("", status_code=status.HTTP_201_CREATED)
@require_login
def create_routing_rule(request: Request, db: DbSession, body: RoutingRuleCreate) -> dict[str, Any]:
"""Create a new routing rule for the current user.
Returns:
The created routing rule.
Raises:
HTTPException 422: If the field or operator is invalid.
HTTPException 404: If the target pipeline does not exist.
HTTPException 409: If the maximum number of rules is reached.
"""
user_id = _get_user_id(request)
_validate_field(body.field)
_validate_operator(body.operator)
# Verify target pipeline exists and is accessible.
pipeline = db.query(Pipeline).filter(Pipeline.id == body.target_pipeline_id).first()
if not pipeline:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target pipeline {body.target_pipeline_id} not found",
)
# Enforce per-owner limit.
count = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.owner_id == user_id).count()
if count >= MAX_RULES_PER_OWNER:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Maximum of {MAX_RULES_PER_OWNER} routing rules per user reached",
)
# Auto-assign position if not specified.
position = body.position
if position is None:
max_pos = (
db.query(PipelineRoutingRule.position)
.filter(PipelineRoutingRule.owner_id == user_id)
.order_by(PipelineRoutingRule.position.desc())
.first()
)
position = (max_pos[0] + 1) if max_pos else 0
rule = PipelineRoutingRule(
owner_id=user_id,
name=body.name.strip(),
position=position,
field=body.field,
operator=body.operator,
value=body.value,
target_pipeline_id=body.target_pipeline_id,
is_active=body.is_active,
)
try:
db.add(rule)
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
logger.exception("Failed to create routing rule for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create routing rule",
)
logger.info("Routing rule created: id=%s, user=%s", rule.id, user_id)
return _serialize_rule(rule)
@router.get("/operators")
def list_operators() -> dict[str, Any]:
"""Return the list of supported operators and fields.
This is a public endpoint (no auth required) so that UIs can populate
dropdowns without hard-coding the catalogue.
"""
return {
"operators": sorted(VALID_OPERATORS),
"builtin_fields": sorted(BUILTIN_FIELDS),
"metadata_prefix": "metadata.",
}
@router.post("/evaluate")
@require_login
def evaluate_rules(request: Request, db: DbSession, body: RoutingRuleEvaluateRequest) -> dict[str, Any]:
"""Dry-run rule evaluation against the provided document properties.
Returns the first matching rule and target pipeline (if any), or
indicates that no rule matched (default pipeline will be used).
"""
user_id = _get_user_id(request)
doc_props: dict[str, Any] = {
"file_type": body.file_type,
"filename": body.filename,
"size": body.size,
"document_type": body.document_type,
"metadata": body.metadata or {},
}
rules = (
db.query(PipelineRoutingRule)
.filter(
PipelineRoutingRule.is_active.is_(True),
(PipelineRoutingRule.owner_id == user_id) | (PipelineRoutingRule.owner_id.is_(None)),
)
.order_by(
PipelineRoutingRule.owner_id.is_(None).asc(),
PipelineRoutingRule.position.asc(),
)
.all()
)
for rule in rules:
actual = _resolve_field(rule.field, doc_props)
if _evaluate_condition(actual, rule.operator, rule.value):
pipeline = db.query(Pipeline).filter(Pipeline.id == rule.target_pipeline_id).first()
return {
"matched": True,
"rule": _serialize_rule(rule),
"target_pipeline": {
"id": pipeline.id,
"name": pipeline.name,
"is_active": pipeline.is_active,
}
if pipeline
else None,
}
return {"matched": False, "rule": None, "target_pipeline": None}
@router.put("/reorder")
@require_login
def reorder_routing_rules(
request: Request,
db: DbSession,
rule_ids: list[int] = Body(..., embed=True),
) -> list[dict[str, Any]]:
"""Reorder the caller's routing rules.
Expects a JSON body ``{"rule_ids": [3, 1, 2]}`` where the list
contains the IDs of the caller's rules in the desired order.
"""
user_id = _get_user_id(request)
rules = (
db.query(PipelineRoutingRule)
.filter(PipelineRoutingRule.owner_id == user_id, PipelineRoutingRule.id.in_(rule_ids))
.all()
)
rule_map = {r.id: r for r in rules}
if len(rule_map) != len(rule_ids) or set(rule_map.keys()) != set(rule_ids):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="rule_ids must contain exactly the IDs of your routing rules",
)
for pos, rid in enumerate(rule_ids):
rule_map[rid].position = pos
try:
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to reorder routing rules for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reorder routing rules",
)
ordered = sorted(rules, key=lambda r: r.position)
return [_serialize_rule(r) for r in ordered]
@router.get("/{rule_id}")
@require_login
def get_routing_rule(rule_id: int, request: Request, db: DbSession) -> dict[str, Any]:
"""Return a single routing rule by ID."""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
return _serialize_rule(rule)
@router.put("/{rule_id}")
@require_login
def update_routing_rule(rule_id: int, request: Request, db: DbSession, body: RoutingRuleUpdate) -> dict[str, Any]:
"""Update a routing rule.
Only the fields present in the request body are updated.
"""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
if not _can_write_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
if body.field is not None:
_validate_field(body.field)
rule.field = body.field
if body.operator is not None:
_validate_operator(body.operator)
rule.operator = body.operator
if body.value is not None:
rule.value = body.value
if body.name is not None:
rule.name = body.name.strip()
if body.target_pipeline_id is not None:
pipeline = db.query(Pipeline).filter(Pipeline.id == body.target_pipeline_id).first()
if not pipeline:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target pipeline {body.target_pipeline_id} not found",
)
rule.target_pipeline_id = body.target_pipeline_id
if body.position is not None:
rule.position = body.position
if body.is_active is not None:
rule.is_active = body.is_active
try:
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
logger.exception("Failed to update routing rule id=%s", rule_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update routing rule",
)
logger.info("Routing rule updated: id=%s, user=%s", rule_id, user_id)
return _serialize_rule(rule)
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_routing_rule(rule_id: int, request: Request, db: DbSession) -> None:
"""Delete a routing rule."""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
if not _can_write_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
try:
db.delete(rule)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete routing rule id=%s", rule_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete routing rule",
)
logger.info("Routing rule deleted: id=%s, user=%s", rule_id, user_id)
+51
View File
@@ -7,6 +7,7 @@ from app.database import Base
# Foreign key constants
_FILES_ID_FK = "files.id"
_PIPELINES_ID_FK = "pipelines.id"
_ROUTING_RULES_TABLE = "pipeline_routing_rules"
class DocumentMetadata(Base):
@@ -970,3 +971,53 @@ class ComplianceTemplate(Base):
applied_by = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class PipelineRoutingRule(Base):
"""Conditional routing rule that assigns documents to pipelines.
Rules are evaluated in ascending ``position`` order for a given owner.
The first rule whose condition matches the document properties wins and
the document is routed to ``target_pipeline_id``. If no rule matches,
the caller falls back to the owner's (or system) default pipeline.
Supported fields:
file_type, document_type, category, filename, size, and any key
inside the AI-extracted metadata JSON (prefixed ``metadata.``).
Supported operators:
equals, not_equals, contains, not_contains, regex, gt, lt, gte, lte.
"""
__tablename__ = _ROUTING_RULES_TABLE
id = Column(Integer, primary_key=True, index=True)
# Owner of this rule. NULL = system-wide rule (admin only).
owner_id = Column(String, nullable=True, index=True)
# Human-readable label for the rule.
name = Column(String(255), nullable=False)
# Evaluation order (lower = earlier). First matching rule wins.
position = Column(Integer, nullable=False, default=0)
# The document property to evaluate.
# Built-in: file_type, document_type, category, filename, size.
# For AI metadata fields, use the "metadata.<key>" prefix.
field = Column(String(255), nullable=False)
# Comparison operator.
operator = Column(String(50), nullable=False)
# Value to compare against (always stored as text; cast as needed).
value = Column(String(1024), nullable=False)
# Target pipeline when the condition matches.
target_pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=False, index=True)
# Soft-disable without deleting.
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+223
View File
@@ -0,0 +1,223 @@
"""Routing engine for conditional document-to-pipeline assignment.
Evaluates a set of :class:`PipelineRoutingRule` rows against document
properties and returns the first matching target pipeline (if any).
Supported document fields
-------------------------
* ``file_type`` MIME type of the file (e.g. ``application/pdf``)
* ``filename`` original filename
* ``size`` file size in bytes (numeric comparison)
* ``document_type`` AI-classified document type (e.g. ``Invoice``)
* ``category`` alias for ``document_type``
* ``metadata.<key>`` arbitrary key inside the AI-extracted JSON metadata
Supported comparison operators
------------------------------
* ``equals`` / ``not_equals``
* ``contains`` / ``not_contains`` (substring match, case-insensitive)
* ``regex`` (Python ``re`` full-match, case-insensitive)
* ``gt`` / ``lt`` / ``gte`` / ``lte`` (numeric comparison)
"""
import json
import logging
import re
from typing import Any
from sqlalchemy.orm import Session
from app.models import Pipeline, PipelineRoutingRule
logger = logging.getLogger(__name__)
# Operators recognised by the engine.
VALID_OPERATORS: frozenset[str] = frozenset(
{
"equals",
"not_equals",
"contains",
"not_contains",
"regex",
"gt",
"lt",
"gte",
"lte",
}
)
# Fields that are resolved directly from the FileRecord.
BUILTIN_FIELDS: frozenset[str] = frozenset(
{
"file_type",
"filename",
"size",
"document_type",
"category",
}
)
def _resolve_field(field: str, doc_props: dict[str, Any]) -> Any:
"""Resolve a *field* name to its actual value from *doc_props*.
``doc_props`` is expected to contain top-level keys that mirror the
built-in field names **plus** a ``metadata`` dict with the parsed
AI metadata JSON.
"""
if field == "category":
# ``category`` is an alias for ``document_type``.
field = "document_type"
if field.startswith("metadata."):
meta_key = field[len("metadata.") :]
metadata = doc_props.get("metadata") or {}
return metadata.get(meta_key)
return doc_props.get(field)
def _to_float(value: Any) -> float | None:
"""Try to convert *value* to a float for numeric comparison."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _evaluate_condition(actual: Any, operator: str, expected: str) -> bool:
"""Return ``True`` when *actual* satisfies *operator* against *expected*.
All string comparisons are case-insensitive. Numeric operators (``gt``,
``lt``, ``gte``, ``lte``) attempt to cast both sides to ``float``.
"""
if actual is None:
# If the document property is missing, the rule cannot match
# (except for ``not_equals`` / ``not_contains`` which should match).
if operator == "not_equals":
return True
if operator == "not_contains":
return True
return False
actual_str = str(actual).lower()
expected_lower = expected.lower()
if operator == "equals":
return actual_str == expected_lower
if operator == "not_equals":
return actual_str != expected_lower
if operator == "contains":
return expected_lower in actual_str
if operator == "not_contains":
return expected_lower not in actual_str
if operator == "regex":
try:
return bool(re.fullmatch(expected, str(actual), flags=re.IGNORECASE))
except re.error:
logger.warning("Invalid regex in routing rule: %s", expected)
return False
# Numeric operators
actual_num = _to_float(actual)
expected_num = _to_float(expected)
if actual_num is None or expected_num is None:
return False
if operator == "gt":
return actual_num > expected_num
if operator == "lt":
return actual_num < expected_num
if operator == "gte":
return actual_num >= expected_num
if operator == "lte":
return actual_num <= expected_num
return False
def build_document_properties(file_record: Any) -> dict[str, Any]:
"""Build the property dict that the engine evaluates against.
Args:
file_record: A :class:`FileRecord` instance (or any object with the
same attributes).
Returns:
A dict with ``file_type``, ``filename``, ``size``, ``document_type``,
and ``metadata`` keys.
"""
metadata: dict[str, Any] = {}
raw_meta = getattr(file_record, "ai_metadata", None)
if raw_meta:
try:
metadata = json.loads(raw_meta) if isinstance(raw_meta, str) else raw_meta
except (json.JSONDecodeError, TypeError):
metadata = {}
return {
"file_type": getattr(file_record, "mime_type", None),
"filename": getattr(file_record, "original_filename", None),
"size": getattr(file_record, "file_size", None),
"document_type": metadata.get("document_type"),
"metadata": metadata,
}
def evaluate_routing_rules(
db: Session,
owner_id: str | None,
doc_props: dict[str, Any],
) -> Pipeline | None:
"""Evaluate routing rules and return the first matching pipeline.
Rules are fetched for the given *owner_id* **plus** any system-wide rules
(``owner_id IS NULL``). Owner rules are evaluated first (by position),
then system rules.
Args:
db: Active database session.
owner_id: The document owner's identifier (may be ``None``).
doc_props: Document property dict as produced by
:func:`build_document_properties`.
Returns:
The first matching :class:`Pipeline`, or ``None`` when no rule
matches (caller should fall back to the default pipeline).
"""
# Fetch active rules for the owner + system rules, ordered by position.
rules = (
db.query(PipelineRoutingRule)
.filter(
PipelineRoutingRule.is_active.is_(True),
(PipelineRoutingRule.owner_id == owner_id) | (PipelineRoutingRule.owner_id.is_(None)),
)
.order_by(
# Owner-specific rules take priority over system rules.
PipelineRoutingRule.owner_id.is_(None).asc(),
PipelineRoutingRule.position.asc(),
)
.all()
)
for rule in rules:
actual = _resolve_field(rule.field, doc_props)
if _evaluate_condition(actual, rule.operator, rule.value):
pipeline = db.query(Pipeline).filter(Pipeline.id == rule.target_pipeline_id).first()
if pipeline and pipeline.is_active:
logger.info(
"Routing rule matched: rule_id=%s, name=%s, target_pipeline=%s",
rule.id,
rule.name,
rule.target_pipeline_id,
)
return pipeline
logger.warning(
"Routing rule %s matched but target pipeline %s is inactive or missing",
rule.id,
rule.target_pipeline_id,
)
return None
+154
View File
@@ -1960,6 +1960,160 @@ Pass no `pipeline_id` query parameter (or omit it) to clear the assignment.
```
## Routing Rules
Routing rules let you conditionally assign documents to different pipelines
based on file properties such as type, size, filename, or AI-extracted
metadata. Rules are evaluated in **position order** (lowest first); the first
rule that matches wins. If no rule matches, the system falls back to the
owner's (or global) default pipeline.
### Supported operators and fields
```bash
GET /api/routing-rules/operators
```
Returns the catalogue of valid operators and built-in fields so UIs can
populate dropdowns without hard-coding values.
**Response (200):**
```json
{
"operators": ["contains", "equals", "gt", "gte", "lt", "lte", "not_contains", "not_equals", "regex"],
"builtin_fields": ["category", "document_type", "file_type", "filename", "size"],
"metadata_prefix": "metadata."
}
```
> **Tip:** For AI metadata fields use the `metadata.` prefix, e.g.
> `metadata.sender`, `metadata.amount`.
### List routing rules
```bash
GET /api/routing-rules
```
Returns the current user's rules **plus** any system-wide rules
(`owner_id = null`), ordered by position.
**Response (200):**
```json
[
{
"id": 1,
"owner_id": "alice",
"name": "Route invoices",
"position": 0,
"field": "document_type",
"operator": "equals",
"value": "Invoice",
"target_pipeline_id": 3,
"is_active": true,
"created_at": "2026-03-09T12:00:00+00:00",
"updated_at": "2026-03-09T12:00:00+00:00"
}
]
```
### Create routing rule
```bash
POST /api/routing-rules
Content-Type: application/json
{
"name": "Route invoices",
"field": "document_type",
"operator": "equals",
"value": "Invoice",
"target_pipeline_id": 3
}
```
Optional fields: `position` (auto-assigned if omitted), `is_active` (default `true`).
**Response (201 Created):** The created rule object.
### Get routing rule
```bash
GET /api/routing-rules/{rule_id}
```
**Response (200):** A single rule object.
### Update routing rule
```bash
PUT /api/routing-rules/{rule_id}
Content-Type: application/json
{ "name": "Renamed rule", "operator": "contains", "is_active": false }
```
Only the supplied fields are updated.
**Response (200):** The updated rule object.
### Delete routing rule
```bash
DELETE /api/routing-rules/{rule_id}
```
Returns **204 No Content**.
### Reorder routing rules
```bash
PUT /api/routing-rules/reorder
Content-Type: application/json
{ "rule_ids": [3, 1, 2] }
```
Provide the complete ordered list of your rule IDs. Positions are reassigned
0, 1, 2, … in the given order.
### Evaluate rules (dry run)
```bash
POST /api/routing-rules/evaluate
Content-Type: application/json
{
"file_type": "application/pdf",
"filename": "invoice_2024.pdf",
"size": 204800,
"document_type": "Invoice",
"metadata": { "sender": "Acme Corp" }
}
```
Tests which rule (if any) would match the given properties **without**
actually routing a document.
**Response (200) match found:**
```json
{
"matched": true,
"rule": { "id": 1, "name": "Route invoices", "..." : "..." },
"target_pipeline": { "id": 3, "name": "Invoice Pipeline", "is_active": true }
}
```
**Response (200) no match:**
```json
{
"matched": false,
"rule": null,
"target_pipeline": null
}
```
## API Tokens
Personal API tokens allow programmatic access to the DocuElevate API without
+52
View File
@@ -626,6 +626,58 @@ Pass no `pipeline_id` to clear the assignment and fall back to the system defaul
Admins can create **system pipelines** that appear in every user's pipeline list. These can be set as the global default so all users benefit from a consistent processing baseline. Navigate to **Pipelines** and check the **System pipeline** box when creating a new one (admin only).
### Conditional routing rules
Routing rules automatically assign incoming documents to the right pipeline
based on their properties — no manual pipeline selection required.
**How it works:**
1. Define one or more routing rules via the API
(`POST /api/routing-rules`).
2. Each rule specifies a **field** to inspect, an **operator** (condition),
a **value** to compare against, and a **target pipeline**.
3. When a document is processed, rules are evaluated **in position order**
(lowest first). The first matching rule wins and the document is routed
to that pipeline.
4. If no rule matches, the document is processed by the default pipeline.
**Available fields:**
| Field | Description |
|-------|-------------|
| `file_type` | MIME type, e.g. `application/pdf` |
| `filename` | Original filename |
| `size` | File size in bytes |
| `document_type` | AI-classified type (Invoice, Contract, …) |
| `category` | Alias for `document_type` |
| `metadata.<key>` | Any key from the AI-extracted metadata JSON |
**Available operators:**
| Operator | Description |
|----------|-------------|
| `equals` / `not_equals` | Exact match (case-insensitive) |
| `contains` / `not_contains` | Substring match (case-insensitive) |
| `regex` | Full Python regex match (case-insensitive) |
| `gt` / `lt` / `gte` / `lte` | Numeric comparison (greater/less than) |
**Example:** Route invoices to one pipeline and large files to another:
```
Rule 1: field=document_type, operator=equals, value=Invoice, target_pipeline=3
Rule 2: field=size, operator=gt, value=1048576, target_pipeline=5
```
With first-match-wins logic, an invoice of any size matches Rule 1 and is
routed to pipeline 3. A non-invoice file larger than 1 MB matches Rule 2
and is routed to pipeline 5. Everything else falls back to the default
pipeline.
You can test your rules without actually routing a document using the
**evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full
API reference, see [API Documentation](API.md#routing-rules).
## API Access
For programmatic access, DocuElevate provides a comprehensive REST API:
+1
View File
@@ -33,6 +33,7 @@ from app.models import ( # noqa: F401
LocalUser,
MobileDevice,
Pipeline,
PipelineRoutingRule,
PipelineStep,
ProcessingLog,
SavedSearch,
@@ -0,0 +1,46 @@
"""Add pipeline_routing_rules table for conditional document routing.
Revision ID: 035_add_routing_rules
Revises: 034_add_user_profile_settings
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "035_add_routing_rules"
down_revision: Union[str, None] = "034_add_user_profile_settings"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create pipeline_routing_rules table."""
op.create_table(
"pipeline_routing_rules",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
sa.Column("field", sa.String(255), nullable=False),
sa.Column("operator", sa.String(50), nullable=False),
sa.Column("value", sa.String(1024), nullable=False),
sa.Column("target_pipeline_id", sa.Integer(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["target_pipeline_id"], ["pipelines.id"]),
)
op.create_index("ix_routing_rules_id", "pipeline_routing_rules", ["id"])
op.create_index("ix_routing_rules_owner_id", "pipeline_routing_rules", ["owner_id"])
op.create_index("ix_routing_rules_target_pipeline_id", "pipeline_routing_rules", ["target_pipeline_id"])
def downgrade() -> None:
"""Drop pipeline_routing_rules table."""
op.drop_index("ix_routing_rules_target_pipeline_id", "pipeline_routing_rules")
op.drop_index("ix_routing_rules_owner_id", "pipeline_routing_rules")
op.drop_index("ix_routing_rules_id", "pipeline_routing_rules")
op.drop_table("pipeline_routing_rules")
+1
View File
@@ -66,6 +66,7 @@ from app.models import ( # noqa: F401, E402
DocumentMetadata,
FileRecord,
Pipeline,
PipelineRoutingRule,
PipelineStep,
ProcessingLog,
SavedSearch,
+803
View File
@@ -0,0 +1,803 @@
"""Tests for the routing rules API and routing engine.
Covers CRUD operations for routing rules, rule evaluation (dry-run and engine),
operator logic, access control, and edge cases.
"""
import json
import pytest
from app.models import FileRecord, Pipeline, PipelineRoutingRule
from app.utils.routing_engine import (
BUILTIN_FIELDS,
VALID_OPERATORS,
_evaluate_condition,
_resolve_field,
_to_float,
build_document_properties,
evaluate_routing_rules,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_pipeline(db_session, name="Test Pipeline", owner_id="testuser", is_active=True):
"""Insert a minimal Pipeline and return it."""
p = Pipeline(owner_id=owner_id, name=name, is_default=False, is_active=is_active)
db_session.add(p)
db_session.commit()
db_session.refresh(p)
return p
def _make_rule(db_session, target_pipeline_id, **kwargs):
"""Insert a PipelineRoutingRule and return it."""
defaults = {
"owner_id": "testuser",
"name": "Test Rule",
"position": 0,
"field": "file_type",
"operator": "equals",
"value": "application/pdf",
"is_active": True,
}
defaults.update(kwargs)
defaults["target_pipeline_id"] = target_pipeline_id
rule = PipelineRoutingRule(**defaults)
db_session.add(rule)
db_session.commit()
db_session.refresh(rule)
return rule
def _make_file_record(db_session, **kwargs):
"""Insert a minimal FileRecord and return it."""
defaults = {
"owner_id": "testuser",
"filehash": "abc123",
"original_filename": "test.pdf",
"local_filename": "/tmp/test.pdf",
"file_size": 1024,
"mime_type": "application/pdf",
}
defaults.update(kwargs)
fr = FileRecord(**defaults)
db_session.add(fr)
db_session.commit()
db_session.refresh(fr)
return fr
# ===========================================================================
# Unit tests routing engine
# ===========================================================================
@pytest.mark.unit
class TestResolveField:
"""Tests for _resolve_field()."""
def test_builtin_field(self):
"""Built-in fields are resolved directly from the dict."""
props = {"file_type": "application/pdf", "size": 1024}
assert _resolve_field("file_type", props) == "application/pdf"
assert _resolve_field("size", props) == 1024
def test_category_alias(self):
"""'category' is an alias for 'document_type'."""
props = {"document_type": "Invoice"}
assert _resolve_field("category", props) == "Invoice"
def test_metadata_field(self):
"""'metadata.<key>' resolves from the nested metadata dict."""
props = {"metadata": {"sender": "Acme Corp", "amount": 100.50}}
assert _resolve_field("metadata.sender", props) == "Acme Corp"
assert _resolve_field("metadata.amount", props) == 100.50
def test_missing_metadata_key(self):
"""Missing metadata key returns None."""
props = {"metadata": {"sender": "Acme"}}
assert _resolve_field("metadata.missing_key", props) is None
def test_missing_metadata_dict(self):
"""Missing metadata dict returns None."""
props = {}
assert _resolve_field("metadata.sender", props) is None
def test_missing_builtin_field(self):
"""Missing built-in field returns None."""
props = {}
assert _resolve_field("file_type", props) is None
@pytest.mark.unit
class TestToFloat:
"""Tests for _to_float()."""
def test_int_value(self):
assert _to_float(42) == 42.0
def test_float_value(self):
assert _to_float(3.14) == 3.14
def test_string_number(self):
assert _to_float("100") == 100.0
def test_none_returns_none(self):
assert _to_float(None) is None
def test_non_numeric_string_returns_none(self):
assert _to_float("not-a-number") is None
@pytest.mark.unit
class TestEvaluateCondition:
"""Tests for _evaluate_condition()."""
def test_equals_match(self):
assert _evaluate_condition("application/pdf", "equals", "application/pdf") is True
def test_equals_case_insensitive(self):
assert _evaluate_condition("Application/PDF", "equals", "application/pdf") is True
def test_equals_no_match(self):
assert _evaluate_condition("image/png", "equals", "application/pdf") is False
def test_not_equals_match(self):
assert _evaluate_condition("image/png", "not_equals", "application/pdf") is True
def test_not_equals_no_match(self):
assert _evaluate_condition("application/pdf", "not_equals", "application/pdf") is False
def test_contains_match(self):
assert _evaluate_condition("invoice_2024.pdf", "contains", "invoice") is True
def test_contains_case_insensitive(self):
assert _evaluate_condition("INVOICE_2024.PDF", "contains", "invoice") is True
def test_contains_no_match(self):
assert _evaluate_condition("receipt.pdf", "contains", "invoice") is False
def test_not_contains_match(self):
assert _evaluate_condition("receipt.pdf", "not_contains", "invoice") is True
def test_not_contains_no_match(self):
assert _evaluate_condition("invoice_2024.pdf", "not_contains", "invoice") is False
def test_regex_match(self):
assert _evaluate_condition("invoice_2024.pdf", "regex", r"invoice_\d+\.pdf") is True
def test_regex_no_match(self):
assert _evaluate_condition("receipt.pdf", "regex", r"invoice_\d+\.pdf") is False
def test_regex_case_insensitive(self):
assert _evaluate_condition("INVOICE_2024.PDF", "regex", r"invoice_\d+\.pdf") is True
def test_regex_invalid_pattern(self):
"""Invalid regex should return False, not raise."""
assert _evaluate_condition("test", "regex", r"[invalid") is False
def test_gt(self):
assert _evaluate_condition(2048, "gt", "1024") is True
assert _evaluate_condition(1024, "gt", "1024") is False
def test_lt(self):
assert _evaluate_condition(512, "lt", "1024") is True
assert _evaluate_condition(1024, "lt", "1024") is False
def test_gte(self):
assert _evaluate_condition(1024, "gte", "1024") is True
assert _evaluate_condition(2048, "gte", "1024") is True
assert _evaluate_condition(512, "gte", "1024") is False
def test_lte(self):
assert _evaluate_condition(1024, "lte", "1024") is True
assert _evaluate_condition(512, "lte", "1024") is True
assert _evaluate_condition(2048, "lte", "1024") is False
def test_none_actual_returns_false(self):
"""When the actual value is None, most operators return False."""
assert _evaluate_condition(None, "equals", "test") is False
assert _evaluate_condition(None, "contains", "test") is False
assert _evaluate_condition(None, "regex", "test") is False
assert _evaluate_condition(None, "gt", "10") is False
def test_none_actual_not_equals_returns_true(self):
"""not_equals should return True when actual is None."""
assert _evaluate_condition(None, "not_equals", "test") is True
def test_none_actual_not_contains_returns_true(self):
"""not_contains should return True when actual is None."""
assert _evaluate_condition(None, "not_contains", "test") is True
def test_non_numeric_gt_returns_false(self):
"""Non-numeric values should return False for numeric operators."""
assert _evaluate_condition("abc", "gt", "100") is False
def test_unknown_operator_returns_false(self):
"""Unknown operator should return False."""
assert _evaluate_condition("test", "unknown_op", "test") is False
@pytest.mark.unit
class TestBuildDocumentProperties:
"""Tests for build_document_properties()."""
def test_basic_properties(self):
"""Properties are extracted from FileRecord attributes."""
fr = _MockFileRecord(
mime_type="application/pdf",
original_filename="test.pdf",
file_size=2048,
ai_metadata=json.dumps({"document_type": "Invoice", "sender": "Acme"}),
)
props = build_document_properties(fr)
assert props["file_type"] == "application/pdf"
assert props["filename"] == "test.pdf"
assert props["size"] == 2048
assert props["document_type"] == "Invoice"
assert props["metadata"]["sender"] == "Acme"
def test_no_metadata(self):
"""When ai_metadata is None, metadata is an empty dict."""
fr = _MockFileRecord(mime_type="image/png", original_filename="img.png", file_size=512, ai_metadata=None)
props = build_document_properties(fr)
assert props["metadata"] == {}
assert props["document_type"] is None
def test_invalid_metadata_json(self):
"""Invalid JSON in ai_metadata should result in empty metadata."""
fr = _MockFileRecord(
mime_type="application/pdf",
original_filename="test.pdf",
file_size=1024,
ai_metadata="not-json",
)
props = build_document_properties(fr)
assert props["metadata"] == {}
class _MockFileRecord:
"""Lightweight stand-in for FileRecord in unit tests."""
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
# ===========================================================================
# Unit tests evaluate_routing_rules (DB-backed)
# ===========================================================================
@pytest.mark.unit
class TestEvaluateRoutingRules:
"""Tests for evaluate_routing_rules() with a real DB session."""
def test_first_match_wins(self, db_session):
"""The first matching rule (by position) is used."""
p1 = _make_pipeline(db_session, name="Pipeline A")
p2 = _make_pipeline(db_session, name="Pipeline B")
_make_rule(db_session, p1.id, position=0, field="file_type", operator="equals", value="application/pdf")
_make_rule(db_session, p2.id, position=1, field="file_type", operator="equals", value="application/pdf")
doc = {"file_type": "application/pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is not None
assert result.id == p1.id
def test_no_match_returns_none(self, db_session):
"""When no rule matches, None is returned (caller uses default)."""
p = _make_pipeline(db_session, name="Pipeline A")
_make_rule(db_session, p.id, field="file_type", operator="equals", value="image/png")
doc = {"file_type": "application/pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is None
def test_inactive_rule_skipped(self, db_session):
"""Inactive rules are not evaluated."""
p = _make_pipeline(db_session, name="Pipeline A")
_make_rule(db_session, p.id, is_active=False)
doc = {"file_type": "application/pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is None
def test_inactive_pipeline_skipped(self, db_session):
"""Matching rule with inactive target pipeline is skipped."""
p = _make_pipeline(db_session, name="Inactive Pipeline", is_active=False)
_make_rule(db_session, p.id)
doc = {"file_type": "application/pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is None
def test_system_rules_evaluated_after_user_rules(self, db_session):
"""System rules (owner_id=NULL) are evaluated after user-specific rules."""
p_user = _make_pipeline(db_session, name="User Pipeline")
p_system = _make_pipeline(db_session, name="System Pipeline", owner_id=None)
# System rule at position 0, user rule at position 1 — user should still win.
_make_rule(
db_session,
p_system.id,
owner_id=None,
position=0,
field="file_type",
operator="equals",
value="application/pdf",
name="System Rule",
)
_make_rule(
db_session,
p_user.id,
owner_id="testuser",
position=1,
field="file_type",
operator="equals",
value="application/pdf",
name="User Rule",
)
doc = {"file_type": "application/pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is not None
assert result.id == p_user.id
def test_metadata_field_routing(self, db_session):
"""Rules can match on metadata.* fields."""
p = _make_pipeline(db_session, name="Invoice Pipeline")
_make_rule(
db_session,
p.id,
field="metadata.sender",
operator="contains",
value="acme",
)
doc = {"file_type": "application/pdf", "metadata": {"sender": "Acme Corporation"}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is not None
assert result.id == p.id
def test_size_routing(self, db_session):
"""Rules can match on file size with numeric comparison."""
p = _make_pipeline(db_session, name="Large File Pipeline")
_make_rule(
db_session,
p.id,
field="size",
operator="gt",
value="1048576",
)
# 2 MB file
doc = {"size": 2097152, "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is not None
assert result.id == p.id
def test_regex_routing(self, db_session):
"""Rules can match using regex on filename."""
p = _make_pipeline(db_session, name="Invoice Pipeline")
_make_rule(
db_session,
p.id,
field="filename",
operator="regex",
value=r"invoice_\d{4}.*",
)
doc = {"filename": "invoice_2024_q1.pdf", "metadata": {}}
result = evaluate_routing_rules(db_session, "testuser", doc)
assert result is not None
assert result.id == p.id
# ===========================================================================
# API tests operators endpoint (public)
# ===========================================================================
@pytest.mark.unit
class TestOperatorsEndpoint:
"""Tests for the /api/routing-rules/operators catalogue endpoint."""
def test_operators_returns_lists(self, client):
"""GET /api/routing-rules/operators returns operators and fields."""
r = client.get("/api/routing-rules/operators")
assert r.status_code == 200
data = r.json()
assert "operators" in data
assert "builtin_fields" in data
assert "metadata_prefix" in data
assert set(data["operators"]) == VALID_OPERATORS
assert set(data["builtin_fields"]) == BUILTIN_FIELDS
# ===========================================================================
# API tests CRUD
# ===========================================================================
@pytest.mark.unit
class TestRoutingRuleCRUD:
"""Tests for the routing rules CRUD API endpoints."""
def test_create_rule(self, client, db_session):
"""POST /api/routing-rules creates a new rule."""
p = _make_pipeline(db_session, name="Target Pipeline")
r = client.post(
"/api/routing-rules",
json={
"name": "Route PDFs",
"field": "file_type",
"operator": "equals",
"value": "application/pdf",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 201
data = r.json()
assert data["name"] == "Route PDFs"
assert data["field"] == "file_type"
assert data["operator"] == "equals"
assert data["target_pipeline_id"] == p.id
assert data["is_active"] is True
def test_create_rule_invalid_field(self, client, db_session):
"""POST with an invalid field returns 422."""
p = _make_pipeline(db_session, name="Target")
r = client.post(
"/api/routing-rules",
json={
"name": "Bad field",
"field": "invalid_field",
"operator": "equals",
"value": "test",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 422
def test_create_rule_invalid_operator(self, client, db_session):
"""POST with an invalid operator returns 422."""
p = _make_pipeline(db_session, name="Target")
r = client.post(
"/api/routing-rules",
json={
"name": "Bad op",
"field": "file_type",
"operator": "invalid_op",
"value": "test",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 422
def test_create_rule_missing_pipeline(self, client):
"""POST referencing a nonexistent pipeline returns 404."""
r = client.post(
"/api/routing-rules",
json={
"name": "No pipeline",
"field": "file_type",
"operator": "equals",
"value": "application/pdf",
"target_pipeline_id": 99999,
},
)
assert r.status_code == 404
def test_list_rules(self, client, db_session):
"""GET /api/routing-rules returns the user's rules."""
p = _make_pipeline(db_session, name="Pipeline")
_make_rule(db_session, p.id, name="Rule 1", position=0, owner_id="anonymous")
_make_rule(db_session, p.id, name="Rule 2", position=1, owner_id="anonymous")
r = client.get("/api/routing-rules")
assert r.status_code == 200
data = r.json()
assert len(data) >= 2
names = [d["name"] for d in data]
assert "Rule 1" in names
assert "Rule 2" in names
def test_get_rule(self, client, db_session):
"""GET /api/routing-rules/{id} returns a specific rule."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, name="My Rule", owner_id="anonymous")
r = client.get(f"/api/routing-rules/{rule.id}")
assert r.status_code == 200
assert r.json()["name"] == "My Rule"
def test_get_rule_not_found(self, client):
"""GET /api/routing-rules/{id} returns 404 for nonexistent rule."""
r = client.get("/api/routing-rules/99999")
assert r.status_code == 404
def test_update_rule(self, client, db_session):
"""PUT /api/routing-rules/{id} updates a rule."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, name="Old Name", owner_id="anonymous")
r = client.put(
f"/api/routing-rules/{rule.id}",
json={"name": "New Name", "operator": "contains"},
)
assert r.status_code == 200
data = r.json()
assert data["name"] == "New Name"
assert data["operator"] == "contains"
def test_update_rule_invalid_field(self, client, db_session):
"""PUT with invalid field returns 422."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, owner_id="anonymous")
r = client.put(f"/api/routing-rules/{rule.id}", json={"field": "bad_field"})
assert r.status_code == 422
def test_update_rule_invalid_operator(self, client, db_session):
"""PUT with invalid operator returns 422."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, owner_id="anonymous")
r = client.put(f"/api/routing-rules/{rule.id}", json={"operator": "bad_op"})
assert r.status_code == 422
def test_update_rule_missing_pipeline(self, client, db_session):
"""PUT with nonexistent target pipeline returns 404."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, owner_id="anonymous")
r = client.put(f"/api/routing-rules/{rule.id}", json={"target_pipeline_id": 99999})
assert r.status_code == 404
def test_delete_rule(self, client, db_session):
"""DELETE /api/routing-rules/{id} removes a rule."""
p = _make_pipeline(db_session, name="Pipeline")
rule = _make_rule(db_session, p.id, owner_id="anonymous")
r = client.delete(f"/api/routing-rules/{rule.id}")
assert r.status_code == 204
# Verify it's gone
r = client.get(f"/api/routing-rules/{rule.id}")
assert r.status_code == 404
def test_delete_rule_not_found(self, client):
"""DELETE for nonexistent rule returns 404."""
r = client.delete("/api/routing-rules/99999")
assert r.status_code == 404
# ===========================================================================
# API tests reorder
# ===========================================================================
@pytest.mark.unit
class TestReorderRoutingRules:
"""Tests for the PUT /api/routing-rules/reorder endpoint."""
def test_reorder_rules(self, client, db_session):
"""Reordering updates the position of rules."""
p = _make_pipeline(db_session, name="Pipeline")
r1 = _make_rule(db_session, p.id, name="A", position=0, owner_id="anonymous")
r2 = _make_rule(db_session, p.id, name="B", position=1, owner_id="anonymous")
r = client.put(
"/api/routing-rules/reorder",
json={"rule_ids": [r2.id, r1.id]},
)
assert r.status_code == 200
data = r.json()
assert data[0]["id"] == r2.id
assert data[0]["position"] == 0
assert data[1]["id"] == r1.id
assert data[1]["position"] == 1
def test_reorder_invalid_ids(self, client, db_session):
"""Reorder with invalid IDs returns 422."""
r = client.put(
"/api/routing-rules/reorder",
json={"rule_ids": [99999]},
)
assert r.status_code == 422
# ===========================================================================
# API tests evaluate (dry-run)
# ===========================================================================
@pytest.mark.unit
class TestEvaluateEndpoint:
"""Tests for the POST /api/routing-rules/evaluate dry-run endpoint."""
def test_evaluate_match(self, client, db_session):
"""Evaluate returns the matching rule and target pipeline."""
p = _make_pipeline(db_session, name="Invoice Pipeline")
_make_rule(
db_session,
p.id,
name="PDF Route",
field="file_type",
operator="equals",
value="application/pdf",
owner_id="anonymous",
)
r = client.post(
"/api/routing-rules/evaluate",
json={"file_type": "application/pdf"},
)
assert r.status_code == 200
data = r.json()
assert data["matched"] is True
assert data["rule"]["name"] == "PDF Route"
assert data["target_pipeline"]["id"] == p.id
def test_evaluate_no_match(self, client, db_session):
"""Evaluate returns matched=False when no rule applies."""
p = _make_pipeline(db_session, name="Pipeline")
_make_rule(
db_session,
p.id,
name="PNG Route",
field="file_type",
operator="equals",
value="image/png",
owner_id="anonymous",
)
r = client.post(
"/api/routing-rules/evaluate",
json={"file_type": "application/pdf"},
)
assert r.status_code == 200
data = r.json()
assert data["matched"] is False
assert data["rule"] is None
def test_evaluate_with_metadata(self, client, db_session):
"""Evaluate with metadata.* fields works."""
p = _make_pipeline(db_session, name="Invoice Pipeline")
_make_rule(
db_session,
p.id,
name="Invoice Route",
field="metadata.sender",
operator="contains",
value="acme",
owner_id="anonymous",
)
r = client.post(
"/api/routing-rules/evaluate",
json={"metadata": {"sender": "Acme Corporation"}},
)
assert r.status_code == 200
data = r.json()
assert data["matched"] is True
def test_evaluate_with_size(self, client, db_session):
"""Evaluate with size comparisons works."""
p = _make_pipeline(db_session, name="Large Pipeline")
_make_rule(
db_session,
p.id,
name="Large Files",
field="size",
operator="gt",
value="1000000",
owner_id="anonymous",
)
r = client.post(
"/api/routing-rules/evaluate",
json={"size": 2000000},
)
assert r.status_code == 200
assert r.json()["matched"] is True
# ===========================================================================
# API tests metadata field validation
# ===========================================================================
@pytest.mark.unit
class TestFieldValidation:
"""Tests for field validation in routing rule creation."""
def test_metadata_prefix_accepted(self, client, db_session):
"""Fields with 'metadata.' prefix are valid."""
p = _make_pipeline(db_session, name="Pipeline")
r = client.post(
"/api/routing-rules",
json={
"name": "Metadata Rule",
"field": "metadata.sender",
"operator": "equals",
"value": "test",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 201
def test_bare_metadata_rejected(self, client, db_session):
"""Just 'metadata.' without a key suffix is invalid."""
p = _make_pipeline(db_session, name="Pipeline")
r = client.post(
"/api/routing-rules",
json={
"name": "Bad Metadata",
"field": "metadata.",
"operator": "equals",
"value": "test",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 422
@pytest.mark.parametrize("field", sorted(BUILTIN_FIELDS))
def test_builtin_fields_accepted(self, client, db_session, field):
"""All built-in fields are accepted."""
p = _make_pipeline(db_session, name=f"Pipeline for {field}")
r = client.post(
"/api/routing-rules",
json={
"name": f"Rule for {field}",
"field": field,
"operator": "equals",
"value": "test",
"target_pipeline_id": p.id,
},
)
assert r.status_code == 201
# ===========================================================================
# API tests auto-position
# ===========================================================================
@pytest.mark.unit
class TestAutoPosition:
"""Tests for automatic position assignment."""
def test_auto_position_increments(self, client, db_session):
"""Rules created without position get incrementing positions."""
p = _make_pipeline(db_session, name="Pipeline")
r1 = client.post(
"/api/routing-rules",
json={
"name": "First",
"field": "file_type",
"operator": "equals",
"value": "application/pdf",
"target_pipeline_id": p.id,
},
)
r2 = client.post(
"/api/routing-rules",
json={
"name": "Second",
"field": "file_type",
"operator": "equals",
"value": "image/png",
"target_pipeline_id": p.id,
},
)
assert r1.status_code == 201
assert r2.status_code == 201
assert r2.json()["position"] > r1.json()["position"]