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 }}

+
+