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:
@@ -23,7 +23,7 @@ features/doc-service/
|
||||
├── app/
|
||||
│ ├── main.py ← FastAPI, lifespan (file watcher start/stop)
|
||||
│ ├── database.py ← Same PostgreSQL instance as backend
|
||||
│ ├── deps.py ← get_user_id (x-user-id), get_user_groups (x-user-groups)
|
||||
│ ├── deps.py ← get_user_id, get_user_groups, get_user_is_admin, get_user_admin_groups (injected headers)
|
||||
│ ├── models/
|
||||
│ │ ├── document.py ← Document model
|
||||
│ │ ├── category.py ← DocumentCategory model
|
||||
@@ -78,12 +78,14 @@ features/doc-service/
|
||||
|
||||
### `document_categories`
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|--------|------|-------------|
|
||||
| `id` | String | PK, UUID |
|
||||
| `user_id` | String | indexed |
|
||||
| `name` | String(128) | NOT NULL |
|
||||
| `created_at` | DateTime(tz) | server_default=now() |
|
||||
| Column | Type | Constraints | Notes |
|
||||
|--------|------|-------------|-------|
|
||||
| `id` | String | PK, UUID | |
|
||||
| `user_id` | String | indexed | owner; "watch" for system categories |
|
||||
| `name` | String(128) | NOT NULL | PascalCase-with-dashes convention enforced on create/rename |
|
||||
| `scope` | String(16) | NOT NULL, default="personal" | "personal" / "group" / "system" |
|
||||
| `group_id` | String | nullable, indexed | set when scope="group" |
|
||||
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||
|
||||
### `document_category_assignments` (composite PK)
|
||||
|
||||
@@ -100,6 +102,7 @@ features/doc-service/
|
||||
| `document_id` | String | indexed, NOT NULL | not FK — trusts proxy |
|
||||
| `group_id` | String | indexed, NOT NULL | group from backend |
|
||||
| `shared_by_user_id` | String | NOT NULL | owner who shared |
|
||||
| `can_delete` | Boolean | NOT NULL, default=false | allows group members to delete the doc |
|
||||
| `created_at` | DateTime(tz) | server_default=now() | |
|
||||
|
||||
Unique constraint: `(document_id, group_id)`
|
||||
@@ -112,6 +115,8 @@ Unique constraint: `(document_id, group_id)`
|
||||
| `0002` | `add_document_title` |
|
||||
| `0003` | `add_watch_columns` |
|
||||
| `0004` | `add_document_shares` |
|
||||
| `0005` | `add_share_can_delete` |
|
||||
| `0006` | `add_category_scope` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add can_delete to document_shares
|
||||
|
||||
Revision ID: 0005
|
||||
Revises: 0004
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0005"
|
||||
down_revision: Union[str, None] = "0004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"document_shares",
|
||||
sa.Column(
|
||||
"can_delete",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default="false",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("document_shares", "can_delete")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add scope and group_id to document_categories
|
||||
|
||||
Revision ID: 0006
|
||||
Revises: 0005
|
||||
Create Date: 2026-04-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0006"
|
||||
down_revision: Union[str, None] = "0005"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"document_categories",
|
||||
sa.Column(
|
||||
"scope",
|
||||
sa.String(16),
|
||||
nullable=False,
|
||||
server_default="personal",
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"document_categories",
|
||||
sa.Column("group_id", sa.String(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_document_categories_group_id", "document_categories", ["group_id"])
|
||||
|
||||
# Migrate existing watch-owned categories to system scope
|
||||
op.execute("UPDATE document_categories SET scope = 'system' WHERE user_id = 'watch'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_document_categories_group_id", table_name="document_categories")
|
||||
op.drop_column("document_categories", "group_id")
|
||||
op.drop_column("document_categories", "scope")
|
||||
@@ -21,3 +21,22 @@ async def get_user_groups(x_user_groups: str = Header(default="")) -> list[str]:
|
||||
if not x_user_groups:
|
||||
return []
|
||||
return [g.strip() for g in x_user_groups.split(",") if g.strip()]
|
||||
|
||||
|
||||
async def get_user_is_admin(x_user_is_admin: str = Header(default="false")) -> bool:
|
||||
"""
|
||||
Extract the superuser flag injected by the main backend proxy.
|
||||
Returns True only when the header value is exactly "true".
|
||||
"""
|
||||
return x_user_is_admin.lower() == "true"
|
||||
|
||||
|
||||
async def get_user_admin_groups(x_user_admin_groups: str = Header(default="")) -> list[str]:
|
||||
"""
|
||||
Extract the group IDs for which the current user is a group admin.
|
||||
Injected by the main backend proxy from GroupMembership.is_group_admin.
|
||||
Returns an empty list if absent or empty.
|
||||
"""
|
||||
if not x_user_admin_groups:
|
||||
return []
|
||||
return [g.strip() for g in x_user_admin_groups.split(",") if g.strip()]
|
||||
|
||||
@@ -13,6 +13,8 @@ class DocumentCategory(Base):
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="personal", server_default="personal")
|
||||
group_id: Mapped[str | None] = mapped_column(String, nullable=True, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,6 +7,8 @@ class CategoryOut(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
name: str
|
||||
scope: str = "personal"
|
||||
group_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -14,6 +16,7 @@ class CategoryOut(BaseModel):
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
name: str
|
||||
group_id: str | None = None
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user