guardia-itsm/routers/si_issues.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

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

222 lines
8.3 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,
}
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
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