Compare commits
2 Commits
c6b14ac25f
...
8e38d4cf4d
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e38d4cf4d | |||
| 0c75045f6f |
@@ -16,6 +16,7 @@ from app.models.notification import Notification as NotificationModel
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.api.rbac import check_project_role, check_permission, ensure_can_edit_task
|
||||
from app.services.activity import log_activity
|
||||
from app.services.dependency_check import check_task_deps
|
||||
|
||||
router = APIRouter(tags=["Tasks"])
|
||||
|
||||
@@ -293,7 +294,7 @@ def transition_task(
|
||||
# P5.1: enforce state-machine
|
||||
_check_transition(old_status, new_status)
|
||||
|
||||
# P5.2: pending -> open requires milestone to be undergoing (dependencies checked later)
|
||||
# P5.2: pending -> open requires milestone to be undergoing + task deps satisfied
|
||||
if old_status == "pending" and new_status == "open":
|
||||
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
||||
if milestone:
|
||||
@@ -303,6 +304,13 @@ def transition_task(
|
||||
status_code=400,
|
||||
detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'",
|
||||
)
|
||||
# P4.3: check task-level depend_on
|
||||
dep_result = check_task_deps(db, task.depend_on)
|
||||
if not dep_result.ok:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot open task: {dep_result.reason}",
|
||||
)
|
||||
|
||||
# P5.3: open -> undergoing requires assignee AND operator must be the assignee
|
||||
if old_status == "open" and new_status == "undergoing":
|
||||
|
||||
@@ -151,61 +151,93 @@ def init_default_permissions(db: Session) -> list[Permission]:
|
||||
return db.query(Permission).all()
|
||||
|
||||
|
||||
def init_admin_role(db: Session, admin_user: models.User) -> None:
|
||||
"""Create admin role with all permissions and guest role with minimal permissions."""
|
||||
# Check if admin role already exists
|
||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
||||
if not admin_role:
|
||||
admin_role = Role(
|
||||
name="admin",
|
||||
description="Administrator - full access to all features",
|
||||
is_global=True
|
||||
)
|
||||
db.add(admin_role)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default role → permission mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# mgr: project management + all milestone/task/propose actions
|
||||
_MGR_PERMISSIONS = {
|
||||
"project.read", "project.write", "project.manage_members",
|
||||
"task.create", "task.read", "task.write", "task.delete",
|
||||
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
|
||||
"milestone.freeze", "milestone.start", "milestone.close",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"propose.accept", "propose.reject", "propose.reopen",
|
||||
"monitor.read",
|
||||
}
|
||||
|
||||
# dev: day-to-day development work — no freeze/start/close milestone, no accept/reject propose
|
||||
_DEV_PERMISSIONS = {
|
||||
"project.read",
|
||||
"task.create", "task.read", "task.write",
|
||||
"milestone.read",
|
||||
"task.close", "task.reopen_closed", "task.reopen_completed",
|
||||
"monitor.read",
|
||||
}
|
||||
|
||||
# Role definitions: (name, description, permission_set)
|
||||
_DEFAULT_ROLES = [
|
||||
("admin", "Administrator - full access to all features", None), # None ⇒ all perms
|
||||
("mgr", "Manager - project & milestone management", _MGR_PERMISSIONS),
|
||||
("dev", "Developer - task execution & daily work", _DEV_PERMISSIONS),
|
||||
("guest", "Guest - read-only access", None), # special: *.read only
|
||||
]
|
||||
|
||||
|
||||
def _ensure_role(db: Session, name: str, description: str, is_global: bool = True) -> Role:
|
||||
"""Get or create a role by name."""
|
||||
role = db.query(Role).filter(Role.name == name).first()
|
||||
if not role:
|
||||
role = Role(name=name, description=description, is_global=is_global)
|
||||
db.add(role)
|
||||
db.commit()
|
||||
db.refresh(admin_role)
|
||||
logger.info("Created admin role (id=%d)", admin_role.id)
|
||||
|
||||
# Check if guest role already exists
|
||||
guest_role = db.query(Role).filter(Role.name == "guest").first()
|
||||
if not guest_role:
|
||||
guest_role = Role(
|
||||
name="guest",
|
||||
description="Guest - read-only access",
|
||||
is_global=True
|
||||
)
|
||||
db.add(guest_role)
|
||||
db.commit()
|
||||
db.refresh(guest_role)
|
||||
logger.info("Created guest role (id=%d)", guest_role.id)
|
||||
|
||||
# Get all permissions
|
||||
db.refresh(role)
|
||||
logger.info("Created role '%s' (id=%d)", name, role.id)
|
||||
return role
|
||||
|
||||
|
||||
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
|
||||
"""Ensure *role* has exactly the permissions in *target_perm_names*.
|
||||
|
||||
* ``None`` means **all** permissions (admin).
|
||||
* The special sentinel ``"__read_only__"`` is handled by the caller passing
|
||||
just the ``*.read`` names.
|
||||
Only adds missing permissions; never removes manually-granted ones (additive).
|
||||
"""
|
||||
all_perms = db.query(Permission).all()
|
||||
|
||||
# Assign all permissions to admin role
|
||||
existing_admin_perm_ids = {rp.permission_id for rp in admin_role.permissions}
|
||||
for perm in all_perms:
|
||||
if perm.id not in existing_admin_perm_ids:
|
||||
rp = RolePermission(role_id=admin_role.id, permission_id=perm.id)
|
||||
db.add(rp)
|
||||
|
||||
if all_perms:
|
||||
perm_by_name = {p.name: p for p in all_perms}
|
||||
|
||||
if target_perm_names is None:
|
||||
wanted_ids = {p.id for p in all_perms}
|
||||
else:
|
||||
wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name}
|
||||
|
||||
existing_ids = {rp.permission_id for rp in role.permissions}
|
||||
added = 0
|
||||
for pid in wanted_ids - existing_ids:
|
||||
db.add(RolePermission(role_id=role.id, permission_id=pid))
|
||||
added += 1
|
||||
|
||||
if added:
|
||||
db.commit()
|
||||
logger.info("Assigned %d permissions to admin role", len(all_perms))
|
||||
|
||||
# Assign only read permissions to guest role
|
||||
read_perms = db.query(Permission).filter(Permission.name.like("%.read")).all()
|
||||
existing_guest_perm_ids = {rp.permission_id for rp in guest_role.permissions}
|
||||
for perm in read_perms:
|
||||
if perm.id not in existing_guest_perm_ids:
|
||||
rp = RolePermission(role_id=guest_role.id, permission_id=perm.id)
|
||||
db.add(rp)
|
||||
|
||||
if read_perms:
|
||||
db.commit()
|
||||
logger.info("Assigned %d read permissions to guest role", len(read_perms))
|
||||
|
||||
logger.info("Admin and guest roles setup complete")
|
||||
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
|
||||
|
||||
|
||||
def init_admin_role(db: Session, admin_user: models.User) -> None:
|
||||
"""Create default roles (admin / mgr / dev / guest) with preset permissions."""
|
||||
|
||||
all_perms = db.query(Permission).all()
|
||||
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
|
||||
|
||||
for name, description, perm_set in _DEFAULT_ROLES:
|
||||
role = _ensure_role(db, name, description)
|
||||
|
||||
if name == "guest":
|
||||
_sync_role_permissions(db, role, read_perm_names)
|
||||
else:
|
||||
_sync_role_permissions(db, role, perm_set)
|
||||
|
||||
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
|
||||
|
||||
|
||||
def run_init(db: Session) -> None:
|
||||
|
||||
@@ -111,3 +111,38 @@ def check_milestone_deps(
|
||||
result.blockers.append(f"Dependent tasks not {required_status}: {incomplete_tasks}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def check_task_deps(
|
||||
db: Session,
|
||||
depend_on: str | None,
|
||||
*,
|
||||
required_status: str = "completed",
|
||||
) -> DepCheckResult:
|
||||
"""Check whether a task's depend_on tasks are all satisfied.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db:
|
||||
Active DB session.
|
||||
depend_on:
|
||||
JSON-encoded list of task IDs (from the task's ``depend_on`` field).
|
||||
required_status:
|
||||
The status dependees must have reached (default ``"completed"``).
|
||||
|
||||
Returns
|
||||
-------
|
||||
DepCheckResult with ``ok=True`` if all deps satisfied, else ``ok=False``
|
||||
with human-readable ``blockers``.
|
||||
"""
|
||||
result = DepCheckResult()
|
||||
task_ids = _parse_json_ids(depend_on)
|
||||
if task_ids:
|
||||
dep_tasks: Sequence[Task] = (
|
||||
db.query(Task).filter(Task.id.in_(task_ids)).all()
|
||||
)
|
||||
incomplete = [t.id for t in dep_tasks if _task_status(t) != required_status]
|
||||
if incomplete:
|
||||
result.ok = False
|
||||
result.blockers.append(f"Dependent tasks not {required_status}: {incomplete}")
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user