feat: category scopes, group-admin role, and permission model
- Three category scopes: personal / group / system (watch) - PascalCase-with-dashes naming convention enforced at backend + frontend - is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it - Categories router: scope-based list/create/rename/delete with _check_can_manage_cat - Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories - Proxy layers inject x-user-is-admin and x-user-admin-groups headers - Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
@@ -8,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import or_
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_id
|
||||
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
@@ -22,14 +23,31 @@ _WATCH_USER_ID = "watch"
|
||||
|
||||
_SIMILARITY_THRESHOLD = 0.4
|
||||
|
||||
# PascalCase-with-dashes: each word starts with a capital, words joined by '-'
|
||||
# Valid: Invoices, Vendor-Invoices, Q1-Reports
|
||||
# Invalid: invoices, Invoice Reports, Invoice-reports
|
||||
_NAME_RE = re.compile(r'^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$')
|
||||
|
||||
|
||||
def _validate_name(name: str) -> None:
|
||||
if not _NAME_RE.match(name):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"Category name must start with a capital letter. "
|
||||
"Multiple words are joined with dashes, each starting with a capital "
|
||||
"(e.g. Invoices, Vendor-Invoices, Q1-Reports)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _name_similarity(a: str, b: str) -> float:
|
||||
"""Return similarity score (0–1) between two category names."""
|
||||
a_low = a.lower()
|
||||
b_low = b.lower()
|
||||
# Word overlap is a strong signal
|
||||
a_words = set(a_low.split())
|
||||
b_words = set(b_low.split())
|
||||
a_words = set(a_low.split("-"))
|
||||
b_words = set(b_low.split("-"))
|
||||
if a_words & b_words:
|
||||
return 0.9
|
||||
# Fallback: character sequence ratio
|
||||
@@ -81,15 +99,44 @@ async def _reanalyze_documents_for_new_category(
|
||||
pass
|
||||
|
||||
|
||||
async def _check_can_manage_cat(
|
||||
cat: DocumentCategory,
|
||||
user_id: str,
|
||||
is_admin: bool,
|
||||
user_admin_groups: list[str],
|
||||
) -> None:
|
||||
"""Raise 403/404 if the current user may not rename or delete this category."""
|
||||
if is_admin:
|
||||
return # superuser can manage anything
|
||||
if cat.scope == "system":
|
||||
raise HTTPException(status_code=403, detail="Only admins can modify system categories")
|
||||
if cat.scope == "personal" and cat.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Category not found")
|
||||
if cat.scope == "group" and cat.group_id not in user_admin_groups:
|
||||
raise HTTPException(status_code=403, detail="Only group admins can modify group categories")
|
||||
|
||||
|
||||
@router.get("", response_model=list[CategoryOut])
|
||||
async def list_categories(
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DocumentCategory]:
|
||||
# Include watch-ingested categories so they appear in the sidebar/filter
|
||||
"""
|
||||
Return all categories visible to the current user:
|
||||
- personal (owned by the user)
|
||||
- group (any group the user belongs to)
|
||||
- system (watch-ingested, scope='system')
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(DocumentCategory)
|
||||
.where(or_(DocumentCategory.user_id == user_id, DocumentCategory.user_id == _WATCH_USER_ID))
|
||||
.where(
|
||||
or_(
|
||||
DocumentCategory.user_id == user_id, # personal
|
||||
DocumentCategory.group_id.in_(user_groups), # group
|
||||
DocumentCategory.scope == "system", # system
|
||||
)
|
||||
)
|
||||
.order_by(DocumentCategory.name)
|
||||
)
|
||||
return result.scalars().all()
|
||||
@@ -100,18 +147,32 @@ async def create_category(
|
||||
body: CategoryCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentCategory:
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
||||
_validate_name(name)
|
||||
|
||||
if body.group_id:
|
||||
# User must be a member of the target group
|
||||
if body.group_id not in user_groups:
|
||||
raise HTTPException(status_code=403, detail="You are not a member of that group")
|
||||
cat = DocumentCategory(
|
||||
user_id=user_id,
|
||||
name=name[:128],
|
||||
scope="group",
|
||||
group_id=body.group_id,
|
||||
)
|
||||
else:
|
||||
cat = DocumentCategory(user_id=user_id, name=name[:128], scope="personal")
|
||||
|
||||
cat = DocumentCategory(user_id=user_id, name=name[:128])
|
||||
db.add(cat)
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
|
||||
# Find existing categories with similar names
|
||||
# Find existing personal categories with similar names for background AI reanalysis
|
||||
result = await db.execute(
|
||||
select(DocumentCategory)
|
||||
.where(DocumentCategory.user_id == user_id)
|
||||
@@ -140,12 +201,18 @@ async def rename_category(
|
||||
cat_id: str,
|
||||
body: CategoryUpdate,
|
||||
user_id: str = Depends(get_user_id),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentCategory:
|
||||
cat = await _get_user_cat(cat_id, user_id, db)
|
||||
cat = await _fetch_visible_cat(cat_id, user_id, db)
|
||||
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
|
||||
|
||||
name = body.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=422, detail="Category name cannot be empty")
|
||||
_validate_name(name)
|
||||
|
||||
cat.name = name[:128]
|
||||
await db.commit()
|
||||
await db.refresh(cat)
|
||||
@@ -156,19 +223,20 @@ async def rename_category(
|
||||
async def delete_category(
|
||||
cat_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
cat = await _get_user_cat(cat_id, user_id, db)
|
||||
cat = await _fetch_visible_cat(cat_id, user_id, db)
|
||||
await _check_can_manage_cat(cat, user_id, is_admin, user_admin_groups)
|
||||
await db.delete(cat)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _get_user_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
|
||||
async def _fetch_visible_cat(cat_id: str, user_id: str, db: AsyncSession) -> DocumentCategory:
|
||||
"""Fetch a category that the user can see (personal/group/system). 404 if absent."""
|
||||
result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.id == cat_id,
|
||||
DocumentCategory.user_id == user_id,
|
||||
)
|
||||
select(DocumentCategory).where(DocumentCategory.id == cat_id)
|
||||
)
|
||||
cat = result.scalar_one_or_none()
|
||||
if cat is None:
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database import AsyncSessionLocal, get_db
|
||||
from app.deps import get_user_groups, get_user_id
|
||||
from app.deps import get_user_admin_groups, get_user_groups, get_user_id, get_user_is_admin
|
||||
from app.models.category import DocumentCategory
|
||||
from app.models.category_assignment import CategoryAssignment
|
||||
from app.models.document import Document
|
||||
@@ -479,14 +479,59 @@ async def reprocess_document(
|
||||
async def delete_document(
|
||||
doc_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
is_admin: bool = Depends(get_user_is_admin),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
user_admin_groups: list[str] = Depends(get_user_admin_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# Fetch the document (owner, watch, or shared with user's groups)
|
||||
result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
select(Document).where(
|
||||
Document.id == doc_id,
|
||||
or_(
|
||||
Document.user_id == user_id,
|
||||
Document.user_id == _WATCH_USER_ID,
|
||||
Document.id.in_(
|
||||
select(DocumentShare.document_id).where(
|
||||
DocumentShare.group_id.in_(user_groups)
|
||||
)
|
||||
) if user_groups else False,
|
||||
),
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
is_owner = doc.user_id == user_id
|
||||
|
||||
if not is_owner and not is_admin:
|
||||
# Check: shared with a group where the user has can_delete=True
|
||||
can_delete_via_share = False
|
||||
if user_groups:
|
||||
share_result = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id.in_(user_groups),
|
||||
DocumentShare.can_delete.is_(True),
|
||||
)
|
||||
)
|
||||
can_delete_via_share = share_result.scalar_one_or_none() is not None
|
||||
|
||||
# Check: user is a group admin for a group the doc is shared with
|
||||
can_delete_as_group_admin = False
|
||||
if user_admin_groups:
|
||||
admin_share_result = await db.execute(
|
||||
select(DocumentShare).where(
|
||||
DocumentShare.document_id == doc_id,
|
||||
DocumentShare.group_id.in_(user_admin_groups),
|
||||
)
|
||||
)
|
||||
can_delete_as_group_admin = admin_share_result.scalar_one_or_none() is not None
|
||||
|
||||
if not can_delete_via_share and not can_delete_as_group_admin:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to delete this document")
|
||||
|
||||
delete_file(doc.file_path)
|
||||
await db.delete(doc)
|
||||
await db.commit()
|
||||
@@ -537,6 +582,7 @@ async def assign_category(
|
||||
doc_id: str,
|
||||
cat_id: str,
|
||||
user_id: str = Depends(get_user_id),
|
||||
user_groups: list[str] = Depends(get_user_groups),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
doc_result = await db.execute(
|
||||
@@ -548,9 +594,15 @@ async def assign_category(
|
||||
if doc_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Accept personal, group (user is a member), or system categories
|
||||
cat_result = await db.execute(
|
||||
select(DocumentCategory).where(
|
||||
DocumentCategory.id == cat_id, DocumentCategory.user_id == user_id
|
||||
DocumentCategory.id == cat_id,
|
||||
or_(
|
||||
DocumentCategory.user_id == user_id,
|
||||
DocumentCategory.group_id.in_(user_groups) if user_groups else False,
|
||||
DocumentCategory.scope == "system",
|
||||
),
|
||||
)
|
||||
)
|
||||
if cat_result.scalar_one_or_none() is None:
|
||||
@@ -574,6 +626,13 @@ async def remove_category(
|
||||
user_id: str = Depends(get_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
# Only the document owner may remove a category assignment
|
||||
doc_result = await db.execute(
|
||||
select(Document).where(Document.id == doc_id, Document.user_id == user_id)
|
||||
)
|
||||
if doc_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(status_code=403, detail="Only the document owner can remove category assignments")
|
||||
|
||||
result = await db.execute(
|
||||
select(CategoryAssignment).where(
|
||||
CategoryAssignment.document_id == doc_id,
|
||||
|
||||
Reference in New Issue
Block a user