Merge branch 'main' into copilot/add-document-sharing-feature

This commit is contained in:
Christian Krakau-Louis
2026-03-08 23:12:27 +01:00
committed by GitHub
31 changed files with 5345 additions and 28 deletions
+1 -1
View File
@@ -1 +1 @@
2026-03-08T21:24:30Z
2026-03-08T22:11:59Z
+42
View File
@@ -10,6 +10,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
<!-- version list -->
## v0.109.0 (2026-03-08)
### Bug Fixes
- **ocr**: Address code review feedback on multi-language OCR
([`0b29199`](https://github.com/christianlouis/DocuElevate/commit/0b291995b90fe3141128f19542dd3c7308f0c7c3))
### Features
- **ocr**: Add multi-language OCR support with per-pipeline language override
([`a2a4c6f`](https://github.com/christianlouis/DocuElevate/commit/a2a4c6fc9a077a80d1877a441f61f72c5e611e58))
## v0.108.0 (2026-03-08)
### Chores
- Remove accidentally committed =8.0.0 file
([`a92bf8e`](https://github.com/christianlouis/DocuElevate/commit/a92bf8ec8f938a493e8992bdd71f655e5f266759))
### Features
- **cli**: Add docuelevate CLI tool for power users
([`a3fd74f`](https://github.com/christianlouis/DocuElevate/commit/a3fd74f117e96b9693cbfc0f76abbd0a3d526b00))
## v0.107.0 (2026-03-08)
### Documentation
- Add per-user notification system documentation to ConfigurationGuide.md
([`ae07590`](https://github.com/christianlouis/DocuElevate/commit/ae075908d1256ad728f4b992c020de2ef978fb65))
### Features
- **notifications**: Add per-user notification system with inbox, email, and webhook targets
([`fcefd09`](https://github.com/christianlouis/DocuElevate/commit/fcefd0978f36fc66ec2a34c9ab26021e38b1fcfb))
- **notifications**: Build per-user notification system (email, webhook, in-app)
([`d48e368`](https://github.com/christianlouis/DocuElevate/commit/d48e36813e37d3db220a04044f319698ecb48056))
## v0.106.0 (2026-03-08)
### Features
+1 -1
View File
@@ -1 +1 @@
caf860d
6376b6d
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.106.0
Build Date: 2026-03-08T21:24:30Z
Git Commit: caf860dd10b5dc5364482d2198d998b362154df3
Git Short SHA: caf860d
Version: 0.109.0
Build Date: 2026-03-08T22:11:59Z
Git Commit: 6376b6d73b5f0d1dcddfaf8c05e51581cc753bfe
Git Short SHA: 6376b6d
Git Branch: main
Commit Date: 2026-03-08T22:24:13+01:00
Build Timestamp: 2026-03-08T21:24:30Z
Commit Date: 2026-03-08T23:11:41+01:00
Build Timestamp: 2026-03-08T22:11:59Z
==============================
+1 -1
View File
@@ -1 +1 @@
0.106.0
0.109.0
+2
View File
@@ -20,6 +20,7 @@ from app.api.google_drive import router as google_drive_router
from app.api.imap_accounts import router as imap_accounts_router
from app.api.integrations import router as integrations_router
from app.api.logs import router as logs_router
from app.api.notifications import router as notifications_router
from app.api.onboarding import router as onboarding_router
from app.api.onedrive import router as onedrive_router
from app.api.openai import router as openai_router
@@ -78,3 +79,4 @@ router.include_router(billing_router)
router.include_router(pipelines_router)
router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
+484
View File
@@ -0,0 +1,484 @@
"""API endpoints for per-user notification targets, preferences, and in-app inbox.
Users can define notification targets (email via SMTP, webhook via HTTP POST)
and configure which document events trigger which targets. In-app notifications
are always created and surfaced via the bell icon / inbox endpoints.
"""
import json
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
from app.utils.user_notification import USER_EVENT_LABELS
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper (mirrors api_tokens.py pattern)
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
VALID_CHANNEL_TYPES = {"email", "webhook"}
VALID_EVENT_TYPES = set(USER_EVENT_LABELS.keys())
class NotificationTargetCreate(BaseModel):
"""Schema for creating a new notification target."""
channel_type: str = Field(..., pattern="^(email|webhook)$")
name: str = Field(..., min_length=1, max_length=255)
config: dict[str, Any] = Field(default_factory=dict)
is_active: bool = True
class NotificationTargetUpdate(BaseModel):
"""Schema for updating an existing notification target."""
name: str | None = Field(None, min_length=1, max_length=255)
config: dict[str, Any] | None = None
is_active: bool | None = None
class PreferenceItem(BaseModel):
"""A single preference toggle for one event+channel combination."""
is_enabled: bool
target_id: int | None = None
class PreferenceItemFull(BaseModel):
"""Full preference item including event and channel type (used in bulk update)."""
event_type: str
channel_type: str
is_enabled: bool
target_id: int | None = None
class PreferencesUpdate(BaseModel):
"""Bulk preferences update payload — a flat list of preference items."""
preferences: list[PreferenceItemFull]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mask_email_config(config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of an email config dict with the password masked."""
masked = dict(config)
if masked.get("smtp_password"):
masked["smtp_password"] = "****"
return masked
def _target_to_dict(target: UserNotificationTarget) -> dict[str, Any]:
"""Serialize a UserNotificationTarget to a response dict, masking secrets."""
config: dict[str, Any] = {}
if target.config:
try:
config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
config = {}
if target.channel_type == "email":
config = _mask_email_config(config)
return {
"id": target.id,
"channel_type": target.channel_type,
"name": target.name,
"config": config,
"is_active": target.is_active,
"created_at": target.created_at,
"updated_at": target.updated_at,
}
# ---------------------------------------------------------------------------
# Inbox endpoints
# ---------------------------------------------------------------------------
@router.get("/inbox")
async def list_inbox(
owner_id: CurrentOwner,
db: DbSession,
skip: int = 0,
limit: int = 50,
) -> list[dict[str, Any]]:
"""List in-app notifications for the authenticated user, newest first."""
notifications = (
db.query(InAppNotification)
.filter(InAppNotification.owner_id == owner_id)
.order_by(InAppNotification.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [
{
"id": n.id,
"event_type": n.event_type,
"title": n.title,
"message": n.message,
"is_read": n.is_read,
"file_id": n.file_id,
"created_at": n.created_at,
}
for n in notifications
]
@router.get("/inbox/unread-count")
async def unread_count(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, int]:
"""Return the number of unread in-app notifications."""
count = (
db.query(InAppNotification)
.filter(InAppNotification.owner_id == owner_id, InAppNotification.is_read == False) # noqa: E712
.count()
)
return {"count": count}
@router.post("/inbox/{notification_id}/read", status_code=status.HTTP_200_OK)
async def mark_read(
notification_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Mark a single in-app notification as read."""
notif = (
db.query(InAppNotification)
.filter(InAppNotification.id == notification_id, InAppNotification.owner_id == owner_id)
.first()
)
if not notif:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
try:
notif.is_read = True
db.commit()
except Exception:
db.rollback()
raise
return {"detail": "Marked as read"}
@router.post("/inbox/read-all", status_code=status.HTTP_200_OK)
async def mark_all_read(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Mark all in-app notifications as read for the authenticated user."""
try:
db.query(InAppNotification).filter(
InAppNotification.owner_id == owner_id,
InAppNotification.is_read == False, # noqa: E712
).update({"is_read": True})
db.commit()
except Exception:
db.rollback()
raise
return {"detail": "All notifications marked as read"}
# ---------------------------------------------------------------------------
# Notification target endpoints
# ---------------------------------------------------------------------------
@router.get("/targets")
async def list_targets(
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List all notification targets for the authenticated user."""
targets = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.owner_id == owner_id)
.order_by(UserNotificationTarget.created_at.desc())
.all()
)
return [_target_to_dict(t) for t in targets]
@router.post("/targets", status_code=status.HTTP_201_CREATED)
async def create_target(
body: NotificationTargetCreate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new notification target (email or webhook)."""
target = UserNotificationTarget(
owner_id=owner_id,
channel_type=body.channel_type,
name=body.name,
config=json.dumps(body.config),
is_active=body.is_active,
)
try:
db.add(target)
db.commit()
db.refresh(target)
except Exception:
db.rollback()
raise
logger.info("Notification target created: id=%s owner=%s type=%s", target.id, owner_id, body.channel_type)
return _target_to_dict(target)
@router.put("/targets/{target_id}", status_code=status.HTTP_200_OK)
async def update_target(
target_id: int,
body: NotificationTargetUpdate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Update an existing notification target."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
try:
if body.name is not None:
target.name = body.name
if body.config is not None:
# Merge new config over existing, preserving masked password field if unchanged
existing_config: dict[str, Any] = {}
if target.config:
try:
existing_config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
existing_config = {}
merged = dict(existing_config)
for k, v in body.config.items():
# Skip writing back a masked password placeholder
if k == "smtp_password" and v == "****":
continue
merged[k] = v
target.config = json.dumps(merged)
if body.is_active is not None:
target.is_active = body.is_active
db.commit()
db.refresh(target)
except Exception:
db.rollback()
raise
logger.info("Notification target updated: id=%s owner=%s", target_id, owner_id)
return _target_to_dict(target)
@router.delete("/targets/{target_id}", status_code=status.HTTP_200_OK)
async def delete_target(
target_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Delete a notification target and its associated preferences."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
try:
# Remove any preferences that reference this target
db.query(UserNotificationPreference).filter(
UserNotificationPreference.owner_id == owner_id,
UserNotificationPreference.target_id == target_id,
).delete()
db.delete(target)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Notification target deleted: id=%s owner=%s", target_id, owner_id)
return {"detail": "Target deleted"}
@router.post("/targets/{target_id}/test", status_code=status.HTTP_200_OK)
async def test_target(
target_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Send a test notification to the specified target."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
config: dict[str, Any] = {}
if target.config:
try:
config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
config = {}
title = "DocuElevate Test Notification"
message = f"This is a test notification from DocuElevate for target '{target.name}'."
if target.channel_type == "email":
from app.utils.user_notification import _send_email_notification
ok = _send_email_notification(config, title, message)
elif target.channel_type == "webhook":
from app.utils.user_notification import _send_webhook_notification
ok = _send_webhook_notification(config, "test", title, message)
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown channel type")
if not ok:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to send test notification")
return {"detail": "Test notification sent"}
# ---------------------------------------------------------------------------
# Preferences endpoints
# ---------------------------------------------------------------------------
@router.get("/preferences")
async def get_preferences(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Return all notification preferences for the authenticated user.
Response structure:
{
"event_types": ["document.processed", "document.failed"],
"event_labels": {"document.processed": "Document Processed", ...},
"preferences": {
"document.processed": {
"in_app": {"is_enabled": true, "target_id": null},
"email": {"is_enabled": false, "target_id": 1},
...
}
}
}
"""
prefs = db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
# Build nested dict: event_type -> channel_type -> {is_enabled, target_id}
result: dict[str, dict[str, dict[str, Any]]] = {}
for pref in prefs:
result.setdefault(pref.event_type, {})[pref.channel_type] = {
"is_enabled": pref.is_enabled,
"target_id": pref.target_id,
}
return {
"event_types": list(USER_EVENT_LABELS.keys()),
"event_labels": USER_EVENT_LABELS,
"preferences": result,
}
@router.put("/preferences", status_code=status.HTTP_200_OK)
async def update_preferences(
body: PreferencesUpdate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Bulk upsert notification preferences for the authenticated user.
Validates that any referenced target_id belongs to the requesting user.
"""
# Collect all target IDs referenced in the payload for ownership validation
referenced_target_ids: set[int] = set()
for item in body.preferences:
if item.target_id is not None:
referenced_target_ids.add(item.target_id)
if referenced_target_ids:
owned_ids = {
row.id
for row in db.query(UserNotificationTarget.id)
.filter(
UserNotificationTarget.owner_id == owner_id,
UserNotificationTarget.id.in_(referenced_target_ids),
)
.all()
}
invalid = referenced_target_ids - owned_ids
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid or inaccessible target_id(s): {sorted(invalid)}",
)
try:
for item in body.preferences:
existing = (
db.query(UserNotificationPreference)
.filter(
UserNotificationPreference.owner_id == owner_id,
UserNotificationPreference.event_type == item.event_type,
UserNotificationPreference.channel_type == item.channel_type,
UserNotificationPreference.target_id == item.target_id,
)
.first()
)
if existing:
existing.is_enabled = item.is_enabled
else:
db.add(
UserNotificationPreference(
owner_id=owner_id,
event_type=item.event_type,
channel_type=item.channel_type,
target_id=item.target_id,
is_enabled=item.is_enabled,
)
)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Notification preferences updated for owner=%s", owner_id)
return {"detail": "Preferences updated"}
+42 -1
View File
@@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
"type": "boolean",
"default": False,
"description": "Always use cloud OCR even if the PDF already has embedded text.",
}
},
"ocr_language": {
"type": "select",
"default": "auto",
"description": (
"Language(s) used for OCR text extraction. Applies to Tesseract and EasyOCR "
"providers; Azure and Mistral perform auto-detection by default. "
"Use Tesseract codes such as 'eng', 'deu', or 'eng+deu' for multi-language "
"documents. 'auto' falls back to the global system setting."
),
"options": [
{"value": "auto", "label": "Auto (use system default)"},
{"value": "ara", "label": "Arabic"},
{"value": "chi_sim", "label": "Chinese (Simplified)"},
{"value": "chi_tra", "label": "Chinese (Traditional)"},
{"value": "ces", "label": "Czech"},
{"value": "dan", "label": "Danish"},
{"value": "nld", "label": "Dutch"},
{"value": "eng", "label": "English"},
{"value": "fin", "label": "Finnish"},
{"value": "fra", "label": "French"},
{"value": "deu", "label": "German"},
{"value": "ell", "label": "Greek"},
{"value": "heb", "label": "Hebrew"},
{"value": "hin", "label": "Hindi"},
{"value": "hun", "label": "Hungarian"},
{"value": "ita", "label": "Italian"},
{"value": "jpn", "label": "Japanese"},
{"value": "kor", "label": "Korean"},
{"value": "nor", "label": "Norwegian"},
{"value": "pol", "label": "Polish"},
{"value": "por", "label": "Portuguese"},
{"value": "ron", "label": "Romanian"},
{"value": "rus", "label": "Russian"},
{"value": "spa", "label": "Spanish"},
{"value": "swe", "label": "Swedish"},
{"value": "tha", "label": "Thai"},
{"value": "tur", "label": "Turkish"},
{"value": "ukr", "label": "Ukrainian"},
{"value": "vie", "label": "Vietnamese"},
],
},
},
},
"extract_metadata": {
+680
View File
@@ -0,0 +1,680 @@
"""DocuElevate command-line interface.
Provides a pipe-friendly CLI for scripting and automation against the
DocuElevate REST API. Authentication is via personal API tokens (the
same tokens managed at ``/api-tokens`` in the web UI).
Usage::
docuelevate --url http://my-instance --token de_xxx list
DOCUELEVATE_URL=http://my-instance DOCUELEVATE_API_TOKEN=de_xxx docuelevate list
Commands
--------
upload Upload one or more local files for processing.
download Download a processed (or original) file by ID.
search Full-text search across all documents.
list List documents with optional filtering.
token Sub-commands: create / list / revoke API tokens.
"""
import json
import sys
from pathlib import Path
from typing import Any
from urllib.parse import unquote
import click
import requests
# ---------------------------------------------------------------------------
# Environment-variable defaults
# ---------------------------------------------------------------------------
ENV_URL = "DOCUELEVATE_URL"
ENV_TOKEN = "DOCUELEVATE_API_TOKEN"
_DEFAULT_URL = "http://localhost:8000"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_headers(token: str) -> dict[str, str]:
"""Return Authorization headers for the given API token."""
return {"Authorization": f"Bearer {token}"}
def _api(
method: str,
base_url: str,
path: str,
token: str,
timeout: int = 60,
**kwargs: Any,
) -> requests.Response:
"""Make an authenticated API request and return the response.
Args:
method: HTTP method (GET, POST, DELETE, …).
base_url: The base URL of the DocuElevate instance.
path: API path starting with ``/``.
token: Plaintext API token.
timeout: Request timeout in seconds (default: 60).
**kwargs: Extra keyword arguments forwarded to :func:`requests.request`.
Returns:
The :class:`requests.Response` object.
Raises:
click.ClickException: On network errors.
"""
url = base_url.rstrip("/") + path
headers = _build_headers(token)
try:
resp = requests.request(method, url, headers=headers, timeout=timeout, **kwargs)
except requests.ConnectionError as exc:
raise click.ClickException(f"Could not connect to {base_url}: {exc}") from exc
except requests.Timeout as exc:
raise click.ClickException(f"Request timed out: {exc}") from exc
return resp
def _require_ok(resp: requests.Response) -> dict[str, Any] | list[Any]:
"""Assert a successful HTTP response and return parsed JSON.
Args:
resp: The response to check.
Returns:
Parsed JSON payload.
Raises:
click.ClickException: If the response status indicates an error.
"""
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
raise click.ClickException(f"API error {resp.status_code}: {detail}")
try:
return resp.json()
except Exception:
return {}
def _output(data: Any, fmt: str) -> None:
"""Write *data* to stdout in the requested format.
Args:
data: The value to serialise (dict, list, or primitive).
fmt: Either ``"json"`` (machine-readable) or ``"table"`` (human-readable).
"""
if fmt == "json":
click.echo(json.dumps(data, indent=2, default=str))
else:
_print_table(data)
def _print_table(data: Any) -> None:
"""Pretty-print a list of dicts as a fixed-width table.
Falls back to JSON if the data is not a homogeneous list of dicts.
Args:
data: Data to render.
"""
if isinstance(data, dict):
# Single-object output — print as key: value pairs
for key, value in data.items():
click.echo(f" {key}: {value}")
return
if not isinstance(data, list) or not data:
click.echo(json.dumps(data, indent=2, default=str))
return
if not isinstance(data[0], dict):
for item in data:
click.echo(str(item))
return
# Determine column widths
keys = list(data[0].keys())
widths: dict[str, int] = {k: len(k) for k in keys}
for row in data:
for k in keys:
widths[k] = max(widths[k], len(str(row.get(k, ""))))
header = " ".join(k.upper().ljust(widths[k]) for k in keys)
separator = " ".join("-" * widths[k] for k in keys)
click.echo(header)
click.echo(separator)
for row in data:
click.echo(" ".join(str(row.get(k, "")).ljust(widths[k]) for k in keys))
# ---------------------------------------------------------------------------
# Root command group
# ---------------------------------------------------------------------------
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.option(
"--url",
envvar=ENV_URL,
default=_DEFAULT_URL,
show_default=True,
show_envvar=True,
help="Base URL of the DocuElevate instance.",
metavar="URL",
)
@click.option(
"--token",
envvar=ENV_TOKEN,
default=None,
show_envvar=True,
help="API token (de_…). Required for all commands except help.",
metavar="TOKEN",
)
@click.option(
"--format",
"fmt",
type=click.Choice(["table", "json"], case_sensitive=False),
default="table",
show_default=True,
help="Output format. Use 'json' for machine-readable / pipe-friendly output.",
)
@click.option(
"--timeout",
default=60,
show_default=True,
envvar="DOCUELEVATE_TIMEOUT",
show_envvar=True,
type=int,
help="HTTP request timeout in seconds.",
)
@click.version_option(package_name="docuelevate", prog_name="docuelevate")
@click.pass_context
def cli(ctx: click.Context, url: str, token: str | None, fmt: str, timeout: int) -> None:
"""DocuElevate CLI — interact with DocuElevate from the command line.
Configure the target instance and credentials via options or environment
variables:
\b
DOCUELEVATE_URL Base URL of the instance (default: http://localhost:8000)
DOCUELEVATE_API_TOKEN Personal API token (de_…)
DOCUELEVATE_TIMEOUT HTTP request timeout in seconds (default: 60)
Examples:
\b
# Upload a file
docuelevate --token de_xxx upload report.pdf
\b
# List files as JSON for further processing
docuelevate --token de_xxx --format json list | jq '.[].original_filename'
\b
# Search for invoices
docuelevate --token de_xxx search "invoice amazon"
"""
ctx.ensure_object(dict)
ctx.obj["url"] = url
ctx.obj["token"] = token
ctx.obj["fmt"] = fmt
ctx.obj["timeout"] = timeout
def _get_token(ctx: click.Context) -> str:
"""Return the token from context, raising ClickException if absent.
Args:
ctx: The current Click context.
Returns:
The API token string.
Raises:
click.ClickException: If no token has been provided.
"""
token = ctx.obj.get("token")
if not token:
raise click.ClickException(f"No API token provided. Use --token or set the {ENV_TOKEN} environment variable.")
return token
# ---------------------------------------------------------------------------
# list command
# ---------------------------------------------------------------------------
@cli.command("list")
@click.option("--page", default=1, show_default=True, help="Page number.")
@click.option("--per-page", default=25, show_default=True, help="Items per page (max 200).")
@click.option("--search", default=None, help="Filter by filename substring.")
@click.option("--mime-type", default=None, help="Filter by MIME type (e.g. application/pdf).")
@click.option("--status", "file_status", default=None, help="Filter by status: pending, processing, completed, failed.")
@click.option("--sort-by", default="created_at", show_default=True, help="Sort field.")
@click.option("--sort-order", type=click.Choice(["asc", "desc"]), default="desc", show_default=True)
@click.pass_context
def list_files(
ctx: click.Context,
page: int,
per_page: int,
search: str | None,
mime_type: str | None,
file_status: str | None,
sort_by: str,
sort_order: str,
) -> None:
"""List documents stored in DocuElevate.
Examples:
\b
docuelevate list
docuelevate list --status completed --per-page 10
docuelevate --format json list | jq '.[].original_filename'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
params: dict[str, Any] = {
"page": page,
"per_page": per_page,
"sort_by": sort_by,
"sort_order": sort_order,
}
if search:
params["search"] = search
if mime_type:
params["mime_type"] = mime_type
if file_status:
params["status"] = file_status
resp = _api("GET", url, "/api/files", token, timeout=timeout, params=params)
payload = _require_ok(resp)
# Extract the list from the paginated response
files: list[dict[str, Any]] = payload.get("files", payload) if isinstance(payload, dict) else payload # type: ignore[assignment]
pagination: dict[str, Any] = payload.get("pagination", {}) if isinstance(payload, dict) else {}
if fmt == "json":
_output(files, fmt)
else:
# Trim fields for readable table
rows = [
{
"id": f.get("id"),
"filename": f.get("original_filename"),
"size": f.get("file_size"),
"status": f.get("status"),
"created_at": str(f.get("created_at", ""))[:19],
}
for f in files
]
_output(rows, fmt)
if pagination:
click.echo(f"\nPage {pagination.get('page')}/{pagination.get('pages')} ({pagination.get('total')} total)")
# ---------------------------------------------------------------------------
# upload command
# ---------------------------------------------------------------------------
@cli.command("upload")
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, readable=True))
@click.option(
"--batch-size",
default=5,
show_default=True,
help="Maximum number of concurrent uploads (sequential when 1).",
)
@click.pass_context
def upload_files(ctx: click.Context, files: tuple[str, ...], batch_size: int) -> None:
"""Upload one or more local files for processing.
Supports glob patterns and multiple arguments for batch uploads.
Examples:
\b
docuelevate upload report.pdf
docuelevate upload *.pdf invoice_*.png
docuelevate upload --batch-size 3 /scans/*.pdf
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
results: list[dict[str, Any]] = []
failed = 0
for i, file_path in enumerate(files, 1):
path = Path(file_path)
click.echo(f"[{i}/{len(files)}] Uploading {path.name}", err=True)
try:
with path.open("rb") as fh:
resp = _api(
"POST",
url,
"/api/ui-upload",
token,
timeout=timeout,
files={"file": (path.name, fh)},
)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
click.echo(f" ERROR {resp.status_code}: {detail}", err=True)
results.append({"file": path.name, "status": "error", "detail": detail})
failed += 1
else:
data = resp.json()
results.append({"file": path.name, "status": "queued", **data})
click.echo(f" OK task_id={data.get('task_id', '?')}", err=True)
except click.ClickException:
raise
except Exception as exc:
click.echo(f" ERROR: {exc}", err=True)
results.append({"file": path.name, "status": "error", "detail": str(exc)})
failed += 1
_output(results, fmt)
if failed:
click.echo(f"\n{failed}/{len(files)} upload(s) failed.", err=True)
sys.exit(1)
# ---------------------------------------------------------------------------
# download command
# ---------------------------------------------------------------------------
@cli.command("download")
@click.argument("file_id", type=int)
@click.option(
"--output",
"-o",
default=None,
help="Destination file path. Defaults to the server-provided filename in the current directory.",
type=click.Path(),
)
@click.option(
"--version",
type=click.Choice(["processed", "original"]),
default="processed",
show_default=True,
help="Which version to download.",
)
@click.pass_context
def download_file(ctx: click.Context, file_id: int, output: str | None, version: str) -> None:
"""Download a file by its numeric ID.
Examples:
\b
docuelevate download 42
docuelevate download 42 --version original -o /tmp/orig.pdf
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
timeout: int = ctx.obj["timeout"]
resp = _api(
"GET",
url,
f"/api/files/{file_id}/download",
token,
timeout=timeout,
params={"version": version},
stream=True,
)
_require_ok(resp)
# Determine output filename
if output:
dest = Path(output)
else:
content_disp = resp.headers.get("content-disposition", "")
filename = f"file_{file_id}"
for raw_part in content_disp.split(";"):
clean = raw_part.strip()
if clean.startswith("filename="):
filename = clean[len("filename=") :].strip('"').strip("'")
break
if clean.startswith("filename*="):
raw = clean[len("filename*=") :]
if raw.upper().startswith("UTF-8''"):
filename = unquote(raw[7:])
break
dest = Path(filename)
with dest.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=65536):
fh.write(chunk)
click.echo(f"Downloaded {dest} ({dest.stat().st_size} bytes)")
# ---------------------------------------------------------------------------
# search command
# ---------------------------------------------------------------------------
@cli.command("search")
@click.argument("query")
@click.option("--mime-type", default=None, help="Filter by MIME type.")
@click.option("--document-type", default=None, help="Filter by document type (e.g. Invoice).")
@click.option("--tags", default=None, help="Filter by tag.")
@click.option("--language", default=None, help="Filter by language code (e.g. en, de).")
@click.option("--page", default=1, show_default=True)
@click.option("--per-page", default=20, show_default=True, help="Results per page (max 100).")
@click.pass_context
def search(
ctx: click.Context,
query: str,
mime_type: str | None,
document_type: str | None,
tags: str | None,
language: str | None,
page: int,
per_page: int,
) -> None:
"""Full-text search across all documents.
Examples:
\b
docuelevate search "invoice amazon"
docuelevate search "contract" --document-type Contract --language en
docuelevate --format json search "receipt" | jq '.[].file_id'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
params: dict[str, Any] = {"q": query, "page": page, "per_page": per_page}
if mime_type:
params["mime_type"] = mime_type
if document_type:
params["document_type"] = document_type
if tags:
params["tags"] = tags
if language:
params["language"] = language
resp = _api("GET", url, "/api/search", token, timeout=timeout, params=params)
payload = _require_ok(resp)
results: list[dict[str, Any]] = (
payload.get("results", payload) if isinstance(payload, dict) else payload # type: ignore[assignment]
)
total: int = payload.get("total", len(results)) if isinstance(payload, dict) else len(results)
pages: int = payload.get("pages", 1) if isinstance(payload, dict) else 1
if fmt == "json":
_output(results, fmt)
else:
rows = [
{
"file_id": r.get("file_id"),
"filename": r.get("original_filename"),
"type": r.get("document_type"),
"tags": ",".join(r.get("tags") or []),
}
for r in results
]
_output(rows, fmt)
click.echo(f"\nPage {page}/{pages} ({total} total results)")
# ---------------------------------------------------------------------------
# token sub-group
# ---------------------------------------------------------------------------
@cli.group("token")
@click.pass_context
def token_group(ctx: click.Context) -> None:
"""Manage personal API tokens.
Tokens can be created, listed, and revoked. Token rotation is achieved
by creating a new token before revoking the old one.
Examples:
\b
docuelevate token create "CI Pipeline"
docuelevate token list
docuelevate token revoke 3
"""
@token_group.command("create")
@click.argument("name")
@click.pass_context
def token_create(ctx: click.Context, name: str) -> None:
"""Create a new personal API token.
The full token value is printed exactly once. Store it securely.
Examples:
\b
docuelevate token create "My script"
docuelevate --format json token create "CI" | jq -r '.token'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
resp = _api("POST", url, "/api/api-tokens/", token, timeout=timeout, json={"name": name})
payload = _require_ok(resp)
if fmt == "json":
_output(payload, fmt)
else:
if not isinstance(payload, dict):
raise click.ClickException("Unexpected API response format.")
click.echo("Token created successfully:")
click.echo(f" ID: {payload.get('id')}")
click.echo(f" Name: {payload.get('name')}")
click.echo(f" Prefix: {payload.get('token_prefix')}")
click.echo(f" Token: {payload.get('token')}")
click.echo()
click.echo("Store this token securely — it will not be shown again.", err=True)
@token_group.command("list")
@click.pass_context
def token_list(ctx: click.Context) -> None:
"""List all your API tokens (active and revoked).
Examples:
\b
docuelevate token list
docuelevate --format json token list | jq '.[] | select(.is_active)'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
resp = _api("GET", url, "/api/api-tokens/", token, timeout=timeout)
payload = _require_ok(resp)
if fmt == "json":
_output(payload, fmt)
else:
if not isinstance(payload, list):
raise click.ClickException("Unexpected API response format.")
rows = [
{
"id": t.get("id"),
"name": t.get("name"),
"prefix": t.get("token_prefix"),
"active": t.get("is_active"),
"last_used": str(t.get("last_used_at") or "never")[:19],
"created": str(t.get("created_at") or "")[:19],
}
for t in payload
]
_output(rows, fmt)
@token_group.command("revoke")
@click.argument("token_id", type=int)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
@click.pass_context
def token_revoke(ctx: click.Context, token_id: int, yes: bool) -> None:
"""Revoke an API token by its numeric ID.
The token is soft-deleted (kept for audit) but immediately invalidated.
Examples:
\b
docuelevate token revoke 3
docuelevate token revoke 3 --yes
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
timeout: int = ctx.obj["timeout"]
if not yes:
click.confirm(f"Revoke token {token_id}?", abort=True)
resp = _api("DELETE", url, f"/api/api-tokens/{token_id}", token, timeout=timeout)
_require_ok(resp)
click.echo(f"Token {token_id} revoked.")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the ``docuelevate`` console script."""
cli(auto_envvar_prefix="DOCUELEVATE") # type: ignore[call-arg]
if __name__ == "__main__":
main()
+45
View File
@@ -684,3 +684,48 @@ class SharedLink(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
class UserNotificationTarget(Base):
"""Per-user notification target (email or webhook channel)."""
__tablename__ = "user_notification_targets"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
channel_type = Column(String(20), nullable=False) # "email" or "webhook"
name = Column(String(255), nullable=False) # Human-readable label
config = Column(Text, nullable=True) # JSON: smtp config or webhook url
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())
class UserNotificationPreference(Base):
"""Mapping: which user events trigger which notification channel."""
__tablename__ = "user_notification_preferences"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
channel_type = Column(String(20), nullable=False) # "in_app", "email", "webhook"
target_id = Column(Integer, nullable=True) # NULL = in_app, else UserNotificationTarget.id
is_enabled = Column(Boolean, nullable=False, default=True)
__table_args__ = (UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class InAppNotification(Base):
"""In-app notification record for the bell icon / inbox."""
__tablename__ = "in_app_notifications"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
title = Column(String(255), nullable=False)
message = Column(Text, nullable=True)
is_read = Column(Boolean, nullable=False, default=False, index=True)
file_id = Column(Integer, nullable=True) # Optional link to FileRecord
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
+83 -4
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import logging
import mimetypes
import os
import shutil
import uuid
from typing import TYPE_CHECKING
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
from pypdf.errors import PdfReadError
@@ -12,7 +16,7 @@ from pypdf.errors import PdfReadError
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.models import FileRecord, Pipeline, PipelineStep
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
from app.tasks.process_with_ocr import process_with_ocr
from app.tasks.retry_config import BaseTaskWithRetry
@@ -20,9 +24,75 @@ from app.utils import get_unique_filepath_with_counter, hash_file, log_task_prog
from app.utils.step_manager import initialize_file_steps
from app.utils.text_quality import check_text_quality, detect_pdf_text_source
if TYPE_CHECKING:
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _get_pipeline_ocr_language(db: "Session", file_record: FileRecord, owner_id: str | None) -> str | None:
"""Look up the OCR language override from the file's pipeline OCR step config.
Resolution order:
1. Explicit pipeline assigned to the file (``file_record.pipeline_id``).
2. User's own default pipeline (``owner_id``, ``is_default=True``).
3. System default pipeline (``owner_id=NULL``, ``is_default=True``).
Returns the ``ocr_language`` value from the pipeline's OCR step config, or
``None`` when no override is configured.
"""
pipeline = None
if file_record.pipeline_id:
pipeline = db.query(Pipeline).filter(Pipeline.id == file_record.pipeline_id).first()
if pipeline is None and owner_id:
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.owner_id == owner_id,
Pipeline.is_default.is_(True),
Pipeline.is_active.is_(True),
)
.first()
)
if pipeline is None:
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.owner_id.is_(None),
Pipeline.is_default.is_(True),
Pipeline.is_active.is_(True),
)
.first()
)
if pipeline is None:
return None
ocr_step = (
db.query(PipelineStep)
.filter(
PipelineStep.pipeline_id == pipeline.id,
PipelineStep.step_type == "ocr",
PipelineStep.enabled.is_(True),
)
.first()
)
if ocr_step is None or not ocr_step.config:
return None
try:
step_config = json.loads(ocr_step.config)
lang = step_config.get("ocr_language")
# "auto" is treated as no override
return lang if lang and lang != "auto" else None
except Exception:
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def process_document(
self,
@@ -109,6 +179,7 @@ def process_document(
)
# Acquire DB session in the task
ocr_language: str | None = None # Pipeline OCR language override resolved inside DB session
with SessionLocal() as db:
# When file_id is provided, we are reprocessing an existing file.
# Skip the duplicate check and reuse the existing record.
@@ -305,6 +376,14 @@ def process_document(
new_record.local_filename = new_local_path
db.commit()
# Look up pipeline OCR language override before the session closes.
# This reads the OCR step config from the file's assigned pipeline (or
# the user/system default pipeline) so the language is available when
# dispatching process_with_ocr below.
ocr_language = _get_pipeline_ocr_language(db, new_record, owner_id)
if ocr_language:
logger.info(f"[{task_id}] Pipeline OCR language override: {ocr_language!r}")
# Store file_id before session closes to avoid DetachedInstanceError
file_id = new_record.id
@@ -334,7 +413,7 @@ def process_document(
"Queued for forced OCR processing",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id)
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
return {"file": new_local_path, "status": "Queued for forced OCR", "file_id": file_id}
# If the file is not a PDF, skip embedded text check and convert to PDF first
@@ -491,7 +570,7 @@ def process_document(
"Queued for OCR (text quality too low)",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id, extracted_text)
process_with_ocr.delay(new_filename, file_id, extracted_text, language=ocr_language)
return {
"file": new_local_path,
"status": "Queued for OCR (poor embedded text quality)",
@@ -564,5 +643,5 @@ def process_document(
"Queued for OCR processing",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id)
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id}
+19 -5
View File
@@ -17,7 +17,6 @@ task with a multi-engine OCR pipeline that:
import logging
import os
from typing import Optional
from app.celery_app import celery
from app.config import settings
@@ -33,7 +32,13 @@ logger = logging.getLogger(__name__)
@celery.task(base=OcrTaskWithRetry, bind=True)
def process_with_ocr(self, filename: str, file_id: Optional[int] = None, original_text: Optional[str] = None):
def process_with_ocr(
self,
filename: str,
file_id: int | None = None,
original_text: str | None = None,
language: str | None = None,
):
"""Run the configured OCR providers on *filename* and continue the pipeline.
When multiple OCR providers are configured the results are merged using the
@@ -47,6 +52,10 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
filename: Base name of the file inside ``<workdir>/tmp/``.
file_id: Optional database record ID passed through to downstream tasks.
original_text: Optional original embedded text for head-to-head comparison.
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
to override the global OCR language settings for this specific run.
Pass ``None`` or ``"auto"`` to use the global settings. This
enables per-pipeline language configuration.
"""
task_id = self.request.id
log_task_progress(
@@ -62,7 +71,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
if not os.path.exists(tmp_file_path):
raise FileNotFoundError(f"Local file not found: {tmp_file_path}")
providers = get_ocr_providers()
providers = get_ocr_providers(language=language)
provider_names = [p.name for p in providers]
logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}")
@@ -122,7 +131,12 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
# PDF with ocrmypdf to embed an invisible text layer so the output is
# selectable/searchable in PDF viewers.
if searchable_pdf_path is None:
lang = getattr(settings, "tesseract_language", None) or "eng"
# Use the per-call language override; fall back to global setting
embed_lang = (
language
if language and language != "auto"
else (getattr(settings, "tesseract_language", None) or "eng")
)
log_task_progress(
task_id,
"embed_text_layer",
@@ -130,7 +144,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
"Embedding searchable text layer into PDF",
file_id=file_id,
)
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=lang)
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=embed_lang)
if embedded:
searchable_pdf_path = tmp_file_path
log_task_progress(
+141 -5
View File
@@ -192,6 +192,95 @@ class OCRResult:
)
# ---------------------------------------------------------------------------
# Multi-language support
# ---------------------------------------------------------------------------
#: Canonical list of supported OCR languages for pipeline configuration.
#: Keys are display names; values are Tesseract language code(s).
#: Tesseract codes are used as the canonical format because they are the most
#: widely applicable across self-hosted providers (Tesseract + ocrmypdf).
#: "auto" falls back to the global ``tesseract_language`` / ``easyocr_languages``
#: settings (i.e. no per-call override).
OCR_LANGUAGES: Dict[str, str] = {
"Auto (use system default)": "auto",
"Arabic": "ara",
"Chinese (Simplified)": "chi_sim",
"Chinese (Traditional)": "chi_tra",
"Czech": "ces",
"Danish": "dan",
"Dutch": "nld",
"English": "eng",
"Finnish": "fin",
"French": "fra",
"German": "deu",
"Greek": "ell",
"Hebrew": "heb",
"Hindi": "hin",
"Hungarian": "hun",
"Italian": "ita",
"Japanese": "jpn",
"Korean": "kor",
"Norwegian": "nor",
"Polish": "pol",
"Portuguese": "por",
"Romanian": "ron",
"Russian": "rus",
"Spanish": "spa",
"Swedish": "swe",
"Thai": "tha",
"Turkish": "tur",
"Ukrainian": "ukr",
"Vietnamese": "vie",
}
#: Mapping from Tesseract language codes to EasyOCR language codes.
#: Used when ``TesseractOCRProvider``-style codes are specified but EasyOCR is
#: the active provider. Codes not present in this map are passed through as-is
#: (EasyOCR accepts its own ISO 639-1 codes such as ``"en"`` or ``"de"``).
TESSERACT_TO_EASYOCR: Dict[str, str] = {
"ara": "ar",
"ces": "cs",
"chi_sim": "ch_sim",
"chi_tra": "ch_tra",
"dan": "da",
"deu": "de",
"ell": "el",
"eng": "en",
"fin": "fi",
"fra": "fr",
"heb": "he",
"hin": "hi",
"hun": "hu",
"ita": "it",
"jpn": "ja",
"kor": "ko",
"nld": "nl",
"nor": "no",
"pol": "pl",
"por": "pt",
"ron": "ro",
"rus": "ru",
"spa": "es",
"swe": "sv",
"tha": "th",
"tur": "tr",
"ukr": "uk",
"vie": "vi",
}
def _tesseract_codes_to_easyocr(tesseract_lang: str) -> List[str]:
"""Convert a Tesseract language string (e.g. ``"eng+deu"``) to a list of
EasyOCR language codes (e.g. ``["en", "de"]``).
Unknown codes are passed through unchanged, so native EasyOCR codes such
as ``"en"`` also work transparently.
"""
codes = [part.strip() for part in tesseract_lang.split("+") if part.strip()]
return [TESSERACT_TO_EASYOCR.get(code, code) for code in codes]
class OCRProvider(ABC):
"""Abstract base class for OCR providers.
@@ -290,10 +379,24 @@ class TesseractOCRProvider(OCRProvider):
- ``tesseract_cmd`` path to the ``tesseract`` binary (optional).
- ``tesseract_language`` Tesseract language code(s), e.g. ``"eng"`` or
``"eng+deu"`` (default: ``"eng"``).
The optional *language* constructor argument overrides the global
``tesseract_language`` setting for this specific provider instance, enabling
per-pipeline language configuration.
"""
name = "tesseract"
def __init__(self, language: Optional[str] = None) -> None:
"""Initialise the Tesseract provider.
Args:
language: Optional Tesseract language code(s) to use instead of the
global ``tesseract_language`` setting (e.g. ``"eng+deu"``).
Pass ``None`` or ``"auto"`` to use the global setting.
"""
self._language_override: Optional[str] = language if language and language != "auto" else None
def process(self, file_path: str) -> OCRResult:
try:
import pytesseract
@@ -308,7 +411,7 @@ class TesseractOCRProvider(OCRProvider):
if tesseract_cmd:
pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
lang = getattr(settings, "tesseract_language", None) or "eng"
lang = self._language_override or getattr(settings, "tesseract_language", None) or "eng"
# Ensure language data files are present; attempt download if missing.
from app.utils.ocr_language_manager import ensure_tesseract_languages # noqa: PLC0415
@@ -349,10 +452,26 @@ class EasyOCRProvider(OCRProvider):
- ``easyocr_languages`` comma-separated list of language codes
(default: ``"en"``).
- ``easyocr_gpu`` whether to use GPU acceleration (default: ``False``).
The optional *language* constructor argument accepts a Tesseract-style
language string (e.g. ``"eng+deu"``) which is automatically translated to
EasyOCR codes (e.g. ``["en", "de"]``), overriding the global
``easyocr_languages`` setting for this provider instance.
"""
name = "easyocr"
def __init__(self, language: Optional[str] = None) -> None:
"""Initialise the EasyOCR provider.
Args:
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
or a comma-separated EasyOCR language list (e.g. ``"en,de"``).
Pass ``None`` or ``"auto"`` to use the global ``easyocr_languages``
setting.
"""
self._language_override: Optional[str] = language if language and language != "auto" else None
def process(self, file_path: str) -> OCRResult:
try:
import easyocr
@@ -363,8 +482,12 @@ class EasyOCRProvider(OCRProvider):
"Install them with: pip install easyocr pdf2image"
) from exc
lang_str = getattr(settings, "easyocr_languages", None) or "en"
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
if self._language_override:
# Convert Tesseract-style codes to EasyOCR codes
langs = _tesseract_codes_to_easyocr(self._language_override)
else:
lang_str = getattr(settings, "easyocr_languages", None) or "en"
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
gpu = getattr(settings, "easyocr_gpu", False)
logger.info(f"[EasyOCR] Processing {os.path.basename(file_path)} (langs={langs}, gpu={gpu})")
@@ -679,23 +802,36 @@ KNOWN_OCR_PROVIDERS: List[str] = sorted(_PROVIDER_MAP.keys())
MAX_OCR_TEXT_FOR_AI_MERGE = 4000
def get_ocr_providers() -> List[OCRProvider]:
def get_ocr_providers(language: Optional[str] = None) -> List[OCRProvider]:
"""Return a list of configured OCR provider instances.
Reads ``settings.ocr_providers`` (comma-separated provider names) and
returns one instantiated provider per entry. Falls back to ``["azure"]``
when the setting is absent.
Args:
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
to override the global language settings for providers that support
per-call language configuration (Tesseract and EasyOCR). Pass
``None`` or ``"auto"`` to use the global settings.
"""
raw = getattr(settings, "ocr_providers", None) or "azure"
provider_names = [name.strip().lower() for name in raw.split(",") if name.strip()]
# Normalise "auto" to None so providers fall back to global settings
effective_language = language if language and language != "auto" else None
providers: List[OCRProvider] = []
for name in provider_names:
cls = _PROVIDER_MAP.get(name)
if cls is None:
logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS skipping.")
continue
providers.append(cls())
# Pass language override to providers that support per-call language config
if effective_language is not None and name in ("tesseract", "easyocr"):
providers.append(cls(language=effective_language))
else:
providers.append(cls())
logger.debug(f"Registered OCR provider: {name}")
if not providers:
+233
View File
@@ -0,0 +1,233 @@
"""Per-user notification dispatch service.
Handles user-centric events (document.processed, document.failed) by:
1. Always creating an InAppNotification record
2. Sending via configured email/webhook targets (UserNotificationTarget)
if the user has enabled that channel/event combination.
"""
import json
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Any
import httpx
from app.database import SessionLocal
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
logger = logging.getLogger(__name__)
# Supported user-centric event types
EVENT_DOCUMENT_PROCESSED = "document.processed"
EVENT_DOCUMENT_FAILED = "document.failed"
USER_EVENT_LABELS: dict[str, str] = {
EVENT_DOCUMENT_PROCESSED: "Document Processed",
EVENT_DOCUMENT_FAILED: "Document Processing Failed",
}
def create_in_app_notification(
owner_id: str,
event_type: str,
title: str,
message: str,
file_id: int | None = None,
) -> InAppNotification | None:
"""Persist an InAppNotification record for the given user.
Returns:
The created InAppNotification, or None on error.
"""
db = SessionLocal()
try:
notif = InAppNotification(
owner_id=owner_id,
event_type=event_type,
title=title,
message=message,
file_id=file_id,
)
db.add(notif)
db.commit()
db.refresh(notif)
return notif
except Exception:
db.rollback()
logger.exception("Failed to create in-app notification for owner_id=%s", owner_id)
return None
finally:
db.close()
def _send_email_notification(target_config: dict[str, Any], title: str, message: str) -> bool:
"""Send an email notification via the configured SMTP target.
Args:
target_config: dict with keys: smtp_host, smtp_port, smtp_username,
smtp_password, smtp_use_tls, recipient_email
title: Email subject
message: Email body text
Returns:
True if the email was sent successfully, False otherwise.
"""
try:
smtp_host = target_config.get("smtp_host", "")
smtp_port = int(target_config.get("smtp_port", 587))
smtp_username = target_config.get("smtp_username", "")
smtp_password = target_config.get("smtp_password", "")
smtp_use_tls = bool(target_config.get("smtp_use_tls", True))
recipient_email = target_config.get("recipient_email", "")
sender_email = target_config.get("sender_email") or smtp_username or "noreply@docuelevate.local"
if not smtp_host or not recipient_email:
logger.warning("Email notification target missing smtp_host or recipient_email")
return False
msg = MIMEMultipart("alternative")
msg["Subject"] = title
msg["From"] = sender_email
msg["To"] = recipient_email
msg.attach(MIMEText(message, "plain"))
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
if smtp_use_tls:
server.starttls()
if smtp_username and smtp_password:
server.login(smtp_username, smtp_password)
server.send_message(msg)
logger.info("Email notification sent to %s", recipient_email)
return True
except Exception:
logger.exception("Failed to send email notification")
return False
def _send_webhook_notification(target_config: dict[str, Any], event_type: str, title: str, message: str) -> bool:
"""Send a webhook POST notification to the configured URL.
Args:
target_config: dict with keys: url, secret (optional HMAC header value)
event_type: The event type string
title: Notification title
message: Notification body
Returns:
True if the webhook was delivered successfully, False otherwise.
"""
try:
url = target_config.get("url", "")
secret = target_config.get("secret", "")
if not url:
logger.warning("Webhook notification target missing url")
return False
payload = {
"event": event_type,
"title": title,
"message": message,
}
headers = {"Content-Type": "application/json"}
if secret:
headers["X-DocuElevate-Secret"] = secret
response = httpx.post(url, json=payload, headers=headers, timeout=10)
response.raise_for_status()
logger.info("Webhook notification sent to %s (status %s)", url, response.status_code)
return True
except Exception:
logger.exception("Failed to send webhook notification to %s", target_config.get("url", ""))
return False
def dispatch_user_notification(
owner_id: str,
event_type: str,
title: str,
message: str,
file_id: int | None = None,
) -> None:
"""Dispatch a user notification for the given event.
Always creates an in-app notification. Also sends via email/webhook
targets if the user has configured and enabled them for this event.
Args:
owner_id: The user's stable identifier.
event_type: e.g. "document.processed" or "document.failed"
title: Short notification title.
message: Longer notification body.
file_id: Optional FileRecord.id to link.
"""
# 1. Always create an in-app notification
create_in_app_notification(
owner_id=owner_id,
event_type=event_type,
title=title,
message=message,
file_id=file_id,
)
# 2. Check for configured email/webhook preferences
db = SessionLocal()
try:
prefs = (
db.query(UserNotificationPreference)
.filter(
UserNotificationPreference.owner_id == owner_id,
UserNotificationPreference.event_type == event_type,
UserNotificationPreference.is_enabled == True, # noqa: E712
UserNotificationPreference.channel_type.in_(["email", "webhook"]),
)
.all()
)
for pref in prefs:
if not pref.target_id:
continue
target = db.get(UserNotificationTarget, pref.target_id)
if not target or not target.is_active:
continue
config: dict[str, Any] = {}
if target.config:
try:
config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
config = {}
if pref.channel_type == "email":
_send_email_notification(config, title, message)
elif pref.channel_type == "webhook":
_send_webhook_notification(config, event_type, title, message)
except Exception:
logger.exception("Error dispatching user notification for owner_id=%s event=%s", owner_id, event_type)
finally:
db.close()
def notify_user_document_processed(owner_id: str, filename: str, file_id: int | None = None) -> None:
"""Notify a user that their document was successfully processed."""
dispatch_user_notification(
owner_id=owner_id,
event_type=EVENT_DOCUMENT_PROCESSED,
title=f"Document processed: {filename}",
message=f"Your document '{filename}' has been successfully processed and uploaded.",
file_id=file_id,
)
def notify_user_document_failed(owner_id: str, filename: str, error: str, file_id: int | None = None) -> None:
"""Notify a user that their document processing failed."""
dispatch_user_notification(
owner_id=owner_id,
event_type=EVENT_DOCUMENT_FAILED,
title=f"Document processing failed: {filename}",
message=f"Processing of '{filename}' failed: {error}",
file_id=file_id,
)
+2
View File
@@ -18,6 +18,7 @@ from app.views.help import router as help_router # Built-in help / How-To docs
from app.views.imap_accounts import router as imap_accounts_router
from app.views.integrations import router as integrations_router # Unified integrations dashboard
from app.views.license_routes import router as license_router # Add the license router
from app.views.notifications import router as notifications_router
from app.views.onboarding import router as onboarding_router
from app.views.onedrive import router as onedrive_router
from app.views.pipelines import router as pipelines_router # Processing pipelines
@@ -56,4 +57,5 @@ router.include_router(onboarding_router) # User onboarding wizard
router.include_router(pipelines_router) # Processing pipelines
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
router.include_router(integrations_router) # Unified integrations dashboard
router.include_router(notifications_router) # User notification dashboard
router.include_router(help_router) # Built-in help / How-To docs
+20
View File
@@ -0,0 +1,20 @@
"""View route for the notifications dashboard."""
import logging
from fastapi import Request
from app.views.base import APIRouter, require_login, templates
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/notifications")
@require_login
async def notifications_dashboard(request: Request):
"""Render the notifications dashboard."""
return templates.TemplateResponse(
"notifications_dashboard.html",
{"request": request, "page_title": "Notifications"},
)
+32 -3
View File
@@ -2,6 +2,9 @@
DocuElevate provides a powerful REST API for programmatic access to all its features. This document serves as a reference for the available endpoints and their usage.
> **Looking for a quick way to script against DocuElevate?**
> The built-in [CLI tool](./CLIGuide.md) wraps the API and is ready to use from a terminal or shell script — no HTTP client code required.
## API Overview
- Base URL: `http://<your-docuelevate-instance>/api`
@@ -1785,12 +1788,27 @@ Returns the catalogue of built-in step types.
"label": "OCR Processing",
"description": "Extract text using Azure Document Intelligence or local Tesseract.",
"config_schema": {
"force_cloud_ocr": { "type": "boolean", "default": false }
"force_cloud_ocr": { "type": "boolean", "default": false },
"ocr_language": {
"type": "select",
"default": "auto",
"description": "Language(s) for OCR. Overrides the global setting for Tesseract/EasyOCR. Azure/Mistral auto-detect.",
"options": [
{ "value": "auto", "label": "Auto (use system default)" },
{ "value": "eng", "label": "English" },
{ "value": "deu", "label": "German" },
{ "value": "fra", "label": "French" },
{ "value": "spa", "label": "Spanish" },
"..."
]
}
}
}
}
```
The `ocr_language` field accepts Tesseract language codes (e.g. `"eng"`, `"deu"`, `"eng+deu"` for multi-language) or `"auto"` to fall back to the global system setting. The full list of 28 supported language codes is returned by the step-types endpoint.
### List pipelines
```bash
@@ -1883,12 +1901,23 @@ Content-Type: application/json
{
"step_type": "ocr",
"label": "Cloud OCR",
"config": { "force_cloud_ocr": true },
"label": "German OCR",
"config": { "force_cloud_ocr": false, "ocr_language": "deu" },
"enabled": true
}
```
Multi-language (Tesseract `+`-separated codes):
```bash
{
"step_type": "ocr",
"config": { "ocr_language": "eng+deu" }
}
```
Use `"ocr_language": "auto"` (or omit the field) to fall back to the global system language setting.
### Update step
```bash
+348
View File
@@ -0,0 +1,348 @@
# DocuElevate CLI Guide
The `docuelevate` command-line tool lets you interact with your DocuElevate instance
from a terminal, shell script, or CI/CD pipeline. It is ideal for:
- Batch uploads from a script or cron job
- Downloading processed documents programmatically
- Searching documents in automation workflows
- Rotating API tokens safely without touching the web UI
---
## Installation
The CLI is included in the standard DocuElevate package. After installing the
Python package (e.g. inside the Docker image or a virtualenv), the `docuelevate`
command is available:
```bash
pip install docuelevate # or: pip install -e . inside the repo
docuelevate --help
```
---
## Authentication
All commands require an API token. Create one at `/api-tokens` in the web UI,
or with the `docuelevate token create` command itself.
Provide the token in either of two ways:
| Method | Example |
|--------|---------|
| `--token` flag | `docuelevate --token de_xxxxx list` |
| Environment variable | `export DOCUELEVATE_API_TOKEN=de_xxxxx` |
The environment variable is recommended for scripts so that secrets never appear
in shell history or process listings.
---
## Configuration
| Option / Variable | Default | Description |
|-------------------|---------|-------------|
| `--url` / `DOCUELEVATE_URL` | `http://localhost:8000` | Base URL of the DocuElevate instance |
| `--token` / `DOCUELEVATE_API_TOKEN` | _(none)_ | API token for authentication |
| `--format` | `table` | Output format: `table` (human-readable) or `json` (pipe-friendly) |
| `--timeout` / `DOCUELEVATE_TIMEOUT` | `60` | HTTP request timeout in seconds |
Setting both `DOCUELEVATE_URL` and `DOCUELEVATE_API_TOKEN` in your environment
removes the need for flags on every invocation:
```bash
export DOCUELEVATE_URL=https://docs.example.com
export DOCUELEVATE_API_TOKEN=de_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
docuelevate list
```
---
## Commands
### `list` — List documents
```
docuelevate [OPTIONS] list [OPTIONS]
```
Returns a paginated list of documents stored in DocuElevate.
| Option | Default | Description |
|--------|---------|-------------|
| `--page` | `1` | Page number |
| `--per-page` | `25` | Items per page (max 200) |
| `--search` | — | Filter by filename substring |
| `--mime-type` | — | Filter by MIME type (e.g. `application/pdf`) |
| `--status` | — | Filter by status: `pending`, `processing`, `completed`, `failed` |
| `--sort-by` | `created_at` | Sort field |
| `--sort-order` | `desc` | Sort direction: `asc` or `desc` |
**Examples:**
```bash
# Human-readable table
docuelevate list
# Only completed PDFs
docuelevate list --status completed --mime-type application/pdf
# Pipe filenames to another command
docuelevate --format json list | jq -r '.[].filename'
# Search by filename
docuelevate list --search invoice
```
---
### `upload` — Upload files
```
docuelevate [OPTIONS] upload [OPTIONS] FILES...
```
Uploads one or more local files to DocuElevate for processing. Multiple file
paths (or shell globs) can be provided for batch uploads.
| Option | Default | Description |
|--------|---------|-------------|
| `--batch-size` | `5` | Maximum uploads before reporting progress |
**Examples:**
```bash
# Upload a single file
docuelevate upload report.pdf
# Batch upload — all PDFs in a folder
docuelevate upload /scans/*.pdf
# Upload multiple files explicitly
docuelevate upload invoice.pdf contract.pdf receipt.png
# JSON output to capture task IDs
docuelevate --format json upload *.pdf | jq '.[].task_id'
```
---
### `download` — Download a file
```
docuelevate [OPTIONS] download [OPTIONS] FILE_ID
```
Downloads a processed (or original) file by its numeric ID.
| Option | Default | Description |
|--------|---------|-------------|
| `-o` / `--output` | _(server filename)_ | Destination file path |
| `--version` | `processed` | `processed` or `original` |
**Examples:**
```bash
# Download processed version of file #42
docuelevate download 42
# Save to a specific path
docuelevate download 42 -o /tmp/invoice.pdf
# Download the original (unprocessed) upload
docuelevate download 42 --version original -o original.pdf
```
---
### `search` — Full-text search
```
docuelevate [OPTIONS] search [OPTIONS] QUERY
```
Searches across document text, filenames, tags, and metadata using Meilisearch.
| Option | Default | Description |
|--------|---------|-------------|
| `--mime-type` | — | Filter by MIME type |
| `--document-type` | — | Filter by document type (e.g. `Invoice`) |
| `--tags` | — | Filter by tag |
| `--language` | — | Filter by language code (e.g. `en`, `de`) |
| `--page` | `1` | Page number |
| `--per-page` | `20` | Results per page (max 100) |
**Examples:**
```bash
# Simple search
docuelevate search "amazon invoice"
# With filters
docuelevate search "contract" --document-type Contract --language en
# Pipe file IDs to the download command
docuelevate --format json search "Q1 report" | jq -r '.[].file_id'
```
---
### `token` — Manage API tokens
The `token` sub-group provides commands to create, list, and revoke personal API
tokens — enabling **token rotation** without logging into the web UI.
#### `token create`
```
docuelevate token create NAME
```
Creates a new token. The full token value is printed exactly once — store it
securely.
```bash
# Create a new token
docuelevate --token de_existing token create "CI Pipeline"
# Capture the new token value in a script
NEW_TOKEN=$(docuelevate --format json --token de_existing token create "Rotation" \
| jq -r '.token')
```
#### `token list`
```
docuelevate token list
```
Lists all your tokens (active and revoked).
```bash
docuelevate token list
# JSON for scripting
docuelevate --format json token list | jq '.[] | select(.is_active) | .id'
```
#### `token revoke`
```
docuelevate token revoke [--yes] TOKEN_ID
```
Revokes a token by its numeric ID. The token is immediately invalidated.
| Option | Description |
|--------|-------------|
| `--yes` / `-y` | Skip confirmation prompt |
```bash
# Interactive confirmation
docuelevate token revoke 3
# Non-interactive (for scripts)
docuelevate token revoke 3 --yes
```
---
## Token Rotation
Rotate an API token safely without any downtime:
```bash
# 1. Create the replacement token
NEW_TOKEN=$(docuelevate --format json --token "$OLD_TOKEN" \
token create "Rotated $(date +%Y-%m-%d)" | jq -r '.token')
# 2. Update consumers to use NEW_TOKEN, then revoke the old one
OLD_ID=$(docuelevate --format json --token "$OLD_TOKEN" token list \
| jq '.[] | select(.is_active and (.token_prefix == "de_old_prefix")) | .id')
docuelevate --token "$NEW_TOKEN" token revoke --yes "$OLD_ID"
```
---
## Output Formats
### Table (default)
Human-readable, suitable for terminal use:
```
ID FILENAME SIZE STATUS CREATED_AT
-- ----------------- ----- --------- -------------------
42 invoice_2026.pdf 98304 completed 2026-03-01T10:30:00
43 contract.pdf 51200 pending 2026-03-01T11:00:00
```
### JSON (`--format json`)
Machine-readable, pipe-friendly, suitable for `jq`, shell scripts, and CI:
```json
[
{
"id": 42,
"filename": "invoice_2026.pdf",
"size": 98304,
"status": "completed",
"created_at": "2026-03-01T10:30:00"
}
]
```
---
## Pipe-Friendly Examples
```bash
# Download all completed PDFs in a folder
docuelevate --format json list --status completed --mime-type application/pdf \
| jq -r '.[].id' \
| xargs -I {} docuelevate download {} -o /backup/{}.pdf
# Count documents by status
docuelevate --format json list --per-page 200 \
| jq 'group_by(.status) | map({status: .[0].status, count: length})'
# Search and get filenames
docuelevate --format json search "2026 invoice" \
| jq -r '.[].filename'
# Batch upload all new files and capture task IDs
find /inbox -name "*.pdf" | xargs docuelevate upload \
&& echo "All uploaded"
```
---
## Exit Codes
| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | One or more uploads failed (partial failure) |
| `2` | Invalid options or arguments |
| other | Fatal error (network, API, authentication) |
---
## Environment Variables Reference
| Variable | Description |
|----------|-------------|
| `DOCUELEVATE_URL` | Base URL of the DocuElevate instance |
| `DOCUELEVATE_API_TOKEN` | Personal API token (`de_…`) |
| `DOCUELEVATE_TIMEOUT` | HTTP request timeout in seconds (default: 60) |
---
## See Also
- [API Documentation](./API.md) — full REST API reference
- [User Guide](./UserGuide.md) — web UI guide including API token management
- [Configuration Guide](./ConfigurationGuide.md) — server-side configuration
+49
View File
@@ -1070,6 +1070,55 @@ payment processors.
For detailed setup instructions, see the [Notifications Setup Guide](NotificationsSetup.md).
#### Per-User Notification System
In addition to the system-level Apprise notifications, DocuElevate includes a **per-user notification system** that gives each user full control over how they are notified about their own document events.
**Notification Dashboard** — available at `/notifications` for every logged-in user. It has three tabs:
| Tab | Description |
|-----|-------------|
| **Inbox** | In-app bell-icon notification feed. Persisted in the database; shows unread count badge in the navigation bar. Users can mark individual items or all items as read. |
| **Targets** | User-defined notification channels: **Email (SMTP)** and **Webhook (HTTP POST)**. Each target can be tested independently from the UI. |
| **Preferences** | Event/channel matrix. Users choose which channels are triggered for each event type. In-app notifications are always enabled. |
**User-centric event types:**
| Event | Description |
|-------|-------------|
| `document.processed` | A document uploaded by the user was successfully processed and uploaded to destinations |
| `document.failed` | A document uploaded by the user failed during processing |
**Email target configuration fields:**
| Field | Description |
|-------|-------------|
| `smtp_host` | SMTP server hostname |
| `smtp_port` | SMTP port (default `587`) |
| `smtp_username` | SMTP login username |
| `smtp_password` | SMTP login password (stored in database, masked in UI) |
| `smtp_use_tls` | Enable STARTTLS (`true`/`false`, default `true`) |
| `sender_email` | From address (defaults to `smtp_username` if omitted) |
| `recipient_email` | Destination address for this target |
**Webhook target configuration fields:**
| Field | Description |
|-------|-------------|
| `url` | HTTP(S) URL to POST the notification payload to |
| `secret` | Optional secret string sent as `X-DocuElevate-Secret` header |
**Webhook payload format:**
```json
{
"event": "document.processed",
"title": "Document processed: invoice.pdf",
"message": "Your document 'invoice.pdf' has been successfully processed and uploaded."
}
```
> **Note:** There are no additional environment variables for the per-user notification system — all settings are stored in the database and managed through the user-facing `/notifications` dashboard.
### Webhooks
Webhooks notify external systems via HTTP POST when document events occur.
+38 -1
View File
@@ -568,13 +568,50 @@ Processing pipelines let you define exactly what happens to your documents when
|-----------|-------------|
| `convert_to_pdf` | Convert non-PDF files to PDF using Gotenberg |
| `check_duplicates` | Detect duplicate files by content hash |
| `ocr` | Extract text with Azure Document Intelligence or local Tesseract |
| `ocr` | Extract text with OCR (supports multi-language configuration, see below) |
| `extract_metadata` | Extract structured metadata (type, sender, tags) with AI |
| `embed_metadata` | Write extracted metadata into the PDF document properties |
| `compute_embedding` | Compute semantic embeddings for similarity search |
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
| `classify` | Classify the document type with AI |
#### OCR step options
The `ocr` step supports two optional configuration fields:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `force_cloud_ocr` | boolean | `false` | Always run cloud OCR even if the PDF already has embedded text |
| `ocr_language` | string | `"auto"` | Language(s) to use for OCR text extraction (see below) |
**`ocr_language` — per-pipeline language override**
This option enables manual language control per pipeline, overriding the global Tesseract/EasyOCR language settings for all documents processed by that pipeline. The following values are supported (28 languages total):
| Value | Language | Value | Language |
|-------|----------|-------|----------|
| `auto` | Auto (use system default) | `jpn` | Japanese |
| `ara` | Arabic | `kor` | Korean |
| `chi_sim` | Chinese (Simplified) | `nor` | Norwegian |
| `chi_tra` | Chinese (Traditional) | `pol` | Polish |
| `ces` | Czech | `por` | Portuguese |
| `dan` | Danish | `ron` | Romanian |
| `nld` | Dutch | `rus` | Russian |
| `eng` | English | `spa` | Spanish |
| `fin` | Finnish | `swe` | Swedish |
| `fra` | French | `tha` | Thai |
| `deu` | German | `tur` | Turkish |
| `ell` | Greek | `ukr` | Ukrainian |
| `heb` | Hebrew | `vie` | Vietnamese |
| `hin` | Hindi | | |
| `hun` | Hungarian | | |
| `ita` | Italian | | |
> **Notes:**
> - The language override applies to **Tesseract** and **EasyOCR** providers. **Azure Document Intelligence** and **Mistral OCR** perform automatic language detection regardless of this setting.
> - For multi-language documents with Tesseract, combine codes with `+`, e.g. `eng+deu`.
> - Setting `ocr_language` to `auto` or leaving it unset uses the global `TESSERACT_LANGUAGE` / `EASYOCR_LANGUAGES` environment variables.
### Assigning a pipeline to a file
You can assign (or change) the pipeline for an individual document via the file detail page or the API:
+42
View File
@@ -194,6 +194,19 @@
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
</a>
<!-- Bell notification icon -->
<a href="/notifications"
id="notificationBell"
class="relative p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-label="Notifications"
title="Notifications"
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
<i class="fas fa-bell" aria-hidden="true"></i>
<span id="notificationBadge"
class="hidden absolute -top-1 -right-1 h-4 w-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold"
aria-live="polite"></span>
</a>
<!-- Dark mode toggle -->
<button
id="darkModeToggle"
@@ -283,6 +296,11 @@
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
</a>
<a href="/notifications"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
<i class="fas fa-bell mr-2 text-gray-400" aria-hidden="true"></i>Notifications
</a>
<!-- Admin section in mobile menu shown only for admin users via JS -->
<div id="mobileAdminSection" class="hidden">
@@ -427,6 +445,30 @@
<!-- Common JS (shared) -->
<script src="/static/js/common.js"></script>
<!-- Notification badge updater -->
<script>
(function() {
async function updateNotificationBadge() {
try {
const resp = await fetch('/api/user-notifications/inbox/unread-count');
if (!resp.ok) return;
const data = await resp.json();
const badge = document.getElementById('notificationBadge');
if (!badge) return;
if (data.count > 0) {
badge.textContent = data.count > 99 ? '99+' : data.count;
badge.classList.remove('hidden');
badge.setAttribute('aria-label', data.count + ' unread notifications');
} else {
badge.classList.add('hidden');
}
} catch (_) {}
}
document.addEventListener('DOMContentLoaded', updateNotificationBadge);
setInterval(updateNotificationBadge, 60000);
})();
</script>
<!-- Let child pages define extra scripts if needed -->
{% block scripts %}{% endblock %}
</body>
@@ -0,0 +1,979 @@
{% extends "base.html" %}
{% block title %}Notifications DocuElevate{% endblock %}
{% block content %}
<div
class="container mx-auto px-4 py-8"
x-data="notificationsDashboard()"
x-init="init()"
>
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-bell text-blue-500" aria-hidden="true"></i>
Notifications
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Manage your notification inbox, targets, and event preferences.
</p>
</div>
<!-- ── Tab nav ────────────────────────────────────────────────────────── -->
<div class="border-b border-gray-200 dark:border-gray-700 mb-6" role="tablist" aria-label="Notification sections">
<nav class="-mb-px flex space-x-6">
<button
type="button"
role="tab"
:aria-selected="activeTab === 'inbox'"
:tabindex="activeTab === 'inbox' ? 0 : -1"
@click="activeTab = 'inbox'"
:class="activeTab === 'inbox'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-controls="panel-inbox"
id="tab-inbox"
style="min-height:44px;"
>
<i class="fas fa-inbox mr-1" aria-hidden="true"></i>
Inbox
<template x-if="unreadCount > 0">
<span
class="ml-1 inline-flex items-center px-1.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700"
aria-label="unread notifications"
x-text="unreadCount > 99 ? '99+' : unreadCount"
></span>
</template>
</button>
<button
type="button"
role="tab"
:aria-selected="activeTab === 'settings'"
:tabindex="activeTab === 'settings' ? 0 : -1"
@click="activeTab = 'settings'"
:class="activeTab === 'settings'
? 'border-blue-500 text-blue-600 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400'"
class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-controls="panel-settings"
id="tab-settings"
style="min-height:44px;"
>
<i class="fas fa-sliders-h mr-1" aria-hidden="true"></i>
Settings
</button>
</nav>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
INBOX PANEL
══════════════════════════════════════════════════════════════════════ -->
<div
role="tabpanel"
id="panel-inbox"
aria-labelledby="tab-inbox"
x-show="activeTab === 'inbox'"
>
<!-- Toolbar -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<label for="inboxFilter" class="text-sm font-medium text-gray-700 dark:text-gray-300">Show:</label>
<select
id="inboxFilter"
x-model="inboxFilter"
@change="loadInbox()"
class="text-sm border border-gray-300 dark:border-gray-600 rounded-md px-2 py-1 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Filter notifications"
>
<option value="all">All</option>
<option value="unread">Unread only</option>
<option value="read">Read only</option>
</select>
</div>
<button
type="button"
@click="markAllRead()"
:disabled="unreadCount === 0"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:44px;"
aria-label="Mark all notifications as read"
>
<i class="fas fa-check-double mr-1" aria-hidden="true"></i> Mark all read
</button>
</div>
<!-- Loading state -->
<template x-if="inboxLoading">
<div class="text-center py-12 text-gray-400" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin text-2xl" aria-hidden="true"></i>
<p class="mt-2 text-sm">Loading notifications…</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!inboxLoading && filteredNotifications().length === 0">
<div class="text-center py-12 text-gray-400" role="status">
<i class="fas fa-bell-slash text-4xl mb-3" aria-hidden="true"></i>
<p class="text-sm">No notifications found.</p>
</div>
</template>
<!-- Notifications list -->
<template x-if="!inboxLoading && filteredNotifications().length > 0">
<ul class="space-y-2" role="list" aria-label="Notification items">
<template x-for="notif in filteredNotifications()" :key="notif.id">
<li
class="flex items-start gap-3 p-4 rounded-lg border transition-colors"
:class="notif.is_read
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'"
>
<!-- Event icon -->
<div
class="flex-shrink-0 h-9 w-9 rounded-full flex items-center justify-center"
:class="notif.event_type === 'document.failed'
? 'bg-red-100 dark:bg-red-900 text-red-600 dark:text-red-400'
: 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'"
aria-hidden="true"
>
<i :class="notif.event_type === 'document.failed' ? 'fas fa-times-circle' : 'fas fa-check-circle'"></i>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p
class="text-sm font-medium text-gray-900 dark:text-white"
:class="notif.is_read ? '' : 'font-semibold'"
x-text="notif.title"
></p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="notif.message"></p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1" x-text="formatDate(notif.created_at)"></p>
</div>
<!-- Actions -->
<div class="flex-shrink-0 flex items-center gap-2">
<template x-if="notif.file_id">
<a
:href="`/files/${notif.file_id}`"
class="text-xs text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
:aria-label="`View file for: ${notif.title}`"
>
<i class="fas fa-external-link-alt" aria-hidden="true"></i>
</a>
</template>
<template x-if="!notif.is_read">
<button
type="button"
@click="markRead(notif)"
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded p-1"
:aria-label="`Mark as read: ${notif.title}`"
style="min-height:44px;min-width:44px;"
title="Mark as read"
>
<i class="fas fa-check" aria-hidden="true"></i>
</button>
</template>
</div>
</li>
</template>
</ul>
</template>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
SETTINGS PANEL
══════════════════════════════════════════════════════════════════════ -->
<div
role="tabpanel"
id="panel-settings"
aria-labelledby="tab-settings"
x-show="activeTab === 'settings'"
>
<!-- ── Notification Targets ──────────────────────────────────────────── -->
<section aria-labelledby="targets-heading" class="mb-10">
<div class="flex items-center justify-between mb-4">
<h2 id="targets-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-satellite-dish mr-2 text-indigo-500" aria-hidden="true"></i>Notification Targets
</h2>
<button
type="button"
@click="openAddTarget()"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
aria-label="Add notification target"
>
<i class="fas fa-plus mr-1" aria-hidden="true"></i> Add Target
</button>
</div>
<template x-if="targetsLoading">
<div class="text-center py-8 text-gray-400" role="status" aria-live="polite">
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
<span class="sr-only">Loading targets…</span>
</div>
</template>
<template x-if="!targetsLoading && targets.length === 0">
<div class="text-center py-8 text-gray-400 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-lg">
<i class="fas fa-satellite-dish text-3xl mb-2" aria-hidden="true"></i>
<p class="text-sm">No notification targets configured.</p>
<p class="text-xs mt-1">Add an email or webhook target to receive notifications outside the app.</p>
</div>
</template>
<template x-if="!targetsLoading && targets.length > 0">
<div class="space-y-3" role="list" aria-label="Notification targets">
<template x-for="target in targets" :key="target.id">
<div
class="flex items-center gap-3 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
role="listitem"
>
<!-- Type icon -->
<div
class="flex-shrink-0 h-10 w-10 rounded-full flex items-center justify-center"
:class="target.channel_type === 'email'
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-400'"
aria-hidden="true"
>
<i :class="target.channel_type === 'email' ? 'fas fa-envelope' : 'fas fa-globe'"></i>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white" x-text="target.name"></p>
<p class="text-xs text-gray-500 dark:text-gray-400 capitalize" x-text="target.channel_type"></p>
</div>
<!-- Active badge -->
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="target.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="target.is_active ? 'Active' : 'Inactive'"
></span>
<!-- Actions -->
<div class="flex items-center gap-2">
<button
type="button"
@click="testTarget(target)"
class="p-2 text-gray-400 hover:text-indigo-600 dark:hover:text-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
:aria-label="`Test target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Send test notification"
>
<i class="fas fa-paper-plane" aria-hidden="true"></i>
</button>
<button
type="button"
@click="openEditTarget(target)"
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
:aria-label="`Edit target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Edit target"
>
<i class="fas fa-pencil-alt" aria-hidden="true"></i>
</button>
<button
type="button"
@click="deleteTarget(target)"
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
:aria-label="`Delete target: ${target.name}`"
style="min-height:44px;min-width:44px;"
title="Delete target"
>
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</template>
</div>
</template>
</section>
<!-- ── Event Preferences ─────────────────────────────────────────────── -->
<section aria-labelledby="prefs-heading">
<div class="flex items-center justify-between mb-4">
<h2 id="prefs-heading" class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-sliders-h mr-2 text-green-500" aria-hidden="true"></i>Event Preferences
</h2>
<button
type="button"
@click="savePreferences()"
:disabled="prefsSaving"
class="inline-flex items-center px-3 py-2 text-sm font-medium rounded-md bg-green-600 hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white focus:outline-none focus:ring-2 focus:ring-green-500"
style="min-height:44px;"
aria-label="Save notification preferences"
>
<template x-if="prefsSaving">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<template x-if="!prefsSaving">
<i class="fas fa-save mr-1" aria-hidden="true"></i>
</template>
Save Preferences
</button>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Notification event preferences">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Event
</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
In-App
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Email
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Webhook
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="eventType in eventTypes" :key="eventType">
<tr>
<!-- Event label -->
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-white" x-text="eventLabels[eventType] || eventType"></td>
<!-- In-App (always enabled, non-editable) -->
<td class="px-4 py-3 text-center">
<input
type="checkbox"
checked
disabled
class="h-4 w-4 text-blue-600 rounded opacity-60 cursor-not-allowed"
:aria-label="`In-app notification for ${eventLabels[eventType] || eventType} (always enabled)`"
title="In-app notifications are always enabled"
/>
</td>
<!-- Email channel -->
<td class="px-4 py-3">
<div class="flex flex-col gap-1">
<template x-for="target in emailTargets()" :key="target.id">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="isPrefEnabled(eventType, 'email', target.id)"
@change="togglePref(eventType, 'email', target.id, $event.target.checked)"
class="h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
:aria-label="`Email via ${target.name} for ${eventLabels[eventType] || eventType}`"
/>
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
</label>
</template>
<template x-if="emailTargets().length === 0">
<span class="text-xs text-gray-400 italic">No email targets</span>
</template>
</div>
</td>
<!-- Webhook channel -->
<td class="px-4 py-3">
<div class="flex flex-col gap-1">
<template x-for="target in webhookTargets()" :key="target.id">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
:checked="isPrefEnabled(eventType, 'webhook', target.id)"
@change="togglePref(eventType, 'webhook', target.id, $event.target.checked)"
class="h-4 w-4 text-purple-600 rounded focus:ring-purple-500"
:aria-label="`Webhook via ${target.name} for ${eventLabels[eventType] || eventType}`"
/>
<span class="text-xs text-gray-600 dark:text-gray-300" x-text="target.name"></span>
</label>
</template>
<template x-if="webhookTargets().length === 0">
<span class="text-xs text-gray-400 italic">No webhook targets</span>
</template>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</section>
</div>
<!-- ══════════════════════════════════════════════════════════════════════
TARGET MODAL (Add / Edit)
══════════════════════════════════════════════════════════════════════ -->
<div
x-show="targetModalOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 p-4"
role="dialog"
aria-modal="true"
:aria-labelledby="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
@keydown.escape.window="targetModalOpen = false"
>
<div
class="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-lg"
@click.stop
>
<!-- Modal header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3
:id="targetModalMode === 'edit' ? 'modal-edit-title' : 'modal-add-title'"
class="text-lg font-semibold text-gray-900 dark:text-white"
x-text="targetModalMode === 'edit' ? 'Edit Notification Target' : 'Add Notification Target'"
></h3>
<button
type="button"
@click="targetModalOpen = false"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="Close modal"
style="min-height:44px;min-width:44px;"
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<!-- Modal body -->
<form @submit.prevent="submitTargetForm()" class="px-6 py-5 space-y-4">
<!-- Channel type (only for new targets) -->
<template x-if="targetModalMode === 'add'">
<div>
<label for="modal-channel-type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Channel Type <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="modal-channel-type"
x-model="targetForm.channel_type"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required
aria-required="true"
>
<option value="email">Email (SMTP)</option>
<option value="webhook">Webhook (HTTP POST)</option>
</select>
</div>
</template>
<!-- Name -->
<div>
<label for="modal-target-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Name <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
type="text"
id="modal-target-name"
x-model="targetForm.name"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="e.g. My Gmail, Production Webhook"
required
aria-required="true"
maxlength="255"
/>
</div>
<!-- Email-specific fields -->
<template x-if="targetForm.channel_type === 'email'">
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label for="modal-smtp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Host</label>
<input
type="text"
id="modal-smtp-host"
x-model="targetForm.config.smtp_host"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label for="modal-smtp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Port</label>
<input
type="number"
id="modal-smtp-port"
x-model.number="targetForm.config.smtp_port"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="587"
min="1"
max="65535"
/>
</div>
</div>
<div>
<label for="modal-smtp-username" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Username</label>
<input
type="text"
id="modal-smtp-username"
x-model="targetForm.config.smtp_username"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="user@example.com"
autocomplete="off"
/>
</div>
<div>
<label for="modal-smtp-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">SMTP Password</label>
<input
type="password"
id="modal-smtp-password"
x-model="targetForm.config.smtp_password"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Leave blank to keep existing"
autocomplete="new-password"
/>
</div>
<div>
<label for="modal-recipient-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Recipient Email</label>
<input
type="email"
id="modal-recipient-email"
x-model="targetForm.config.recipient_email"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="you@example.com"
/>
</div>
<div>
<label for="modal-sender-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Sender Email (optional)</label>
<input
type="email"
id="modal-sender-email"
x-model="targetForm.config.sender_email"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="noreply@docuelevate.local"
/>
</div>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="targetForm.config.smtp_use_tls"
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
aria-label="Use STARTTLS"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Use STARTTLS</span>
</label>
</div>
</template>
<!-- Webhook-specific fields -->
<template x-if="targetForm.channel_type === 'webhook'">
<div class="space-y-3">
<div>
<label for="modal-webhook-url" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL</label>
<input
type="url"
id="modal-webhook-url"
x-model="targetForm.config.url"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="https://hooks.example.com/..."
/>
</div>
<div>
<label for="modal-webhook-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Secret (optional)
</label>
<input
type="password"
id="modal-webhook-secret"
x-model="targetForm.config.secret"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="Sent as X-DocuElevate-Secret header"
autocomplete="new-password"
/>
</div>
</div>
</template>
<!-- Active toggle (edit mode) -->
<template x-if="targetModalMode === 'edit'">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
x-model="targetForm.is_active"
class="h-4 w-4 text-indigo-600 rounded focus:ring-indigo-500"
aria-label="Target is active"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
</label>
</template>
<!-- Error -->
<template x-if="targetFormError">
<p class="text-sm text-red-600 dark:text-red-400" role="alert" x-text="targetFormError"></p>
</template>
<!-- Footer -->
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@click="targetModalOpen = false"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
>
Cancel
</button>
<button
type="submit"
:disabled="targetFormSaving"
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:44px;"
>
<template x-if="targetFormSaving">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<span x-text="targetModalMode === 'edit' ? 'Save Changes' : 'Add Target'"></span>
</button>
</div>
</form>
</div>
</div>
<!-- ── Global toast ───────────────────────────────────────────────────── -->
<div
x-show="toast.visible"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed bottom-4 right-4 z-50 max-w-sm rounded-lg shadow-lg px-4 py-3 text-white text-sm font-medium"
:class="toast.type === 'error' ? 'bg-red-600' : 'bg-green-600'"
role="status"
aria-live="polite"
x-text="toast.message"
></div>
</div>
{% endblock %}
{% block scripts %}
<script>
function notificationsDashboard() {
return {
// Tab state
activeTab: 'inbox',
// Inbox
notifications: [],
inboxLoading: false,
inboxFilter: 'all',
unreadCount: 0,
// Targets
targets: [],
targetsLoading: false,
// Preferences
eventTypes: [],
eventLabels: {},
prefs: {}, // event_type -> channel_type -> {is_enabled, target_id}
prefsSaving: false,
// Target modal
targetModalOpen: false,
targetModalMode: 'add',
editingTargetId: null,
targetForm: {
channel_type: 'email',
name: '',
is_active: true,
config: { smtp_use_tls: true, smtp_port: 587 },
},
targetFormSaving: false,
targetFormError: '',
// Toast
toast: { visible: false, message: '', type: 'success' },
async init() {
await Promise.all([this.loadInbox(), this.loadTargets(), this.loadPreferences()]);
},
// ── Inbox ────────────────────────────────────────────────────────────
async loadInbox() {
this.inboxLoading = true;
try {
const resp = await fetch('/api/user-notifications/inbox?limit=100');
if (!resp.ok) throw new Error('Failed to load');
this.notifications = await resp.json();
this.unreadCount = this.notifications.filter(n => !n.is_read).length;
} catch (e) {
this.showToast('Failed to load notifications', 'error');
} finally {
this.inboxLoading = false;
}
},
filteredNotifications() {
if (this.inboxFilter === 'unread') return this.notifications.filter(n => !n.is_read);
if (this.inboxFilter === 'read') return this.notifications.filter(n => n.is_read);
return this.notifications;
},
async markRead(notif) {
try {
const resp = await fetch(`/api/user-notifications/inbox/${notif.id}/read`, { method: 'POST' });
if (!resp.ok) throw new Error('Failed');
notif.is_read = true;
this.unreadCount = Math.max(0, this.unreadCount - 1);
this.updateBadge();
} catch (e) {
this.showToast('Failed to mark as read', 'error');
}
},
async markAllRead() {
try {
const resp = await fetch('/api/user-notifications/inbox/read-all', { method: 'POST' });
if (!resp.ok) throw new Error('Failed');
this.notifications.forEach(n => { n.is_read = true; });
this.unreadCount = 0;
this.updateBadge();
this.showToast('All notifications marked as read');
} catch (e) {
this.showToast('Failed to mark all as read', 'error');
}
},
updateBadge() {
const badge = document.getElementById('notificationBadge');
if (!badge) return;
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.classList.remove('hidden');
badge.setAttribute('aria-label', this.unreadCount + ' unread notifications');
} else {
badge.classList.add('hidden');
}
},
formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleString();
},
// ── Targets ──────────────────────────────────────────────────────────
async loadTargets() {
this.targetsLoading = true;
try {
const resp = await fetch('/api/user-notifications/targets');
if (!resp.ok) throw new Error('Failed to load targets');
this.targets = await resp.json();
} catch (e) {
this.showToast('Failed to load notification targets', 'error');
} finally {
this.targetsLoading = false;
}
},
emailTargets() {
return this.targets.filter(t => t.channel_type === 'email' && t.is_active);
},
webhookTargets() {
return this.targets.filter(t => t.channel_type === 'webhook' && t.is_active);
},
openAddTarget() {
this.targetModalMode = 'add';
this.editingTargetId = null;
this.targetForm = {
channel_type: 'email',
name: '',
is_active: true,
config: { smtp_use_tls: true, smtp_port: 587 },
};
this.targetFormError = '';
this.targetModalOpen = true;
},
openEditTarget(target) {
this.targetModalMode = 'edit';
this.editingTargetId = target.id;
this.targetForm = {
channel_type: target.channel_type,
name: target.name,
is_active: target.is_active,
config: Object.assign({}, target.config),
};
this.targetFormError = '';
this.targetModalOpen = true;
},
async submitTargetForm() {
this.targetFormSaving = true;
this.targetFormError = '';
try {
let url, method, body;
if (this.targetModalMode === 'add') {
url = '/api/user-notifications/targets';
method = 'POST';
body = {
channel_type: this.targetForm.channel_type,
name: this.targetForm.name,
is_active: this.targetForm.is_active,
config: this.targetForm.config,
};
} else {
url = `/api/user-notifications/targets/${this.editingTargetId}`;
method = 'PUT';
body = {
name: this.targetForm.name,
is_active: this.targetForm.is_active,
config: this.targetForm.config,
};
}
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save target');
}
await this.loadTargets();
this.targetModalOpen = false;
this.showToast(this.targetModalMode === 'edit' ? 'Target updated' : 'Target created');
} catch (e) {
this.targetFormError = e.message;
} finally {
this.targetFormSaving = false;
}
},
async deleteTarget(target) {
if (!confirm(`Delete notification target "${target.name}"? Associated preferences will also be removed.`)) return;
try {
const resp = await fetch(`/api/user-notifications/targets/${target.id}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to delete');
await this.loadTargets();
await this.loadPreferences();
this.showToast('Target deleted');
} catch (e) {
this.showToast('Failed to delete target', 'error');
}
},
async testTarget(target) {
try {
const resp = await fetch(`/api/user-notifications/targets/${target.id}/test`, { method: 'POST' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Test failed');
}
this.showToast('Test notification sent!');
} catch (e) {
this.showToast(`Test failed: ${e.message}`, 'error');
}
},
// ── Preferences ──────────────────────────────────────────────────────
async loadPreferences() {
try {
const resp = await fetch('/api/user-notifications/preferences');
if (!resp.ok) throw new Error('Failed to load preferences');
const data = await resp.json();
this.eventTypes = data.event_types || [];
this.eventLabels = data.event_labels || {};
this.prefs = data.preferences || {};
// Build flat list for isPrefEnabled lookups
this._prefsList = [];
for (const [et, channelMap] of Object.entries(this.prefs)) {
for (const [ct, item] of Object.entries(channelMap)) {
this._prefsList.push({
event_type: et,
channel_type: ct,
target_id: item.target_id,
is_enabled: item.is_enabled,
});
}
}
} catch (e) {
this.showToast('Failed to load preferences', 'error');
}
},
isPrefEnabled(eventType, channelType, targetId) {
const entry = this._prefKey(eventType, channelType, targetId);
return entry ? entry.is_enabled : false;
},
_prefKey(eventType, channelType, targetId) {
if (!this._prefsList) return null;
return this._prefsList.find(
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
) || null;
},
// Store pending pref changes as a dict: `${eventType}|${channelType}|${targetId}` -> is_enabled
_pendingPrefs: {},
_prefsList: [],
togglePref(eventType, channelType, targetId, enabled) {
const k = `${eventType}|${channelType}|${targetId}`;
this._pendingPrefs[k] = { event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled };
// Update in _prefsList for immediate UI feedback
const idx = this._prefsList.findIndex(
p => p.event_type === eventType && p.channel_type === channelType && p.target_id === targetId
);
if (idx >= 0) {
this._prefsList[idx].is_enabled = enabled;
} else {
this._prefsList.push({ event_type: eventType, channel_type: channelType, target_id: targetId, is_enabled: enabled });
}
},
async savePreferences() {
this.prefsSaving = true;
try {
// Build flat list from all pending changes
const prefsList = Object.values(this._pendingPrefs).map(entry => ({
event_type: entry.event_type,
channel_type: entry.channel_type,
target_id: entry.target_id,
is_enabled: entry.is_enabled,
}));
const resp = await fetch('/api/user-notifications/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ preferences: prefsList }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to save');
}
this._pendingPrefs = {};
await this.loadPreferences();
this.showToast('Preferences saved');
} catch (e) {
this.showToast(`Failed to save preferences: ${e.message}`, 'error');
} finally {
this.prefsSaving = false;
}
},
// ── Toast ─────────────────────────────────────────────────────────────
showToast(message, type = 'success') {
this.toast = { visible: true, message, type };
setTimeout(() => { this.toast.visible = false; }, 3500);
},
};
}
</script>
{% endblock %}
+47
View File
@@ -404,6 +404,53 @@
</label>
</template>
<!-- ocr_language (only shown for ocr step) -->
<template x-if="stepModal.form.step_type === 'ocr'">
<div>
<label for="ocrLanguage" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
OCR Language
</label>
<select
id="ocrLanguage"
x-model="stepModal.form.config.ocr_language"
class="w-full border border-gray-300 dark:border-gray-600 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:bg-gray-700 dark:text-white"
>
<option value="auto">Auto (use system default)</option>
<option value="ara">Arabic</option>
<option value="chi_sim">Chinese (Simplified)</option>
<option value="chi_tra">Chinese (Traditional)</option>
<option value="ces">Czech</option>
<option value="dan">Danish</option>
<option value="nld">Dutch</option>
<option value="eng">English</option>
<option value="fin">Finnish</option>
<option value="fra">French</option>
<option value="deu">German</option>
<option value="ell">Greek</option>
<option value="heb">Hebrew</option>
<option value="hin">Hindi</option>
<option value="hun">Hungarian</option>
<option value="ita">Italian</option>
<option value="jpn">Japanese</option>
<option value="kor">Korean</option>
<option value="nor">Norwegian</option>
<option value="pol">Polish</option>
<option value="por">Portuguese</option>
<option value="ron">Romanian</option>
<option value="rus">Russian</option>
<option value="spa">Spanish</option>
<option value="swe">Swedish</option>
<option value="tha">Thai</option>
<option value="tur">Turkish</option>
<option value="ukr">Ukrainian</option>
<option value="vie">Vietnamese</option>
</select>
<p class="mt-1 text-xs text-gray-400">
Overrides the global language setting for Tesseract/EasyOCR. Azure and Mistral auto-detect the language.
</p>
</div>
</template>
<!-- Enabled -->
<label class="inline-flex items-center gap-2 cursor-pointer">
<input
@@ -0,0 +1,83 @@
"""Add user notification tables (targets, preferences, in-app inbox)
Revision ID: 025_add_user_notifications
Revises: 024_add_api_tokens
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "025_add_user_notifications"
down_revision: Union[str, None] = "024_add_api_tokens"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create user_notification_targets, user_notification_preferences, and in_app_notifications tables."""
op.create_table(
"user_notification_targets",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("channel_type", sa.String(20), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("config", sa.Text(), nullable=True),
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"),
)
op.create_index("ix_user_notification_targets_id", "user_notification_targets", ["id"])
op.create_index("ix_user_notification_targets_owner_id", "user_notification_targets", ["owner_id"])
op.create_table(
"user_notification_preferences",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("event_type", sa.String(50), nullable=False),
sa.Column("channel_type", sa.String(20), nullable=False),
sa.Column("target_id", sa.Integer(), nullable=True),
sa.Column("is_enabled", 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.UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),
)
op.create_index("ix_user_notification_preferences_id", "user_notification_preferences", ["id"])
op.create_index("ix_user_notification_preferences_owner_id", "user_notification_preferences", ["owner_id"])
op.create_table(
"in_app_notifications",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("event_type", sa.String(50), nullable=False),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("file_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_in_app_notifications_id", "in_app_notifications", ["id"])
op.create_index("ix_in_app_notifications_owner_id", "in_app_notifications", ["owner_id"])
op.create_index("ix_in_app_notifications_is_read", "in_app_notifications", ["is_read"])
op.create_index("ix_in_app_notifications_created_at", "in_app_notifications", ["created_at"])
def downgrade() -> None:
"""Drop user notification tables."""
op.drop_index("ix_in_app_notifications_created_at", "in_app_notifications")
op.drop_index("ix_in_app_notifications_is_read", "in_app_notifications")
op.drop_index("ix_in_app_notifications_owner_id", "in_app_notifications")
op.drop_index("ix_in_app_notifications_id", "in_app_notifications")
op.drop_table("in_app_notifications")
op.drop_index("ix_user_notification_preferences_owner_id", "user_notification_preferences")
op.drop_index("ix_user_notification_preferences_id", "user_notification_preferences")
op.drop_table("user_notification_preferences")
op.drop_index("ix_user_notification_targets_owner_id", "user_notification_targets")
op.drop_index("ix_user_notification_targets_id", "user_notification_targets")
op.drop_table("user_notification_targets")
+1
View File
@@ -41,6 +41,7 @@ nav:
- Email Ingestion: howto/EmailIngestion.md
- Mobile Scanning: howto/MobileScanning.md
- API: API
- CLI: CLIGuide
- Deployment:
- Overview: DeploymentGuide
- Kubernetes / Helm: KubernetesDeployment
+3
View File
@@ -32,6 +32,9 @@ Changelog = "https://github.com/christianlouis/DocuElevate/blob/main/CHANGELOG.m
[tool.setuptools.dynamic]
version = {file = "VERSION"}
[project.scripts]
docuelevate = "app.cli:main"
[tool.semantic_release]
version_toml = []
version_source = "tag"
+1
View File
@@ -8,6 +8,7 @@ cryptography>=41.0.0 # Encryption for sensitive settings in database
openai # GPT integration for metadata extraction
pypdf>=3.9.0 # PDF processing for text extraction, metadata editing and rotation (upgraded from PyPDF2 to fix CVE-2023-36464)
requests # HTTP client
click>=8.0.0 # CLI framework for docuelevate command
puremagic>=1.25,<2.0 # File type detection (pure Python)
filetype>=1.2.0,<2.0 # File type detection fallback (pure Python)
dropbox>=11.36.0 # Dropbox integration
+671
View File
@@ -0,0 +1,671 @@
"""Unit tests for app/cli.py — DocuElevate CLI tool.
Tests cover:
- Root command group option handling (URL, token, format)
- list command with various filters
- upload command (single file, batch, error handling)
- download command (with/without --output, Content-Disposition parsing)
- search command with filters
- token sub-commands (create, list, revoke)
- Helper functions (_build_headers, _api, _require_ok, _output, _print_table)
- Environment variable configuration
- Missing-token error handling
"""
import json
from unittest.mock import MagicMock, patch
import pytest
import requests as req_module
from click.testing import CliRunner
from app.cli import (
_api,
_build_headers,
_output,
_print_table,
_require_ok,
cli,
main,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_response(status_code: int = 200, json_data=None, text: str = "", headers: dict | None = None):
"""Create a mock requests.Response."""
mock = MagicMock(spec=req_module.Response)
mock.status_code = status_code
mock.text = text
mock.headers = headers or {}
if json_data is not None:
mock.json.return_value = json_data
else:
mock.json.side_effect = ValueError("No JSON")
return mock
# ---------------------------------------------------------------------------
# Unit tests for helper functions
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildHeaders:
def test_returns_authorization_header(self):
headers = _build_headers("de_mytoken")
assert headers == {"Authorization": "Bearer de_mytoken"}
@pytest.mark.unit
class TestApi:
def test_successful_request(self):
mock_resp = _make_response(200, json_data={"ok": True})
with patch("app.cli.requests.request", return_value=mock_resp) as mock_req:
resp = _api("GET", "http://localhost:8000", "/api/files", "de_tok")
mock_req.assert_called_once()
call_kwargs = mock_req.call_args
assert call_kwargs[0][0] == "GET"
assert call_kwargs[0][1] == "http://localhost:8000/api/files"
assert resp.status_code == 200
def test_strips_trailing_slash_from_base_url(self):
mock_resp = _make_response(200, json_data={})
with patch("app.cli.requests.request", return_value=mock_resp) as mock_req:
_api("GET", "http://localhost:8000/", "/api/files", "de_tok")
assert mock_req.call_args[0][1] == "http://localhost:8000/api/files"
def test_connection_error_raises_click_exception(self):
import click
with patch("app.cli.requests.request", side_effect=req_module.ConnectionError("refused")):
with pytest.raises(click.ClickException, match="Could not connect"):
_api("GET", "http://localhost:8000", "/api/files", "de_tok")
def test_timeout_raises_click_exception(self):
import click
with patch("app.cli.requests.request", side_effect=req_module.Timeout("timed out")):
with pytest.raises(click.ClickException, match="timed out"):
_api("GET", "http://localhost:8000", "/api/files", "de_tok")
@pytest.mark.unit
class TestRequireOk:
def test_returns_json_on_success(self):
mock_resp = _make_response(200, json_data={"data": [1, 2, 3]})
result = _require_ok(mock_resp)
assert result == {"data": [1, 2, 3]}
def test_raises_on_400(self):
import click
mock_resp = _make_response(400, json_data={"detail": "Bad request"})
with pytest.raises(click.ClickException, match="API error 400"):
_require_ok(mock_resp)
def test_raises_on_404(self):
import click
mock_resp = _make_response(404, json_data={"detail": "Not found"})
with pytest.raises(click.ClickException, match="404"):
_require_ok(mock_resp)
def test_raises_on_500_with_text_fallback(self):
import click
mock_resp = _make_response(500, text="Internal Server Error")
mock_resp.json.side_effect = ValueError("no json")
with pytest.raises(click.ClickException, match="500"):
_require_ok(mock_resp)
def test_returns_empty_dict_when_no_json(self):
mock_resp = _make_response(200)
mock_resp.json.side_effect = ValueError("no json")
result = _require_ok(mock_resp)
assert result == {}
@pytest.mark.unit
class TestOutput:
def test_json_format(self, capsys):
_output({"key": "value"}, "json")
captured = capsys.readouterr()
parsed = json.loads(captured.out)
assert parsed == {"key": "value"}
def test_table_format_dict(self, capsys):
_output({"id": 1, "name": "test"}, "table")
captured = capsys.readouterr()
assert "id" in captured.out
assert "name" in captured.out
def test_table_format_list(self, capsys):
_output([{"id": 1, "name": "file1"}, {"id": 2, "name": "file2"}], "table")
captured = capsys.readouterr()
assert "file1" in captured.out
assert "file2" in captured.out
def test_table_empty_list(self, capsys):
_output([], "table")
# Should not raise, output can be empty or a JSON representation
capsys.readouterr()
def test_table_non_dict_items(self, capsys):
_output(["item1", "item2"], "table")
captured = capsys.readouterr()
assert "item1" in captured.out
@pytest.mark.unit
class TestPrintTable:
def test_single_dict(self, capsys):
_print_table({"id": 42, "name": "doc"})
captured = capsys.readouterr()
assert "42" in captured.out
assert "doc" in captured.out
def test_list_of_dicts(self, capsys):
_print_table([{"id": 1, "name": "a"}, {"id": 2, "name": "bb"}])
captured = capsys.readouterr()
assert "ID" in captured.out
assert "NAME" in captured.out
assert "a" in captured.out
assert "bb" in captured.out
def test_fallback_json_for_non_dict_list_items(self, capsys):
_print_table([1, 2, 3])
captured = capsys.readouterr()
assert "1" in captured.out
def test_fallback_json_for_scalar(self, capsys):
_print_table("plain string")
capsys.readouterr() # just assert no exception
# ---------------------------------------------------------------------------
# CLI integration tests via CliRunner
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMissingToken:
"""Commands must fail gracefully when no token is supplied."""
def test_list_without_token(self):
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "list"])
assert result.exit_code != 0
assert "DOCUELEVATE_API_TOKEN" in result.output or "No API token" in result.output
def test_upload_without_token(self, tmp_path):
f = tmp_path / "test.pdf"
f.write_bytes(b"%PDF-1.4")
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "upload", str(f)])
assert result.exit_code != 0
def test_search_without_token(self):
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "search", "invoice"])
assert result.exit_code != 0
def test_token_create_without_token(self):
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "token", "create", "test"])
assert result.exit_code != 0
def test_token_list_without_token(self):
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "token", "list"])
assert result.exit_code != 0
def test_token_revoke_without_token(self):
runner = CliRunner()
result = runner.invoke(cli, ["--url", "http://localhost:8000", "token", "revoke", "--yes", "1"])
assert result.exit_code != 0
@pytest.mark.unit
class TestListCommand:
def test_list_success_table(self):
files_data = {
"files": [
{
"id": 1,
"original_filename": "test.pdf",
"file_size": 1024,
"status": "completed",
"created_at": "2026-01-01T00:00:00",
},
],
"pagination": {"page": 1, "pages": 1, "total": 1},
}
mock_resp = _make_response(200, json_data=files_data)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "list"])
assert result.exit_code == 0
assert "test.pdf" in result.output
def test_list_success_json(self):
files_data = {
"files": [{"id": 1, "original_filename": "file.pdf"}],
"pagination": {"page": 1, "pages": 1, "total": 1},
}
mock_resp = _make_response(200, json_data=files_data)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "list"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert isinstance(parsed, list)
assert parsed[0]["id"] == 1
def test_list_with_filters(self):
mock_resp = _make_response(200, json_data={"files": [], "pagination": {"page": 1, "pages": 0, "total": 0}})
with patch("app.cli._api", return_value=mock_resp) as mock_api:
runner = CliRunner()
result = runner.invoke(
cli,
["--token", "de_tok", "list", "--status", "completed", "--mime-type", "application/pdf"],
)
assert result.exit_code == 0
call_kwargs = mock_api.call_args[1]
assert call_kwargs["params"]["status"] == "completed"
assert call_kwargs["params"]["mime_type"] == "application/pdf"
def test_list_api_error(self):
mock_resp = _make_response(401, json_data={"detail": "Unauthorized"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_bad", "list"])
assert result.exit_code != 0
assert "401" in result.output
def test_list_raw_list_response(self):
"""Handles when the API returns a plain list (not paginated dict)."""
files_data = [{"id": 1, "original_filename": "a.pdf"}]
mock_resp = _make_response(200, json_data=files_data)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "list"])
assert result.exit_code == 0
@pytest.mark.unit
class TestUploadCommand:
def test_upload_single_file_success(self, tmp_path):
f = tmp_path / "report.pdf"
f.write_bytes(b"%PDF-1.4 content")
mock_resp = _make_response(201, json_data={"task_id": "abc-123", "status": "queued"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "upload", str(f)])
assert result.exit_code == 0
assert "abc-123" in result.output
def test_upload_multiple_files_success(self, tmp_path):
files = []
for i in range(3):
f = tmp_path / f"file{i}.pdf"
f.write_bytes(b"PDF")
files.append(str(f))
mock_resp = _make_response(201, json_data={"task_id": f"task-{0}", "status": "queued"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "upload", *files])
assert result.exit_code == 0
def test_upload_single_file_api_error(self, tmp_path):
f = tmp_path / "bad.pdf"
f.write_bytes(b"data")
mock_resp = _make_response(413, json_data={"detail": "File too large"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "upload", str(f)])
assert result.exit_code == 1
assert "failed" in result.output.lower() or "error" in result.output.lower()
def test_upload_json_output(self, tmp_path):
f = tmp_path / "test.pdf"
f.write_bytes(b"PDF")
mock_resp = _make_response(201, json_data={"task_id": "t1", "status": "queued"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "upload", str(f)])
assert result.exit_code == 0
# Progress lines (stderr) are mixed with JSON stdout in CliRunner.
# The JSON array is the last block in the output starting with '['.
import re
json_match = re.search(r"(\[\s*\{.*?\}\s*\])", result.output, re.DOTALL)
assert json_match is not None, f"No JSON array found in: {result.output!r}"
parsed = json.loads(json_match.group(1))
assert isinstance(parsed, list)
assert parsed[0]["status"] == "queued"
def test_upload_partial_failure(self, tmp_path):
"""Mixed success/failure: exit code 1 if any upload fails."""
f1 = tmp_path / "ok.pdf"
f1.write_bytes(b"PDF")
f2 = tmp_path / "fail.pdf"
f2.write_bytes(b"PDF")
ok_resp = _make_response(201, json_data={"task_id": "t1"})
err_resp = _make_response(500, json_data={"detail": "Server error"})
with patch("app.cli._api", side_effect=[ok_resp, err_resp]):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "upload", str(f1), str(f2)])
assert result.exit_code == 1
@pytest.mark.unit
class TestDownloadCommand:
def test_download_with_explicit_output(self, tmp_path):
dest = tmp_path / "out.pdf"
mock_resp = _make_response(200, headers={"content-disposition": 'attachment; filename="doc.pdf"'})
mock_resp.iter_content.return_value = [b"PDF content"]
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(
cli,
["--token", "de_tok", "download", "42", "--output", str(dest)],
)
assert result.exit_code == 0
assert dest.exists()
def test_download_filename_from_content_disposition(self, tmp_path):
mock_resp = _make_response(200, headers={"content-disposition": 'attachment; filename="invoice.pdf"'})
mock_resp.iter_content.return_value = [b"PDF data"]
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["--token", "de_tok", "download", "7"])
assert result.exit_code == 0
assert "invoice.pdf" in result.output
def test_download_filename_from_content_disposition_utf8(self, tmp_path):
mock_resp = _make_response(
200,
headers={"content-disposition": "attachment; filename*=UTF-8''Rechnung%202026.pdf"},
)
mock_resp.iter_content.return_value = [b"PDF data"]
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["--token", "de_tok", "download", "8"])
assert result.exit_code == 0
assert "Rechnung" in result.output
def test_download_fallback_filename(self):
mock_resp = _make_response(200, headers={"content-disposition": ""})
mock_resp.iter_content.return_value = [b"data"]
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["--token", "de_tok", "download", "99"])
assert result.exit_code == 0
assert "file_99" in result.output
def test_download_api_error(self):
mock_resp = _make_response(404, json_data={"detail": "Not found"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "download", "999"])
assert result.exit_code != 0
assert "404" in result.output
def test_download_original_version(self, tmp_path):
mock_resp = _make_response(200, headers={"content-disposition": 'attachment; filename="orig.pdf"'})
mock_resp.iter_content.return_value = [b"original"]
with patch("app.cli._api", return_value=mock_resp) as mock_api:
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["--token", "de_tok", "download", "5", "--version", "original"])
assert result.exit_code == 0
call_kwargs = mock_api.call_args[1]
assert call_kwargs["params"]["version"] == "original"
@pytest.mark.unit
class TestSearchCommand:
def test_search_success_table(self):
payload = {
"results": [
{
"file_id": 1,
"original_filename": "inv.pdf",
"document_type": "Invoice",
"tags": ["amazon"],
}
],
"total": 1,
"pages": 1,
}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "search", "invoice"])
assert result.exit_code == 0
assert "inv.pdf" in result.output
def test_search_success_json(self):
payload = {"results": [{"file_id": 2}], "total": 1, "pages": 1}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "search", "test"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert isinstance(parsed, list)
assert parsed[0]["file_id"] == 2
def test_search_with_filters_passed_to_api(self):
payload = {"results": [], "total": 0, "pages": 0}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp) as mock_api:
runner = CliRunner()
result = runner.invoke(
cli,
[
"--token",
"de_tok",
"search",
"contract",
"--document-type",
"Contract",
"--tags",
"legal",
"--language",
"en",
"--mime-type",
"application/pdf",
],
)
assert result.exit_code == 0
params = mock_api.call_args[1]["params"]
assert params["document_type"] == "Contract"
assert params["tags"] == "legal"
assert params["language"] == "en"
assert params["mime_type"] == "application/pdf"
def test_search_api_error(self):
mock_resp = _make_response(400, json_data={"detail": "Invalid query"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "search", "bad"])
assert result.exit_code != 0
def test_search_plain_list_response(self):
"""Handles when API returns a plain list."""
mock_resp = _make_response(200, json_data=[{"file_id": 3}])
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "search", "x"])
assert result.exit_code == 0
@pytest.mark.unit
class TestTokenCreate:
def test_create_token_table(self):
payload = {
"id": 5,
"name": "CI Pipeline",
"token_prefix": "de_Abc123",
"token": "de_Abc123_fulltoken",
"is_active": True,
"last_used_at": None,
"last_used_ip": None,
"created_at": "2026-01-01T00:00:00",
"revoked_at": None,
}
mock_resp = _make_response(201, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "create", "CI Pipeline"])
assert result.exit_code == 0
assert "de_Abc123_fulltoken" in result.output
assert "CI Pipeline" in result.output
def test_create_token_json(self):
payload = {"id": 6, "name": "Script", "token": "de_full", "token_prefix": "de_fu"}
mock_resp = _make_response(201, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "token", "create", "Script"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert parsed["token"] == "de_full"
def test_create_token_api_error(self):
mock_resp = _make_response(422, json_data={"detail": "name too short"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "create", "x"])
assert result.exit_code != 0
def test_create_token_unexpected_response_format(self):
"""If API returns a list instead of dict, should fail gracefully."""
mock_resp = _make_response(201, json_data=[{"id": 1}])
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "create", "bad"])
assert result.exit_code != 0
@pytest.mark.unit
class TestTokenList:
def test_list_tokens_table(self):
payload = [
{
"id": 1,
"name": "CI",
"token_prefix": "de_Ab",
"is_active": True,
"last_used_at": "2026-01-15T10:00:00",
"last_used_ip": "10.0.0.1",
"created_at": "2026-01-01T00:00:00",
"revoked_at": None,
}
]
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "list"])
assert result.exit_code == 0
assert "CI" in result.output
def test_list_tokens_json(self):
payload = [{"id": 2, "name": "S", "is_active": False}]
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "--format", "json", "token", "list"])
assert result.exit_code == 0
parsed = json.loads(result.output)
assert parsed[0]["id"] == 2
def test_list_tokens_unexpected_format(self):
"""If API returns a dict instead of list, should fail gracefully."""
mock_resp = _make_response(200, json_data={"id": 1})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "list"])
assert result.exit_code != 0
@pytest.mark.unit
class TestTokenRevoke:
def test_revoke_with_yes_flag(self):
mock_resp = _make_response(200, json_data={"detail": "Token revoked"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "revoke", "--yes", "3"])
assert result.exit_code == 0
assert "revoked" in result.output.lower()
def test_revoke_prompts_for_confirmation(self):
mock_resp = _make_response(200, json_data={"detail": "Token revoked"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "revoke", "3"], input="y\n")
assert result.exit_code == 0
def test_revoke_aborts_on_no(self):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "revoke", "3"], input="n\n")
assert result.exit_code != 0
def test_revoke_api_error(self):
mock_resp = _make_response(404, json_data={"detail": "Token not found"})
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner()
result = runner.invoke(cli, ["--token", "de_tok", "token", "revoke", "--yes", "999"])
assert result.exit_code != 0
assert "404" in result.output
@pytest.mark.unit
class TestEnvironmentVariables:
def test_token_from_env_var(self):
payload = {"files": [], "pagination": {"page": 1, "pages": 0, "total": 0}}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp):
runner = CliRunner(env={"DOCUELEVATE_API_TOKEN": "de_envtoken"})
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
def test_url_from_env_var(self):
payload = {"files": [], "pagination": {"page": 1, "pages": 0, "total": 0}}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp) as mock_api:
runner = CliRunner(env={"DOCUELEVATE_URL": "http://my-server:9000", "DOCUELEVATE_API_TOKEN": "de_tok"})
result = runner.invoke(cli, ["list"])
assert result.exit_code == 0
assert mock_api.call_args[0][1] == "http://my-server:9000"
def test_explicit_token_overrides_env(self):
payload = {"files": [], "pagination": {"page": 1, "pages": 0, "total": 0}}
mock_resp = _make_response(200, json_data=payload)
with patch("app.cli._api", return_value=mock_resp) as mock_api:
runner = CliRunner(env={"DOCUELEVATE_API_TOKEN": "de_env"})
result = runner.invoke(cli, ["--token", "de_explicit", "list"])
assert result.exit_code == 0
# Token passed to _api should be the explicit one
token_arg = mock_api.call_args[0][3]
assert token_arg == "de_explicit"
@pytest.mark.unit
class TestMainEntryPoint:
def test_main_invokes_cli(self):
"""main() should be callable without errors (help flag)."""
runner = CliRunner()
with patch("app.cli.cli") as mock_cli:
main()
mock_cli.assert_called_once()
+832
View File
@@ -0,0 +1,832 @@
"""Tests for the per-user notification system (app/api/notifications.py)."""
import json
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "notifuser@example.com"
_OTHER_OWNER = "other@example.com"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def notif_engine():
"""In-memory SQLite engine for notification tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def notif_session(notif_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=notif_engine)
session = Session()
yield session
session.close()
def _make_client(notif_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.notifications import _get_owner_id
from app.main import app
Session = sessionmaker(bind=notif_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
return TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests Auth / 401 guard
# ---------------------------------------------------------------------------
class TestAuthGuard:
"""Verify that unauthenticated requests are rejected."""
@pytest.mark.unit
def test_inbox_requires_auth(self):
"""GET /api/user-notifications/inbox should return 401 when not authenticated."""
from app.main import app
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
resp = client.get("/api/user-notifications/inbox")
assert resp.status_code == 401
@pytest.mark.unit
def test_unread_count_requires_auth(self):
"""GET /api/user-notifications/inbox/unread-count should return 401 when not authenticated."""
from app.main import app
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
resp = client.get("/api/user-notifications/inbox/unread-count")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# Tests Inbox
# ---------------------------------------------------------------------------
class TestInbox:
"""Tests for the in-app notification inbox."""
@pytest.mark.unit
def test_unread_count_empty(self, notif_engine):
"""Unread count should be 0 when no notifications exist."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/inbox/unread-count")
assert resp.status_code == 200
assert resp.json() == {"count": 0}
finally:
_cleanup(app)
@pytest.mark.unit
def test_inbox_empty(self, notif_engine):
"""Listing inbox when empty should return an empty list."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/inbox")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_inbox_shows_notifications(self, notif_engine, notif_session):
"""Inbox should return notifications for the authenticated user."""
from app.main import app
notif_session.add(
InAppNotification(
owner_id=_OWNER,
event_type="document.processed",
title="Test",
message="Done",
)
)
notif_session.commit()
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/inbox")
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["title"] == "Test"
assert items[0]["is_read"] is False
finally:
_cleanup(app)
@pytest.mark.unit
def test_inbox_isolation(self, notif_engine, notif_session):
"""Users should only see their own notifications."""
from app.main import app
notif_session.add(
InAppNotification(
owner_id=_OTHER_OWNER,
event_type="document.processed",
title="Other user notif",
message="Not yours",
)
)
notif_session.commit()
client = _make_client(notif_engine, _OWNER)
try:
resp = client.get("/api/user-notifications/inbox")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_unread_count_reflects_notifications(self, notif_engine, notif_session):
"""Unread count should reflect actual unread notifications."""
from app.main import app
for i in range(3):
notif_session.add(
InAppNotification(
owner_id=_OWNER,
event_type="document.processed",
title=f"Notif {i}",
message="",
is_read=False,
)
)
notif_session.commit()
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/inbox/unread-count")
assert resp.status_code == 200
assert resp.json()["count"] == 3
finally:
_cleanup(app)
@pytest.mark.unit
def test_mark_read(self, notif_engine, notif_session):
"""Marking a notification as read should update is_read."""
from app.main import app
notif = InAppNotification(
owner_id=_OWNER,
event_type="document.processed",
title="Unread",
message="",
)
notif_session.add(notif)
notif_session.commit()
notif_session.refresh(notif)
notif_id = notif.id
client = _make_client(notif_engine)
try:
resp = client.post(f"/api/user-notifications/inbox/{notif_id}/read")
assert resp.status_code == 200
# Verify in DB
notif_session.refresh(notif)
assert notif.is_read is True
finally:
_cleanup(app)
@pytest.mark.unit
def test_mark_read_wrong_user(self, notif_engine, notif_session):
"""Marking another user's notification should return 404."""
from app.main import app
notif = InAppNotification(
owner_id=_OTHER_OWNER,
event_type="document.processed",
title="Other",
message="",
)
notif_session.add(notif)
notif_session.commit()
notif_session.refresh(notif)
notif_id = notif.id
client = _make_client(notif_engine, _OWNER)
try:
resp = client.post(f"/api/user-notifications/inbox/{notif_id}/read")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_mark_all_read(self, notif_engine, notif_session):
"""Mark all read should set all user's notifications to read."""
from app.main import app
for i in range(4):
notif_session.add(
InAppNotification(
owner_id=_OWNER,
event_type="document.processed",
title=f"N{i}",
message="",
is_read=False,
)
)
notif_session.commit()
client = _make_client(notif_engine)
try:
resp = client.post("/api/user-notifications/inbox/read-all")
assert resp.status_code == 200
count_resp = client.get("/api/user-notifications/inbox/unread-count")
assert count_resp.json()["count"] == 0
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Notification Targets
# ---------------------------------------------------------------------------
class TestTargets:
"""Tests for notification target CRUD."""
@pytest.mark.unit
def test_list_targets_empty(self, notif_engine):
"""Listing targets when none exist should return empty list."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/targets")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_email_target(self, notif_engine):
"""Creating an email target should persist and mask the password in response."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.post(
"/api/user-notifications/targets",
json={
"channel_type": "email",
"name": "My Gmail",
"config": {
"smtp_host": "smtp.gmail.com",
"smtp_port": 587,
"smtp_username": "me@gmail.com",
"smtp_password": "s3cr3t",
"recipient_email": "me@gmail.com",
"smtp_use_tls": True,
},
"is_active": True,
},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["channel_type"] == "email"
assert data["name"] == "My Gmail"
assert data["is_active"] is True
# Password must be masked
assert data["config"]["smtp_password"] == "****"
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_webhook_target(self, notif_engine):
"""Creating a webhook target should persist correctly."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.post(
"/api/user-notifications/targets",
json={
"channel_type": "webhook",
"name": "Slack Webhook",
"config": {"url": "https://hooks.slack.com/abc", "secret": ""},
"is_active": True,
},
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["channel_type"] == "webhook"
assert data["name"] == "Slack Webhook"
finally:
_cleanup(app)
@pytest.mark.unit
def test_create_target_invalid_channel_type(self, notif_engine):
"""Creating a target with an invalid channel_type should return 422."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.post(
"/api/user-notifications/targets",
json={"channel_type": "sms", "name": "Bad", "config": {}},
)
assert resp.status_code == 422
finally:
_cleanup(app)
@pytest.mark.unit
def test_list_targets_returns_created(self, notif_engine):
"""Listing targets should include newly created ones."""
from app.main import app
client = _make_client(notif_engine)
try:
client.post(
"/api/user-notifications/targets",
json={"channel_type": "webhook", "name": "W1", "config": {"url": "https://example.com"}},
)
client.post(
"/api/user-notifications/targets",
json={"channel_type": "email", "name": "E1", "config": {"smtp_host": "smtp.example.com"}},
)
resp = client.get("/api/user-notifications/targets")
assert resp.status_code == 200
assert len(resp.json()) == 2
finally:
_cleanup(app)
@pytest.mark.unit
def test_targets_isolation(self, notif_engine):
"""Users should only see their own targets."""
from app.main import app
client_a = _make_client(notif_engine, _OWNER)
try:
client_a.post(
"/api/user-notifications/targets",
json={"channel_type": "webhook", "name": "Owner A target", "config": {"url": "https://a.example.com"}},
)
finally:
_cleanup(app)
client_b = _make_client(notif_engine, _OTHER_OWNER)
try:
resp = client_b.get("/api/user-notifications/targets")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_update_target(self, notif_engine):
"""Updating a target should change its name and active status."""
from app.main import app
client = _make_client(notif_engine)
try:
create_resp = client.post(
"/api/user-notifications/targets",
json={"channel_type": "webhook", "name": "Old Name", "config": {"url": "https://x.com"}},
)
target_id = create_resp.json()["id"]
resp = client.put(
f"/api/user-notifications/targets/{target_id}",
json={"name": "New Name", "is_active": False},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "New Name"
assert data["is_active"] is False
finally:
_cleanup(app)
@pytest.mark.unit
def test_update_target_wrong_user(self, notif_engine, notif_session):
"""Updating another user's target should return 404."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OTHER_OWNER,
channel_type="webhook",
name="Other target",
config=json.dumps({"url": "https://other.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
resp = client.put(
f"/api/user-notifications/targets/{target.id}",
json={"name": "Hacked"},
)
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_delete_target(self, notif_engine):
"""Deleting a target should remove it from the list."""
from app.main import app
client = _make_client(notif_engine)
try:
create_resp = client.post(
"/api/user-notifications/targets",
json={"channel_type": "webhook", "name": "To Delete", "config": {"url": "https://x.com"}},
)
target_id = create_resp.json()["id"]
del_resp = client.delete(f"/api/user-notifications/targets/{target_id}")
assert del_resp.status_code == 200
list_resp = client.get("/api/user-notifications/targets")
assert list_resp.json() == []
finally:
_cleanup(app)
@pytest.mark.unit
def test_delete_target_wrong_user(self, notif_engine, notif_session):
"""Deleting another user's target should return 404."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OTHER_OWNER,
channel_type="webhook",
name="Not yours",
config=json.dumps({"url": "https://other.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
resp = client.delete(f"/api/user-notifications/targets/{target.id}")
assert resp.status_code == 404
finally:
_cleanup(app)
@pytest.mark.unit
def test_delete_target_also_removes_preferences(self, notif_engine, notif_session):
"""Deleting a target should also remove associated preferences."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="With prefs",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
pref = UserNotificationPreference(
owner_id=_OWNER,
event_type="document.processed",
channel_type="webhook",
target_id=target.id,
is_enabled=True,
)
notif_session.add(pref)
notif_session.commit()
client = _make_client(notif_engine, _OWNER)
try:
resp = client.delete(f"/api/user-notifications/targets/{target.id}")
assert resp.status_code == 200
remaining = (
notif_session.query(UserNotificationPreference)
.filter(UserNotificationPreference.owner_id == _OWNER)
.all()
)
assert remaining == []
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests Preferences
# ---------------------------------------------------------------------------
class TestPreferences:
"""Tests for notification preferences CRUD."""
@pytest.mark.unit
def test_get_preferences_empty(self, notif_engine):
"""Getting preferences returns event_types and event_labels even with no prefs set."""
from app.main import app
client = _make_client(notif_engine)
try:
resp = client.get("/api/user-notifications/preferences")
assert resp.status_code == 200
data = resp.json()
assert "event_types" in data
assert "event_labels" in data
assert "preferences" in data
assert "document.processed" in data["event_types"]
assert "document.failed" in data["event_types"]
finally:
_cleanup(app)
@pytest.mark.unit
def test_update_preferences(self, notif_engine, notif_session):
"""Updating preferences should persist the changes."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="My Webhook",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
resp = client.put(
"/api/user-notifications/preferences",
json={
"preferences": [
{
"event_type": "document.processed",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
]
},
)
assert resp.status_code == 200
# Verify stored
pref = (
notif_session.query(UserNotificationPreference)
.filter(
UserNotificationPreference.owner_id == _OWNER,
UserNotificationPreference.event_type == "document.processed",
UserNotificationPreference.channel_type == "webhook",
)
.first()
)
assert pref is not None
assert pref.is_enabled is True
assert pref.target_id == target.id
finally:
_cleanup(app)
@pytest.mark.unit
def test_update_preferences_upsert(self, notif_engine, notif_session):
"""Updating preferences twice should upsert (not duplicate)."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="webhook",
name="W",
config=json.dumps({"url": "https://x.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
client = _make_client(notif_engine, _OWNER)
try:
pref_item = {
"event_type": "document.processed",
"channel_type": "webhook",
"is_enabled": True,
"target_id": target.id,
}
client.put("/api/user-notifications/preferences", json={"preferences": [pref_item]})
# Disable it
pref_item["is_enabled"] = False
resp = client.put("/api/user-notifications/preferences", json={"preferences": [pref_item]})
assert resp.status_code == 200
prefs = (
notif_session.query(UserNotificationPreference)
.filter(UserNotificationPreference.owner_id == _OWNER)
.all()
)
assert len(prefs) == 1
assert prefs[0].is_enabled is False
finally:
_cleanup(app)
@pytest.mark.unit
def test_update_preferences_rejects_foreign_target(self, notif_engine, notif_session):
"""Preferences referencing another user's target_id should be rejected."""
from app.main import app
other_target = UserNotificationTarget(
owner_id=_OTHER_OWNER,
channel_type="webhook",
name="Other webhook",
config=json.dumps({"url": "https://other.com"}),
)
notif_session.add(other_target)
notif_session.commit()
notif_session.refresh(other_target)
client = _make_client(notif_engine, _OWNER)
try:
resp = client.put(
"/api/user-notifications/preferences",
json={
"preferences": [
{
"event_type": "document.processed",
"channel_type": "webhook",
"is_enabled": True,
"target_id": other_target.id,
}
]
},
)
assert resp.status_code == 400
finally:
_cleanup(app)
@pytest.mark.unit
def test_get_preferences_reflects_saved(self, notif_engine, notif_session):
"""GET preferences should reflect previously saved preferences."""
from app.main import app
target = UserNotificationTarget(
owner_id=_OWNER,
channel_type="email",
name="Email target",
config=json.dumps({"smtp_host": "smtp.example.com", "recipient_email": "me@example.com"}),
)
notif_session.add(target)
notif_session.commit()
notif_session.refresh(target)
notif_session.add(
UserNotificationPreference(
owner_id=_OWNER,
event_type="document.failed",
channel_type="email",
target_id=target.id,
is_enabled=True,
)
)
notif_session.commit()
client = _make_client(notif_engine, _OWNER)
try:
resp = client.get("/api/user-notifications/preferences")
assert resp.status_code == 200
data = resp.json()
assert "document.failed" in data["preferences"]
assert "email" in data["preferences"]["document.failed"]
assert data["preferences"]["document.failed"]["email"]["is_enabled"] is True
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests user_notification service
# ---------------------------------------------------------------------------
class TestUserNotificationService:
"""Unit tests for the user notification dispatch service."""
@pytest.mark.unit
def test_create_in_app_notification(self, notif_engine, notif_session):
"""create_in_app_notification should persist a record."""
from unittest.mock import patch
from app.utils.user_notification import create_in_app_notification
Session = sessionmaker(bind=notif_engine)
with patch("app.utils.user_notification.SessionLocal", Session):
result = create_in_app_notification(
owner_id=_OWNER,
event_type="document.processed",
title="Test",
message="Done",
file_id=42,
)
assert result is not None
assert result.owner_id == _OWNER
assert result.title == "Test"
assert result.file_id == 42
@pytest.mark.unit
def test_notify_user_document_processed(self, notif_engine):
"""notify_user_document_processed should create an in-app notification."""
from unittest.mock import patch
from app.utils.user_notification import notify_user_document_processed
Session = sessionmaker(bind=notif_engine)
with patch("app.utils.user_notification.SessionLocal", Session):
notify_user_document_processed(owner_id=_OWNER, filename="test.pdf", file_id=1)
s = Session()
notifs = s.query(InAppNotification).filter(InAppNotification.owner_id == _OWNER).all()
s.close()
assert len(notifs) == 1
assert "test.pdf" in notifs[0].title
@pytest.mark.unit
def test_notify_user_document_failed(self, notif_engine):
"""notify_user_document_failed should create an in-app notification."""
from unittest.mock import patch
from app.utils.user_notification import notify_user_document_failed
Session = sessionmaker(bind=notif_engine)
with patch("app.utils.user_notification.SessionLocal", Session):
notify_user_document_failed(owner_id=_OWNER, filename="doc.pdf", error="OCR timeout")
s = Session()
notifs = s.query(InAppNotification).filter(InAppNotification.owner_id == _OWNER).all()
s.close()
assert len(notifs) == 1
assert notifs[0].event_type == "document.failed"
assert "OCR timeout" in notifs[0].message
@pytest.mark.unit
def test_send_webhook_notification_missing_url(self):
"""_send_webhook_notification should return False when url is missing."""
from app.utils.user_notification import _send_webhook_notification
result = _send_webhook_notification({}, "document.processed", "Title", "Body")
assert result is False
@pytest.mark.unit
def test_send_email_notification_missing_host(self):
"""_send_email_notification should return False when smtp_host is missing."""
from app.utils.user_notification import _send_email_notification
result = _send_email_notification({"recipient_email": "me@example.com"}, "Title", "Body")
assert result is False
@pytest.mark.unit
def test_send_email_notification_missing_recipient(self):
"""_send_email_notification should return False when recipient_email is missing."""
from app.utils.user_notification import _send_email_notification
result = _send_email_notification({"smtp_host": "smtp.example.com"}, "Title", "Body")
assert result is False
+238
View File
@@ -1057,3 +1057,241 @@ class TestMergeOCRResults:
ms.openai_model = "gpt-4"
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "this is the longer text from tesseract engine"
# ---------------------------------------------------------------------------
# Multi-language OCR support
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOCRLanguageConstants:
"""Tests for the OCR_LANGUAGES constant and TESSERACT_TO_EASYOCR mapping."""
def test_ocr_languages_has_20_plus_entries(self):
"""OCR_LANGUAGES contains at least 20 language options (excluding 'auto')."""
from app.utils.ocr_provider import OCR_LANGUAGES
language_entries = {k: v for k, v in OCR_LANGUAGES.items() if v != "auto"}
assert len(language_entries) >= 20, f"Expected ≥20 languages, got {len(language_entries)}"
def test_ocr_languages_includes_auto(self):
"""OCR_LANGUAGES includes 'auto' as the first option."""
from app.utils.ocr_provider import OCR_LANGUAGES
assert "auto" in OCR_LANGUAGES.values()
def test_ocr_languages_common_languages(self):
"""OCR_LANGUAGES includes the most common European and Asian languages."""
from app.utils.ocr_provider import OCR_LANGUAGES
expected_codes = {"eng", "deu", "fra", "spa", "ita", "por", "rus", "chi_sim", "jpn", "kor"}
all_codes = set(OCR_LANGUAGES.values())
missing = expected_codes - all_codes
assert not missing, f"Missing expected language codes: {missing}"
def test_tesseract_to_easyocr_mapping(self):
"""TESSERACT_TO_EASYOCR maps common Tesseract codes to EasyOCR codes."""
from app.utils.ocr_provider import TESSERACT_TO_EASYOCR
assert TESSERACT_TO_EASYOCR["eng"] == "en"
assert TESSERACT_TO_EASYOCR["deu"] == "de"
assert TESSERACT_TO_EASYOCR["fra"] == "fr"
assert TESSERACT_TO_EASYOCR["chi_sim"] == "ch_sim"
def test_tesseract_codes_to_easyocr_single(self):
"""_tesseract_codes_to_easyocr converts a single Tesseract code."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
result = _tesseract_codes_to_easyocr("eng")
assert result == ["en"]
def test_tesseract_codes_to_easyocr_multi(self):
"""_tesseract_codes_to_easyocr splits '+'-separated Tesseract codes."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
result = _tesseract_codes_to_easyocr("eng+deu")
assert result == ["en", "de"]
def test_tesseract_codes_to_easyocr_passthrough_unknown(self):
"""_tesseract_codes_to_easyocr passes through codes not in the mapping."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
# EasyOCR-native codes are passed through unchanged
result = _tesseract_codes_to_easyocr("en")
assert result == ["en"]
@pytest.mark.unit
class TestTesseractLanguageOverride:
"""Tests for per-call language override in TesseractOCRProvider."""
def test_language_override_used_in_process(self, tmp_path):
"""Language override is used instead of global setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language="deu")
mock_pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = "Deutsches Text"
mock_pytesseract.pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
):
ms.tesseract_cmd = None
ms.tesseract_language = "eng" # global setting; should be overridden
result = provider.process(pdf)
# Ensure image_to_string was called with the override language ("deu"), not global "eng"
mock_pytesseract.image_to_string.assert_called_once()
call_kwargs = mock_pytesseract.image_to_string.call_args
assert call_kwargs[1].get("lang") == "deu" or (call_kwargs[0] and call_kwargs[0][1] == "deu")
assert result.provider == "tesseract"
def test_auto_language_falls_back_to_global(self, tmp_path):
"""'auto' language override falls back to global tesseract_language setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language="auto")
mock_pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = ""
mock_pytesseract.pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
):
ms.tesseract_cmd = None
ms.tesseract_language = "fra"
provider.process(pdf)
# Should use global setting "fra" since "auto" means no override
mock_pytesseract.image_to_string.assert_called_once()
call_kwargs = mock_pytesseract.image_to_string.call_args
lang_used = call_kwargs[1].get("lang") if call_kwargs[1] else call_kwargs[0][1]
assert lang_used == "fra"
def test_none_language_falls_back_to_global(self, tmp_path):
"""None language override falls back to global setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language=None)
assert provider._language_override is None
@pytest.mark.unit
class TestEasyOCRLanguageOverride:
"""Tests for per-call language override in EasyOCRProvider."""
def test_language_override_converted_and_used(self, tmp_path):
"""Tesseract-style language override is converted to EasyOCR codes."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider(language="deu")
mock_reader = Mock()
mock_reader.readtext.return_value = ["Deutsches Text"]
mock_easyocr = Mock()
mock_easyocr.Reader.return_value = mock_reader
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.easyocr_languages = "en" # global; should be overridden
ms.easyocr_gpu = False
provider.process(pdf)
# Should call Reader with ["de"] (converted from "deu"), not global ["en"]
mock_easyocr.Reader.assert_called_once()
langs_arg = mock_easyocr.Reader.call_args[0][0]
assert langs_arg == ["de"]
def test_auto_language_uses_global_setting(self, tmp_path):
"""'auto' language override falls back to global easyocr_languages setting."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider(language="auto")
mock_reader = Mock()
mock_reader.readtext.return_value = []
mock_easyocr = Mock()
mock_easyocr.Reader.return_value = mock_reader
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.easyocr_languages = "fr,es"
ms.easyocr_gpu = False
provider.process(pdf)
langs_arg = mock_easyocr.Reader.call_args[0][0]
assert langs_arg == ["fr", "es"]
@pytest.mark.unit
class TestGetOCRProvidersWithLanguage:
"""Tests for get_ocr_providers(language=...) factory."""
def test_language_passed_to_tesseract_provider(self):
"""Language override is passed to TesseractOCRProvider."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language="deu")
assert len(providers) == 1
assert isinstance(providers[0], TesseractOCRProvider)
assert providers[0]._language_override == "deu"
def test_language_passed_to_easyocr_provider(self):
"""Language override is passed to EasyOCRProvider."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "easyocr"
providers = get_ocr_providers(language="fra")
assert len(providers) == 1
assert isinstance(providers[0], EasyOCRProvider)
assert providers[0]._language_override == "fra"
def test_language_not_passed_to_azure(self):
"""Language override is NOT passed to AzureOCRProvider (it auto-detects)."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "azure"
providers = get_ocr_providers(language="deu")
assert len(providers) == 1
assert isinstance(providers[0], AzureOCRProvider)
# AzureOCRProvider has no _language_override attribute
assert not hasattr(providers[0], "_language_override")
def test_auto_language_not_passed_as_override(self):
"""'auto' language is treated as no override for Tesseract."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language="auto")
assert providers[0]._language_override is None
def test_none_language_no_override(self):
"""None language results in no override."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language=None)
assert providers[0]._language_override is None
+179
View File
@@ -842,3 +842,182 @@ startxref
mock_init_steps.assert_called_once()
called_file_id = mock_init_steps.call_args[0][1]
assert called_file_id == result["file_id"]
# ---------------------------------------------------------------------------
# _get_pipeline_ocr_language helper
# ---------------------------------------------------------------------------
@pytest.mark.unit
@pytest.mark.requires_db
def test_get_pipeline_ocr_language_returns_none_when_no_pipeline(db_session):
"""Returns None when no pipeline exists in the database."""
from app.tasks.process_document import _get_pipeline_ocr_language
# FileRecord with no pipeline_id
file_record = FileRecord(
filehash="abc123",
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf",
is_duplicate=False,
)
db_session.add(file_record)
db_session.commit()
result = _get_pipeline_ocr_language(db_session, file_record, owner_id=None)
assert result is None
@pytest.mark.unit
@pytest.mark.requires_db
def test_get_pipeline_ocr_language_returns_language_from_system_default(db_session):
"""Returns ocr_language from the system default pipeline's OCR step config."""
import json
from app.models import Pipeline, PipelineStep
from app.tasks.process_document import _get_pipeline_ocr_language
# Create system default pipeline with OCR step configured to "deu"
pipeline = Pipeline(
owner_id=None,
name="System Default",
is_default=True,
is_active=True,
)
db_session.add(pipeline)
db_session.commit()
ocr_step = PipelineStep(
pipeline_id=pipeline.id,
position=0,
step_type="ocr",
config=json.dumps({"force_cloud_ocr": False, "ocr_language": "deu"}),
enabled=True,
)
db_session.add(ocr_step)
db_session.commit()
file_record = FileRecord(
filehash="def456",
original_filename="doc.pdf",
local_filename="/tmp/doc.pdf",
file_size=512,
mime_type="application/pdf",
is_duplicate=False,
)
db_session.add(file_record)
db_session.commit()
result = _get_pipeline_ocr_language(db_session, file_record, owner_id=None)
assert result == "deu"
@pytest.mark.unit
@pytest.mark.requires_db
def test_get_pipeline_ocr_language_auto_returns_none(db_session):
"""Returns None when ocr_language is 'auto' (should use global settings)."""
import json
from app.models import Pipeline, PipelineStep
from app.tasks.process_document import _get_pipeline_ocr_language
pipeline = Pipeline(
owner_id=None,
name="Auto Lang Pipeline",
is_default=True,
is_active=True,
)
db_session.add(pipeline)
db_session.commit()
ocr_step = PipelineStep(
pipeline_id=pipeline.id,
position=0,
step_type="ocr",
config=json.dumps({"ocr_language": "auto"}),
enabled=True,
)
db_session.add(ocr_step)
db_session.commit()
file_record = FileRecord(
filehash="ghi789",
original_filename="auto.pdf",
local_filename="/tmp/auto.pdf",
file_size=128,
mime_type="application/pdf",
is_duplicate=False,
)
db_session.add(file_record)
db_session.commit()
result = _get_pipeline_ocr_language(db_session, file_record, owner_id=None)
assert result is None
@pytest.mark.unit
@pytest.mark.requires_db
def test_get_pipeline_ocr_language_explicit_pipeline_takes_priority(db_session):
"""Explicit pipeline_id on file takes priority over system default pipeline."""
import json
from app.models import Pipeline, PipelineStep
from app.tasks.process_document import _get_pipeline_ocr_language
# System default pipeline with "eng"
sys_pipeline = Pipeline(
owner_id=None,
name="System Default",
is_default=True,
is_active=True,
)
db_session.add(sys_pipeline)
db_session.commit()
sys_step = PipelineStep(
pipeline_id=sys_pipeline.id,
position=0,
step_type="ocr",
config=json.dumps({"ocr_language": "eng"}),
enabled=True,
)
db_session.add(sys_step)
db_session.commit()
# Explicit pipeline with "fra"
explicit_pipeline = Pipeline(
owner_id="user1",
name="French Pipeline",
is_default=False,
is_active=True,
)
db_session.add(explicit_pipeline)
db_session.commit()
explicit_step = PipelineStep(
pipeline_id=explicit_pipeline.id,
position=0,
step_type="ocr",
config=json.dumps({"ocr_language": "fra"}),
enabled=True,
)
db_session.add(explicit_step)
db_session.commit()
file_record = FileRecord(
filehash="jkl012",
original_filename="french.pdf",
local_filename="/tmp/french.pdf",
file_size=256,
mime_type="application/pdf",
is_duplicate=False,
pipeline_id=explicit_pipeline.id,
)
db_session.add(file_record)
db_session.commit()
result = _get_pipeline_ocr_language(db_session, file_record, owner_id="user1")
assert result == "fra"