Files
LinkDesk/backend/routers/roles.py
T
indigo db2c414c1a Add multi-role permission system with a Role Management admin page
Users can now hold multiple roles, each with its own editable set of
create/edit/delete-style permissions across assets, shots, tasks, task
assignment, review approve/retake, submissions, uploads, and notes
(including internal vs. client note visibility). The 4 existing roles
(coordinator/director/artist/developer) are migrated into the new system
as system roles, seeded to reproduce today's actual behavior exactly;
admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the
new Role Management page and assign multiple roles to a user via a new
"Manage Roles" action on the Team page.

Backend:
- New Role/Permission models and role_permissions/user_roles tables,
  plus a one-off, idempotent seed/backfill migration script.
- New require_permission()/user_has_permission() dependency, wired into
  the actual mutation endpoints across shots/assets/tasks/reviews,
  always preserving existing ownership- and self-service-based access
  (e.g. artists editing their own task status, own notes, own uploads,
  own submissions) as an unconditional fallback alongside the new
  permission checks - nothing that worked before now requires a role.
- New endpoints: PUT/DELETE on task submissions (wires up soft-deletion
  columns that existed on the model but were never exposed), plus full
  role CRUD and per-user role assignment.
- Along the way: fixed newly-created users not being linked to their
  matching system role (silently leaving them with zero permissions),
  and unified an inconsistency between the single vs. bulk task status
  endpoints that allowed different roles to bulk-update status.

Frontend:
- Role Management page with a grouped, human-readable permission editor
  (icons, plain-language action labels, per-resource select-all, live
  selected count) replacing an earlier dense matrix prototype.
- hasPermission() added to the existing usePermission() composable
  without touching its current isAdmin/isCoordinatorOrAdmin consumers.
- Note composer gets an Internal/Client toggle; submissions gain inline
  edit/delete actions gated the same ownership-or-permission way as notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-18 20:41:17 +08:00

165 lines
5.7 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from database import get_db
from models.role import Role, Permission
from models.user import User
from schemas.role import (
RoleResponse, RoleCreate, RoleUpdate, PermissionResponse
)
from utils.auth import require_admin_permission
router = APIRouter()
def _role_to_response(role: Role) -> RoleResponse:
return RoleResponse(
id=role.id,
name=role.name,
description=role.description,
is_system=role.is_system,
permissions=[PermissionResponse.model_validate(p) for p in role.permissions],
user_count=len(role.users),
created_at=role.created_at,
updated_at=role.updated_at,
)
@router.get("/", response_model=List[RoleResponse])
async def list_roles(
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission())
):
"""List all roles with their permissions and assigned-user count (admin only)."""
roles = db.query(Role).order_by(Role.is_system.desc(), Role.name).all()
return [_role_to_response(role) for role in roles]
@router.get("/permissions", response_model=List[PermissionResponse])
async def list_permissions(
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission())
):
"""List the full permission catalog (admin only)."""
return db.query(Permission).order_by(Permission.resource, Permission.action).all()
@router.post("/", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
async def create_role(
role_data: RoleCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission())
):
"""Create a custom role (admin only)."""
existing = db.query(Role).filter(Role.name == role_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{role_data.name}' already exists"
)
permissions = []
if role_data.permission_ids:
permissions = db.query(Permission).filter(Permission.id.in_(role_data.permission_ids)).all()
if len(permissions) != len(set(role_data.permission_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more permission_ids are invalid"
)
role = Role(
name=role_data.name,
description=role_data.description,
is_system=False,
permissions=permissions,
)
db.add(role)
db.commit()
db.refresh(role)
return _role_to_response(role)
@router.put("/{role_id}", response_model=RoleResponse)
async def update_role(
role_id: int,
role_update: RoleUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission())
):
"""Update a role's name/description/permissions (admin only).
System roles cannot be renamed or have their description changed, but
their permissions ARE editable, same as custom roles.
"""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
if role.is_system:
if role_update.name is not None and role_update.name != role.name:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="System role name cannot be changed"
)
if role_update.description is not None and role_update.description != role.description:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="System role description cannot be changed"
)
else:
if role_update.name is not None:
existing = db.query(Role).filter(Role.name == role_update.name, Role.id != role_id).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{role_update.name}' already exists"
)
role.name = role_update.name
if role_update.description is not None:
role.description = role_update.description
if role_update.permission_ids is not None:
permissions = db.query(Permission).filter(Permission.id.in_(role_update.permission_ids)).all()
if len(permissions) != len(set(role_update.permission_ids)):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="One or more permission_ids are invalid"
)
role.permissions = permissions
db.commit()
db.refresh(role)
return _role_to_response(role)
@router.delete("/{role_id}")
async def delete_role(
role_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin_permission())
):
"""Delete a custom role (admin only). System roles and roles with assigned users cannot be deleted."""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
if role.is_system:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="System roles cannot be deleted"
)
user_count = len(role.users)
if user_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"{user_count} user(s) still have this role — reassign them first"
)
role.permissions = []
db.delete(role)
db.commit()
return {"message": f"Role '{role.name}' deleted successfully"}