From 6b3e42195db735dde6423954751c8f546ac6be25 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 11 Mar 2026 23:55:52 +0000 Subject: [PATCH 01/17] feat: add task type hierarchy with subtypes (issue/meeting/support/maintenance/research/review/story/test) --- app/api/routers/issues.py | 35 +++++++++++++++++++++++++++++++++-- app/api/routers/misc.py | 2 +- app/main.py | 1 + app/models/models.py | 17 ++++++++++++----- app/schemas/schemas.py | 13 +++++++++++-- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index deaca01..d6fd16b 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'}, + 'maintenance': {'deploy', 'release'}, + 'review': {'code_review', 'decision_review', 'function_review'}, + 'story': {'feature', 'improvement', 'refactor'}, + 'test': {'regression', 'security', 'smoke', 'stress'}, + 'research': set(), + 'task': set(), + '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: @@ -108,7 +139,7 @@ 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(): + for field, value in update_data.items(): setattr(issue, field, value) db.commit() db.refresh(issue) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index 20fe36a..d5c4f9f 100644 --- a/app/api/routers/misc.py +++ b/app/api/routers/misc.py @@ -298,7 +298,7 @@ def export_issues_csv(project_id: int = None, db: Session = Depends(get_db)): "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/main.py b/app/main.py index 2ea5790..7c1968a 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,7 @@ def startup(): from app.core.config import Base, engine, SessionLocal from app.models import webhook, apikey, activity, milestone, notification, worklog, monitor 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..ca1af3a 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -6,10 +6,16 @@ import enum class IssueType(str, enum.Enum): - TASK = "task" - STORY = "story" - TEST = "test" - RESOLUTION = "resolution" # 决议案 - 用于 Agent 僵局提交 + 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 +39,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) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 77e2619..b8fdf48 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 -- 2.49.1 From e5775bb9c89dccbe785db42cad406668c11b6328 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 12 Mar 2026 09:25:26 +0000 Subject: [PATCH 02/17] feat: add project code generation + remove issues/milestones from nav --- app/api/routers/issues.py | 5 ++ app/api/routers/projects.py | 109 +++++++++++++++++++++++++++++++++++- app/main.py | 27 ++++++++- app/models/models.py | 9 +++ app/schemas/schemas.py | 1 + 5 files changed, 149 insertions(+), 2 deletions(-) diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index d6fd16b..1e9f792 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -139,6 +139,11 @@ 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") + 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() diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 4ca9ab1..cde2e11 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -1,4 +1,5 @@ """Projects router with RBAC.""" +import re from typing import List from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session @@ -11,10 +12,116 @@ from app.api.rbac import check_project_role router = APIRouter(prefix="/projects", tags=["Projects"]) +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()) + payload = project.model_dump() + payload["project_code"] = _generate_project_code(db, project.name) + db_project = models.Project(**payload) db.add(db_project) db.commit() db.refresh(db_project) diff --git a/app/main.py b/app/main.py index 7c1968a..2a92031 100644 --- a/app/main.py +++ b/app/main.py @@ -45,11 +45,36 @@ app.include_router(webhooks_router) app.include_router(misc_router) app.include_router(monitor_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)")) + 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 Base.metadata.create_all(bind=engine) _migrate_schema() diff --git a/app/models/models.py b/app/models/models.py index ca1af3a..5cfdace 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -92,6 +92,7 @@ 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) description = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) @@ -131,3 +132,11 @@ class ProjectMember(Base): 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/schemas/schemas.py b/app/schemas/schemas.py index b8fdf48..a12c5aa 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -134,6 +134,7 @@ class ProjectUpdate(BaseModel): class ProjectResponse(ProjectBase): id: int owner_id: int + project_code: str | None = None created_at: datetime class Config: -- 2.49.1 From d5bf47f4fc83a90295e4414c23ea92c466507f88 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 12 Mar 2026 09:37:19 +0000 Subject: [PATCH 03/17] fix: quote enum values and csv export subtype --- app/api/routers/misc.py | 4 ++-- app/models/models.py | 22 +++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/api/routers/misc.py b/app/api/routers/misc.py index d5c4f9f..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.issue_subtype or , 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/models/models.py b/app/models/models.py index 5cfdace..c7f8315 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -6,16 +6,16 @@ import enum class IssueType(str, enum.Enum): - MEETING = meeting - SUPPORT = support - ISSUE = issue - MAINTENANCE = maintenance - RESEARCH = research - REVIEW = review - STORY = story - TEST = test - RESOLUTION = resolution # 决议案 - 用于 Agent 僵局提交 - TASK = task # legacy generic type + 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): @@ -135,7 +135,7 @@ class ProjectMember(Base): class ProjectCodeCounter(Base): - __tablename__ = project_code_counters + __tablename__ = "project_code_counters" id = Column(Integer, primary_key=True, index=True) prefix = Column(String(16), unique=True, index=True, nullable=False) -- 2.49.1 From 1eb90cd61c5d5e45176efc9489bccbe361f9729e Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 10:52:46 +0000 Subject: [PATCH 04/17] fix: project create schema - owner_name auto-fill from owner_id, sub/related projects as list --- app/api/routers/issues.py | 38 ++++++++++++++++++++++++--- app/api/routers/projects.py | 52 ++++++++++++++++++++++++++++++++++++- app/init_wizard.py | 5 ++-- app/main.py | 11 ++++++++ app/models/models.py | 5 +++- app/schemas/schemas.py | 20 ++++++++++++-- 6 files changed, 121 insertions(+), 10 deletions(-) diff --git a/app/api/routers/issues.py b/app/api/routers/issues.py index 1e9f792..2ca4348 100644 --- a/app/api/routers/issues.py +++ b/app/api/routers/issues.py @@ -21,13 +21,13 @@ router = APIRouter(tags=["Issues"]) ISSUE_SUBTYPE_MAP = { 'meeting': {'conference', 'handover', 'recap'}, 'support': {'access', 'information'}, - 'issue': {'infrastructure', 'performance', 'regression', 'security', 'user_experience'}, + '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': set(), + 'task': {'defect'}, 'resolution': set(), } ALLOWED_ISSUE_TYPES = set(ISSUE_SUBTYPE_MAP.keys()) @@ -129,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 @@ -140,7 +145,7 @@ def update_issue(issue_id: int, issue_update: schemas.IssueUpdate, db: Session = 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: + 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) @@ -158,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() @@ -174,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() @@ -192,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") @@ -246,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("") @@ -259,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("") @@ -342,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/projects.py b/app/api/routers/projects.py index cde2e11..d44d594 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -1,4 +1,5 @@ """Projects router with RBAC.""" +import json import re from typing import List from fastapi import APIRouter, Depends, HTTPException, status @@ -12,6 +13,25 @@ from app.api.rbac import check_project_role 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]+") @@ -119,8 +139,28 @@ def _generate_project_code(db, name: str) -> str: @router.post("", response_model=schemas.ProjectResponse, status_code=status.HTTP_201_CREATED) def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): + # 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() @@ -156,7 +196,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) 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 2a92031..b588204 100644 --- a/app/main.py +++ b/app/main.py @@ -65,6 +65,17 @@ def _migrate_schema(): 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: diff --git a/app/models/models.py b/app/models/models.py index c7f8315..93a1e7c 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,4 +1,4 @@ -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 @@ -93,6 +93,9 @@ 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) description = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index a12c5aa..307b438 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -119,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): @@ -127,11 +130,24 @@ 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 -- 2.49.1 From 2f659e14304e717a704eb0d6647b5b591ff4003f Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 11:04:04 +0000 Subject: [PATCH 05/17] feat: add project creation permission (admin only), add milestones API with RBAC --- app/api/routers/milestones.py | 67 +++++++++++++++++++++++++++++++++++ app/api/routers/projects.py | 5 ++- app/main.py | 2 ++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 app/api/routers/milestones.py diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py new file mode 100644 index 0000000..fd48653 --- /dev/null +++ b/app/api/routers/milestones.py @@ -0,0 +1,67 @@ +"""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.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(models.Milestone).filter(models.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 = models.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(models.Milestone).filter(models.Milestone.id == milestone_id, models.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(models.Milestone).filter(models.Milestone.id == milestone_id, models.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(models.Milestone).filter(models.Milestone.id == milestone_id, models.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/projects.py b/app/api/routers/projects.py index d44d594..5585c21 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -138,7 +138,10 @@ def _generate_project_code(db, name: str) -> str: 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)): +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: diff --git a/app/main.py b/app/main.py index b588204..3fa5488 100644 --- a/app/main.py +++ b/app/main.py @@ -35,6 +35,7 @@ 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 app.include_router(auth_router) app.include_router(issues_router) @@ -44,6 +45,7 @@ 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) # Auto schema migration for lightweight deployments -- 2.49.1 From 74177915df8131cfb51fc5728708b6ef535cbe55 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 11:41:55 +0000 Subject: [PATCH 06/17] feat: add configurable role/permission system --- app/api/rbac.py | 92 +++++++++------ app/api/routers/roles.py | 214 ++++++++++++++++++++++++++++++++++ app/main.py | 4 +- app/models/models.py | 4 +- app/models/role_permission.py | 44 +++++++ 5 files changed, 323 insertions(+), 35 deletions(-) create mode 100644 app/api/routers/roles.py create mode 100644 app/models/role_permission.py diff --git a/app/api/rbac.py b/app/api/rbac.py index 501acef..3087c02 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.""" +def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None: + """Get user's role in a project.""" member = db.query(ProjectMember).filter( ProjectMember.user_id == user_id, 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/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/main.py b/app/main.py index 3fa5488..d31feee 100644 --- a/app/main.py +++ b/app/main.py @@ -36,6 +36,7 @@ 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) @@ -46,6 +47,7 @@ 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 @@ -87,7 +89,7 @@ def _migrate_schema(): @app.on_event("startup") def startup(): from app.core.config import Base, engine, SessionLocal - from app.models import models, 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() diff --git a/app/models/models.py b/app/models/models.py index 93a1e7c..545d499 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -2,6 +2,7 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum 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 @@ -131,7 +132,8 @@ 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") 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") -- 2.49.1 From ace07073949209a7e02eeaf042a5cfbdb178c60c Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:00:37 +0000 Subject: [PATCH 07/17] fix: member/milestone endpoints - role_id column, schema fixes --- app/api/routers/projects.py | 33 ++++++++++++++++++++++++++++++--- app/schemas/schemas.py | 7 +++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 5585c21..5c8a039 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -252,16 +252,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) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 307b438..12020ce 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -190,8 +190,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 @@ -205,7 +208,7 @@ class MilestoneBase(BaseModel): class MilestoneCreate(MilestoneBase): - project_id: int + pass class MilestoneUpdate(BaseModel): -- 2.49.1 From c695ef903f5bd7f4c5183d4a9d304f44ff6b6f37 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:04:51 +0000 Subject: [PATCH 08/17] fix: rbac ProjectMember reference, add repo field to Project --- app/api/rbac.py | 6 +++--- app/models/models.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/api/rbac.py b/app/api/rbac.py index 3087c02..49793a2 100644 --- a/app/api/rbac.py +++ b/app/api/rbac.py @@ -8,9 +8,9 @@ from app.models import models def get_user_role(db: Session, user_id: int, project_id: int) -> Role | None: """Get user's role in a project.""" - member = db.query(ProjectMember).filter( - ProjectMember.user_id == user_id, - ProjectMember.project_id == project_id, + member = db.query(models.ProjectMember).filter( + models.ProjectMember.user_id == user_id, + models.ProjectMember.project_id == project_id, ).first() if member and member.role_id: diff --git a/app/models/models.py b/app/models/models.py index 545d499..2db9658 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -97,6 +97,7 @@ class Project(Base): 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()) -- 2.49.1 From 818dbf12b9b0f61c37eafe2485f8fdf13d553a2d Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:13:14 +0000 Subject: [PATCH 09/17] fix: add member.remove permission check --- app/api/routers/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 5c8a039..925615a 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -298,7 +298,7 @@ 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() -- 2.49.1 From afd769bc128c83f6f11800e1350ef48e899a6bb3 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:16:39 +0000 Subject: [PATCH 10/17] fix: create_project auto-add member use role_id --- app/api/routers/projects.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 925615a..71e9151 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -169,7 +169,8 @@ def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db) 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 -- 2.49.1 From 9254723f2c390551116b573a52ee8eaaf3fa6dc4 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:19:14 +0000 Subject: [PATCH 11/17] fix: import Role model --- app/api/routers/projects.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 71e9151..5e22cd7 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -7,6 +7,7 @@ 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 -- 2.49.1 From dac2de62f6a1776ae17431e78a419ea10068d1dd Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:26:38 +0000 Subject: [PATCH 12/17] fix: import Milestone model in milestones router --- app/api/routers/milestones.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index fd48653..0729f37 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -7,6 +7,7 @@ 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"]) @@ -16,7 +17,7 @@ router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones 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(models.Milestone).filter(models.Milestone.project_id == project_id).all() + milestones = db.query(Milestone).filter(models.Milestone.project_id == project_id).all() return milestones @@ -24,7 +25,7 @@ def list_milestones(project_id: int, db: Session = Depends(get_db), current_user 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 = models.Milestone(project_id=project_id, **milestone.model_dump()) + db_milestone = Milestone(project_id=project_id, **milestone.model_dump()) db.add(db_milestone) db.commit() db.refresh(db_milestone) @@ -35,7 +36,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se 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(models.Milestone).filter(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + milestone = db.query(Milestone).filter(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() if not milestone: raise HTTPException(status_code=404, detail="Milestone not found") return milestone @@ -45,7 +46,7 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_ 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(models.Milestone).filter(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + db_milestone = db.query(Milestone).filter(models.Milestone.id == milestone_id, models.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(): @@ -59,7 +60,7 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile 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(models.Milestone).filter(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + db_milestone = db.query(Milestone).filter(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() if not db_milestone: raise HTTPException(status_code=404, detail="Milestone not found") db.delete(db_milestone) -- 2.49.1 From 4a32ed921a989aec65a1a68e2d6579cbda34060a Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:27:38 +0000 Subject: [PATCH 13/17] fix: remove project_id from ProjectMemberBase schema --- app/schemas/schemas.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 12020ce..5d6dd10 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -182,7 +182,6 @@ class UserResponse(UserBase): # Project Member schemas class ProjectMemberBase(BaseModel): user_id: int - project_id: int role: str = "dev" -- 2.49.1 From 2e14077668565b508a115512469d4aba050124a8 Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:33:41 +0000 Subject: [PATCH 14/17] fix: milestones router use Milestone model correctly --- app/api/routers/milestones.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index 0729f37..0092419 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -17,7 +17,7 @@ router = APIRouter(prefix="/projects/{project_id}/milestones", tags=["Milestones 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(models.Milestone.project_id == project_id).all() + milestones = db.query(Milestone).filter(Milestone.project_id == project_id).all() return milestones @@ -36,7 +36,7 @@ def create_milestone(project_id: int, milestone: schemas.MilestoneCreate, db: Se 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(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + 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 @@ -46,7 +46,7 @@ def get_milestone(project_id: int, milestone_id: int, db: Session = Depends(get_ 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(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + 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(): @@ -60,7 +60,7 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile 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(models.Milestone.id == milestone_id, models.Milestone.project_id == project_id).first() + 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) -- 2.49.1 From d1f91299229ddf700cfe768fc1675e115624e88f Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:42:50 +0000 Subject: [PATCH 15/17] fix: import check_permission --- app/api/routers/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 5e22cd7..799b0a2 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -10,7 +10,7 @@ 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"]) -- 2.49.1 From 50f5e360e4ca216f785abb3c7dc7886798716ced Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:47:15 +0000 Subject: [PATCH 16/17] fix: prevent deleting project owner --- app/api/routers/projects.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 799b0a2..0424275 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -304,6 +304,18 @@ def remove_project_member( 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) -- 2.49.1 From 7b2ac29f2caef542b8295afed330875aeab9cdfa Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 12:55:14 +0000 Subject: [PATCH 17/17] fix: cascade delete milestones/issues, clean references --- app/api/routers/projects.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/api/routers/projects.py b/app/api/routers/projects.py index 0424275..d790144 100644 --- a/app/api/routers/projects.py +++ b/app/api/routers/projects.py @@ -228,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 -- 2.49.1