feat/task-type-hierarchy #5

Merged
hzhang merged 17 commits from feat/task-type-hierarchy into main 2026-03-12 13:05:00 +00:00
11 changed files with 807 additions and 64 deletions

View File

@@ -1,46 +1,72 @@
"""Role-based access control helpers."""
from functools import wraps
"""Role-based access control helpers - using configurable permissions."""
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.models import ProjectMember, User
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
from app.models import models
# Role hierarchy: admin > mgr > dev > ops > viewer
ROLE_LEVELS = {
"admin": 50,
"mgr": 40,
"dev": 30,
"ops": 20,
"viewer": 10,
}
def get_member_role(db: Session, user_id: int, project_id: int) -> str | None:
"""Get user's role in a project. Returns None if not a member."""
member = db.query(ProjectMember).filter(
ProjectMember.user_id == user_id,
ProjectMember.project_id == project_id,
def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None:
"""Get user's role in a project."""
member = db.query(models.ProjectMember).filter(
models.ProjectMember.user_id == user_id,
models.ProjectMember.project_id == project_id,
).first()
if member:
return member.role
# Check if user is global admin
user = db.query(User).filter(User.id == user_id).first()
if member and member.role_id:
return db.query(Role).filter(Role.id == member.role_id).first()
# Check global admin
user = db.query(models.User).filter(models.User.id == user_id).first()
if user and user.is_admin:
return "admin"
# Return global admin role
return db.query(Role).filter(Role.is_global == True, Role.name == "superadmin").first()
return None
def has_permission(db: Session, user_id: int, project_id: int, permission: str) -> bool:
"""Check if user has a specific permission in a project."""
role = get_user_role(db, user_id, project_id)
if not role:
return False
# Check if role has the permission
perm = db.query(Permission).filter(Permission.name == permission).first()
if not perm:
return False
role_perm = db.query(RolePermission).filter(
RolePermission.role_id == role.id,
RolePermission.permission_id == perm.id
).first()
return role_perm is not None
def check_permission(db: Session, user_id: int, project_id: int, permission: str):
"""Raise 403 if user doesn't have the permission."""
if not has_permission(db, user_id, project_id, permission):
role = get_user_role(db, user_id, project_id)
role_name = role.name if role else "none"
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Permission '{permission}' required. Your role: {role_name}"
)
# Keep old function for backward compatibility (deprecated)
def check_project_role(db: Session, user_id: int, project_id: int, min_role: str = "viewer"):
"""Raise 403 if user doesn't have the minimum required role in the project."""
role = get_member_role(db, user_id, project_id)
if role is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this project"
)
if ROLE_LEVELS.get(role, 0) < ROLE_LEVELS.get(min_role, 0):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires role '{min_role}' or higher, you have '{role}'"
)
return role
"""Legacy function - maps old role names to new permission system."""
# Map old roles to permissions
role_to_perm = {
"admin": "project.edit",
"mgr": "milestone.create",
"dev": "issue.create",
"ops": "issue.view",
"viewer": "project.view",
}
perm = role_to_perm.get(min_role, "project.view")
check_permission(db, user_id, project_id, perm)

View File

