feat(multi-user): add unclaimed doc visibility, claim/assign-owner endpoints, default_owner_id

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-05 21:22:51 +00:00
parent a8d44b189c
commit 5722252dcb
7 changed files with 427 additions and 3 deletions
+142
View File
@@ -1403,3 +1403,145 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
if exact_duplicate_warning:
response["duplicate_warning"] = exact_duplicate_warning
return response
# ---------------------------------------------------------------------------
# Document ownership / claim endpoints
# ---------------------------------------------------------------------------
@router.post("/files/{file_id}/claim")
@require_login
def claim_file(request: Request, file_id: int, db: DbSession):
"""
Claim an unowned document for the current user.
Only documents with ``owner_id IS NULL`` can be claimed. The requesting
user's identifier is written into ``owner_id``. In single-user mode
the endpoint is a no-op (returns the file unchanged).
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=401, detail="Authentication required to claim a document")
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
if file_record.owner_id is not None:
if file_record.owner_id == owner_id:
return {"status": "already_owned", "message": "You already own this document", "file_id": file_id}
raise HTTPException(status_code=403, detail="This document is already owned by another user")
file_record.owner_id = owner_id
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error claiming file {file_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to claim document")
logger.info(f"File {file_id} claimed by user '{owner_id}'")
return {"status": "success", "message": "Document claimed successfully", "file_id": file_id, "owner_id": owner_id}
@router.post("/files/bulk-claim")
@require_login
def bulk_claim_files(request: Request, file_ids: List[int], db: DbSession):
"""
Claim multiple unowned documents for the current user.
Only documents with ``owner_id IS NULL`` will be claimed. Documents
already owned (by anyone) are skipped and reported in ``skipped``.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=401, detail="Authentication required to claim documents")
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
claimed = []
skipped = []
for rec in file_records:
if rec.owner_id is None:
rec.owner_id = owner_id
claimed.append(rec.id)
else:
skipped.append({"file_id": rec.id, "reason": "already owned"})
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error during bulk claim: {e}")
raise HTTPException(status_code=500, detail="Failed to claim documents")
logger.info(f"Bulk claim by '{owner_id}': claimed={claimed}, skipped={[s['file_id'] for s in skipped]}")
return {
"status": "success",
"claimed_count": len(claimed),
"claimed_ids": claimed,
"skipped": skipped,
"owner_id": owner_id,
}
@router.post("/files/assign-owner")
@require_login
def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), file_ids: List[int] | None = None):
"""
Admin-only: assign an owner to documents.
If ``file_ids`` is provided, only those files are updated. If omitted,
**all** currently unowned documents (``owner_id IS NULL``) are assigned
to the given ``owner_id``.
"""
if not settings.multi_user_enabled:
raise HTTPException(status_code=400, detail="Multi-user mode is not enabled")
user = request.session.get("user")
if not isinstance(user, dict) or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Only admins can assign document owners")
if not owner_id or not owner_id.strip():
raise HTTPException(status_code=422, detail="owner_id must be a non-empty string")
owner_id = owner_id.strip()
if file_ids is not None:
# Assign to specific files
updated = (
db.query(FileRecord)
.filter(FileRecord.id.in_(file_ids))
.update({FileRecord.owner_id: owner_id}, synchronize_session="fetch")
)
else:
# Assign to all currently unowned documents
updated = (
db.query(FileRecord)
.filter(FileRecord.owner_id.is_(None))
.update({FileRecord.owner_id: owner_id}, synchronize_session="fetch")
)
try:
db.commit()
except Exception as e:
db.rollback()
logger.exception(f"Error assigning owner: {e}")
raise HTTPException(status_code=500, detail="Failed to assign owner")
admin_name = get_current_owner_id(request) or "admin"
logger.info(f"Admin '{admin_name}' assigned owner_id='{owner_id}' to {updated} file(s)")
return {
"status": "success",
"message": f"Assigned owner to {updated} document(s)",
"updated_count": updated,
"owner_id": owner_id,
}
+18
View File
@@ -134,6 +134,24 @@ class Settings(BaseSettings):
"Individual user limits can override this default. Default: 0 (unlimited)."
),
)
unowned_docs_visible_to_all: bool = Field(
default=True,
description=(
"In multi-user mode, controls whether documents without an owner (owner_id is NULL) "
"are visible to all authenticated users. When True, unowned documents appear in every "
"user's file list alongside their own files. When False, only admins can see unowned "
"documents. Default: True."
),
)
default_owner_id: Optional[str] = Field(
default=None,
description=(
"When set, automatically assigns this owner ID to newly ingested documents that would "
"otherwise have no owner (e.g. documents from IMAP, API without session, or legacy imports). "
"Use the admin /api/files/assign-owner endpoint to bulk-assign existing unclaimed documents. "
"Default: None (documents remain unowned until claimed)."
),
)
# Authentik
authentik_client_id: Optional[str] = None
+4
View File
@@ -55,6 +55,10 @@ def process_document(
- Otherwise, queue Azure Document Intelligence processing
3. If force_cloud_ocr is True, skip local text extraction and use cloud OCR
"""
# Fall back to the configured default_owner_id when no explicit owner was provided
if owner_id is None and settings.default_owner_id:
owner_id = settings.default_owner_id
task_id = self.request.id
logger.info(f"[{task_id}] Starting document processing: {original_local_file}")
log_task_progress(
+23
View File
@@ -100,6 +100,29 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"unowned_docs_visible_to_all": {
"category": "Authentication",
"description": (
"In multi-user mode, controls whether documents without an owner are visible to all users. "
"When True, unowned documents appear alongside each user's own files. "
"When False, only admins can see unowned documents."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
"default_owner_id": {
"category": "Authentication",
"description": (
"Automatically assigns this owner ID to newly ingested documents that would otherwise "
"have no owner. Leave empty to keep documents unowned until claimed."
),
"type": "string",
"sensitive": False,
"required": False,
"restart_required": False,
},
"session_secret": {
"category": "Authentication",
"description": "Secret key for session encryption (min 32 characters)",
+13 -1
View File
@@ -10,6 +10,7 @@ documents are visible to all users (single-user / shared mode).
import logging
from fastapi import Request
from sqlalchemy import or_
from sqlalchemy.orm import Query
from sqlalchemy.sql import false
@@ -47,6 +48,10 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
matches the authenticated user are returned. Admin users bypass
the filter and see all documents.
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
with ``owner_id IS NULL`` (unclaimed) are also included for every
authenticated user so they can be discovered and claimed.
When multi-user mode is disabled the query is returned unchanged.
Args:
@@ -69,4 +74,11 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
# No authenticated user — return empty result set
return query.filter(false())
return query.filter(FileRecord.owner_id == owner_id)
# Build filter: user's own documents
conditions = [FileRecord.owner_id == owner_id]
# Optionally include unclaimed (owner_id IS NULL) documents
if settings.unowned_docs_visible_to_all:
conditions.append(FileRecord.owner_id.is_(None))
return query.filter(or_(*conditions))