guardia-itsm/routers/si_issues.py
DESKTOP-TKLFCPRython 1f8b926066 feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright
[PMS 완성]
- core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX)
- routers/si_report.py: daily|weekly|monthly + 메신저 발송
- routers/deliverables.py: 산출물 CRUD + 제출/검토
- si_issues.py: 이슈→SR 자동 연결
- scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서
- models.py: Deliverable 모델

[준수성 자동 점검]
- core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙
- routers/compliance.py: 스캔 + HTML/Excel 보고서

[JMeter 성능 테스트]
- routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서

[공공기관 필수 기능]
- routers/public_checklist.py: 행안부 기준 19개 항목

[UI/브랜드]
- 로고(ziologo.png) + Copyright 2026 All Rights Reserved
- Nifty 계층형 사이드바 (PMS 서브메뉴)
- X-Powered-By + X-Copyright 응답 헤더
- manual/15_UI_Nifty_가이드.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 22:50:29 +09:00

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