From 1eb90cd61c5d5e45176efc9489bccbe361f9729e Mon Sep 17 00:00:00 2001 From: Zhi Date: Thu, 12 Mar 2026 10:52:46 +0000 Subject: [PATCH] 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