diff --git a/app/api/rbac.py b/app/api/rbac.py index 501acef..49793a2 100644 --- a/app/api/rbac.py +++ b/app/api/rbac.py @@ -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) diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index deaca01..2ca4348 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -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("") @@ -306,4 +372,4 @@ def search_issues(q: str, project_id: int = None, page: int = 1, page_size: int total_pages = math.ceil(total / page_size) if total else 1 items = query.offset((page - 1) * page_size).limit(page_size).all() return {"items": [schemas.IssueResponse.model_validate(i) for i in items], - "total": total, "page": page, "page_size": page_size, "total_pages": total_pages} + "total": total, "page": page, "page_size": page_size, "total_pages": total_pages} \ No newline at end of file diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py new file mode 100644 index 0000000..0092419 --- /dev/null +++ b/app/api/routers/milestones.py @@ -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 diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index 20fe36a..07a22b9 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -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) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 4ca9ab1..d790144 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -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) diff --git a/app/api/routers/roles.py b/app/api/routers/roles.py new file mode 100644 index 0000000..130afd6 --- /dev/null +++ b/app/api/routers/roles.py @@ -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 + ) diff --git a/app/init_wizard.py b/app/init_wizard.py index 85b9e4d..e05e8f3 100644 --- a/app/init_wizard.py +++ b/app/init_wizard.py @@ -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") diff --git a/app/main.py b/app/main.py index 2ea5790..d31feee 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/models.py b/app/models/models.py index 207d316..2db9658 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -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) diff --git a/app/models/role_permission.py b/app/models/role_permission.py new file mode 100644 index 0000000..d92d39a --- /dev/null +++ b/app/models/role_permission.py @@ -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") diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 77e2619..5d6dd10 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -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):