Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db2c414c1a | |||
| 976ec40b52 |
+2
-1
@@ -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("/")
|
||||
|
||||
@@ -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!")
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"<Permission(id={self.id}, resource='{self.resource}', action='{self.action}')>"
|
||||
|
||||
|
||||
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"<Role(id={self.id}, name='{self.name}', is_system={self.is_system})>"
|
||||
@@ -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,6 +195,7 @@ 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())
|
||||
|
||||
@@ -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"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +55,10 @@ async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
@@ -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
|
||||
|
||||
+143
-41
@@ -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
|
||||
@@ -790,6 +776,10 @@ async def update_task(
|
||||
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,7 +959,7 @@ 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."""
|
||||
|
||||
@@ -982,6 +975,12 @@ async def assign_task(
|
||||
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."""
|
||||
|
||||
@@ -1119,6 +1118,17 @@ async def get_task_notes(
|
||||
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,9 +1182,12 @@ 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:
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -1600,3 +1623,82 @@ async def submit_work(
|
||||
}
|
||||
|
||||
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"}
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
@@ -256,6 +297,11 @@ async def admin_create_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
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -27,12 +27,65 @@
|
||||
<SidebarGroupLabel>Navigation</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem v-for="item in navigationItems" :key="item.title">
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
<!-- Collapsed sidebar + Projects (with an active project): hovering reveals sub-links,
|
||||
while the icon itself still navigates to /projects like any other nav item. -->
|
||||
<div
|
||||
v-if="item.title === 'Projects' && isCollapsed && projectTabs.length > 0"
|
||||
@mouseenter="showProjectFlyout = true"
|
||||
@mouseleave="showProjectFlyout = false"
|
||||
>
|
||||
<Popover :open="showProjectFlyout">
|
||||
<PopoverAnchor>
|
||||
<SidebarMenuButton as-child :title="item.title">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
class="w-48 p-1"
|
||||
@open-auto-focus.prevent
|
||||
@mouseenter="showProjectFlyout = true"
|
||||
@mouseleave="showProjectFlyout = false"
|
||||
>
|
||||
<router-link
|
||||
v-for="tab in projectTabs"
|
||||
:key="tab.id"
|
||||
:to="tab.route"
|
||||
class="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
:class="activeProjectTab === tab.id && 'bg-accent text-accent-foreground'"
|
||||
@click="showProjectFlyout = false"
|
||||
>
|
||||
<component :is="tab.icon" class="size-4" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</router-link>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<SidebarMenuButton as-child :tooltip="isCollapsed ? item.title : undefined">
|
||||
<router-link :to="item.url" class="flex items-center gap-2">
|
||||
<component :is="item.icon" class="size-4" />
|
||||
<span class="group-data-[collapsible=icon]:hidden">{{ item.title }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<!-- Project sub-navigation - shown under "Projects" when inside a specific project -->
|
||||
<SidebarMenuSub v-if="item.title === 'Projects' && projectTabs.length > 0">
|
||||
<SidebarMenuSubItem v-for="tab in projectTabs" :key="tab.id">
|
||||
<SidebarMenuSubButton as-child :is-active="activeProjectTab === tab.id">
|
||||
<router-link :to="tab.route" class="flex items-center gap-2">
|
||||
<component :is="tab.icon" class="size-4" />
|
||||
<span class="truncate">{{ tab.label }}</span>
|
||||
</router-link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
</SidebarMenuSub>
|
||||
</template>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
@@ -93,8 +146,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -105,6 +159,9 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
SidebarProps
|
||||
} from '@/components/ui/sidebar'
|
||||
@@ -120,6 +177,11 @@ import {
|
||||
Database,
|
||||
BarChart3,
|
||||
RotateCcw,
|
||||
LayoutDashboard,
|
||||
Camera,
|
||||
Package,
|
||||
ListTodo,
|
||||
ShieldCheck,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -131,6 +193,7 @@ const authStore = useAuthStore()
|
||||
const { isCoordinatorOrAdmin } = usePermission()
|
||||
const { state } = useSidebar()
|
||||
const route = useRoute()
|
||||
const showProjectFlyout = ref(false)
|
||||
const user = computed(() => authStore.user)
|
||||
const userRole = computed(() => authStore.user?.role || 'artist')
|
||||
|
||||
@@ -142,6 +205,38 @@ const isOnShotPage = computed(() => {
|
||||
return route.path.endsWith('/shots') || route.path.includes('/project/')
|
||||
})
|
||||
|
||||
// The project id when on a specific project's page (null on /projects, /projects/new, etc.)
|
||||
const currentProjectId = computed(() => {
|
||||
const id = route.params.projectId
|
||||
const parsed = typeof id === 'string' ? parseInt(id) : NaN
|
||||
return isNaN(parsed) ? null : parsed
|
||||
})
|
||||
|
||||
// Sub-nav items shown under the "Projects" nav item when inside a specific project
|
||||
const projectTabs = computed(() => {
|
||||
const id = currentProjectId.value
|
||||
if (id === null) return []
|
||||
return [
|
||||
{ id: 'overview', label: 'Overview', icon: LayoutDashboard, route: `/projects/${id}` },
|
||||
{ id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` },
|
||||
{ id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` },
|
||||
{ id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` },
|
||||
]
|
||||
})
|
||||
|
||||
const activeProjectTab = computed(() => {
|
||||
const id = currentProjectId.value
|
||||
if (id === null) return null
|
||||
const path = route.path
|
||||
if (path === `/projects/${id}`) return 'overview'
|
||||
if (path.startsWith(`/projects/${id}/shots`)) return 'shots'
|
||||
if (path.startsWith(`/projects/${id}/assets`)) return 'assets'
|
||||
if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks'
|
||||
if (path.startsWith(`/projects/${id}/settings`)) return 'settings'
|
||||
return null
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<SidebarProps>(), {
|
||||
collapsible: "icon",
|
||||
})
|
||||
@@ -177,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
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ role ? 'Edit Role' : 'Create Role' }}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ role?.is_system
|
||||
? 'System role permissions are editable, but the name and description cannot be changed.'
|
||||
: 'Define a role and the permissions it grants.' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="flex-1 min-h-0 flex flex-col gap-4">
|
||||
<div class="grid grid-cols-2 gap-4 flex-shrink-0">
|
||||
<div class="space-y-2">
|
||||
<Label for="role_name">Name</Label>
|
||||
<Input
|
||||
id="role_name"
|
||||
v-model="formData.name"
|
||||
placeholder="e.g. Reviewer"
|
||||
required
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="role_description">Description</Label>
|
||||
<Input
|
||||
id="role_description"
|
||||
v-model="formData.description"
|
||||
placeholder="What this role is for"
|
||||
:disabled="role?.is_system"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between flex-shrink-0">
|
||||
<Label>Permissions</Label>
|
||||
<span class="text-xs text-muted-foreground">{{ selectedPermissionIds.size }} of {{ permissions.length }} selected</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 overflow-y-auto rounded-md border p-3 space-y-4">
|
||||
<div v-for="group in permissionGroups" :key="group.title" class="space-y-2">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{{ group.title }}</h4>
|
||||
<div class="space-y-2">
|
||||
<div v-for="resource in group.resources" :key="resource" class="rounded-md border bg-muted/30 p-2.5">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2 text-sm font-medium capitalize">
|
||||
<component :is="resourceIcon(resource)" class="h-4 w-4 text-muted-foreground" />
|
||||
{{ resource }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
@click="toggleAllForResource(resource)"
|
||||
>
|
||||
{{ allSelectedForResource(resource) ? 'Clear' : 'Select all' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
<label
|
||||
v-for="perm in permissionsForResource(resource)"
|
||||
:key="perm.id"
|
||||
class="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||
:title="perm.description || undefined"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedPermissionIds.has(perm.id)"
|
||||
@update:model-value="(val) => togglePermission(perm.id, !!val)"
|
||||
/>
|
||||
{{ actionLabel(perm.action) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="text-sm text-destructive flex-shrink-0">{{ formError }}</p>
|
||||
|
||||
<DialogFooter class="flex-shrink-0">
|
||||
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
||||
<Button type="submit" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Package, Camera, ListTodo, UserCheck, CheckCircle2, UploadCloud, Paperclip, MessageSquare, Shield
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import type { Role, Permission } from '@/services/role'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
role: Role | null
|
||||
permissions: Permission[]
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [data: { name?: string; description?: string; permission_ids: number[] }]
|
||||
}>()
|
||||
|
||||
const formData = ref({ name: '', description: '' })
|
||||
const selectedPermissionIds = ref<Set<number>>(new Set())
|
||||
const formError = ref('')
|
||||
|
||||
// Resources grouped into scannable sections, in a fixed, deliberate order
|
||||
// (not alphabetical) so related concepts sit together.
|
||||
const RESOURCE_GROUPS: { title: string; resources: string[] }[] = [
|
||||
{ title: 'Production', resources: ['asset', 'shot', 'task'] },
|
||||
{ title: 'Task workflow', resources: ['assignment', 'submission', 'upload'] },
|
||||
{ title: 'Review', resources: ['review'] },
|
||||
{ title: 'Notes', resources: ['note'] },
|
||||
]
|
||||
|
||||
const RESOURCE_ICONS: Record<string, any> = {
|
||||
asset: Package,
|
||||
shot: Camera,
|
||||
task: ListTodo,
|
||||
assignment: UserCheck,
|
||||
submission: UploadCloud,
|
||||
upload: Paperclip,
|
||||
review: CheckCircle2,
|
||||
note: MessageSquare,
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
publish: 'Approve',
|
||||
retake: 'Request Retake',
|
||||
view_internal: 'View Internal',
|
||||
view_client: 'View Client',
|
||||
change_status: 'Change Status',
|
||||
}
|
||||
|
||||
function resourceIcon(resource: string) {
|
||||
return RESOURCE_ICONS[resource] ?? Shield
|
||||
}
|
||||
|
||||
function actionLabel(action: string): string {
|
||||
return ACTION_LABELS[action] ?? action
|
||||
}
|
||||
|
||||
// Only show groups/resources that actually have permissions in the catalog,
|
||||
// and fall back to a catch-all group for any future resource not yet
|
||||
// assigned to a section above (so nothing silently disappears from the UI).
|
||||
const permissionGroups = computed(() => {
|
||||
const knownResources = new Set(RESOURCE_GROUPS.flatMap(g => g.resources))
|
||||
const groups = RESOURCE_GROUPS
|
||||
.map(g => ({ title: g.title, resources: g.resources.filter(r => permissionsForResource(r).length > 0) }))
|
||||
.filter(g => g.resources.length > 0)
|
||||
|
||||
const otherResources = [...new Set(props.permissions.map(p => p.resource))].filter(r => !knownResources.has(r))
|
||||
if (otherResources.length > 0) {
|
||||
groups.push({ title: 'Other', resources: otherResources })
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function permissionsForResource(resource: string): Permission[] {
|
||||
return props.permissions.filter(p => p.resource === resource)
|
||||
}
|
||||
|
||||
function allSelectedForResource(resource: string): boolean {
|
||||
const perms = permissionsForResource(resource)
|
||||
return perms.length > 0 && perms.every(p => selectedPermissionIds.value.has(p.id))
|
||||
}
|
||||
|
||||
function toggleAllForResource(resource: string) {
|
||||
const perms = permissionsForResource(resource)
|
||||
const shouldSelect = !allSelectedForResource(resource)
|
||||
for (const perm of perms) {
|
||||
if (shouldSelect) selectedPermissionIds.value.add(perm.id)
|
||||
else selectedPermissionIds.value.delete(perm.id)
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(id: number, checked: boolean) {
|
||||
if (checked) selectedPermissionIds.value.add(id)
|
||||
else selectedPermissionIds.value.delete(id)
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
formError.value = ''
|
||||
formData.value = {
|
||||
name: props.role?.name ?? '',
|
||||
description: props.role?.description ?? ''
|
||||
}
|
||||
selectedPermissionIds.value = new Set(props.role?.permissions.map(p => p.id) ?? [])
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
formError.value = ''
|
||||
if (!props.role && !formData.value.name.trim()) {
|
||||
formError.value = 'Name is required'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = props.role?.is_system
|
||||
? { permission_ids: [...selectedPermissionIds.value] }
|
||||
: {
|
||||
name: formData.value.name,
|
||||
description: formData.value.description || undefined,
|
||||
permission_ids: [...selectedPermissionIds.value]
|
||||
}
|
||||
emit('saved', payload)
|
||||
}
|
||||
</script>
|
||||
@@ -30,6 +30,7 @@
|
||||
<span v-if="note.updated_at !== note.created_at" class="text-xs text-muted-foreground">
|
||||
(edited)
|
||||
</span>
|
||||
<Badge v-if="note.note_type === 'client'" variant="outline" class="text-xs">Client</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Note Content -->
|
||||
@@ -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 {
|
||||
|
||||
@@ -56,11 +56,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.notes" class="text-sm bg-muted p-2 rounded">
|
||||
<div v-if="!editing && submission.notes" class="text-sm bg-muted p-2 rounded">
|
||||
<p class="font-semibold text-xs mb-1">Notes:</p>
|
||||
<p class="line-clamp-2">{{ submission.notes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="editing" class="space-y-2">
|
||||
<Textarea v-model="editNotes" rows="2" class="resize-none text-sm" placeholder="Notes about this submission..." />
|
||||
<div class="flex gap-2">
|
||||
<Button size="sm" @click="handleSaveNotes">Save</Button>
|
||||
<Button size="sm" variant="outline" @click="editing = false">Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="submission.latest_review?.feedback" class="text-sm border-l-2 pl-2" :class="getReviewBorderClass(submission.latest_review.decision)">
|
||||
<p class="font-semibold text-xs mb-1">Review Feedback:</p>
|
||||
<p class="line-clamp-2">{{ submission.latest_review.feedback }}</p>
|
||||
@@ -69,37 +77,122 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('view', submission)"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<div v-if="!editing" class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="emit('view', submission)"
|
||||
>
|
||||
<Eye class="h-4 w-4 mr-2" />
|
||||
View Details
|
||||
</Button>
|
||||
<Button v-if="canEdit" variant="ghost" size="sm" @click="startEdit">
|
||||
<Pencil class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button v-if="canDelete" variant="ghost" size="sm" @click="showDeleteDialog = true">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Submission</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this submission? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { FileIcon, Download, Eye, Play } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { FileIcon, Download, Eye, Play, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Card } from '@/components/ui/card'
|
||||
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 type { Submission } from '@/services/task'
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { taskService, type Submission } from '@/services/task'
|
||||
import { apiClient } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { usePermission } from '@/composables/usePermission'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
submission: Submission
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
view: [submission: Submission]
|
||||
submissionUpdated: []
|
||||
}>()
|
||||
|
||||
const { toast } = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const isOwnSubmission = computed(() => authStore.user?.id === props.submission.user_id)
|
||||
const canEdit = computed(() => isOwnSubmission.value || authStore.user?.is_admin || hasPermission('submission', 'edit'))
|
||||
const canDelete = computed(() => isOwnSubmission.value || authStore.user?.is_admin || hasPermission('submission', 'delete'))
|
||||
|
||||
const editing = ref(false)
|
||||
const editNotes = ref('')
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
function startEdit() {
|
||||
editing.value = true
|
||||
editNotes.value = props.submission.notes || ''
|
||||
}
|
||||
|
||||
async function handleSaveNotes() {
|
||||
try {
|
||||
await taskService.updateSubmission(props.taskId, props.submission.id, editNotes.value)
|
||||
editing.value = false
|
||||
emit('submissionUpdated')
|
||||
toast({ title: 'Success', description: 'Submission updated successfully' })
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to update submission',
|
||||
variant: 'destructive'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await taskService.deleteSubmission(props.taskId, props.submission.id)
|
||||
emit('submissionUpdated')
|
||||
toast({ title: 'Success', description: 'Submission deleted successfully' })
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to delete submission',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const thumbnailBlobUrl = ref<string | null>(null)
|
||||
|
||||
function getFileExtension(filename: string): string {
|
||||
|
||||
@@ -26,7 +26,25 @@
|
||||
rows="2"
|
||||
class="resize-none text-sm"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="newNoteType === 'internal' ? 'secondary' : 'ghost'"
|
||||
@click="newNoteType = 'internal'"
|
||||
>
|
||||
Internal
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="newNoteType === 'client' ? 'secondary' : 'ghost'"
|
||||
@click="newNoteType = 'client'"
|
||||
>
|
||||
Client
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
@click="handleAddNote"
|
||||
:disabled="!newNoteContent.trim() || submitting"
|
||||
@@ -47,7 +65,7 @@ import { MessageSquarePlus } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import NoteItem from './NoteItem.vue'
|
||||
import { taskService, type ProductionNote } from '@/services/task'
|
||||
import { taskService, type ProductionNote, type NoteType } from '@/services/task'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -62,6 +80,7 @@ const emit = defineEmits<{
|
||||
const { toast } = useToast()
|
||||
|
||||
const newNoteContent = ref('')
|
||||
const newNoteType = ref<NoteType>('internal')
|
||||
const submitting = ref(false)
|
||||
const replyToNoteId = ref<number | null>(null)
|
||||
|
||||
@@ -73,9 +92,11 @@ async function handleAddNote() {
|
||||
await taskService.createTaskNote(
|
||||
props.taskId,
|
||||
newNoteContent.value,
|
||||
replyToNoteId.value || undefined
|
||||
replyToNoteId.value || undefined,
|
||||
newNoteType.value
|
||||
)
|
||||
newNoteContent.value = ''
|
||||
newNoteType.value = 'internal'
|
||||
replyToNoteId.value = null
|
||||
emit('notesUpdated')
|
||||
toast({
|
||||
|
||||
@@ -11,8 +11,10 @@
|
||||
<SubmissionCard
|
||||
v-for="submission in submissions"
|
||||
:key="submission.id"
|
||||
:task-id="taskId"
|
||||
:submission="submission"
|
||||
@view="handleView"
|
||||
@submission-updated="emit('submissionsUpdated')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,10 @@
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{{ formatRole(user.role) }}</Badge>
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline">{{ formatRole(user.role) }}</Badge>
|
||||
<Badge v-for="role in user.roles" :key="role.id" variant="secondary">{{ role.name }}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="user.is_admin" variant="destructive">Admin</Badge>
|
||||
@@ -144,6 +147,10 @@
|
||||
<Key class="h-4 w-4 mr-2" />
|
||||
Reset Password
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="handleManageRoles(user)">
|
||||
<ShieldCheck class="h-4 w-4 mr-2" />
|
||||
Manage Roles
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
v-if="!user.is_approved"
|
||||
@@ -216,7 +223,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown } from "lucide-vue-next";
|
||||
import { MoreHorizontal, Eye, Check, UserX, UserCheck, Edit, Key, Trash2, ArrowUpDown, ShieldCheck } from "lucide-vue-next";
|
||||
import type { User } from "@/types/auth";
|
||||
|
||||
interface Props {
|
||||
@@ -232,6 +239,7 @@ interface Emits {
|
||||
(e: "approveUser", userId: number): void;
|
||||
(e: "editUser", user: User): void;
|
||||
(e: "resetPassword", user: User): void;
|
||||
(e: "manageRoles", user: User): void;
|
||||
(e: "deleteUser", user: User): void;
|
||||
}
|
||||
|
||||
@@ -378,6 +386,10 @@ const handleResetPassword = (user: User) => {
|
||||
emit("resetPassword", user);
|
||||
};
|
||||
|
||||
const handleManageRoles = (user: User) => {
|
||||
emit("manageRoles", user);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (user: User) => {
|
||||
emit("deleteUser", user);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Roles</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ user ? `${user.first_name} ${user.last_name}` : '' }} — assign custom roles in addition to their base role
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-2 max-h-72 overflow-auto">
|
||||
<label
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
class="flex items-center gap-2 rounded-md p-2 hover:bg-accent cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="selectedRoleIds.has(role.id)"
|
||||
@update:model-value="(val) => toggleRole(role.id, !!val)"
|
||||
/>
|
||||
<span class="flex-1">{{ role.name }}</span>
|
||||
<Badge :variant="role.is_system ? 'secondary' : 'outline'">{{ role.is_system ? 'System' : 'Custom' }}</Badge>
|
||||
</label>
|
||||
<p v-if="roles.length === 0" class="text-sm text-muted-foreground p-2">No roles available.</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" @click="$emit('update:open', false)">Cancel</Button>
|
||||
<Button :disabled="saving" @click="handleSave">{{ saving ? 'Saving...' : 'Save' }}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Role } from '@/services/role'
|
||||
import type { User } from '@/types/auth'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
user: User | null
|
||||
roles: Role[]
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
saved: [roleIds: number[]]
|
||||
}>()
|
||||
|
||||
const selectedRoleIds = ref<Set<number>>(new Set())
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
selectedRoleIds.value = new Set(props.user?.roles?.map(r => r.id) ?? [])
|
||||
})
|
||||
|
||||
function toggleRole(id: number, checked: boolean) {
|
||||
if (checked) selectedRoleIds.value.add(id)
|
||||
else selectedRoleIds.value.delete(id)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit('saved', [...selectedRoleIds.value])
|
||||
}
|
||||
</script>
|
||||
@@ -15,5 +15,15 @@ export function usePermission() {
|
||||
authStore.user?.role === 'coordinator' || !!authStore.user?.is_admin
|
||||
)
|
||||
|
||||
return { isAdmin, isCoordinatorOrAdmin }
|
||||
/**
|
||||
* Resource/action check backed by the new multi-role permission system
|
||||
* (e.g. hasPermission('shot', 'create')). Reads authStore.user.permissions,
|
||||
* which is only ever populated on the current session's own user.
|
||||
*/
|
||||
function hasPermission(resource: string, action: string): boolean {
|
||||
if (authStore.isAdmin) return true
|
||||
return authStore.user?.permissions?.includes(`${resource}:${action}`) ?? false
|
||||
}
|
||||
|
||||
return { isAdmin, isCoordinatorOrAdmin, hasPermission }
|
||||
}
|
||||
|
||||
@@ -135,6 +135,16 @@ const routes: RouteRecordRaw[] = [
|
||||
title: 'Recovery Management'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/roles',
|
||||
name: 'RoleManagement',
|
||||
component: () => import('@/views/admin/RoleManagementView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
adminPermission: 'required',
|
||||
title: 'Role Management'
|
||||
}
|
||||
},
|
||||
|
||||
// Developer routes
|
||||
{
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { apiClient } from './api'
|
||||
|
||||
export interface Permission {
|
||||
id: number
|
||||
resource: string
|
||||
action: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: number
|
||||
name: string
|
||||
description?: string | null
|
||||
is_system: boolean
|
||||
permissions: Permission[]
|
||||
user_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface RoleCreate {
|
||||
name: string
|
||||
description?: string
|
||||
permission_ids?: number[]
|
||||
}
|
||||
|
||||
export interface RoleUpdate {
|
||||
name?: string
|
||||
description?: string
|
||||
permission_ids?: number[]
|
||||
}
|
||||
|
||||
export const roleService = {
|
||||
async getRoles(): Promise<Role[]> {
|
||||
const response = await apiClient.get('/roles/')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getPermissions(): Promise<Permission[]> {
|
||||
const response = await apiClient.get('/roles/permissions')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createRole(data: RoleCreate): Promise<Role> {
|
||||
const response = await apiClient.post('/roles/', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
|
||||
const response = await apiClient.put(`/roles/${roleId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async deleteRole(roleId: number): Promise<{ message: string }> {
|
||||
const response = await apiClient.delete(`/roles/${roleId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateUserRoles(userId: number, roleIds: number[]): Promise<{ message: string; user_id: number; role_ids: number[] }> {
|
||||
const response = await apiClient.put(`/users/${userId}/roles`, { role_ids: roleIds })
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -55,9 +55,12 @@ export interface TaskStatusInfo {
|
||||
assigned_user_id?: number
|
||||
}
|
||||
|
||||
export type NoteType = 'internal' | 'client'
|
||||
|
||||
export interface ProductionNote {
|
||||
id: number
|
||||
content: string
|
||||
note_type: NoteType
|
||||
parent_note_id?: number
|
||||
task_id: number
|
||||
user_id: number
|
||||
@@ -184,10 +187,11 @@ class TaskService {
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createTaskNote(taskId: number, content: string, parentNoteId?: number): Promise<ProductionNote> {
|
||||
async createTaskNote(taskId: number, content: string, parentNoteId?: number, noteType: NoteType = 'internal'): Promise<ProductionNote> {
|
||||
const response = await apiClient.post(`/tasks/${taskId}/notes`, {
|
||||
content,
|
||||
parent_note_id: parentNoteId
|
||||
parent_note_id: parentNoteId,
|
||||
note_type: noteType
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
@@ -256,6 +260,15 @@ class TaskService {
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updateSubmission(taskId: number, submissionId: number, notes: string): Promise<Submission> {
|
||||
const response = await apiClient.put(`/tasks/${taskId}/submissions/${submissionId}`, { notes })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteSubmission(taskId: number, submissionId: number): Promise<void> {
|
||||
await apiClient.delete(`/tasks/${taskId}/submissions/${submissionId}`)
|
||||
}
|
||||
|
||||
async createAssetTask(assetId: number, taskType: string): Promise<TaskStatusInfo> {
|
||||
const response = await apiClient.post(`/assets/${assetId}/tasks?task_type=${taskType}`)
|
||||
return response.data
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { roleService, type Role, type Permission, type RoleCreate, type RoleUpdate } from '@/services/role'
|
||||
|
||||
// Cache duration: 5 minutes
|
||||
const CACHE_DURATION = 5 * 60 * 1000
|
||||
|
||||
export const useRolesStore = defineStore('roles', () => {
|
||||
const roles = ref<Role[] | null>(null)
|
||||
const permissions = ref<Permission[] | null>(null)
|
||||
const rolesLastFetched = ref<number>(0)
|
||||
const permissionsLastFetched = ref<number>(0)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
let rolesInFlight: Promise<Role[]> | null = null
|
||||
let permissionsInFlight: Promise<Permission[]> | null = null
|
||||
|
||||
async function fetchRoles(force = false): Promise<Role[]> {
|
||||
const now = Date.now()
|
||||
if (!force && roles.value && now - rolesLastFetched.value < CACHE_DURATION) {
|
||||
return roles.value
|
||||
}
|
||||
if (rolesInFlight) return rolesInFlight
|
||||
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
rolesInFlight = (async () => {
|
||||
try {
|
||||
const data = await roleService.getRoles()
|
||||
roles.value = data
|
||||
rolesLastFetched.value = Date.now()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch roles'
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
rolesInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return rolesInFlight
|
||||
}
|
||||
|
||||
async function fetchPermissions(force = false): Promise<Permission[]> {
|
||||
const now = Date.now()
|
||||
if (!force && permissions.value && now - permissionsLastFetched.value < CACHE_DURATION) {
|
||||
return permissions.value
|
||||
}
|
||||
if (permissionsInFlight) return permissionsInFlight
|
||||
|
||||
permissionsInFlight = (async () => {
|
||||
try {
|
||||
const data = await roleService.getPermissions()
|
||||
permissions.value = data
|
||||
permissionsLastFetched.value = Date.now()
|
||||
return data
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || 'Failed to fetch permissions'
|
||||
throw err
|
||||
} finally {
|
||||
permissionsInFlight = null
|
||||
}
|
||||
})()
|
||||
|
||||
return permissionsInFlight
|
||||
}
|
||||
|
||||
async function createRole(data: RoleCreate): Promise<Role> {
|
||||
const role = await roleService.createRole(data)
|
||||
if (roles.value) roles.value = [...roles.value, role]
|
||||
return role
|
||||
}
|
||||
|
||||
async function updateRole(roleId: number, data: RoleUpdate): Promise<Role> {
|
||||
const role = await roleService.updateRole(roleId, data)
|
||||
if (roles.value) roles.value = roles.value.map(r => r.id === roleId ? role : r)
|
||||
return role
|
||||
}
|
||||
|
||||
async function deleteRole(roleId: number): Promise<void> {
|
||||
await roleService.deleteRole(roleId)
|
||||
if (roles.value) roles.value = roles.value.filter(r => r.id !== roleId)
|
||||
}
|
||||
|
||||
return {
|
||||
roles,
|
||||
permissions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchRoles,
|
||||
fetchPermissions,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,9 @@
|
||||
export interface RoleSummary {
|
||||
id: number
|
||||
name: string
|
||||
is_system: boolean
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
@@ -9,6 +15,9 @@ export interface User {
|
||||
avatar_url?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
roles?: RoleSummary[]
|
||||
/** Only populated on the current session's own user (from GET /users/me). */
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
@approve-user="handleApproveUser"
|
||||
@edit-user="handleEditUser"
|
||||
@reset-password="handleResetPassword"
|
||||
@manage-roles="handleManageRoles"
|
||||
@delete-user="handleDeleteUser"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,6 +113,16 @@
|
||||
:is-deleting="isDeletingUser"
|
||||
/>
|
||||
|
||||
<!-- User Roles (custom multi-role) Dialog -->
|
||||
<UserRolesDialog
|
||||
:open="showRolesDialog"
|
||||
@update:open="showRolesDialog = $event"
|
||||
:user="selectedUser"
|
||||
:roles="rolesStore.roles ?? []"
|
||||
:saving="isSavingRoles"
|
||||
@saved="handleUserRolesSubmit"
|
||||
/>
|
||||
|
||||
<!-- Success Toast -->
|
||||
<div v-if="successMessage" class="fixed bottom-4 right-4 z-50">
|
||||
<Alert class="w-80 bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800">
|
||||
@@ -131,12 +142,15 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Users, UserPlus, RefreshCw, AlertCircle, Loader2, CheckCircle } from 'lucide-vue-next'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useRolesStore } from '@/stores/roles'
|
||||
import { roleService } from '@/services/role'
|
||||
import PendingUsersDashboard from '@/components/user/PendingUsersDashboard.vue'
|
||||
import UserManagementTable from '@/components/user/UserManagementTable.vue'
|
||||
import UserCreateDialog from '@/components/user/UserCreateDialog.vue'
|
||||
import UserEditDialog from '@/components/user/UserEditDialog.vue'
|
||||
import PasswordResetDialog from '@/components/user/PasswordResetDialog.vue'
|
||||
import UserDeleteConfirmDialog from '@/components/user/UserDeleteConfirmDialog.vue'
|
||||
import UserRolesDialog from '@/components/user/UserRolesDialog.vue'
|
||||
import type { User } from '@/types/auth'
|
||||
import type { UserCreateData } from '@/components/user/UserCreateDialog.vue'
|
||||
import type { UserEditData } from '@/components/user/UserEditDialog.vue'
|
||||
@@ -144,6 +158,7 @@ import type { UserEditData } from '@/components/user/UserEditDialog.vue'
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const authStore = useAuthStore()
|
||||
const rolesStore = useRolesStore()
|
||||
|
||||
// Local state
|
||||
const processingUserId = ref<number | null>(null)
|
||||
@@ -157,12 +172,14 @@ const showCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const showPasswordResetDialog = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const showRolesDialog = ref(false)
|
||||
|
||||
// Loading states
|
||||
const isCreatingUser = ref(false)
|
||||
const isEditingUser = ref(false)
|
||||
const isResettingPassword = ref(false)
|
||||
const isDeletingUser = ref(false)
|
||||
const isSavingRoles = ref(false)
|
||||
|
||||
// Computed
|
||||
const users = computed(() => userStore.users)
|
||||
@@ -287,6 +304,31 @@ const handleResetPassword = (user: User) => {
|
||||
showPasswordResetDialog.value = true
|
||||
}
|
||||
|
||||
const handleManageRoles = async (user: User) => {
|
||||
selectedUser.value = user
|
||||
showRolesDialog.value = true
|
||||
try {
|
||||
await rolesStore.fetchRoles()
|
||||
} catch (err) {
|
||||
console.error('Failed to load roles:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserRolesSubmit = async (roleIds: number[]) => {
|
||||
if (!selectedUser.value) return
|
||||
try {
|
||||
isSavingRoles.value = true
|
||||
await roleService.updateUserRoles(selectedUser.value.id, roleIds)
|
||||
showRolesDialog.value = false
|
||||
showSuccessMessage('User roles updated successfully')
|
||||
await refreshData()
|
||||
} catch (err: any) {
|
||||
showErrorMessage(err.response?.data?.detail || 'Failed to update user roles')
|
||||
} finally {
|
||||
isSavingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordResetSubmit = async (userId: number, password: string) => {
|
||||
try {
|
||||
isResettingPassword.value = true
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<div class="container mx-auto py-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">Role Management</h1>
|
||||
<p class="text-muted-foreground">
|
||||
Create custom roles and edit create/edit/delete permissions for assets, shots, and tasks
|
||||
</p>
|
||||
</div>
|
||||
<Button @click="openCreateDialog">
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Create Role
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent class="p-0">
|
||||
<div v-if="isLoading" class="p-6 text-center text-muted-foreground">Loading roles...</div>
|
||||
<table v-else class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b bg-muted/50">
|
||||
<th class="text-left font-medium p-3">Name</th>
|
||||
<th class="text-left font-medium p-3">Description</th>
|
||||
<th class="text-left font-medium p-3">Permissions</th>
|
||||
<th class="text-left font-medium p-3">Users</th>
|
||||
<th class="text-right font-medium p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="role in roles" :key="role.id" class="border-b last:border-b-0">
|
||||
<td class="p-3 font-medium">
|
||||
{{ role.name }}
|
||||
<Badge :variant="role.is_system ? 'secondary' : 'outline'" class="ml-2">
|
||||
{{ role.is_system ? 'System' : 'Custom' }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="p-3 text-muted-foreground">{{ role.description || '—' }}</td>
|
||||
<td class="p-3">{{ role.permissions.length }}</td>
|
||||
<td class="p-3">{{ role.user_count }}</td>
|
||||
<td class="p-3 text-right space-x-2">
|
||||
<Button variant="ghost" size="sm" @click="openEditDialog(role)">
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="role.is_system || role.user_count > 0"
|
||||
:title="role.is_system ? 'System roles cannot be deleted' : role.user_count > 0 ? 'Reassign users before deleting' : 'Delete role'"
|
||||
@click="confirmDelete(role)"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<RoleFormDialog
|
||||
v-model:open="showFormDialog"
|
||||
:role="editingRole"
|
||||
:permissions="permissions"
|
||||
:saving="isSaving"
|
||||
@saved="handleSave"
|
||||
/>
|
||||
|
||||
<AlertDialog :open="showDeleteDialog" @update:open="(val: boolean) => { showDeleteDialog = val }">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete role "{{ roleToDelete?.name }}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This cannot be undone.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { useRolesStore } from '@/stores/roles'
|
||||
import RoleFormDialog from '@/components/role/RoleFormDialog.vue'
|
||||
import type { Role } from '@/services/role'
|
||||
|
||||
const { toast } = useToast()
|
||||
const rolesStore = useRolesStore()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const isSaving = ref(false)
|
||||
const showFormDialog = ref(false)
|
||||
const editingRole = ref<Role | null>(null)
|
||||
const showDeleteDialog = ref(false)
|
||||
const roleToDelete = ref<Role | null>(null)
|
||||
|
||||
const roles = computed(() => rolesStore.roles ?? [])
|
||||
const permissions = computed(() => rolesStore.permissions ?? [])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([rolesStore.fetchRoles(), rolesStore.fetchPermissions()])
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to load roles', variant: 'destructive' })
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function openCreateDialog() {
|
||||
editingRole.value = null
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(role: Role) {
|
||||
editingRole.value = role
|
||||
showFormDialog.value = true
|
||||
}
|
||||
|
||||
async function handleSave(data: { name?: string; description?: string; permission_ids: number[] }) {
|
||||
isSaving.value = true
|
||||
try {
|
||||
if (editingRole.value) {
|
||||
await rolesStore.updateRole(editingRole.value.id, data)
|
||||
toast({ title: 'Role updated', description: `"${editingRole.value.name}" was updated.` })
|
||||
} else {
|
||||
const role = await rolesStore.createRole({ name: data.name!, description: data.description, permission_ids: data.permission_ids })
|
||||
toast({ title: 'Role created', description: `"${role.name}" was created.` })
|
||||
}
|
||||
showFormDialog.value = false
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to save role', variant: 'destructive' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(role: Role) {
|
||||
roleToDelete.value = role
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
// Captured locally: AlertDialogAction closes the dialog (and fires @update:open)
|
||||
// as part of the same click, so showDeleteDialog can't be trusted to still
|
||||
// reflect "open" by the time this runs - roleToDelete is never reset by that
|
||||
// close, only here, so it's safe to read.
|
||||
const role = roleToDelete.value
|
||||
if (!role) return
|
||||
try {
|
||||
await rolesStore.deleteRole(role.id)
|
||||
toast({ title: 'Role deleted', description: `"${role.name}" was deleted.` })
|
||||
} catch (err: any) {
|
||||
toast({ title: 'Error', description: err.response?.data?.detail || 'Failed to delete role', variant: 'destructive' })
|
||||
} finally {
|
||||
showDeleteDialog.value = false
|
||||
roleToDelete.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user