651b48658c
Merge origin/main into feature branch, resolving 3 conflicts: - app/api/__init__.py: add classification_rules_router alongside new routers from main (audit_logs, i18n, mobile, compliance, translation) - app/models.py: keep ClassificationRuleModel alongside new models from main (MobileDevice, ComplianceTemplate, PipelineRoutingRule) - tests/conftest.py: import both ClassificationRuleModel and new models from main (AuditLog, ComplianceTemplate) Also renumber migration from 027 to 037 to chain from the latest migration on main (036_add_document_translation_fields). Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
432 lines
14 KiB
Python
432 lines
14 KiB
Python
"""
|
|
GraphQL API endpoint for DocuElevate.
|
|
|
|
Provides a flexible query interface alongside the existing REST API.
|
|
Schema covers: documents, pipelines, settings, and users.
|
|
|
|
Endpoint: /graphql
|
|
GraphiQL playground: /graphql (via browser)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Annotated, Any
|
|
|
|
import strawberry
|
|
from fastapi import Depends, Request
|
|
from sqlalchemy.orm import Session
|
|
from strawberry.fastapi import GraphQLRouter
|
|
|
|
from app.auth import get_current_user
|
|
from app.config import settings
|
|
from app.database import get_db
|
|
from app.models import ApplicationSettings, FileRecord, Pipeline, PipelineStep, UserProfile
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strawberry types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@strawberry.type
|
|
class DocumentType:
|
|
"""A processed document stored in the system."""
|
|
|
|
id: int
|
|
owner_id: str | None
|
|
original_filename: str | None
|
|
local_filename: str
|
|
file_size: int
|
|
mime_type: str | None
|
|
document_title: str | None
|
|
is_duplicate: bool
|
|
ocr_quality_score: int | None
|
|
pipeline_id: int | None
|
|
created_at: datetime | None
|
|
|
|
|
|
@strawberry.type
|
|
class PipelineStepType:
|
|
"""A single step within a processing pipeline."""
|
|
|
|
id: int
|
|
pipeline_id: int
|
|
position: int
|
|
step_type: str
|
|
label: str | None
|
|
enabled: bool
|
|
created_at: datetime | None
|
|
|
|
|
|
@strawberry.type
|
|
class PipelineType:
|
|
"""A processing pipeline with its ordered steps."""
|
|
|
|
id: int
|
|
owner_id: str | None
|
|
name: str
|
|
description: str | None
|
|
is_default: bool
|
|
is_active: bool
|
|
steps: list[PipelineStepType]
|
|
created_at: datetime | None
|
|
updated_at: datetime | None
|
|
|
|
|
|
@strawberry.type
|
|
class SettingType:
|
|
"""An application configuration setting stored in the database."""
|
|
|
|
id: int
|
|
key: str
|
|
value: str | None
|
|
created_at: datetime | None
|
|
updated_at: datetime | None
|
|
|
|
|
|
@strawberry.type
|
|
class UserType:
|
|
"""A user profile in the system."""
|
|
|
|
id: int
|
|
user_id: str
|
|
display_name: str | None
|
|
is_blocked: bool
|
|
subscription_tier: str | None
|
|
onboarding_completed: bool
|
|
created_at: datetime | None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conversion helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _document_from_record(rec: FileRecord) -> DocumentType:
|
|
return DocumentType(
|
|
id=rec.id,
|
|
owner_id=rec.owner_id,
|
|
original_filename=rec.original_filename,
|
|
local_filename=rec.local_filename,
|
|
file_size=rec.file_size,
|
|
mime_type=rec.mime_type,
|
|
document_title=rec.document_title,
|
|
is_duplicate=rec.is_duplicate,
|
|
ocr_quality_score=rec.ocr_quality_score,
|
|
pipeline_id=rec.pipeline_id,
|
|
created_at=rec.created_at,
|
|
)
|
|
|
|
|
|
def _pipeline_step_from_record(step: PipelineStep) -> PipelineStepType:
|
|
return PipelineStepType(
|
|
id=step.id,
|
|
pipeline_id=step.pipeline_id,
|
|
position=step.position,
|
|
step_type=step.step_type,
|
|
label=step.label,
|
|
enabled=step.enabled,
|
|
created_at=step.created_at,
|
|
)
|
|
|
|
|
|
def _pipeline_from_record(pipeline: Pipeline, db: Session) -> PipelineType:
|
|
steps = db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline.id).order_by(PipelineStep.position).all()
|
|
return PipelineType(
|
|
id=pipeline.id,
|
|
owner_id=pipeline.owner_id,
|
|
name=pipeline.name,
|
|
description=pipeline.description,
|
|
is_default=pipeline.is_default,
|
|
is_active=pipeline.is_active,
|
|
steps=[_pipeline_step_from_record(s) for s in steps],
|
|
created_at=pipeline.created_at,
|
|
updated_at=pipeline.updated_at,
|
|
)
|
|
|
|
|
|
def _setting_from_record(setting: ApplicationSettings) -> SettingType:
|
|
return SettingType(
|
|
id=setting.id,
|
|
key=setting.key,
|
|
value=setting.value,
|
|
created_at=setting.created_at,
|
|
updated_at=setting.updated_at,
|
|
)
|
|
|
|
|
|
def _user_from_profile(profile: UserProfile) -> UserType:
|
|
return UserType(
|
|
id=profile.id,
|
|
user_id=profile.user_id,
|
|
display_name=profile.display_name,
|
|
is_blocked=profile.is_blocked,
|
|
subscription_tier=profile.subscription_tier,
|
|
onboarding_completed=profile.onboarding_completed,
|
|
created_at=profile.created_at,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Context helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Keys that contain sensitive data and must never be returned via GraphQL
|
|
_SENSITIVE_SETTING_KEYS: frozenset[str] = frozenset(
|
|
{
|
|
"openai_api_key",
|
|
"azure_ai_key",
|
|
"session_secret",
|
|
"database_url",
|
|
"redis_url",
|
|
"dropbox_app_secret",
|
|
"dropbox_refresh_token",
|
|
"google_drive_credentials_json",
|
|
"onedrive_client_secret",
|
|
"onedrive_refresh_token",
|
|
"smtp_password",
|
|
"nextcloud_password",
|
|
"s3_secret_access_key",
|
|
"ftp_password",
|
|
"sftp_password",
|
|
"webdav_password",
|
|
"stripe_secret_key",
|
|
"stripe_webhook_secret",
|
|
"sentry_dsn",
|
|
"social_auth_google_client_secret",
|
|
"social_auth_microsoft_client_secret",
|
|
"social_auth_apple_private_key",
|
|
"social_auth_dropbox_app_secret",
|
|
}
|
|
)
|
|
|
|
|
|
def _get_current_user_id(user: dict[str, Any] | None) -> str | None:
|
|
"""Extract the stable user identifier from the user dict."""
|
|
if not user:
|
|
return None
|
|
return user.get("preferred_username") or user.get("email") or user.get("id") or None
|
|
|
|
|
|
def _get_db_and_user(info: strawberry.types.Info) -> tuple[Session, dict[str, Any] | None]:
|
|
"""Extract the database session and current user from the Strawberry context."""
|
|
db: Session = info.context["db"]
|
|
user: dict[str, Any] | None = info.context.get("user")
|
|
return db, user
|
|
|
|
|
|
def _require_auth(user: dict[str, Any] | None) -> None:
|
|
"""Raise an error when authentication is enabled and no valid user is present."""
|
|
if settings.auth_enabled and not user:
|
|
raise strawberry.exceptions.StrawberryGraphQLError("Authentication required")
|
|
|
|
|
|
def _require_admin(user: dict[str, Any] | None) -> None:
|
|
"""Raise an error when the current user is not an admin.
|
|
|
|
When ``auth_enabled`` is *False* (single-user / development mode) all
|
|
callers are implicitly treated as administrators.
|
|
"""
|
|
if not settings.auth_enabled:
|
|
# Single-user mode: no auth, treat caller as admin
|
|
return
|
|
_require_auth(user)
|
|
if not (user and user.get("is_admin")):
|
|
raise strawberry.exceptions.StrawberryGraphQLError("Admin access required")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Query resolvers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@strawberry.type
|
|
class Query:
|
|
"""Root query type for the DocuElevate GraphQL API."""
|
|
|
|
@strawberry.field(description="List documents, optionally filtered by owner.")
|
|
def documents(
|
|
self,
|
|
info: strawberry.types.Info,
|
|
owner_id: str | None = None,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[DocumentType]:
|
|
"""Return a paginated list of documents.
|
|
|
|
When *auth_enabled* the caller must be authenticated. Non-admin users
|
|
receive only their own documents; admins may query any *owner_id*.
|
|
"""
|
|
db, user = _get_db_and_user(info)
|
|
_require_auth(user)
|
|
|
|
limit = max(1, min(limit, 100))
|
|
offset = max(0, offset)
|
|
|
|
query = db.query(FileRecord)
|
|
|
|
if settings.auth_enabled and user:
|
|
is_admin = user.get("is_admin", False)
|
|
current_user_id = _get_current_user_id(user)
|
|
if not is_admin:
|
|
# Non-admins can only see their own documents
|
|
query = query.filter(FileRecord.owner_id == current_user_id)
|
|
elif owner_id:
|
|
query = query.filter(FileRecord.owner_id == owner_id)
|
|
elif owner_id:
|
|
query = query.filter(FileRecord.owner_id == owner_id)
|
|
|
|
records = query.order_by(FileRecord.created_at.desc()).offset(offset).limit(limit).all()
|
|
return [_document_from_record(r) for r in records]
|
|
|
|
@strawberry.field(description="Fetch a single document by ID.")
|
|
def document(self, info: strawberry.types.Info, id: int) -> DocumentType | None:
|
|
"""Return one document by its primary key, or *null* if not found."""
|
|
db, user = _get_db_and_user(info)
|
|
_require_auth(user)
|
|
|
|
rec = db.query(FileRecord).filter(FileRecord.id == id).first()
|
|
if rec is None:
|
|
return None
|
|
|
|
if settings.auth_enabled and user:
|
|
is_admin = user.get("is_admin", False)
|
|
current_user_id = _get_current_user_id(user)
|
|
if not is_admin and rec.owner_id != current_user_id:
|
|
return None
|
|
|
|
return _document_from_record(rec)
|
|
|
|
@strawberry.field(description="List processing pipelines.")
|
|
def pipelines(
|
|
self,
|
|
info: strawberry.types.Info,
|
|
owner_id: str | None = None,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[PipelineType]:
|
|
"""Return a paginated list of pipelines."""
|
|
db, user = _get_db_and_user(info)
|
|
_require_auth(user)
|
|
|
|
limit = max(1, min(limit, 100))
|
|
offset = max(0, offset)
|
|
|
|
query = db.query(Pipeline)
|
|
|
|
if settings.auth_enabled and user:
|
|
is_admin = user.get("is_admin", False)
|
|
current_user_id = _get_current_user_id(user)
|
|
if not is_admin:
|
|
query = query.filter((Pipeline.owner_id == current_user_id) | (Pipeline.owner_id.is_(None)))
|
|
elif owner_id:
|
|
query = query.filter(Pipeline.owner_id == owner_id)
|
|
elif owner_id:
|
|
query = query.filter(Pipeline.owner_id == owner_id)
|
|
|
|
rows = query.order_by(Pipeline.id).offset(offset).limit(limit).all()
|
|
return [_pipeline_from_record(p, db) for p in rows]
|
|
|
|
@strawberry.field(description="Fetch a single pipeline by ID.")
|
|
def pipeline(self, info: strawberry.types.Info, id: int) -> PipelineType | None:
|
|
"""Return one pipeline by its primary key, or *null* if not found."""
|
|
db, user = _get_db_and_user(info)
|
|
_require_auth(user)
|
|
|
|
row = db.query(Pipeline).filter(Pipeline.id == id).first()
|
|
if row is None:
|
|
return None
|
|
|
|
if settings.auth_enabled and user:
|
|
is_admin = user.get("is_admin", False)
|
|
current_user_id = _get_current_user_id(user)
|
|
if not is_admin and row.owner_id is not None and row.owner_id != current_user_id:
|
|
return None
|
|
|
|
return _pipeline_from_record(row, db)
|
|
|
|
@strawberry.field(description="List non-sensitive application settings (admin only).")
|
|
def settings(
|
|
self,
|
|
info: strawberry.types.Info,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> list[SettingType]:
|
|
"""Return application settings stored in the database.
|
|
|
|
Sensitive keys (API secrets, passwords, etc.) are automatically
|
|
excluded. Requires admin privileges when auth is enabled.
|
|
"""
|
|
db, user = _get_db_and_user(info)
|
|
_require_admin(user)
|
|
|
|
limit = max(1, min(limit, 200))
|
|
offset = max(0, offset)
|
|
|
|
rows = (
|
|
db.query(ApplicationSettings)
|
|
.filter(ApplicationSettings.key.notin_(_SENSITIVE_SETTING_KEYS))
|
|
.order_by(ApplicationSettings.key)
|
|
.offset(offset)
|
|
.limit(limit)
|
|
.all()
|
|
)
|
|
return [_setting_from_record(r) for r in rows]
|
|
|
|
@strawberry.field(description="List user profiles (admin only).")
|
|
def users(
|
|
self,
|
|
info: strawberry.types.Info,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[UserType]:
|
|
"""Return a paginated list of user profiles. Requires admin privileges."""
|
|
db, user = _get_db_and_user(info)
|
|
_require_admin(user)
|
|
|
|
limit = max(1, min(limit, 100))
|
|
offset = max(0, offset)
|
|
|
|
rows = db.query(UserProfile).order_by(UserProfile.user_id).offset(offset).limit(limit).all()
|
|
return [_user_from_profile(r) for r in rows]
|
|
|
|
@strawberry.field(description="Fetch a user profile by user_id (admin only).")
|
|
def user(self, info: strawberry.types.Info, user_id: str) -> UserType | None:
|
|
"""Return one user profile by *user_id*, or *null* if not found."""
|
|
db, user = _get_db_and_user(info)
|
|
_require_admin(user)
|
|
|
|
row = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
|
return _user_from_profile(row) if row else None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema and router
|
|
# ---------------------------------------------------------------------------
|
|
|
|
schema = strawberry.Schema(query=Query)
|
|
|
|
|
|
async def get_graphql_context(
|
|
request: Request,
|
|
db: Annotated[Session, Depends(get_db)],
|
|
) -> dict[str, Any]:
|
|
"""Build the per-request context injected into every resolver."""
|
|
try:
|
|
user = get_current_user(request)
|
|
except Exception:
|
|
logger.debug("Could not resolve current user for GraphQL context", exc_info=True)
|
|
user = None
|
|
return {"request": request, "db": db, "user": user}
|
|
|
|
|
|
graphql_router = GraphQLRouter(
|
|
schema,
|
|
context_getter=get_graphql_context,
|
|
graphql_ide="graphiql",
|
|
)
|