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>
394 lines
15 KiB
Python
394 lines
15 KiB
Python
"""작업 타임테이블 CRUD + Excel 다운로드 엔드포인트."""
|
|
import io
|
|
from datetime import datetime, date
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from sqlalchemy import select, and_, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user
|
|
from database import get_db
|
|
from models import (
|
|
Institution, Server, ShellScript,
|
|
WorkTimetable, WorkTimetableCreate, WorkTimetableOut, WorkTimetableUpdate,
|
|
User, UserRole,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/timetable", tags=["timetable"])
|
|
|
|
WORK_TYPE_KO = {
|
|
"REGULAR_CHECK": "정기점검",
|
|
"PM": "예방정비",
|
|
"SR": "SR작업",
|
|
"ADHOC": "수시점검",
|
|
"DEPLOY": "배포작업",
|
|
"EMERGENCY": "긴급대응",
|
|
}
|
|
|
|
RESULT_STATUS_KO = {
|
|
"PENDING": "예정",
|
|
"SUCCESS": "완료",
|
|
"FAILED": "실패",
|
|
"PARTIAL": "부분완료",
|
|
"CANCELLED": "취소",
|
|
}
|
|
|
|
|
|
async def _build_query(
|
|
db: AsyncSession,
|
|
current_user: User,
|
|
inst_id: Optional[int],
|
|
work_type: Optional[str],
|
|
result_status: Optional[str],
|
|
assignee: Optional[str],
|
|
date_from: Optional[date],
|
|
date_to: Optional[date],
|
|
keyword: Optional[str],
|
|
):
|
|
q = select(WorkTimetable)
|
|
|
|
# CUSTOMER 역할: 자기 기관만
|
|
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
|
|
r = await db.execute(
|
|
select(Institution).where(Institution.inst_code == current_user.inst_code)
|
|
)
|
|
own_inst = r.scalars().first()
|
|
if own_inst:
|
|
q = q.where(WorkTimetable.inst_id == own_inst.id)
|
|
else:
|
|
q = q.where(WorkTimetable.id == -1)
|
|
elif inst_id:
|
|
q = q.where(WorkTimetable.inst_id == inst_id)
|
|
|
|
if work_type:
|
|
q = q.where(WorkTimetable.work_type == work_type)
|
|
if result_status:
|
|
q = q.where(WorkTimetable.result_status == result_status)
|
|
if assignee:
|
|
q = q.where(WorkTimetable.assignee.contains(assignee))
|
|
if date_from:
|
|
q = q.where(WorkTimetable.scheduled_at >= datetime.combine(date_from, datetime.min.time()))
|
|
if date_to:
|
|
q = q.where(WorkTimetable.scheduled_at <= datetime.combine(date_to, datetime.max.time()))
|
|
if keyword:
|
|
q = q.where(
|
|
or_(
|
|
WorkTimetable.title.contains(keyword),
|
|
WorkTimetable.content.contains(keyword),
|
|
)
|
|
)
|
|
return q
|
|
|
|
|
|
@router.get("", response_model=List[WorkTimetableOut])
|
|
async def list_timetable(
|
|
inst_id: Optional[int] = Query(None),
|
|
work_type: Optional[str] = Query(None),
|
|
result_status: Optional[str] = Query(None),
|
|
assignee: Optional[str] = Query(None),
|
|
date_from: Optional[date] = Query(None),
|
|
date_to: Optional[date] = 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),
|
|
):
|
|
q = await _build_query(
|
|
db, current_user, inst_id, work_type, result_status,
|
|
assignee, date_from, date_to, keyword,
|
|
)
|
|
q = q.order_by(WorkTimetable.scheduled_at.desc()).offset(skip).limit(limit)
|
|
result = await db.execute(q)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.get("/stats")
|
|
async def timetable_stats(
|
|
inst_id: Optional[int] = Query(None),
|
|
date_from: Optional[date] = Query(None),
|
|
date_to: Optional[date] = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""작업 유형별·결과별 집계."""
|
|
q = await _build_query(
|
|
db, current_user, inst_id, None, None, None, date_from, date_to, None
|
|
)
|
|
result = await db.execute(q)
|
|
rows = result.scalars().all()
|
|
by_type = {}
|
|
by_status = {}
|
|
for r in rows:
|
|
by_type[r.work_type] = by_type.get(r.work_type, 0) + 1
|
|
by_status[r.result_status] = by_status.get(r.result_status, 0) + 1
|
|
return {"total": len(rows), "by_type": by_type, "by_status": by_status}
|
|
|
|
|
|
@router.get("/export/excel")
|
|
async def export_excel(
|
|
inst_id: Optional[int] = Query(None),
|
|
work_type: Optional[str] = Query(None),
|
|
result_status: Optional[str] = Query(None),
|
|
assignee: Optional[str] = Query(None),
|
|
date_from: Optional[date] = Query(None),
|
|
date_to: Optional[date] = Query(None),
|
|
keyword: Optional[str] = Query(None),
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Excel 파일 생성 후 다운로드 (openpyxl 사용, 외부 API 없음)."""
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "권한이 없습니다.")
|
|
|
|
try:
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import (
|
|
Font, PatternFill, Alignment, Border, Side,
|
|
)
|
|
from openpyxl.utils import get_column_letter
|
|
except ImportError:
|
|
raise HTTPException(500, "openpyxl 라이브러리가 설치되지 않았습니다. pip install openpyxl")
|
|
|
|
q = await _build_query(
|
|
db, current_user, inst_id, work_type, result_status,
|
|
assignee, date_from, date_to, keyword,
|
|
)
|
|
q = q.order_by(WorkTimetable.scheduled_at)
|
|
res = await db.execute(q)
|
|
rows = res.scalars().all()
|
|
|
|
# 기관·서버 이름 매핑 (ID → 이름)
|
|
inst_map: dict[int, str] = {}
|
|
srv_map: dict[int, str] = {}
|
|
inst_ids = {r.inst_id for r in rows if r.inst_id}
|
|
srv_ids = {r.server_id for r in rows if r.server_id}
|
|
if inst_ids:
|
|
ir = await db.execute(select(Institution).where(Institution.id.in_(inst_ids)))
|
|
for inst in ir.scalars():
|
|
inst_map[inst.id] = inst.inst_name
|
|
if srv_ids:
|
|
sr = await db.execute(select(Server).where(Server.id.in_(srv_ids)))
|
|
for srv in sr.scalars():
|
|
srv_map[srv.id] = srv.server_name
|
|
|
|
# ── Workbook 구성 ────────────────────────────────────────────────────────────
|
|
wb = Workbook()
|
|
|
|
# ── 스타일 정의 ──────────────────────────────────────────────────────────────
|
|
hdr_fill = PatternFill("solid", fgColor="1E3A5F")
|
|
hdr_font = Font(name="맑은 고딕", bold=True, color="FFFFFF", size=10)
|
|
hdr_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
thin_side = Side(style="thin", color="CCCCCC")
|
|
thin_bdr = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
|
|
even_fill = PatternFill("solid", fgColor="F2F6FA")
|
|
data_align_center = Alignment(horizontal="center", vertical="center")
|
|
data_align_left = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
|
|
|
status_fills = {
|
|
"SUCCESS": PatternFill("solid", fgColor="C6EFCE"),
|
|
"FAILED": PatternFill("solid", fgColor="FFC7CE"),
|
|
"PARTIAL": PatternFill("solid", fgColor="FFEB9C"),
|
|
"PENDING": PatternFill("solid", fgColor="F4F4F4"),
|
|
"CANCELLED": PatternFill("solid", fgColor="E2E8F0"),
|
|
}
|
|
|
|
# ── 작업이력 시트 ────────────────────────────────────────────────────────────
|
|
ws = wb.active
|
|
ws.title = "작업이력"
|
|
ws.freeze_panes = "A2"
|
|
|
|
headers = [
|
|
("번호", 7),
|
|
("작업유형", 12),
|
|
("제목", 35),
|
|
("기관명", 14),
|
|
("대상서버", 16),
|
|
("처리예정", 17),
|
|
("시작", 17),
|
|
("완료", 17),
|
|
("소요(분)", 9),
|
|
("처리내용", 40),
|
|
("명령어/쉘", 30),
|
|
("처리결과", 35),
|
|
("결과상태", 10),
|
|
("담당자", 12),
|
|
("검토자", 12),
|
|
("SR번호", 20),
|
|
("비고", 20),
|
|
]
|
|
for col_idx, (hdr, width) in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col_idx, value=hdr)
|
|
cell.font = hdr_font
|
|
cell.fill = hdr_fill
|
|
cell.alignment = hdr_align
|
|
cell.border = thin_bdr
|
|
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
|
ws.row_dimensions[1].height = 30
|
|
|
|
for i, row in enumerate(rows, start=2):
|
|
is_even = (i % 2 == 0)
|
|
bg_fill = even_fill if is_even else None
|
|
|
|
def _duration_min(r):
|
|
if r.started_at and r.completed_at:
|
|
return int((r.completed_at - r.started_at).total_seconds() / 60)
|
|
return ""
|
|
|
|
def _fmt(dt):
|
|
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
|
|
|
|
vals = [
|
|
i - 1,
|
|
WORK_TYPE_KO.get(row.work_type, row.work_type),
|
|
row.title,
|
|
inst_map.get(row.inst_id, "") if row.inst_id else "",
|
|
srv_map.get(row.server_id, "") if row.server_id else "",
|
|
_fmt(row.scheduled_at),
|
|
_fmt(row.started_at),
|
|
_fmt(row.completed_at),
|
|
_duration_min(row),
|
|
row.content,
|
|
row.command_or_shell or "",
|
|
row.result or "",
|
|
RESULT_STATUS_KO.get(row.result_status, row.result_status),
|
|
row.assignee or "",
|
|
row.reviewer or "",
|
|
row.sr_id or "",
|
|
row.note or "",
|
|
]
|
|
center_cols = {1, 2, 4, 5, 6, 7, 8, 9, 13, 14, 15, 16}
|
|
for col_idx, val in enumerate(vals, 1):
|
|
cell = ws.cell(row=i, column=col_idx, value=val)
|
|
cell.border = thin_bdr
|
|
cell.alignment = data_align_center if col_idx in center_cols else data_align_left
|
|
if col_idx == 13: # 결과상태 강조
|
|
cell.fill = status_fills.get(row.result_status, bg_fill or PatternFill())
|
|
elif bg_fill:
|
|
cell.fill = bg_fill
|
|
|
|
# ── 통계 시트 ────────────────────────────────────────────────────────────────
|
|
ws2 = wb.create_sheet("통계요약")
|
|
ws2.column_dimensions["A"].width = 20
|
|
ws2.column_dimensions["B"].width = 12
|
|
|
|
ws2.cell(1, 1, "◆ 작업 유형별 집계").font = Font(name="맑은 고딕", bold=True, size=11)
|
|
ws2.cell(2, 1, "작업유형").fill = hdr_fill
|
|
ws2.cell(2, 1).font = hdr_font
|
|
ws2.cell(2, 2, "건수").fill = hdr_fill
|
|
ws2.cell(2, 2).font = hdr_font
|
|
by_type: dict[str, int] = {}
|
|
for r in rows:
|
|
by_type[r.work_type] = by_type.get(r.work_type, 0) + 1
|
|
for ri, (wt, cnt) in enumerate(sorted(by_type.items()), start=3):
|
|
ws2.cell(ri, 1, WORK_TYPE_KO.get(wt, wt))
|
|
ws2.cell(ri, 2, cnt)
|
|
|
|
offset = len(by_type) + 5
|
|
ws2.cell(offset, 1, "◆ 결과 상태별 집계").font = Font(name="맑은 고딕", bold=True, size=11)
|
|
ws2.cell(offset+1, 1, "결과상태").fill = hdr_fill
|
|
ws2.cell(offset+1, 1).font = hdr_font
|
|
ws2.cell(offset+1, 2, "건수").fill = hdr_fill
|
|
ws2.cell(offset+1, 2).font = hdr_font
|
|
by_status: dict[str, int] = {}
|
|
for r in rows:
|
|
by_status[r.result_status] = by_status.get(r.result_status, 0) + 1
|
|
for ri, (st, cnt) in enumerate(sorted(by_status.items()), start=offset+2):
|
|
ws2.cell(ri, 1, RESULT_STATUS_KO.get(st, st))
|
|
ws2.cell(ri, 2, cnt)
|
|
ws2.cell(ri, 1).fill = status_fills.get(st, PatternFill())
|
|
ws2.cell(ri, 2).fill = status_fills.get(st, PatternFill())
|
|
|
|
# ── 파일 이름 생성 및 반환 ────────────────────────────────────────────────────
|
|
inst_suffix = inst_map.get(inst_id, "ALL") if inst_id else "ALL"
|
|
today_str = date.today().strftime("%Y%m%d")
|
|
filename = f"GUARDiA_작업이력_{inst_suffix}_{today_str}.xlsx"
|
|
|
|
buf = io.BytesIO()
|
|
wb.save(buf)
|
|
buf.seek(0)
|
|
|
|
from urllib.parse import quote
|
|
encoded_name = quote(filename)
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
headers={
|
|
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}",
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{timetable_id}", response_model=WorkTimetableOut)
|
|
async def get_timetable(
|
|
timetable_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
_u: User = Depends(get_current_user),
|
|
):
|
|
r = await db.execute(select(WorkTimetable).where(WorkTimetable.id == timetable_id))
|
|
row = r.scalars().first()
|
|
if not row:
|
|
raise HTTPException(404, "타임테이블 항목을 찾을 수 없습니다.")
|
|
return row
|
|
|
|
|
|
@router.post("", response_model=WorkTimetableOut, status_code=201)
|
|
async def create_timetable(
|
|
payload: WorkTimetableCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "권한이 없습니다.")
|
|
row = WorkTimetable(**payload.model_dump(), created_by=current_user.username)
|
|
db.add(row)
|
|
# 스크립트 사용 횟수 증가
|
|
if payload.script_id:
|
|
sc = (await db.execute(
|
|
select(ShellScript).where(ShellScript.id == payload.script_id)
|
|
)).scalars().first()
|
|
if sc:
|
|
sc.use_count = (sc.use_count or 0) + 1
|
|
await db.commit()
|
|
await db.refresh(row)
|
|
return row
|
|
|
|
|
|
@router.patch("/{timetable_id}", response_model=WorkTimetableOut)
|
|
async def update_timetable(
|
|
timetable_id: int,
|
|
payload: WorkTimetableUpdate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "권한이 없습니다.")
|
|
r = await db.execute(select(WorkTimetable).where(WorkTimetable.id == timetable_id))
|
|
row = r.scalars().first()
|
|
if not row:
|
|
raise HTTPException(404, "타임테이블 항목을 찾을 수 없습니다.")
|
|
for k, v in payload.model_dump(exclude_unset=True).items():
|
|
setattr(row, k, v)
|
|
row.updated_at = datetime.now()
|
|
await db.commit()
|
|
await db.refresh(row)
|
|
return row
|
|
|
|
|
|
@router.delete("/{timetable_id}", status_code=204)
|
|
async def delete_timetable(
|
|
timetable_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, "ADMIN 또는 PM 권한이 필요합니다.")
|
|
r = await db.execute(select(WorkTimetable).where(WorkTimetable.id == timetable_id))
|
|
row = r.scalars().first()
|
|
if not row:
|
|
raise HTTPException(404, "타임테이블 항목을 찾을 수 없습니다.")
|
|
await db.delete(row)
|
|
await db.commit()
|