- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
10 KiB
Python
281 lines
10 KiB
Python
"""
|
|
프로젝트 이슈 관리 API — 일정/자원/기술/요구사항변경 등 장애 요인 추적.
|
|
|
|
엔드포인트:
|
|
GET /api/si/projects/{pid}/issues — 이슈 목록
|
|
POST /api/si/projects/{pid}/issues — 이슈 등록
|
|
GET /api/si/projects/{pid}/issues/{id} — 이슈 상세
|
|
PATCH /api/si/projects/{pid}/issues/{id} — 이슈 수정
|
|
PATCH /api/si/projects/{pid}/issues/{id}/resolve — 이슈 해결 처리
|
|
DELETE /api/si/projects/{pid}/issues/{id} — 이슈 삭제
|
|
GET /api/si/projects/{pid}/issues/stats — 유형별·상태별 통계
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
from uuid import uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import select, desc
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import (
|
|
SiProject, ProjectIssue,
|
|
ProjectIssueCreate, ProjectIssueOut, ProjectIssueUpdate,
|
|
IssueStatus, IssueType,
|
|
User, UserRole,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/si/projects", tags=["si-issues"])
|
|
|
|
|
|
def _new_issue_id() -> str:
|
|
return f"ISS-{datetime.now().strftime('%Y%m%d')}-{str(uuid4())[:5].upper()}"
|
|
|
|
|
|
# ── 목록 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/issues", response_model=List[ProjectIssueOut])
|
|
async def list_issues(
|
|
project_id: int,
|
|
issue_type: Optional[str] = Query(None),
|
|
status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
assigned_to: Optional[str] = Query(None),
|
|
keyword: Optional[str] = Query(None),
|
|
skip: int = 0,
|
|
limit: int = 100,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
q = select(ProjectIssue).where(ProjectIssue.project_id == project_id)
|
|
|
|
if issue_type:
|
|
q = q.where(ProjectIssue.issue_type == issue_type)
|
|
if status:
|
|
q = q.where(ProjectIssue.status == status)
|
|
if priority:
|
|
q = q.where(ProjectIssue.priority == priority)
|
|
if assigned_to:
|
|
q = q.where(ProjectIssue.assigned_to == assigned_to)
|
|
if keyword:
|
|
q = q.where(ProjectIssue.title.contains(keyword))
|
|
|
|
q = q.order_by(desc(ProjectIssue.created_at)).offset(skip).limit(limit)
|
|
return (await db.execute(q)).scalars().all()
|
|
|
|
|
|
# ── 생성 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.post("/{project_id}/issues", response_model=ProjectIssueOut, status_code=201)
|
|
async def create_issue(
|
|
project_id: int,
|
|
body: ProjectIssueCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
|
|
issue = ProjectIssue(
|
|
issue_id = _new_issue_id(),
|
|
project_id = project_id,
|
|
wbs_item_id = body.wbs_item_id,
|
|
issue_type = body.issue_type,
|
|
title = body.title,
|
|
description = body.description,
|
|
priority = body.priority,
|
|
raised_by = body.raised_by or current_user.username,
|
|
assigned_to = body.assigned_to,
|
|
due_date = body.due_date,
|
|
impact = body.impact,
|
|
)
|
|
db.add(issue)
|
|
await db.commit()
|
|
await db.refresh(issue)
|
|
return issue
|
|
|
|
|
|
# ── 상세 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/issues/{issue_id}", response_model=ProjectIssueOut)
|
|
async def get_issue(
|
|
project_id: int, issue_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
return await _get_issue_or_404(project_id, issue_id, db)
|
|
|
|
|
|
# ── 수정 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/issues/{issue_id}", response_model=ProjectIssueOut)
|
|
async def update_issue(
|
|
project_id: int, issue_id: int,
|
|
body: ProjectIssueUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
issue = await _get_issue_or_404(project_id, issue_id, db)
|
|
|
|
for field, val in body.model_dump(exclude_none=True).items():
|
|
setattr(issue, field, val)
|
|
|
|
await db.commit()
|
|
await db.refresh(issue)
|
|
return issue
|
|
|
|
|
|
# ── 해결 처리 ─────────────────────────────────────────────────────────────────
|
|
|
|
@router.patch("/{project_id}/issues/{issue_id}/resolve", response_model=ProjectIssueOut)
|
|
async def resolve_issue(
|
|
project_id: int, issue_id: int,
|
|
resolution: str = Query(..., description="해결 내용"),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
issue = await _get_issue_or_404(project_id, issue_id, db)
|
|
|
|
issue.status = IssueStatus.RESOLVED
|
|
issue.resolution = resolution
|
|
issue.resolved_at = datetime.now()
|
|
|
|
await db.commit()
|
|
await db.refresh(issue)
|
|
return issue
|
|
|
|
|
|
# ── 삭제 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.delete("/{project_id}/issues/{issue_id}", status_code=204)
|
|
async def delete_issue(
|
|
project_id: int, issue_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
|
|
raise HTTPException(403, "PM 이상 권한 필요")
|
|
issue = await _get_issue_or_404(project_id, issue_id, db)
|
|
await db.delete(issue)
|
|
await db.commit()
|
|
|
|
|
|
# ── 통계 ──────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/{project_id}/issues/stats")
|
|
async def get_issue_stats(
|
|
project_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
await _assert_project(project_id, db)
|
|
issues = (await db.execute(
|
|
select(ProjectIssue).where(ProjectIssue.project_id == project_id)
|
|
)).scalars().all()
|
|
|
|
by_type = {}
|
|
by_status = {}
|
|
overdue = 0
|
|
from datetime import date
|
|
today = date.today()
|
|
|
|
for i in issues:
|
|
by_type[i.issue_type] = by_type.get(i.issue_type, 0) + 1
|
|
by_status[i.status] = by_status.get(i.status, 0) + 1
|
|
if i.due_date and i.due_date < today and i.status not in ("RESOLVED", "CLOSED"):
|
|
overdue += 1
|
|
|
|
return {
|
|
"project_id": project_id,
|
|
"total": len(issues),
|
|
"by_type": by_type,
|
|
"by_status": by_status,
|
|
"overdue": overdue,
|
|
}
|
|
|
|
|
|
# ── 이슈 → SR 자동 연결 ───────────────────────────────────────────────────────
|
|
|
|
@router.post("/{project_id}/issues/{issue_id}/create-sr", status_code=201)
|
|
async def create_sr_from_issue(
|
|
project_id: int,
|
|
issue_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""
|
|
프로젝트 이슈를 ITSM SR(서비스 요청)로 자동 변환.
|
|
|
|
- 이슈 내용 → SR 제목/설명 자동 매핑
|
|
- 이슈 우선순위 → SR 우선순위 변환
|
|
- 생성된 SR ID를 이슈의 note에 기록
|
|
"""
|
|
from models import SRRequest, SRStatus, SiProject
|
|
from uuid import uuid4
|
|
from datetime import datetime as dt
|
|
|
|
issue = await _get_issue_or_404(project_id, issue_id, db)
|
|
proj = await db.get(SiProject, project_id)
|
|
|
|
# 우선순위 매핑
|
|
prio_map = {"CRITICAL": "CRITICAL", "HIGH": "HIGH", "MEDIUM": "MEDIUM", "LOW": "LOW"}
|
|
priority = prio_map.get(getattr(issue, "priority", "MEDIUM"), "MEDIUM")
|
|
|
|
sr_id = f"SR-{dt.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
|
|
sr = SRRequest(
|
|
sr_id = sr_id,
|
|
title = f"[{proj.project_code if proj else ''}] {issue.title}",
|
|
description = (
|
|
f"프로젝트 이슈에서 자동 생성\n"
|
|
f"이슈 ID: {issue.issue_id}\n"
|
|
f"이슈 유형: {issue.issue_type}\n"
|
|
f"담당자: {issue.assigned_to or '—'}\n\n"
|
|
f"{issue.description or ''}"
|
|
),
|
|
sr_type = "OTHER",
|
|
priority = priority,
|
|
requested_by = current_user.username,
|
|
assigned_to = issue.assigned_to,
|
|
status = SRStatus.RECEIVED,
|
|
)
|
|
db.add(sr)
|
|
|
|
# 이슈에 SR 연결 기록
|
|
old_note = issue.note or ""
|
|
issue.note = f"{old_note}\n[SR 연결] {sr_id} (생성자: {current_user.username})".strip()
|
|
issue.updated_at = dt.now()
|
|
|
|
await db.commit()
|
|
return {
|
|
"message": f"이슈 '{issue.title}' → SR {sr_id} 자동 생성 완료",
|
|
"sr_id": sr_id,
|
|
"issue_id": issue.issue_id,
|
|
}
|
|
|
|
|
|
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
|
|
|
async def _assert_project(project_id: int, db: AsyncSession) -> None:
|
|
if not (await db.execute(
|
|
select(SiProject).where(SiProject.id == project_id)
|
|
)).scalars().first():
|
|
raise HTTPException(404, f"SI 프로젝트 {project_id}를 찾을 수 없습니다")
|
|
|
|
|
|
async def _get_issue_or_404(project_id: int, issue_id: int, db: AsyncSession) -> ProjectIssue:
|
|
issue = (await db.execute(
|
|
select(ProjectIssue).where(
|
|
ProjectIssue.id == issue_id,
|
|
ProjectIssue.project_id == project_id,
|
|
)
|
|
)).scalars().first()
|
|
if not issue:
|
|
raise HTTPException(404, "이슈를 찾을 수 없습니다")
|
|
return issue
|