From db2c414c1ab142153df3419f9320677da0bbffdb Mon Sep 17 00:00:00 2001 From: indigo Date: Sat, 18 Jul 2026 20:41:17 +0800 Subject: [PATCH] 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 --- backend/main.py | 3 +- backend/migrate_role_permissions.py | 201 ++++++++++++++++ backend/models/__init__.py | 9 +- backend/models/role.py | 56 +++++ backend/models/task.py | 8 +- backend/models/user.py | 1 + backend/routers/assets.py | 28 +-- backend/routers/auth.py | 6 +- backend/routers/reviews.py | 10 +- backend/routers/roles.py | 164 +++++++++++++ backend/routers/shots.py | 30 +-- backend/routers/tasks.py | 200 ++++++++++++---- backend/routers/users.py | 56 ++++- backend/schemas/role.py | 52 ++++ backend/schemas/task.py | 9 +- backend/schemas/user.py | 5 +- backend/utils/auth.py | 95 ++++++++ frontend/src/components/layout/AppSidebar.vue | 2 + .../src/components/role/RoleFormDialog.vue | 226 ++++++++++++++++++ frontend/src/components/task/NoteItem.vue | 10 +- .../src/components/task/SubmissionCard.vue | 117 ++++++++- frontend/src/components/task/TaskNotes.vue | 27 ++- .../src/components/task/TaskSubmissions.vue | 2 + .../components/user/UserManagementTable.vue | 16 +- .../src/components/user/UserRolesDialog.vue | 74 ++++++ frontend/src/composables/usePermission.ts | 12 +- frontend/src/router/index.ts | 14 +- frontend/src/services/role.ts | 63 +++++ frontend/src/services/task.ts | 17 +- frontend/src/stores/roles.ts | 99 ++++++++ frontend/src/types/auth.ts | 9 + frontend/src/views/UsersView.vue | 42 ++++ .../src/views/admin/RoleManagementView.vue | 171 +++++++++++++ 33 files changed, 1697 insertions(+), 137 deletions(-) create mode 100644 backend/migrate_role_permissions.py create mode 100644 backend/models/role.py create mode 100644 backend/routers/roles.py create mode 100644 backend/schemas/role.py create mode 100644 frontend/src/components/role/RoleFormDialog.vue create mode 100644 frontend/src/components/user/UserRolesDialog.vue create mode 100644 frontend/src/services/role.ts create mode 100644 frontend/src/stores/roles.ts create mode 100644 frontend/src/views/admin/RoleManagementView.vue diff --git a/backend/main.py b/backend/main.py index 645c821..2c5194e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,7 +8,7 @@ import json import os from database import engine, Base -from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency +from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency, roles # Import models to ensure they are registered with SQLAlchemy import models @@ -109,6 +109,7 @@ app.include_router(notifications.router, tags=["notifications"]) app.include_router(activities.router, tags=["activities"]) app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"]) +app.include_router(roles.router, prefix="/roles", tags=["roles"]) @app.get("/") diff --git a/backend/migrate_role_permissions.py b/backend/migrate_role_permissions.py new file mode 100644 index 0000000..e0255b3 --- /dev/null +++ b/backend/migrate_role_permissions.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +One-off migration script for the multi-role permission system. + +Creates the new roles/permissions/role_permissions/user_roles tables, seeds +the permission catalog (asset/shot/task create/edit/delete, plus assignment, +review, submission, upload, and note actions), seeds the 4 system roles +(coordinator/director/artist/developer) with role_permissions matching +today's actual behavior, then backfills user_roles from each existing +user's legacy `role` column. + +Does not read or write users.role or users.is_admin beyond reading `role` to +resolve which system Role to link. Safe to re-run (every step is idempotent +and purely additive - re-running after adding new PERMISSIONS/grants below +only inserts what's missing, never removes or resets existing data). +""" + +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import DATABASE_URL, Base +from models.user import User +from models.role import Role, Permission +import logging + +import models # noqa: F401 - ensures every model is registered on Base.metadata + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# The full permission catalog: (resource, action, description). +PERMISSIONS = [ + ("asset", "create", "Can create assets"), + ("asset", "edit", "Can edit assets"), + ("asset", "delete", "Can delete assets"), + ("shot", "create", "Can create shots"), + ("shot", "edit", "Can edit shots"), + ("shot", "delete", "Can delete shots"), + ("task", "create", "Can create tasks"), + ("task", "edit", "Can edit tasks"), + ("task", "delete", "Can delete tasks"), + ("task", "change_status", "Can change task status"), + ("assignment", "create", "Can assign a task to a user"), + ("assignment", "edit", "Can reassign a task to a different user"), + ("assignment", "delete", "Can unassign a task"), + ("review", "publish", "Can approve a submission"), + ("review", "retake", "Can request a retake on a submission"), + ("submission", "create", "Can submit work for a task"), + ("submission", "edit", "Can edit a submission's notes"), + ("submission", "delete", "Can delete a submission"), + ("upload", "create", "Can upload task attachments"), + ("upload", "delete", "Can delete task attachments"), + ("note", "create", "Can add task notes"), + ("note", "edit", "Can edit task notes"), + ("note", "delete", "Can delete task notes"), + ("note", "view_internal", "Can view internal notes"), + ("note", "view_client", "Can view client notes"), +] + +SYSTEM_ROLE_DESCRIPTIONS = { + "coordinator": "Can create, edit, and delete assets, shots, and tasks.", + "director": "Director role, migrated from the legacy single-role system.", + "artist": "Artist role, migrated from the legacy single-role system.", + "developer": "Developer role, migrated from the legacy single-role system.", +} + +# Explicit per-role grants, faithful to today's actual behavior for each +# action (not a blanket "coordinator gets everything" - e.g. director +# already has real elevated rights on review publish/retake today, and +# non-artist roles already have unrestricted note/upload create today). +# submission:create is granted to nobody by default since today it's a pure +# ownership check (must literally be the assigned user), not role-based - +# is_admin still bypasses it, and self-service is preserved in the endpoint. +# note:view_internal goes to everyone (matches today - all notes are visible +# to anyone with task access); note:view_client is new/more restrictive by +# default, granted only to coordinator+director. +# task:change_status is granted to coordinator only. This deliberately +# resolves a pre-existing inconsistency: the single-task status endpoint only +# allowed COORDINATOR (+artist self-service), while the bulk endpoint also +# allowed DIRECTOR - now both use this one permission, so director loses the +# bulk-only status-change access they had before (can be re-granted via a +# custom role if that capability is actually wanted). +# submission:edit/delete are brand new endpoints (didn't exist before this +# permission was added), so there's no "today's behavior" to preserve - +# granted to coordinator as the natural production-lead capability; artists +# keep editing/deleting their own submissions via the ownership check. +SYSTEM_ROLE_GRANTS = { + "coordinator": { + ("asset", "create"), ("asset", "edit"), ("asset", "delete"), + ("shot", "create"), ("shot", "edit"), ("shot", "delete"), + ("task", "create"), ("task", "edit"), ("task", "delete"), ("task", "change_status"), + ("assignment", "create"), ("assignment", "edit"), ("assignment", "delete"), + ("review", "publish"), ("review", "retake"), + ("submission", "edit"), ("submission", "delete"), + ("upload", "create"), ("upload", "delete"), + ("note", "create"), ("note", "view_internal"), ("note", "view_client"), + }, + "director": { + ("review", "publish"), ("review", "retake"), + ("upload", "create"), + ("note", "create"), ("note", "view_internal"), ("note", "view_client"), + }, + "artist": { + ("note", "view_internal"), + }, + "developer": { + ("upload", "create"), + ("note", "create"), ("note", "view_internal"), + }, +} + + +def migrate_role_permissions(): + engine = create_engine(DATABASE_URL) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + + try: + # 0. Add note_type to production_notes if missing (create_all only + # creates missing tables, it doesn't alter existing ones). + try: + db.execute(text("SELECT note_type FROM production_notes LIMIT 1")) + logger.info("note_type column already exists") + except Exception: + db.rollback() + logger.info("Adding note_type column to production_notes table") + db.execute(text( + "ALTER TABLE production_notes ADD COLUMN note_type VARCHAR(8) NOT NULL DEFAULT 'internal'" + )) + db.commit() + + # 1. Seed permissions (idempotent by resource+action) + permissions_by_key = {} + created_permissions = 0 + for resource, action, description in PERMISSIONS: + perm = db.query(Permission).filter( + Permission.resource == resource, Permission.action == action + ).first() + if not perm: + perm = Permission(resource=resource, action=action, description=description) + db.add(perm) + db.flush() + created_permissions += 1 + permissions_by_key[(resource, action)] = perm + logger.info(f"Permissions: {created_permissions} created, {len(permissions_by_key)} total") + + # 2. Seed system roles (idempotent by name) + roles_by_name = {} + created_roles = 0 + for name, description in SYSTEM_ROLE_DESCRIPTIONS.items(): + role = db.query(Role).filter(Role.name == name).first() + if not role: + role = Role(name=name, description=description, is_system=True) + db.add(role) + db.flush() + created_roles += 1 + roles_by_name[name] = role + logger.info(f"System roles: {created_roles} created, {len(roles_by_name)} total") + + # 3. Link role_permissions (idempotent - only add missing links) + linked = 0 + for name, grants in SYSTEM_ROLE_GRANTS.items(): + role = roles_by_name[name] + for key in grants: + perm = permissions_by_key[key] + if perm not in role.permissions: + role.permissions.append(perm) + linked += 1 + db.commit() + logger.info(f"role_permissions: {linked} new links created") + + # 4. Backfill user_roles from users.role (idempotent - only add missing links) + users = db.query(User).all() + backfilled = 0 + for user in users: + role = roles_by_name.get(user.role.value) + if role and role not in user.roles: + user.roles.append(role) + backfilled += 1 + db.commit() + logger.info(f"user_roles: {backfilled} new links created for {len(users)} users") + + # Verification summary + logger.info("--- Migration summary ---") + for name, role in roles_by_name.items(): + logger.info(f" Role '{name}': is_system={role.is_system}, permissions={len(role.permissions)}, users={len(role.users)}") + + except Exception as e: + logger.error(f"Migration failed: {e}") + db.rollback() + raise + finally: + db.close() + + +if __name__ == "__main__": + logger.info("Starting role/permission migration...") + migrate_role_permissions() + logger.info("Migration completed!") diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 9e931bf..2881687 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -6,13 +6,14 @@ from .asset import Asset, AssetCategory, AssetStatus from .shot import Shot, ShotStatus from .task import ( Task, Submission, Review, ProductionNote, TaskAttachment, - TaskType, TaskStatus, ReviewDecision, AttachmentType + TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType ) from .api_key import APIKey, APIKeyScope from .api_key_usage import APIKeyUsage from .global_settings import GlobalSettings from .notification import Notification, UserNotificationPreference, NotificationType from .activity import Activity, ActivityType +from .role import Role, Permission, role_permissions, user_roles __all__ = [ # User models @@ -27,7 +28,7 @@ __all__ = [ "Shot", "ShotStatus", # Task models "Task", "Submission", "Review", "ProductionNote", "TaskAttachment", - "TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", + "TaskType", "TaskStatus", "ReviewDecision", "AttachmentType", "NoteType", # API Key models "APIKey", "APIKeyScope", "APIKeyUsage", # Global Settings models @@ -35,5 +36,7 @@ __all__ = [ # Notification models "Notification", "UserNotificationPreference", "NotificationType", # Activity models - "Activity", "ActivityType" + "Activity", "ActivityType", + # Role models + "Role", "Permission", "role_permissions", "user_roles" ] \ No newline at end of file diff --git a/backend/models/role.py b/backend/models/role.py new file mode 100644 index 0000000..524b83f --- /dev/null +++ b/backend/models/role.py @@ -0,0 +1,56 @@ +from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Table, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + + +role_permissions = Table( + "role_permissions", + Base.metadata, + Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), + Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True), +) + +user_roles = Table( + "user_roles", + Base.metadata, + Column("user_id", Integer, ForeignKey("users.id"), primary_key=True), + Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True), +) + + +class Permission(Base): + __tablename__ = "permissions" + + id = Column(Integer, primary_key=True, index=True) + resource = Column(String, nullable=False, index=True) # e.g. "asset", "shot", "task" + action = Column(String, nullable=False) # e.g. "create", "edit", "delete" + description = Column(String, nullable=True) + + __table_args__ = ( + UniqueConstraint("resource", "action", name="uq_permission_resource_action"), + ) + + # Relationships + roles = relationship("Role", secondary=role_permissions, back_populates="permissions") + + def __repr__(self): + return f"" + + +class Role(Base): + __tablename__ = "roles" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, nullable=False, index=True) + description = Column(String, nullable=True) + is_system = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + permissions = relationship("Permission", secondary=role_permissions, back_populates="roles") + users = relationship("User", secondary=user_roles, back_populates="roles") + + def __repr__(self): + return f"" diff --git a/backend/models/task.py b/backend/models/task.py index 6cb6232..05d0b82 100644 --- a/backend/models/task.py +++ b/backend/models/task.py @@ -38,6 +38,11 @@ class AttachmentType(str, enum.Enum): DOCUMENTATION = "documentation" +class NoteType(str, enum.Enum): + INTERNAL = "internal" + CLIENT = "client" + + class Task(Base): __tablename__ = "tasks" @@ -190,10 +195,11 @@ class ProductionNote(Base): task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) content = Column(Text, nullable=False) + note_type = Column(Enum(NoteType), nullable=False, default=NoteType.INTERNAL) parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - + # Soft deletion columns deleted_at = Column(DateTime(timezone=True), nullable=True) deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True) diff --git a/backend/models/user.py b/backend/models/user.py index bcf8886..0fd1292 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -47,6 +47,7 @@ class User(Base): api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan") notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") notification_preferences = relationship("UserNotificationPreference", back_populates="user", uselist=False, cascade="all, delete-orphan") + roles = relationship("Role", secondary="user_roles", back_populates="users") def __repr__(self): return f"" \ No newline at end of file diff --git a/backend/routers/assets.py b/backend/routers/assets.py index f2f5ecf..4ca6281 100644 --- a/backend/routers/assets.py +++ b/backend/routers/assets.py @@ -9,7 +9,7 @@ from models.task import Task, TaskType, TaskStatus from models.user import User, UserRole from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo from schemas.task import TaskCreate -from utils.auth import get_current_user_from_token +from utils.auth import get_current_user_from_token, require_permission from services.asset_soft_deletion import AssetSoftDeletionService router = APIRouter() @@ -24,22 +24,6 @@ def get_current_user_with_db( return _get_user_from_db(db, token_data["user_id"]) -def require_coordinator_or_admin( - token_data: dict = Depends(get_current_user_from_token), - db: Session = Depends(get_db) -): - """Require coordinator or admin role.""" - from utils.auth import _get_user_from_db - current_user = _get_user_from_db(db, token_data["user_id"]) - - if current_user.role != UserRole.COORDINATOR and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Insufficient permissions" - ) - return current_user - - def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int: """Get sort order for task status, including custom statuses.""" # Default system status order @@ -374,7 +358,7 @@ async def create_asset( asset: AssetCreate, project_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('asset', 'create')) ): """Create a new asset in a project with optional default tasks""" # Check project access @@ -536,7 +520,7 @@ async def create_asset_task( asset_id: int, task_type: str, # Changed from TaskType enum to str db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('task', 'create')) ): """Create a new task for an asset""" # Exclude soft deleted assets @@ -595,7 +579,7 @@ async def update_asset( asset_id: int, asset_update: AssetUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('asset', 'edit')) ): """Update an asset""" # Exclude soft deleted assets @@ -651,7 +635,7 @@ async def update_asset( async def get_asset_deletion_info( asset_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('asset', 'delete')) ): """Get information about what will be deleted when deleting an asset""" # Exclude soft deleted assets @@ -701,7 +685,7 @@ async def get_asset_deletion_info( async def delete_asset( asset_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('asset', 'delete')) ): """Soft delete an asset and all its associated data""" # Exclude soft deleted assets diff --git a/backend/routers/auth.py b/backend/routers/auth.py index dab3f15..7659a7c 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -54,7 +54,11 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)): db.add(new_user) db.commit() db.refresh(new_user) - + + from utils.auth import link_system_role + link_system_role(new_user, db) + db.commit() + return { "message": "User registered successfully. Awaiting admin approval.", "user_id": new_user.id diff --git a/backend/routers/reviews.py b/backend/routers/reviews.py index d12c122..1427e32 100644 --- a/backend/routers/reviews.py +++ b/backend/routers/reviews.py @@ -7,7 +7,7 @@ from database import get_db from models.task import Task, Submission, Review, TaskStatus from models.user import User, UserRole from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse -from utils.auth import get_current_user_from_token, _get_user_from_db +from utils.auth import get_current_user_from_token, _get_user_from_db, require_permission from utils.notifications import notification_service router = APIRouter() @@ -126,9 +126,9 @@ async def approve_submission( submission_id: int, review: ReviewCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_director_coordinator_or_admin) + current_user: User = Depends(require_permission('review', 'publish')) ): - """Approve a submission. Only directors, coordinators, and users with admin permission can approve.""" + """Approve a submission. Requires review:publish permission (or admin).""" submission = db.query(Submission).options( joinedload(Submission.task) @@ -189,9 +189,9 @@ async def request_retake( submission_id: int, review: ReviewCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_director_coordinator_or_admin) + current_user: User = Depends(require_permission('review', 'retake')) ): - """Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes.""" + """Request a retake for a submission. Requires review:retake permission (or admin).""" submission = db.query(Submission).options( joinedload(Submission.task) diff --git a/backend/routers/roles.py b/backend/routers/roles.py new file mode 100644 index 0000000..ca5b21a --- /dev/null +++ b/backend/routers/roles.py @@ -0,0 +1,164 @@ +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"} diff --git a/backend/routers/shots.py b/backend/routers/shots.py index 2b0a442..7c80035 100644 --- a/backend/routers/shots.py +++ b/backend/routers/shots.py @@ -12,7 +12,7 @@ from schemas.shot import ( ShotCreate, ShotUpdate, ShotResponse, ShotListResponse, BulkShotCreate, BulkShotResponse, TaskStatusInfo ) -from utils.auth import get_current_user_from_token +from utils.auth import get_current_user_from_token, require_permission from services.shot_soft_deletion import ShotSoftDeletionService router = APIRouter() @@ -27,22 +27,6 @@ def get_current_user_with_db( return _get_user_from_db(db, token_data["user_id"]) -def require_coordinator_or_admin( - token_data: dict = Depends(get_current_user_from_token), - db: Session = Depends(get_db) -): - """Require coordinator or admin role.""" - from utils.auth import _get_user_from_db - current_user = _get_user_from_db(db, token_data["user_id"]) - - if current_user.role != UserRole.COORDINATOR and not current_user.is_admin: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Insufficient permissions" - ) - return current_user - - def check_episode_access(episode_id: int, current_user: User, db: Session): """Check if user has access to the episode and its project.""" # Debug logging @@ -381,7 +365,7 @@ async def create_shot( episode_id: int, create_default_tasks: bool = True, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('shot', 'create')) ): """Create a new shot in an episode""" # Check episode access @@ -452,7 +436,7 @@ async def create_shots_bulk( bulk_shot: BulkShotCreate, episode_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('shot', 'create')) ): """Create multiple shots with naming pattern and default tasks""" # Check episode access @@ -674,7 +658,7 @@ async def create_shot_task( shot_id: int, task_type: str, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('task', 'create')) ): """Create a new task for a shot""" # Exclude soft deleted shots @@ -737,7 +721,7 @@ async def update_shot( shot_id: int, shot_update: ShotUpdate, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('shot', 'edit')) ): """Update a shot""" from sqlalchemy.orm import selectinload @@ -818,7 +802,7 @@ async def update_shot( async def get_shot_deletion_info( shot_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('shot', 'delete')) ): """Get information about what will be deleted when deleting a shot""" # Exclude soft deleted shots @@ -868,7 +852,7 @@ async def get_shot_deletion_info( async def delete_shot( shot_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_coordinator_or_admin) + current_user: User = Depends(require_permission('shot', 'delete')) ): """Soft delete a shot and all its associated data""" # Exclude soft deleted shots diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index a4df7d4..c7a34d9 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -8,7 +8,7 @@ import json from datetime import datetime from database import get_db -from models.task import Task, ProductionNote, TaskAttachment, Submission, Review +from models.task import Task, ProductionNote, TaskAttachment, Submission, Review, NoteType from models.user import User, UserRole, DepartmentRole from models.project import Project, ProjectMember from models.asset import Asset @@ -19,10 +19,10 @@ from schemas.task import ( TaskCreate, TaskUpdate, TaskResponse, TaskListResponse, TaskStatusUpdate, TaskAssignment, ProductionNoteCreate, ProductionNoteUpdate, ProductionNoteResponse, TaskAttachmentCreate, TaskAttachmentResponse, - SubmissionCreate, SubmissionResponse, + SubmissionCreate, SubmissionUpdate, SubmissionResponse, BulkStatusUpdate, BulkAssignment, BulkActionResult ) -from utils.auth import get_current_user_from_token, _get_user_from_db, require_role +from utils.auth import get_current_user_from_token, _get_user_from_db, require_role, require_permission, user_has_permission from utils.notifications import notification_service from utils.file_handler import file_handler @@ -93,20 +93,6 @@ def validate_task_status(db: Session, project_id: int, status_value: str) -> boo return False -def require_admin_or_coordinator( - token_data: dict = Depends(get_current_user_from_token), - db: Session = Depends(get_db) -): - """Dependency to require admin permission or coordinator role.""" - current_user = _get_user_from_db(db, token_data["user_id"]) - if not current_user.is_admin and current_user.role != UserRole.COORDINATOR: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Admin permission or Coordinator role required" - ) - return current_user - - def get_current_user( token_data: dict = Depends(get_current_user_from_token), db: Session = Depends(get_db) @@ -372,7 +358,7 @@ async def get_my_tasks( async def create_task( task: TaskCreate, db: Session = Depends(get_db), - current_user: User = Depends(require_admin_or_coordinator) + current_user: User = Depends(require_permission('task', 'create')) ): """Create a new task. Only coordinators and users with admin permission can create tasks.""" @@ -520,7 +506,7 @@ async def bulk_update_task_status( }) failed_count += 1 continue - elif current_user.role not in [UserRole.COORDINATOR, UserRole.DIRECTOR] and not current_user.is_admin: + elif not user_has_permission(current_user, 'task', 'change_status', db): errors.append({ "task_id": task_id, "error": "Insufficient permissions" @@ -597,7 +583,7 @@ async def bulk_update_task_status( async def bulk_assign_tasks( bulk_assignment: BulkAssignment, db: Session = Depends(get_db), - current_user: User = Depends(require_admin_or_coordinator) + current_user: User = Depends(require_permission('assignment', 'edit')) ): """ Assign multiple tasks to a user atomically. @@ -780,7 +766,7 @@ async def update_task( # Artists can only update status if task_update.model_dump(exclude_unset=True).keys() - {"status"}: raise HTTPException(status_code=403, detail="Artists can only update task status") - elif current_user.role != UserRole.COORDINATOR and not current_user.is_admin: + elif not user_has_permission(current_user, 'task', 'edit', db): raise HTTPException(status_code=403, detail="Not authorized to update tasks") # Verify assigned user if being updated @@ -789,7 +775,11 @@ async def update_task( assigned_user = db.query(User).filter(User.id == task_update.assigned_user_id).first() if not assigned_user: raise HTTPException(status_code=404, detail="Assigned user not found") - + + assignment_action = 'edit' if task.assigned_user_id else 'create' + if not user_has_permission(current_user, 'assignment', assignment_action, db): + raise HTTPException(status_code=403, detail="Not authorized to assign this task") + # Check if user is a project member project_member = db.query(ProjectMember).filter( and_( @@ -800,6 +790,8 @@ async def update_task( if not project_member: raise HTTPException(status_code=400, detail="Assigned user is not a member of this project") else: + if not user_has_permission(current_user, 'assignment', 'delete', db): + raise HTTPException(status_code=403, detail="Not authorized to unassign this task") task_update.assigned_user_id = None # Validate status if being updated @@ -893,9 +885,10 @@ async def update_task_status( raise HTTPException(status_code=404, detail="Task not found") # Permission check - if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized to update this task") - elif current_user.role not in [UserRole.ARTIST, UserRole.COORDINATOR] and not current_user.is_admin: + if current_user.role == UserRole.ARTIST: + if task.assigned_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to update this task") + elif not user_has_permission(current_user, 'task', 'change_status', db): raise HTTPException(status_code=403, detail="Not authorized to update task status") # Validate the status for the task's project @@ -966,10 +959,10 @@ async def assign_task( task_id: int, assignment: TaskAssignment, db: Session = Depends(get_db), - current_user: User = Depends(require_admin_or_coordinator) + current_user: User = Depends(get_current_user) ): """Assign a task to a user with department role filtering.""" - + task = db.query(Task).outerjoin(Shot, Task.shot_id == Shot.id).outerjoin(Asset, Task.asset_id == Asset.id).filter( Task.id == task_id, Task.deleted_at.is_(None), @@ -981,7 +974,13 @@ async def assign_task( ).first() if not task: raise HTTPException(status_code=404, detail="Task not found") - + + # Assigning a previously-unassigned task requires assignment:create; + # reassigning an already-assigned task requires assignment:edit. + assignment_action = 'edit' if task.assigned_user_id else 'create' + if not user_has_permission(current_user, 'assignment', assignment_action, db): + raise HTTPException(status_code=403, detail="Not authorized to assign this task") + # Verify assigned user exists and is a project member assigned_user = db.query(User).filter(User.id == assignment.assigned_user_id).first() if not assigned_user: @@ -1064,7 +1063,7 @@ async def assign_task( async def delete_task( task_id: int, db: Session = Depends(get_db), - current_user: User = Depends(require_admin_or_coordinator) + current_user: User = Depends(require_permission('task', 'delete')) ): """Delete a task. Only coordinators and users with admin permission can delete tasks.""" @@ -1118,7 +1117,18 @@ async def get_task_notes( ProductionNote.task_id == task_id, ProductionNote.deleted_at.is_(None) ).order_by(ProductionNote.created_at).all() - + + # Filter by note-type view permission (a note whose type the viewer can't + # see is dropped entirely - any reply to it is dropped too, since it + # would otherwise reference content the viewer isn't allowed to see) + can_view_internal = user_has_permission(current_user, 'note', 'view_internal', db) + can_view_client = user_has_permission(current_user, 'note', 'view_client', db) + notes = [ + note for note in notes + if (note.note_type == NoteType.INTERNAL and can_view_internal) + or (note.note_type == NoteType.CLIENT and can_view_client) + ] + # Build threaded structure notes_dict = {} root_notes = [] @@ -1127,6 +1137,7 @@ async def get_task_notes( note_data = { "id": note.id, "content": note.content, + "note_type": note.note_type, "parent_note_id": note.parent_note_id, "task_id": note.task_id, "user_id": note.user_id, @@ -1171,10 +1182,13 @@ async def create_task_note( if not task: raise HTTPException(status_code=404, detail="Task not found") - # Artists can only add notes to their own tasks - if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized to add notes to this task") - + # Artists can only add notes to their own tasks; everyone else needs note:create + if current_user.role == UserRole.ARTIST: + if task.assigned_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to add notes to this task") + elif not user_has_permission(current_user, 'note', 'create', db): + raise HTTPException(status_code=403, detail="Not authorized to add notes") + # Verify parent note exists if specified if note.parent_note_id: parent_note = db.query(ProductionNote).filter( @@ -1192,6 +1206,7 @@ async def create_task_note( task_id=task_id, user_id=current_user.id, content=note.content, + note_type=note.note_type, parent_note_id=note.parent_note_id ) db.add(db_note) @@ -1206,6 +1221,7 @@ async def create_task_note( note_data = { "id": db_note.id, "content": db_note.content, + "note_type": db_note.note_type, "parent_note_id": db_note.parent_note_id, "task_id": db_note.task_id, "user_id": db_note.user_id, @@ -1217,7 +1233,7 @@ async def create_task_note( "user_avatar_url": db_note.user.avatar_url, "child_notes": [] } - + return ProductionNoteResponse(**note_data) @@ -1244,8 +1260,9 @@ async def update_task_note( if not note: raise HTTPException(status_code=404, detail="Note not found") - # Users can only update their own notes, unless they have admin permission - if note.user_id != current_user.id and not current_user.is_admin: + # Users can only update their own notes, unless they have admin permission or note:edit + if (note.user_id != current_user.id and not current_user.is_admin + and not user_has_permission(current_user, 'note', 'edit', db)): raise HTTPException(status_code=403, detail="Not authorized to update this note") note.content = note_update.content @@ -1255,6 +1272,7 @@ async def update_task_note( note_data = { "id": note.id, "content": note.content, + "note_type": note.note_type, "parent_note_id": note.parent_note_id, "task_id": note.task_id, "user_id": note.user_id, @@ -1290,8 +1308,9 @@ async def delete_task_note( if not note: raise HTTPException(status_code=404, detail="Note not found") - # Users can only delete their own notes, unless they have admin permission - if note.user_id != current_user.id and not current_user.is_admin: + # Users can only delete their own notes, unless they have admin permission or note:delete + if (note.user_id != current_user.id and not current_user.is_admin + and not user_has_permission(current_user, 'note', 'delete', db)): raise HTTPException(status_code=403, detail="Not authorized to delete this note") db.delete(note) @@ -1368,9 +1387,12 @@ async def upload_task_attachment( if not task: raise HTTPException(status_code=404, detail="Task not found") - # Artists can only upload attachments to their own tasks - if current_user.role == UserRole.ARTIST and task.assigned_user_id != current_user.id: - raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task") + # Artists can only upload attachments to their own tasks; everyone else needs upload:create + if current_user.role == UserRole.ARTIST: + if task.assigned_user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to upload attachments to this task") + elif not user_has_permission(current_user, 'upload', 'create', db): + raise HTTPException(status_code=403, detail="Not authorized to upload attachments") # Validate file using file handler file_handler.validate_file(file, file_handler.MAX_ATTACHMENT_SIZE, db) @@ -1443,9 +1465,9 @@ async def delete_task_attachment( if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") - # Users can only delete their own attachments, unless they're admin/coordinator - if (attachment.user_id != current_user.id and - not current_user.is_admin and current_user.role != UserRole.COORDINATOR): + # Users can only delete their own attachments, unless they have admin permission or upload:delete + if (attachment.user_id != current_user.id and not current_user.is_admin + and not user_has_permission(current_user, 'upload', 'delete', db)): raise HTTPException(status_code=403, detail="Not authorized to delete this attachment") # Delete file from filesystem using file handler @@ -1534,8 +1556,9 @@ async def submit_work( if not task: raise HTTPException(status_code=404, detail="Task not found") - # Only assigned artist can submit work - if task.assigned_user_id != current_user.id: + # Only the assigned artist can submit work, unless the caller holds submission:create + if (task.assigned_user_id != current_user.id + and not user_has_permission(current_user, 'submission', 'create', db)): raise HTTPException(status_code=403, detail="Only the assigned artist can submit work for this task") # Validate file using file handler @@ -1598,5 +1621,84 @@ async def submit_work( "thumbnail_url": f"/files/submissions/{db_submission.id}?thumbnail=true" if file_handler.is_image_file(db_submission.file_path) else None, "stream_url": f"/files/submissions/{db_submission.id}/stream" if file_handler.is_video_file(db_submission.file_path) else None } - - return SubmissionResponse(**submission_data) \ No newline at end of file + + return SubmissionResponse(**submission_data) + + +@router.put("/{task_id}/submissions/{submission_id}", response_model=SubmissionResponse) +async def update_task_submission( + task_id: int, + submission_id: int, + submission_update: SubmissionUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Update a submission's notes. Users can only update their own submissions.""" + + submission = db.query(Submission).options( + joinedload(Submission.user) + ).filter( + Submission.id == submission_id, + Submission.task_id == task_id, + Submission.deleted_at.is_(None) + ).first() + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + + # Users can only update their own submissions, unless they have admin permission or submission:edit + if (submission.user_id != current_user.id and not current_user.is_admin + and not user_has_permission(current_user, 'submission', 'edit', db)): + raise HTTPException(status_code=403, detail="Not authorized to update this submission") + + if submission_update.notes is not None: + submission.notes = submission_update.notes + db.commit() + db.refresh(submission) + + submission_data = { + "id": submission.id, + "task_id": submission.task_id, + "user_id": submission.user_id, + "file_path": submission.file_path, + "file_name": submission.file_name, + "version_number": submission.version_number, + "notes": submission.notes, + "submitted_at": submission.submitted_at, + "user_first_name": submission.user.first_name, + "user_last_name": submission.user.last_name, + "latest_review": None, + "download_url": f"/files/submissions/{submission.id}", + "thumbnail_url": f"/files/submissions/{submission.id}?thumbnail=true" if file_handler.is_image_file(submission.file_path) else None, + "stream_url": f"/files/submissions/{submission.id}/stream" if file_handler.is_video_file(submission.file_path) else None + } + + return SubmissionResponse(**submission_data) + + +@router.delete("/{task_id}/submissions/{submission_id}") +async def delete_task_submission( + task_id: int, + submission_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """Soft delete a submission. Users can only delete their own submissions.""" + + submission = db.query(Submission).filter( + Submission.id == submission_id, + Submission.task_id == task_id, + Submission.deleted_at.is_(None) + ).first() + if not submission: + raise HTTPException(status_code=404, detail="Submission not found") + + # Users can only delete their own submissions, unless they have admin permission or submission:delete + if (submission.user_id != current_user.id and not current_user.is_admin + and not user_has_permission(current_user, 'submission', 'delete', db)): + raise HTTPException(status_code=403, detail="Not authorized to delete this submission") + + submission.deleted_at = datetime.utcnow() + submission.deleted_by = current_user.id + db.commit() + + return {"message": "Submission deleted successfully"} \ No newline at end of file diff --git a/backend/routers/users.py b/backend/routers/users.py index 3d28e05..99c9b56 100644 --- a/backend/routers/users.py +++ b/backend/routers/users.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from typing import List, Optional from passlib.context import CryptContext from pathlib import Path @@ -12,7 +12,9 @@ from database import get_db from models.user import User, UserRole from models.project import ProjectMember from models.task import Task +from models.role import Role from schemas.user import UserResponse, UserApproval, UserRoleUpdate, UserUpdate, UserAdminUpdate, UserAdminCreate, UserAdminEdit, UserPasswordReset, UserPasswordChange +from schemas.role import UserRolesUpdate from utils.auth import get_current_user_from_token, _get_user_from_db, require_admin_permission pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -98,7 +100,7 @@ async def update_user_role( user.role = role_data.role db.commit() - + return { "message": f"User {user.email} role updated to {role_data.role}", "user_id": user.id, @@ -106,6 +108,42 @@ async def update_user_role( } +@router.put("/{user_id}/roles", response_model=dict) +async def update_user_roles( + user_id: int, + roles_data: UserRolesUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin_permission_with_db) +): + """Replace a user's full set of assigned custom roles (Admin permission required). + + This is the new multi-role system and is independent of the legacy + single `role` field updated by PUT /{user_id}/role above. + """ + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + roles = db.query(Role).filter(Role.id.in_(roles_data.role_ids)).all() + if len(roles) != len(set(roles_data.role_ids)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="One or more role_ids are invalid" + ) + + user.roles = roles + db.commit() + + return { + "message": f"User {user.email} roles updated", + "user_id": user.id, + "role_ids": [role.id for role in user.roles] + } + + @router.put("/{user_id}/admin", response_model=dict) async def update_user_admin_permission( user_id: int, @@ -149,7 +187,7 @@ async def list_users( current_user: User = Depends(require_admin_or_coordinator) ): """List all users (Admin and Coordinator only).""" - users = db.query(User).offset(skip).limit(limit).all() + users = db.query(User).options(selectinload(User.roles)).offset(skip).limit(limit).all() return users @@ -169,7 +207,10 @@ async def get_current_user_profile( db: Session = Depends(get_db) ): """Get current user's profile.""" + from utils.auth import compute_effective_permissions + current_user = _get_user_from_db(db, token_data["user_id"]) + current_user.permissions = compute_effective_permissions(current_user, db) return current_user @@ -214,7 +255,7 @@ async def get_user( current_user: User = Depends(require_admin_or_coordinator) ): """Get user by ID (Admin and Coordinator only).""" - user = db.query(User).filter(User.id == user_id).first() + user = db.query(User).options(selectinload(User.roles)).filter(User.id == user_id).first() if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -255,7 +296,12 @@ async def admin_create_user( db.add(new_user) db.commit() db.refresh(new_user) - + + from utils.auth import link_system_role + link_system_role(new_user, db) + db.commit() + db.refresh(new_user) + return new_user diff --git a/backend/schemas/role.py b/backend/schemas/role.py new file mode 100644 index 0000000..15b9762 --- /dev/null +++ b/backend/schemas/role.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel +from typing import Optional, List +from datetime import datetime + + +class PermissionResponse(BaseModel): + id: int + resource: str + action: str + description: Optional[str] = None + + class Config: + from_attributes = True + + +class RoleSummary(BaseModel): + id: int + name: str + is_system: bool + + class Config: + from_attributes = True + + +class RoleResponse(BaseModel): + id: int + name: str + description: Optional[str] = None + is_system: bool + permissions: List[PermissionResponse] + user_count: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class RoleCreate(BaseModel): + name: str + description: Optional[str] = None + permission_ids: List[int] = [] + + +class RoleUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + permission_ids: Optional[List[int]] = None + + +class UserRolesUpdate(BaseModel): + role_ids: List[int] diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 7bbde44..8ced5bc 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -3,7 +3,7 @@ from typing import Optional, List from datetime import date, datetime from enum import Enum -from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType +from models.task import TaskType, TaskStatus, ReviewDecision, AttachmentType, NoteType from models.user import DepartmentRole @@ -94,7 +94,7 @@ class ProductionNoteBase(BaseModel): class ProductionNoteCreate(ProductionNoteBase): - pass + note_type: NoteType = NoteType.INTERNAL class ProductionNoteUpdate(BaseModel): @@ -105,6 +105,7 @@ class ProductionNoteResponse(ProductionNoteBase): id: int task_id: int user_id: int + note_type: NoteType created_at: datetime updated_at: datetime @@ -162,6 +163,10 @@ class SubmissionCreate(SubmissionBase): pass +class SubmissionUpdate(BaseModel): + notes: Optional[str] = None + + class SubmissionResponse(SubmissionBase): id: int task_id: int diff --git a/backend/schemas/user.py b/backend/schemas/user.py index fcecf53..68db011 100644 --- a/backend/schemas/user.py +++ b/backend/schemas/user.py @@ -1,7 +1,8 @@ from pydantic import BaseModel, EmailStr -from typing import Optional +from typing import Optional, List from datetime import datetime from models.user import UserRole +from schemas.role import RoleSummary class UserBase(BaseModel): @@ -30,6 +31,8 @@ class UserResponse(UserBase): avatar_url: Optional[str] = None created_at: datetime updated_at: datetime + roles: List[RoleSummary] = [] + permissions: Optional[List[str]] = None class Config: from_attributes = True diff --git a/backend/utils/auth.py b/backend/utils/auth.py index 899e2ae..344cb04 100644 --- a/backend/utils/auth.py +++ b/backend/utils/auth.py @@ -244,6 +244,101 @@ def require_admin_permission(): return admin_checker +def compute_effective_permissions(current_user, db: Session) -> list: + """Return the current user's effective permissions as "resource:action" strings. + + is_admin gets every permission in the catalog; otherwise the union of all + permissions granted by any of the user's assigned roles (most-permissive-wins). + """ + from models.role import Permission, role_permissions, user_roles + + if current_user.is_admin: + rows = db.query(Permission.resource, Permission.action).all() + else: + rows = ( + db.query(Permission.resource, Permission.action) + .join(role_permissions, role_permissions.c.permission_id == Permission.id) + .join(user_roles, user_roles.c.role_id == role_permissions.c.role_id) + .filter(user_roles.c.user_id == current_user.id) + .distinct() + .all() + ) + return [f"{resource}:{action}" for resource, action in rows] + + +def link_system_role(user, db: Session) -> None: + """Link a newly-created user to the system Role matching their legacy + `role` value, mirroring the one-time migration backfill so new users + start with the same baseline permissions as already-migrated ones. + No-op if the matching system role doesn't exist or is already linked. + Caller is responsible for commit/refresh. + """ + from models.role import Role + + role = db.query(Role).filter(Role.name == user.role.value, Role.is_system == True).first() + if role and role not in user.roles: + user.roles.append(role) + + +def user_has_permission(current_user, resource: str, action: str, db: Session) -> bool: + """Most-permissive-wins check across all of current_user's assigned roles. + + is_admin is a universal bypass and never needs a role/permission row. + """ + if current_user.is_admin: + return True + + from models.role import Permission, role_permissions, user_roles + + exists = ( + db.query(Permission.id) + .join(role_permissions, role_permissions.c.permission_id == Permission.id) + .join(user_roles, user_roles.c.role_id == role_permissions.c.role_id) + .filter( + user_roles.c.user_id == current_user.id, + Permission.resource == resource, + Permission.action == action, + ) + .first() + ) + return exists is not None + + +def require_permission(resource: str, action: str): + """Dependency factory requiring the current user to hold resource:action + via any assigned role (or be an admin). Usage: Depends(require_permission('shot', 'create')) + """ + def permission_checker( + token_data: dict = Depends(get_current_user_from_token), + db: Session = Depends(lambda: None) + ): + from database import get_db + + # Get database session if not provided + if db is None: + db_gen = get_db() + db = next(db_gen) + try: + current_user = _get_user_from_db(db, token_data["user_id"]) + if not user_has_permission(current_user, resource, action, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {resource}:{action}" + ) + return current_user + finally: + db.close() + else: + current_user = _get_user_from_db(db, token_data["user_id"]) + if not user_has_permission(current_user, resource, action, db): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Missing permission: {resource}:{action}" + ) + return current_user + return permission_checker + + def create_role_dependency(required_roles: list): """Create a dependency that requires specific user roles with proper DB injection.""" def role_checker( diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index 63c8ae4..db269ef 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -181,6 +181,7 @@ import { Camera, Package, ListTodo, + ShieldCheck, } from 'lucide-vue-next' import { useAuthStore } from '@/stores/auth' @@ -271,6 +272,7 @@ const navigationItems = computed(() => { // Admin-specific navigation items const adminItems = computed(() => [ { title: 'Recovery Management', url: '/admin/deleted-items', icon: RotateCcw }, + { title: 'Role Management', url: '/admin/roles', icon: ShieldCheck }, ]) // Developer-specific navigation items diff --git a/frontend/src/components/role/RoleFormDialog.vue b/frontend/src/components/role/RoleFormDialog.vue new file mode 100644 index 0000000..56d5b37 --- /dev/null +++ b/frontend/src/components/role/RoleFormDialog.vue @@ -0,0 +1,226 @@ + + + diff --git a/frontend/src/components/task/NoteItem.vue b/frontend/src/components/task/NoteItem.vue index e3e3cbe..96625b8 100644 --- a/frontend/src/components/task/NoteItem.vue +++ b/frontend/src/components/task/NoteItem.vue @@ -30,6 +30,7 @@ (edited) + Client @@ -117,6 +118,7 @@ import { ref, computed } from 'vue' import { Reply, Pencil, Trash2 } from 'lucide-vue-next' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { Textarea } from '@/components/ui/textarea' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { @@ -131,6 +133,7 @@ import { } from '@/components/ui/alert-dialog' import { taskService, type ProductionNote } from '@/services/task' import { useAuthStore } from '@/stores/auth' +import { usePermission } from '@/composables/usePermission' import { useToast } from '@/components/ui/toast/use-toast' const props = defineProps<{ @@ -145,17 +148,20 @@ const emit = defineEmits<{ const { toast } = useToast() const authStore = useAuthStore() +const { hasPermission } = usePermission() const editing = ref(false) const editContent = ref('') const showDeleteDialog = ref(false) +const isOwnNote = computed(() => authStore.user?.id === props.note.user_id) + const canEdit = computed(() => { - return authStore.user?.id === props.note.user_id || authStore.user?.is_admin + return isOwnNote.value || authStore.user?.is_admin || hasPermission('note', 'edit') }) const canDelete = computed(() => { - return authStore.user?.id === props.note.user_id || authStore.user?.is_admin + return isOwnNote.value || authStore.user?.is_admin || hasPermission('note', 'delete') }) function getInitials(firstName: string, lastName: string): string { diff --git a/frontend/src/components/task/SubmissionCard.vue b/frontend/src/components/task/SubmissionCard.vue index 6e6ae0b..15d176d 100644 --- a/frontend/src/components/task/SubmissionCard.vue +++ b/frontend/src/components/task/SubmissionCard.vue @@ -56,11 +56,19 @@ -
+

Notes:

{{ submission.notes }}

+
+