db2c414c1a
Users can now hold multiple roles, each with its own editable set of create/edit/delete-style permissions across assets, shots, tasks, task assignment, review approve/retake, submissions, uploads, and notes (including internal vs. client note visibility). The 4 existing roles (coordinator/director/artist/developer) are migrated into the new system as system roles, seeded to reproduce today's actual behavior exactly; admins can create custom roles (e.g. "Reviewer", "Outsourcing") via the new Role Management page and assign multiple roles to a user via a new "Manage Roles" action on the Team page. Backend: - New Role/Permission models and role_permissions/user_roles tables, plus a one-off, idempotent seed/backfill migration script. - New require_permission()/user_has_permission() dependency, wired into the actual mutation endpoints across shots/assets/tasks/reviews, always preserving existing ownership- and self-service-based access (e.g. artists editing their own task status, own notes, own uploads, own submissions) as an unconditional fallback alongside the new permission checks - nothing that worked before now requires a role. - New endpoints: PUT/DELETE on task submissions (wires up soft-deletion columns that existed on the model but were never exposed), plus full role CRUD and per-user role assignment. - Along the way: fixed newly-created users not being linked to their matching system role (silently leaving them with zero permissions), and unified an inconsistency between the single vs. bulk task status endpoints that allowed different roles to bulk-update status. Frontend: - Role Management page with a grouped, human-readable permission editor (icons, plain-language action labels, per-resource select-all, live selected count) replacing an earlier dense matrix prototype. - hasPermission() added to the existing usePermission() composable without touching its current isAdmin/isCoordinatorOrAdmin consumers. - Note composer gets an Internal/Client toggle; submissions gain inline edit/delete actions gated the same ownership-or-permission way as notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
202 lines
8.7 KiB
Python
202 lines
8.7 KiB
Python
#!/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!")
|