@@ -17,6 +17,35 @@ from app.services.activity import log_activity
router = APIRouter(tags=["Issues"])
# ---- Type / Subtype validation ----
ISSUE_SUBTYPE_MAP = {
'meeting': {'conference', 'handover', 'recap'},
'support': {'access', 'information'},
'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience', 'defect'},
'maintenance': {'deploy', 'release'},
'review': {'code_review', 'decision_review', 'function_review'},
'story': {'feature', 'improvement', 'refactor'},
'test': {'regression', 'security', 'smoke', 'stress'},
'research': set(),
'task': {'defect'},
'resolution': set(),
}
ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys())
def _validate_issue_type_subtype(issue_type: str | None, issue_subtype: str | None, require_subtype: bool = False):
if issue_type is None:
raise HTTPException(status_code=400, detail='issue_type is required')
if issue_type not in ALLOWED_ISSUE_TYPES:
raise HTTPException(status_code=400, detail=f'Invalid issue_type: {issue_type}')
allowed = ISSUE_SUBTYPE_MAP.get(issue_type, set())
if issue_subtype:
if issue_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid issue_subtype for {issue_type}: {issue_subtype}')
else:
if require_subtype and allowed:
raise HTTPException(status_code=400, detail=f'issue_subtype required for type: {issue_type}')
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
n = NotificationModel(user_id=user_id, type=ntype, title=title, message=message,
@@ -45,7 +74,7 @@ def create_issue(issue: schemas.IssueCreate, bg: BackgroundTasks, db: Session =
@router.get("/issues")
def list_issues(
project_id: int = None, issue_status: str = None, issue_type: str = None,
project_id: int = None, issue_status: str = None, issue_type: str = None, issue_subtype: str = None,
assignee_id: int = None, tag: str = None,
sort_by: str = "created_at", sort_order: str = "desc",
page: int = 1, page_size: int = 50,
@@ -59,6 +88,8 @@ def list_issues(
query = query.filter(models.Issue.status == issue_status)
if issue_type:
query = query.filter(models.Issue.issue_type == issue_type)
if issue_subtype:
query = query.filter(models.Issue.issue_subtype == issue_subtype)
if assignee_id:
query = query.filter(models.Issue.assignee_id == assignee_id)
if tag:
@@ -98,6 +129,11 @@ def get_issue(issue_id: int, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
return issue
@@ -108,7 +144,12 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session =
check_project_role(db, current_user.id, issue.project_id, min_role="dev")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
for field, value in issue_update.model_dump(exclude_unset=True).items():
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
for field, value in update_data.items():
setattr(issue, field, value)
db.commit()
db.refresh(issue)
@@ -122,6 +163,11 @@ def delete_issue(issue_id: int, db: Session = Depends(get_db), current_user: mod
check_project_role(db, current_user.id, issue.project_id, min_role="mgr")
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
log_activity(db, "issue.deleted", "issue", issue.id, current_user.id, {"title": issue.title})
db.delete(issue)
db.commit()
@@ -138,6 +184,11 @@ def transition_issue(issue_id: int, new_status: str, bg: BackgroundTasks, db: Se
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
old_status = issue.status
issue.status = new_status
db.commit()
@@ -156,6 +207,11 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db))
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
user = db.query(models.User).filter(models.User.id == assignee_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
@@ -210,6 +266,11 @@ def add_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set()
current.add(tag.strip())
current.discard("")
@@ -223,6 +284,11 @@ def remove_tag(issue_id: int, tag: str, db: Session = Depends(get_db)):
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
update_data = issue_update.model_dump(exclude_unset=True)
if "issue_type" in update_data or "issue_subtype" in update_data:
new_type = update_data.get("issue_type", issue.issue_type)
new_subtype = update_data.get("issue_subtype", issue.issue_subtype)
_validate_issue_type_subtype(new_type, new_subtype, require_subtype="issue_type" in update_data)
current = set(issue.tags.split(",")) if issue.tags else set()
current.discard(tag.strip())
current.discard("")

View File

@@ -0,0 +1,68 @@
"""Milestones API router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.models import models
from app.models.milestone import Milestone
from app.schemas import schemas
router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones"])
@router.get("", response_model=List[schemas.MilestoneResponse])
def list_milestones(project_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""List all milestones for a project."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
return milestones
@router.post("", response_model=schemas.MilestoneResponse, status_code=status.HTTP_201_CREATED)
def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new milestone for a project."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = Milestone(project_id=project_id, **milestone.model_dump())
db.add(db_milestone)
db.commit()
db.refresh(db_milestone)
return db_milestone
@router.get("/{milestone_id}", response_model=schemas.MilestoneResponse)
def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Get a milestone by ID."""
check_project_role(db, current_user.id, project_id, min_role="viewer")
milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
return milestone
@router.patch("/{milestone_id}", response_model=schemas.MilestoneResponse)
def update_milestone(project_id: int, milestone_id: int, milestone: schemas.MilestoneUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a milestone."""
check_project_role(db, current_user.id, project_id, min_role="mgr")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
for key, value in milestone.model_dump(exclude_unset=True).items():
setattr(db_milestone, key, value)
db.commit()
db.refresh(db_milestone)
return db_milestone
@router.delete("/{milestone_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a milestone."""
check_project_role(db, current_user.id, project_id, min_role="admin")
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found")
db.delete(db_milestone)
db.commit()
return None

View File

@@ -294,11 +294,11 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)):
issues = query.all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "title", "type", "status", "priority", "project_id",
writer.writerow(["id", "title", "type", "subtype", "status", "priority", "project_id",
"reporter_id", "assignee_id", "milestone_id", "due_date",
"tags", "created_at", "updated_at"])
for i in issues:
writer.writerow([i.id, i.title, i.issue_type, i.status, i.priority, i.project_id,
writer.writerow([i.id, i.title, i.issue_type, i.issue_subtype or "", i.status, i.priority, i.project_id,
i.reporter_id, i.assignee_id, i.milestone_id, i.due_date,
i.tags, i.created_at, i.updated_at])
output.seek(0)

View File

@@ -1,25 +1,177 @@
"""Projects router with RBAC."""
import json
import re
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.config import get_db
from app.models import models
from app.models.role_permission import Role
from app.schemas import schemas
from app.api.deps import get_current_user_or_apikey
from app.api.rbac import check_project_role
from app.api.rbac import check_project_role, check_permission
router = APIRouter(prefix="/projects", tags=["Projects"])
def _validate_project_links(db, codes: list[str] | None, self_code: str | None = None) -> list[str] | None:
if not codes:
return None
# dedupe preserve order
seen = set()
ordered = []
for c in codes:
if c and c not in seen:
ordered.append(c)
seen.add(c)
if self_code and self_code in seen:
raise HTTPException(status_code=400, detail='Project cannot link to itself')
existing = {p.project_code for p in db.query(models.Project).filter(models.Project.project_code.in_(ordered)).all()}
missing = [c for c in ordered if c not in existing]
if missing:
raise HTTPException(status_code=400, detail=f'Unknown project codes: {", ".join(missing)}')
return ordered
WORD_SEGMENT_RE = re.compile(r"[A-Za-z]+")
CAMEL_RE = re.compile(r"[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+")
def _split_words(name: str):
segments = WORD_SEGMENT_RE.findall(name or '')
words = []
for seg in segments:
parts = CAMEL_RE.findall(seg)
for part in parts:
if part.isupper() and len(part) > 1:
words.extend(list(part))
else:
words.append(part)
return words
def _code_exists(db, code: str) -> bool:
return db.query(models.Project).filter(models.Project.project_code == code).first() is not None
def _next_counter(db, prefix: str, width: int) -> str:
if width <= 0:
return ''
counter = db.query(models.ProjectCodeCounter).filter(models.ProjectCodeCounter.prefix == prefix).first()
if not counter:
counter = models.ProjectCodeCounter(prefix=prefix, next_value=0)
db.add(counter)
db.flush()
value = counter.next_value
counter.next_value += 1
db.flush()
return format(value, 'x').upper().zfill(width)
def _generate_with_counter(db, prefix: str, width: int) -> str:
if prefix.upper() == 'UN':
prefix = 'UN'
while True:
suffix = _next_counter(db, prefix, width)
code = (prefix + suffix).upper()
if not _code_exists(db, code):
return code
def _generate_project_code(db, name: str) -> str:
words = _split_words(name)
if not words:
return _generate_with_counter(db, 'UN', 4)
if len(words) == 1:
letters = ''.join([c for c in words[0] if c.isalpha()]).upper()
if not letters:
return _generate_with_counter(db, 'UN', 4)
if len(letters) >= 6:
code = letters[:6]
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
prefix = letters
width = 6 - len(prefix)
return _generate_with_counter(db, prefix, width)
total_letters = sum(len(w) for w in words)
if len(words) > 6:
code = ''.join([w[0] for w in words[:6]]).upper()
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
if total_letters < 6:
prefix = ''.join(words).upper()
width = 6 - len(prefix)
return _generate_with_counter(db, prefix, width)
if total_letters == 6:
code = ''.join(words).upper()
if _code_exists(db, code):
raise HTTPException(status_code=400, detail='Project code collision')
return code
word_count = len(words)
needed = 6 - word_count
for idx in range(word_count - 1, -1, -1):
extra_letters = list(words[idx][1:])
if needed > len(extra_letters):
continue
indices = list(range(len(extra_letters)))
def combos(start, depth, path):
if depth == 0:
yield path
return
for i in range(start, len(indices) - depth + 1):
yield from combos(i + 1, depth - 1, path + [indices[i]])
for combo in combos(0, needed, []):
pieces = []
for wi, w in enumerate(words):
pieces.append(w[0])
if wi == idx:
pieces.extend([extra_letters[i] for i in combo])
code = ''.join(pieces)[:6].upper()
if not _code_exists(db, code):
return code
raise HTTPException(status_code=400, detail='Project code collision')
@router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
db_project = models.Project(**project.model_dump())
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
# Check if user is admin
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Only admins can create projects")
# Auto-fill owner_name from owner_id
user = db.query(models.User).filter(models.User.id == project.owner_id).first()
if not user:
raise HTTPException(status_code=400, detail="Invalid owner_id: user not found")
payload = project.model_dump()
payload["owner_name"] = payload.get("owner_name") or user.username
payload["project_code"] = _generate_project_code(db, project.name)
# Validate and serialize sub_projects
sub_codes = payload.get("sub_projects")
if sub_codes:
payload["sub_projects"] = json.dumps(_validate_project_links(db, sub_codes, payload["project_code"]))
else:
payload["sub_projects"] = None
# Validate and serialize related_projects
related_codes = payload.get("related_projects")
if related_codes:
payload["related_projects"] = json.dumps(_validate_project_links(db, related_codes, payload["project_code"]))
else:
payload["related_projects"] = None
db_project = models.Project(**payload)
db.add(db_project)
db.commit()
db.refresh(db_project)
# Auto-add creator as admin member
db_member = models.ProjectMember(project_id=db_project.id, user_id=project.owner_id, role="admin")
admin_role = db.query(Role).filter(Role.name == "admin").first()
db_member = models.ProjectMember(project_id=db_project.id, user_id=project.owner_id, role_id=admin_role.id if admin_role else None)
db.add(db_member)
db.commit()
return db_project
@@ -49,7 +201,17 @@ def update_project(
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
for field, value in project_update.model_dump(exclude_unset=True).items():
update_data = project_update.model_dump(exclude_unset=True)
update_data.pop("name", None)
if "sub_projects" in update_data and update_data["sub_projects"]:
update_data["sub_projects"] = json.dumps(update_data["sub_projects"])
elif "sub_projects" in update_data:
update_data["sub_projects"] = None
if "related_projects" in update_data and update_data["related_projects"]:
update_data["related_projects"] = json.dumps(update_data["related_projects"])
elif "related_projects" in update_data:
update_data["related_projects"] = None
for field, value in update_data.items():
setattr(project, field, value)
db.commit()
db.refresh(project)
@@ -66,6 +228,38 @@ def delete_project(
project = db.query(models.Project).filter(models.Project.id == project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_code = project.project_code
# Delete milestones and their issues
from app.models.milestone import Milestone
milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all()
for ms in milestones:
# Delete issues under milestone
issues = db.query(models.Issue).filter(models.Issue.milestone_id == ms.id).all()
for issue in issues:
db.delete(issue)
db.delete(ms)
# Delete project members
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
for m in members:
db.delete(m)
# Remove from other projects' sub_projects and related_projects
import json
all_projects = db.query(models.Project).all()
for p in all_projects:
if p.sub_projects and project_code in p.sub_projects:
subs = json.loads(p.sub_projects) if p.sub_projects else []
subs = [s for s in subs if s != project_code]
p.sub_projects = json.dumps(subs) if subs else None
if p.related_projects and project_code in p.related_projects:
related = json.loads(p.related_projects) if p.related_projects else []
related = [r for r in related if r != project_code]
p.related_projects = json.dumps(related) if related else None
db.delete(project)
db.commit()
return None
@@ -92,16 +286,43 @@ def add_project_member(
).first()
if existing:
raise HTTPException(status_code=400, detail="User already a member")
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role=member.role)
# Convert role name to role_id
role = db.query(Role).filter(Role.name == member.role).first()
role_id = role.id if role else None
db_member = models.ProjectMember(project_id=project_id, user_id=member.user_id, role_id=role_id)
db.add(db_member)
db.commit()
db.refresh(db_member)
return db_member
role_name = "developer"
if db_member.role_id:
role = db.query(Role).filter(Role.id == db_member.role_id).first()
if role:
role_name = role.name
return {
"id": db_member.id,
"user_id": db_member.user_id,
"project_id": db_member.project_id,
"role": role_name
}
@router.get("/{project_id}/members", response_model=List[schemas.ProjectMemberResponse])
def list_project_members(project_id: int, db: Session = Depends(get_db)):
return db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
members = db.query(models.ProjectMember).filter(models.ProjectMember.project_id == project_id).all()
result = []
for m in members:
role_name = "developer"
if m.role_id:
role = db.query(Role).filter(Role.id == m.role_id).first()
if role:
role_name = role.name
result.append({
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"role": role_name
})
return result
@router.delete("/{project_id}/members/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -111,10 +332,22 @@ def remove_project_member(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
check_project_role(db, current_user.id, project_id, min_role="admin")
check_permission(db, current_user.id, project_id, "member.remove")
member = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id, models.ProjectMember.user_id == user_id
).first()
# Prevent removing project owner (admin role)
if member.role_id:
role = db.query(Role).filter(Role.id == member.role_id).first()
if role and role.name == "admin":
# Check if this is the only admin
admin_count = db.query(models.ProjectMember).filter(
models.ProjectMember.project_id == project_id,
models.ProjectMember.role_id == member.role_id
).count()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot remove the last owner of the project")
if not member:
raise HTTPException(status_code=404, detail="Member not found")
db.delete(member)

214
app/api/routers/roles.py Normal file
View File

@@ -0,0 +1,214 @@
"""Roles and Permissions API router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from app.core.config import get_db
from app.api.deps import get_current_user_or_apikey
from app.models import models
from app.models.role_permission import Role, Permission, RolePermission
router = APIRouter(prefix="/roles", tags=["Roles"])
# Schemas
class PermissionResponse(BaseModel):
id: int
name: str
description: str | None
category: str
class Config:
from_attributes = True
class RoleResponse(BaseModel):
id: int
name: str
description: str | None
is_global: bool
permission_ids: List[int] = []
class Config:
from_attributes = True
class RoleDetailResponse(BaseModel):
id: int
name: str
description: str | None
is_global: bool
permissions: List[PermissionResponse] = []
class Config:
from_attributes = True
class RoleCreate(BaseModel):
name: str
description: str | None = None
is_global: bool = False
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
class PermissionAssign(BaseModel):
permission_ids: List[int]
@router.get("/permissions", response_model=List[PermissionResponse])
def list_permissions(db: Session = Depends(get_db)):
"""List all permissions."""
return db.query(Permission).all()
@router.get("", response_model=List[RoleResponse])
def list_roles(db: Session = Depends(get_db)):
"""List all roles."""
roles = db.query(Role).all()
result = []
for role in roles:
perm_ids = [rp.permission_id for rp in role.permissions]
result.append(RoleResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permission_ids=perm_ids
))
return result
@router.get("/{role_id}", response_model=RoleDetailResponse)
def get_role(role_id: int, db: Session = Depends(get_db)):
"""Get a role with its permissions."""
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
perms = []
for rp in role.permissions:
perms.append(PermissionResponse(
id=rp.permission.id,
name=rp.permission.name,
description=rp.permission.description,
category=rp.permission.category
))
return RoleDetailResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permissions=perms
)
@router.post("", response_model=RoleResponse, status_code=status.HTTP_201_CREATED)
def create_role(role: RoleCreate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Create a new role. Requires is_admin."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can create roles")
existing = db.query(Role).filter(Role.name == role.name).first()
if existing:
raise HTTPException(status_code=400, detail="Role already exists")
db_role = Role(**role.model_dump())
db.add(db_role)
db.commit()
db.refresh(db_role)
return RoleResponse(
id=db_role.id,
name=db_role.name,
description=db_role.description,
is_global=db_role.is_global,
permission_ids=[]
)
@router.patch("/{role_id}", response_model=RoleResponse)
def update_role(role_id: int, role: RoleUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Update a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can edit roles")
db_role = db.query(Role).filter(Role.id == role_id).first()
if not db_role:
raise HTTPException(status_code=404, detail="Role not found")
for key, value in role.model_dump(exclude_unset=True).items():
setattr(db_role, key, value)
db.commit()
db.refresh(db_role)
perm_ids = [rp.permission_id for rp in db_role.permissions]
return RoleResponse(
id=db_role.id,
name=db_role.name,
description=db_role.description,
is_global=db_role.is_global,
permission_ids=perm_ids
)
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_role(role_id: int, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Delete a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can delete roles")
db_role = db.query(Role).filter(Role.id == role_id).first()
if not db_role:
raise HTTPException(status_code=404, detail="Role not found")
member_count = db.query(models.ProjectMember).filter(models.ProjectMember.role_id == role_id).count()
if member_count > 0:
raise HTTPException(status_code=400, detail="Role is in use by members")
db.delete(db_role)
db.commit()
return None
@router.post("/{role_id}/permissions", response_model=RoleDetailResponse)
def assign_permissions(role_id: int, perm_assign: PermissionAssign, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey)):
"""Assign permissions to a role."""
if not getattr(current_user, 'is_admin', False):
raise HTTPException(status_code=403, detail="Only admins can edit role permissions")
role = db.query(Role).filter(Role.id == role_id).first()
if not role:
raise HTTPException(status_code=404, detail="Role not found")
db.query(RolePermission).filter(RolePermission.role_id == role_id).delete()
for perm_id in perm_assign.permission_ids:
perm = db.query(Permission).filter(Permission.id == perm_id).first()
if perm:
rp = RolePermission(role_id=role_id, permission_id=perm_id)
db.add(rp)
db.commit()
db.refresh(role)
perms = []
for rp in role.permissions:
perms.append(PermissionResponse(
id=rp.permission.id,
name=rp.permission.name,
description=rp.permission.description,
category=rp.permission.category
))
return RoleDetailResponse(
id=role.id,
name=role.name,
description=role.description,
is_global=role.is_global,
permissions=perms
)

View File

@@ -70,7 +70,7 @@ def init_admin_user(db: Session, admin_cfg: dict) -> models.User | None:
return user
def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
def init_default_project(db: Session, project_cfg: dict, owner_id: int, owner_name: str = "") -> None:
"""Create default project if configured and not exists."""
name = project_cfg.get("name")
if not name:
@@ -83,6 +83,7 @@ def init_default_project(db: Session, project_cfg: dict, owner_id: int) -> None:
project = models.Project(
name=name,
description=project_cfg.get("description", ""),
owner_name=project_cfg.get("owner") or owner_name or "",
owner_id=owner_id,
)
db.add(project)
@@ -108,6 +109,6 @@ def run_init(db: Session) -> None:
# Default project
project_cfg = config.get("default_project")
if project_cfg and admin_user:
init_default_project(db, project_cfg, admin_user.id)
init_default_project(db, project_cfg, admin_user.id, admin_user.username)
logger.info("Initialization complete")

View File

@@ -35,6 +35,8 @@ from app.api.routers.comments import router as comments_router
from app.api.routers.webhooks import router as webhooks_router
from app.api.routers.misc import router as misc_router
from app.api.routers.monitor import router as monitor_router
from app.api.routers.milestones import router as milestones_router
from app.api.routers.roles import router as roles_router
app.include_router(auth_router)
app.include_router(issues_router)
@@ -44,13 +46,52 @@ app.include_router(comments_router)
app.include_router(webhooks_router)
app.include_router(misc_router)
app.include_router(monitor_router)
app.include_router(milestones_router)
app.include_router(roles_router)
# Auto schema migration for lightweight deployments
def _migrate_schema():
from sqlalchemy import text
from app.core.config import SessionLocal
db = SessionLocal()
try:
# issues.issue_subtype
result = db.execute(text("SHOW COLUMNS FROM issues LIKE 'issue_subtype'")).fetchone()
if not result:
db.execute(text("ALTER TABLE issues ADD COLUMN issue_subtype VARCHAR(64) NULL"))
# issues.issue_type enum -> varchar
result = db.execute(text("SHOW COLUMNS FROM issues WHERE Field='issue_type'")).fetchone()
if result and 'enum' in result[1].lower():
db.execute(text("ALTER TABLE issues MODIFY issue_type VARCHAR(32) DEFAULT 'issue'"))
# projects.project_code
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'project_code'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN project_code VARCHAR(16) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_projects_project_code ON projects (project_code)"))
# projects.owner_name
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'owner_name'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN owner_name VARCHAR(128) NOT NULL DEFAULT ''"))
# projects.sub_projects / related_projects
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'sub_projects'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN sub_projects VARCHAR(512) NULL"))
result = db.execute(text("SHOW COLUMNS FROM projects LIKE 'related_projects'")).fetchone()
if not result:
db.execute(text("ALTER TABLE projects ADD COLUMN related_projects VARCHAR(512) NULL"))
except Exception as e:
print(f"Migration warning: {e}")
finally:
db.close()
# Run database migration on startup
@app.on_event("startup")
def startup():
from app.core.config import Base, engine, SessionLocal
from app.models import webhook, apikey, activity, milestone, notification, worklog, monitor
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission
Base.metadata.create_all(bind=engine)
_migrate_schema()
# Initialize from AbstractWizard (admin user, default project, etc.)
from app.init_wizard import run_init

View File

@@ -1,15 +1,22 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, Boolean, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
from app.models.role_permission import Role
import enum
class IssueType(str, enum.Enum):
TASK = "task"
MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story"
TEST = "test"
RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交
TASK = "task" # legacy generic type
class IssueStatus(str, enum.Enum):
@@ -33,7 +40,8 @@ class Issue(Base):
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
issue_type = Column(Enum(IssueType), default=IssueType.TASK)
issue_type = Column(String(32), default=IssueType.ISSUE.value)
issue_subtype = Column(String(64), nullable=True)
status = Column(Enum(IssueStatus), default=IssueStatus.OPEN)
priority = Column(Enum(IssuePriority), default=IssuePriority.MEDIUM)
@@ -85,6 +93,11 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
project_code = Column(String(16), unique=True, index=True, nullable=True)
owner_name = Column(String(128), nullable=False)
sub_projects = Column(String(512), nullable=True)
related_projects = Column(String(512), nullable=True)
repo = Column(String(512), nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
@@ -120,7 +133,16 @@ class ProjectMember(Base):
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role = Column(String(20), default="dev") # admin, dev, mgr, ops
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
role = relationship("Role")
project = relationship("Project", back_populates="members")
user = relationship("User", back_populates="project_memberships")
class ProjectCodeCounter(Base):
__tablename__ = "project_code_counters"
id = Column(Integer, primary_key=True, index=True)
prefix = Column(String(16), unique=True, index=True, nullable=False)
next_value = Column(Integer, default=0)

View File

@@ -0,0 +1,44 @@
"""Role and Permission models."""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.config import Base
class Role(Base):
"""Role definition - configurable roles."""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False)
description = Column(String(255), nullable=True)
is_global = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
permissions = relationship("RolePermission", back_populates="role")
class Permission(Base):
"""Permission definitions - granular permissions."""
__tablename__ = "permissions"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(255), nullable=True)
category = Column(String(50), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
roles = relationship("RolePermission", back_populates="permission")
class RolePermission(Base):
"""Maps roles to permissions."""
__tablename__ = "role_permissions"
id = Column(Integer, primary_key=True, index=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
permission_id = Column(Integer, ForeignKey("permissions.id"), nullable=False)
role = relationship("Role", back_populates="permissions")
permission = relationship("Permission", back_populates="roles")

View File

@@ -5,10 +5,16 @@ from enum import Enum
class IssueTypeEnum(str, Enum):
TASK = "task"
MEETING = "meeting"
SUPPORT = "support"
ISSUE = "issue"
MAINTENANCE = "maintenance"
RESEARCH = "research"
REVIEW = "review"
STORY = "story"
TEST = "test"
RESOLUTION = "resolution"
TASK = "task" # legacy
class IssueStatusEnum(str, Enum):
@@ -30,7 +36,8 @@ class IssuePriorityEnum(str, Enum):
class IssueBase(BaseModel):
title: str
description: Optional[str] = None
issue_type: IssueTypeEnum = IssueTypeEnum.TASK
issue_type: IssueTypeEnum = IssueTypeEnum.ISSUE
issue_subtype: Optional[str] = None
priority: IssuePriorityEnum = IssuePriorityEnum.MEDIUM
tags: Optional[str] = None
depends_on_id: Optional[int] = None
@@ -51,6 +58,8 @@ class IssueCreate(IssueBase):
class IssueUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
issue_type: Optional[IssueTypeEnum] = None
issue_subtype: Optional[str] = None
status: Optional[IssueStatusEnum] = None
priority: Optional[IssuePriorityEnum] = None
assignee_id: Optional[int] = None
@@ -110,7 +119,10 @@ class CommentResponse(CommentBase):
# Project schemas
class ProjectBase(BaseModel):
name: str
owner_name: Optional[str] = None
description: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
class ProjectCreate(ProjectBase):
@@ -118,13 +130,27 @@ class ProjectCreate(ProjectBase):
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
owner_name: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
class ProjectResponse(ProjectBase):
class ProjectResponse(BaseModel):
id: int
name: str
owner_name: Optional[str] = None
project_code: Optional[str] = None
description: Optional[str] = None
sub_projects: Optional[list[str]] = None
related_projects: Optional[list[str]] = None
owner_id: int
created_at: datetime
class _ProjectResponse_Inactive(ProjectBase):
id: int
owner_id: int
project_code: str | None = None
created_at: datetime
class Config:
@@ -156,7 +182,6 @@ class UserResponse(UserBase):
# Project Member schemas
class ProjectMemberBase(BaseModel):
user_id: int
project_id: int
role: str = "dev"
@@ -164,8 +189,11 @@ class ProjectMemberCreate(ProjectMemberBase):
pass
class ProjectMemberResponse(ProjectMemberBase):
class ProjectMemberResponse(BaseModel):
id: int
user_id: int
project_id: int
role: str = "dev"
class Config:
from_attributes = True
@@ -179,7 +207,7 @@ class MilestoneBase(BaseModel):
class MilestoneCreate(MilestoneBase):
project_id: int
pass
class MilestoneUpdate(BaseModel):