Merge pull request 'feat/task-type-hierarchy' (#5) from feat/task-type-hierarchy into main
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
68
app/api/routers/milestones.py
Normal file
68
app/api/routers/milestones.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
214
app/api/routers/roles.py
Normal 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
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
43
app/main.py
43
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
44
app/models/role_permission.py
Normal file
44
app/models/role_permission.py
Normal 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")
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user