fix: project create schema - owner_name auto-fill from owner_id, sub/related projects as list

This commit is contained in:
Zhi
2026-03-12 10:52:46 +00:00
parent d5bf47f4fc
commit 1eb90cd61c
6 changed files with 121 additions and 10 deletions

View File

@@ -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}

View File

@@ -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)

View File

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

View File

@@ -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:

View File

@@ -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())

View File

@@ -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