Merge branch 'main' into copilot/add-document-sharing-feature
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
2026-03-08T21:24:30Z
|
2026-03-08T22:11:59Z
|
||||||
|
|||||||
@@ -10,6 +10,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- 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)
|
## v0.106.0 (2026-03-08)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.106.0
|
Version: 0.109.0
|
||||||
Build Date: 2026-03-08T21:24:30Z
|
Build Date: 2026-03-08T22:11:59Z
|
||||||
Git Commit: caf860dd10b5dc5364482d2198d998b362154df3
|
Git Commit: 6376b6d73b5f0d1dcddfaf8c05e51581cc753bfe
|
||||||
Git Short SHA: caf860d
|
Git Short SHA: 6376b6d
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-08T22:24:13+01:00
|
Commit Date: 2026-03-08T23:11:41+01:00
|
||||||
Build Timestamp: 2026-03-08T21:24:30Z
|
Build Timestamp: 2026-03-08T22:11:59Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
@@ -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.imap_accounts import router as imap_accounts_router
|
||||||
from app.api.integrations import router as integrations_router
|
from app.api.integrations import router as integrations_router
|
||||||
from app.api.logs import router as logs_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.onboarding import router as onboarding_router
|
||||||
from app.api.onedrive import router as onedrive_router
|
from app.api.onedrive import router as onedrive_router
|
||||||
from app.api.openai import router as openai_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(pipelines_router)
|
||||||
router.include_router(imap_accounts_router)
|
router.include_router(imap_accounts_router)
|
||||||
router.include_router(integrations_router)
|
router.include_router(integrations_router)
|
||||||
|
router.include_router(notifications_router)
|
||||||
|
|||||||
@@ -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
@@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": False,
|
"default": False,
|
||||||
"description": "Always use cloud OCR even if the PDF already has embedded text.",
|
"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": {
|
"extract_metadata": {
|
||||||
|
|||||||
+680
@@ -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()
|
||||||
@@ -684,3 +684,48 @@ class SharedLink(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
revoked_at = Column(DateTime(timezone=True), nullable=True)
|
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)
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
|
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
|
||||||
from pypdf.errors import PdfReadError
|
from pypdf.errors import PdfReadError
|
||||||
@@ -12,7 +16,7 @@ from pypdf.errors import PdfReadError
|
|||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import SessionLocal
|
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.extract_metadata_with_gpt import extract_metadata_with_gpt
|
||||||
from app.tasks.process_with_ocr import process_with_ocr
|
from app.tasks.process_with_ocr import process_with_ocr
|
||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
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.step_manager import initialize_file_steps
|
||||||
from app.utils.text_quality import check_text_quality, detect_pdf_text_source
|
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__)
|
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)
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
def process_document(
|
def process_document(
|
||||||
self,
|
self,
|
||||||
@@ -109,6 +179,7 @@ def process_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Acquire DB session in the task
|
# Acquire DB session in the task
|
||||||
|
ocr_language: str | None = None # Pipeline OCR language override resolved inside DB session
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
# When file_id is provided, we are reprocessing an existing file.
|
# When file_id is provided, we are reprocessing an existing file.
|
||||||
# Skip the duplicate check and reuse the existing record.
|
# Skip the duplicate check and reuse the existing record.
|
||||||
@@ -305,6 +376,14 @@ def process_document(
|
|||||||
new_record.local_filename = new_local_path
|
new_record.local_filename = new_local_path
|
||||||
db.commit()
|
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
|
# Store file_id before session closes to avoid DetachedInstanceError
|
||||||
file_id = new_record.id
|
file_id = new_record.id
|
||||||
|
|
||||||
@@ -334,7 +413,7 @@ def process_document(
|
|||||||
"Queued for forced OCR processing",
|
"Queued for forced OCR processing",
|
||||||
file_id=file_id,
|
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}
|
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
|
# 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)",
|
"Queued for OCR (text quality too low)",
|
||||||
file_id=file_id,
|
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 {
|
return {
|
||||||
"file": new_local_path,
|
"file": new_local_path,
|
||||||
"status": "Queued for OCR (poor embedded text quality)",
|
"status": "Queued for OCR (poor embedded text quality)",
|
||||||
@@ -564,5 +643,5 @@ def process_document(
|
|||||||
"Queued for OCR processing",
|
"Queued for OCR processing",
|
||||||
file_id=file_id,
|
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}
|
return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ task with a multi-engine OCR pipeline that:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -33,7 +32,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
@celery.task(base=OcrTaskWithRetry, bind=True)
|
@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.
|
"""Run the configured OCR providers on *filename* and continue the pipeline.
|
||||||
|
|
||||||
When multiple OCR providers are configured the results are merged using the
|
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/``.
|
filename: Base name of the file inside ``<workdir>/tmp/``.
|
||||||
file_id: Optional database record ID passed through to downstream tasks.
|
file_id: Optional database record ID passed through to downstream tasks.
|
||||||
original_text: Optional original embedded text for head-to-head comparison.
|
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
|
task_id = self.request.id
|
||||||
log_task_progress(
|
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):
|
if not os.path.exists(tmp_file_path):
|
||||||
raise FileNotFoundError(f"Local file not found: {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]
|
provider_names = [p.name for p in providers]
|
||||||
logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}")
|
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
|
# PDF with ocrmypdf to embed an invisible text layer so the output is
|
||||||
# selectable/searchable in PDF viewers.
|
# selectable/searchable in PDF viewers.
|
||||||
if searchable_pdf_path is None:
|
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(
|
log_task_progress(
|
||||||
task_id,
|
task_id,
|
||||||
"embed_text_layer",
|
"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",
|
"Embedding searchable text layer into PDF",
|
||||||
file_id=file_id,
|
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:
|
if embedded:
|
||||||
searchable_pdf_path = tmp_file_path
|
searchable_pdf_path = tmp_file_path
|
||||||
log_task_progress(
|
log_task_progress(
|
||||||
|
|||||||
+141
-5
@@ -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):
|
class OCRProvider(ABC):
|
||||||
"""Abstract base class for OCR providers.
|
"""Abstract base class for OCR providers.
|
||||||
|
|
||||||
@@ -290,10 +379,24 @@ class TesseractOCRProvider(OCRProvider):
|
|||||||
- ``tesseract_cmd`` – path to the ``tesseract`` binary (optional).
|
- ``tesseract_cmd`` – path to the ``tesseract`` binary (optional).
|
||||||
- ``tesseract_language`` – Tesseract language code(s), e.g. ``"eng"`` or
|
- ``tesseract_language`` – Tesseract language code(s), e.g. ``"eng"`` or
|
||||||
``"eng+deu"`` (default: ``"eng"``).
|
``"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"
|
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:
|
def process(self, file_path: str) -> OCRResult:
|
||||||
try:
|
try:
|
||||||
import pytesseract
|
import pytesseract
|
||||||
@@ -308,7 +411,7 @@ class TesseractOCRProvider(OCRProvider):
|
|||||||
if tesseract_cmd:
|
if tesseract_cmd:
|
||||||
pytesseract.pytesseract.tesseract_cmd = 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.
|
# Ensure language data files are present; attempt download if missing.
|
||||||
from app.utils.ocr_language_manager import ensure_tesseract_languages # noqa: PLC0415
|
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
|
- ``easyocr_languages`` – comma-separated list of language codes
|
||||||
(default: ``"en"``).
|
(default: ``"en"``).
|
||||||
- ``easyocr_gpu`` – whether to use GPU acceleration (default: ``False``).
|
- ``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"
|
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:
|
def process(self, file_path: str) -> OCRResult:
|
||||||
try:
|
try:
|
||||||
import easyocr
|
import easyocr
|
||||||
@@ -363,8 +482,12 @@ class EasyOCRProvider(OCRProvider):
|
|||||||
"Install them with: pip install easyocr pdf2image"
|
"Install them with: pip install easyocr pdf2image"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
lang_str = getattr(settings, "easyocr_languages", None) or "en"
|
if self._language_override:
|
||||||
langs = [lang.strip() for lang in lang_str.split(",") if lang.strip()]
|
# 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)
|
gpu = getattr(settings, "easyocr_gpu", False)
|
||||||
|
|
||||||
logger.info(f"[EasyOCR] Processing {os.path.basename(file_path)} (langs={langs}, gpu={gpu})")
|
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
|
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.
|
"""Return a list of configured OCR provider instances.
|
||||||
|
|
||||||
Reads ``settings.ocr_providers`` (comma-separated provider names) and
|
Reads ``settings.ocr_providers`` (comma-separated provider names) and
|
||||||
returns one instantiated provider per entry. Falls back to ``["azure"]``
|
returns one instantiated provider per entry. Falls back to ``["azure"]``
|
||||||
when the setting is absent.
|
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"
|
raw = getattr(settings, "ocr_providers", None) or "azure"
|
||||||
provider_names = [name.strip().lower() for name in raw.split(",") if name.strip()]
|
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] = []
|
providers: List[OCRProvider] = []
|
||||||
for name in provider_names:
|
for name in provider_names:
|
||||||
cls = _PROVIDER_MAP.get(name)
|
cls = _PROVIDER_MAP.get(name)
|
||||||
if cls is None:
|
if cls is None:
|
||||||
logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS – skipping.")
|
logger.warning(f"Unknown OCR provider '{name}' in OCR_PROVIDERS – skipping.")
|
||||||
continue
|
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}")
|
logger.debug(f"Registered OCR provider: {name}")
|
||||||
|
|
||||||
if not providers:
|
if not providers:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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.imap_accounts import router as imap_accounts_router
|
||||||
from app.views.integrations import router as integrations_router # Unified integrations dashboard
|
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.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.onboarding import router as onboarding_router
|
||||||
from app.views.onedrive import router as onedrive_router
|
from app.views.onedrive import router as onedrive_router
|
||||||
from app.views.pipelines import router as pipelines_router # Processing pipelines
|
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(pipelines_router) # Processing pipelines
|
||||||
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
|
router.include_router(imap_accounts_router) # Per-user IMAP ingestion accounts
|
||||||
router.include_router(integrations_router) # Unified integrations dashboard
|
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
|
router.include_router(help_router) # Built-in help / How-To docs
|
||||||
|
|||||||
@@ -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
@@ -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.
|
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
|
## API Overview
|
||||||
|
|
||||||
- Base URL: `http://<your-docuelevate-instance>/api`
|
- Base URL: `http://<your-docuelevate-instance>/api`
|
||||||
@@ -1785,12 +1788,27 @@ Returns the catalogue of built-in step types.
|
|||||||
"label": "OCR Processing",
|
"label": "OCR Processing",
|
||||||
"description": "Extract text using Azure Document Intelligence or local Tesseract.",
|
"description": "Extract text using Azure Document Intelligence or local Tesseract.",
|
||||||
"config_schema": {
|
"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
|
### List pipelines
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1883,12 +1901,23 @@ Content-Type: application/json
|
|||||||
|
|
||||||
{
|
{
|
||||||
"step_type": "ocr",
|
"step_type": "ocr",
|
||||||
"label": "Cloud OCR",
|
"label": "German OCR",
|
||||||
"config": { "force_cloud_ocr": true },
|
"config": { "force_cloud_ocr": false, "ocr_language": "deu" },
|
||||||
"enabled": true
|
"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
|
### Update step
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1070,6 +1070,55 @@ payment processors.
|
|||||||
|
|
||||||
For detailed setup instructions, see the [Notifications Setup Guide](NotificationsSetup.md).
|
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
|
||||||
|
|
||||||
Webhooks notify external systems via HTTP POST when document events occur.
|
Webhooks notify external systems via HTTP POST when document events occur.
|
||||||
|
|||||||
+38
-1
@@ -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 |
|
| `convert_to_pdf` | Convert non-PDF files to PDF using Gotenberg |
|
||||||
| `check_duplicates` | Detect duplicate files by content hash |
|
| `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 |
|
| `extract_metadata` | Extract structured metadata (type, sender, tags) with AI |
|
||||||
| `embed_metadata` | Write extracted metadata into the PDF document properties |
|
| `embed_metadata` | Write extracted metadata into the PDF document properties |
|
||||||
| `compute_embedding` | Compute semantic embeddings for similarity search |
|
| `compute_embedding` | Compute semantic embeddings for similarity search |
|
||||||
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
|
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
|
||||||
| `classify` | Classify the document type with AI |
|
| `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
|
### 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:
|
You can assign (or change) the pipeline for an individual document via the file detail page or the API:
|
||||||
|
|||||||
@@ -194,6 +194,19 @@
|
|||||||
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
|
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
|
||||||
</a>
|
</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 -->
|
<!-- Dark mode toggle -->
|
||||||
<button
|
<button
|
||||||
id="darkModeToggle"
|
id="darkModeToggle"
|
||||||
@@ -283,6 +296,11 @@
|
|||||||
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
|
{% 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
|
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
|
||||||
</a>
|
</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 -->
|
<!-- Admin section in mobile menu – shown only for admin users via JS -->
|
||||||
<div id="mobileAdminSection" class="hidden">
|
<div id="mobileAdminSection" class="hidden">
|
||||||
@@ -427,6 +445,30 @@
|
|||||||
<!-- Common JS (shared) -->
|
<!-- Common JS (shared) -->
|
||||||
<script src="/static/js/common.js"></script>
|
<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 -->
|
<!-- Let child pages define extra scripts if needed -->
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</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 %}
|
||||||
@@ -404,6 +404,53 @@
|
|||||||
</label>
|
</label>
|
||||||
</template>
|
</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 -->
|
<!-- Enabled -->
|
||||||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<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")
|
||||||
@@ -41,6 +41,7 @@ nav:
|
|||||||
- Email Ingestion: howto/EmailIngestion.md
|
- Email Ingestion: howto/EmailIngestion.md
|
||||||
- Mobile Scanning: howto/MobileScanning.md
|
- Mobile Scanning: howto/MobileScanning.md
|
||||||
- API: API
|
- API: API
|
||||||
|
- CLI: CLIGuide
|
||||||
- Deployment:
|
- Deployment:
|
||||||
- Overview: DeploymentGuide
|
- Overview: DeploymentGuide
|
||||||
- Kubernetes / Helm: KubernetesDeployment
|
- Kubernetes / Helm: KubernetesDeployment
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ Changelog = "https://github.com/christianlouis/DocuElevate/blob/main/CHANGELOG.m
|
|||||||
[tool.setuptools.dynamic]
|
[tool.setuptools.dynamic]
|
||||||
version = {file = "VERSION"}
|
version = {file = "VERSION"}
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
docuelevate = "app.cli:main"
|
||||||
|
|
||||||
[tool.semantic_release]
|
[tool.semantic_release]
|
||||||
version_toml = []
|
version_toml = []
|
||||||
version_source = "tag"
|
version_source = "tag"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ cryptography>=41.0.0 # Encryption for sensitive settings in database
|
|||||||
openai # GPT integration for metadata extraction
|
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)
|
pypdf>=3.9.0 # PDF processing for text extraction, metadata editing and rotation (upgraded from PyPDF2 to fix CVE-2023-36464)
|
||||||
requests # HTTP client
|
requests # HTTP client
|
||||||
|
click>=8.0.0 # CLI framework for docuelevate command
|
||||||
puremagic>=1.25,<2.0 # File type detection (pure Python)
|
puremagic>=1.25,<2.0 # File type detection (pure Python)
|
||||||
filetype>=1.2.0,<2.0 # File type detection fallback (pure Python)
|
filetype>=1.2.0,<2.0 # File type detection fallback (pure Python)
|
||||||
dropbox>=11.36.0 # Dropbox integration
|
dropbox>=11.36.0 # Dropbox integration
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -1057,3 +1057,241 @@ class TestMergeOCRResults:
|
|||||||
ms.openai_model = "gpt-4"
|
ms.openai_model = "gpt-4"
|
||||||
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
|
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
|
||||||
assert text == "this is the longer text from tesseract engine"
|
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
|
||||||
|
|||||||
@@ -842,3 +842,182 @@ startxref
|
|||||||
mock_init_steps.assert_called_once()
|
mock_init_steps.assert_called_once()
|
||||||
called_file_id = mock_init_steps.call_args[0][1]
|
called_file_id = mock_init_steps.call_args[0][1]
|
||||||
assert called_file_id == result["file_id"]
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user