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.deps import get_current_user_or_apikey
|
||||||
from app.api.rbac import check_project_role, check_permission, ensure_can_edit_task
|
from app.api.rbac import check_project_role, check_permission, ensure_can_edit_task
|
||||||
from app.services.activity import log_activity
|
from app.services.activity import log_activity
|
||||||
|
from app.services.dependency_check import check_task_deps
|
||||||
|
|
||||||
router = APIRouter(tags=["Tasks"])
|
router = APIRouter(tags=["Tasks"])
|
||||||
|
|
||||||
@@ -293,7 +294,7 @@ def transition_task(
|
|||||||
# P5.1: enforce state-machine
|
# P5.1: enforce state-machine
|
||||||
_check_transition(old_status, new_status)
|
_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":
|
if old_status == "pending" and new_status == "open":
|
||||||
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first()
|
||||||
if milestone:
|
if milestone:
|
||||||
@@ -303,6 +304,13 @@ def transition_task(
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'",
|
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
|
# P5.3: open -> undergoing requires assignee AND operator must be the assignee
|
||||||
if old_status == "open" and new_status == "undergoing":
|
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()
|
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."""
|
# Default role → permission mapping
|
||||||
# Check if admin role already exists
|
# ---------------------------------------------------------------------------
|
||||||
admin_role = db.query(Role).filter(Role.name == "admin").first()
|
|
||||||
if not admin_role:
|
# mgr: project management + all milestone/task/propose actions
|
||||||
admin_role = Role(
|
_MGR_PERMISSIONS = {
|
||||||
name="admin",
|
"project.read", "project.write", "project.manage_members",
|
||||||
description="Administrator - full access to all features",
|
"task.create", "task.read", "task.write", "task.delete",
|
||||||
is_global=True
|
"milestone.create", "milestone.read", "milestone.write", "milestone.delete",
|
||||||
)
|
"milestone.freeze", "milestone.start", "milestone.close",
|
||||||
db.add(admin_role)
|
"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.commit()
|
||||||
db.refresh(admin_role)
|
db.refresh(role)
|
||||||
logger.info("Created admin role (id=%d)", admin_role.id)
|
logger.info("Created role '%s' (id=%d)", name, role.id)
|
||||||
|
return role
|
||||||
# Check if guest role already exists
|
|
||||||
guest_role = db.query(Role).filter(Role.name == "guest").first()
|
|
||||||
if not guest_role:
|
def _sync_role_permissions(db: Session, role: Role, target_perm_names: set[str] | None) -> None:
|
||||||
guest_role = Role(
|
"""Ensure *role* has exactly the permissions in *target_perm_names*.
|
||||||
name="guest",
|
|
||||||
description="Guest - read-only access",
|
* ``None`` means **all** permissions (admin).
|
||||||
is_global=True
|
* The special sentinel ``"__read_only__"`` is handled by the caller passing
|
||||||
)
|
just the ``*.read`` names.
|
||||||
db.add(guest_role)
|
Only adds missing permissions; never removes manually-granted ones (additive).
|
||||||
db.commit()
|
"""
|
||||||
db.refresh(guest_role)
|
|
||||||
logger.info("Created guest role (id=%d)", guest_role.id)
|
|
||||||
|
|
||||||
# Get all permissions
|
|
||||||
all_perms = db.query(Permission).all()
|
all_perms = db.query(Permission).all()
|
||||||
|
perm_by_name = {p.name: p for p in all_perms}
|
||||||
# Assign all permissions to admin role
|
|
||||||
existing_admin_perm_ids = {rp.permission_id for rp in admin_role.permissions}
|
if target_perm_names is None:
|
||||||
for perm in all_perms:
|
wanted_ids = {p.id for p in all_perms}
|
||||||
if perm.id not in existing_admin_perm_ids:
|
else:
|
||||||
rp = RolePermission(role_id=admin_role.id, permission_id=perm.id)
|
wanted_ids = {perm_by_name[n].id for n in target_perm_names if n in perm_by_name}
|
||||||
db.add(rp)
|
|
||||||
|
existing_ids = {rp.permission_id for rp in role.permissions}
|
||||||
if all_perms:
|
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()
|
db.commit()
|
||||||
logger.info("Assigned %d permissions to admin role", len(all_perms))
|
logger.info("Assigned %d new permissions to role '%s'", added, role.name)
|
||||||
|
|
||||||
# Assign only read permissions to guest role
|
|
||||||
read_perms = db.query(Permission).filter(Permission.name.like("%.read")).all()
|
def init_admin_role(db: Session, admin_user: models.User) -> None:
|
||||||
existing_guest_perm_ids = {rp.permission_id for rp in guest_role.permissions}
|
"""Create default roles (admin / mgr / dev / guest) with preset permissions."""
|
||||||
for perm in read_perms:
|
|
||||||
if perm.id not in existing_guest_perm_ids:
|
all_perms = db.query(Permission).all()
|
||||||
rp = RolePermission(role_id=guest_role.id, permission_id=perm.id)
|
read_perm_names = {p.name for p in all_perms if p.name.endswith(".read")}
|
||||||
db.add(rp)
|
|
||||||
|
for name, description, perm_set in _DEFAULT_ROLES:
|
||||||
if read_perms:
|
role = _ensure_role(db, name, description)
|
||||||
db.commit()
|
|
||||||
logger.info("Assigned %d read permissions to guest role", len(read_perms))
|
if name == "guest":
|
||||||
|
_sync_role_permissions(db, role, read_perm_names)
|
||||||
logger.info("Admin and guest roles setup complete")
|
else:
|
||||||
|
_sync_role_permissions(db, role, perm_set)
|
||||||
|
|
||||||
|
logger.info("Default roles setup complete (admin, mgr, dev, guest)")
|
||||||
|
|
||||||
|
|
||||||
def run_init(db: Session) -> None:
|
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}")
|
result.blockers.append(f"Dependent tasks not {required_status}: {incomplete_tasks}")
|
||||||
|
|
||||||
return result
|
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