zioinfo-mail/workspace/guardia-itsm/routers/pm.py
DESKTOP-TKLFCPR\ython cfe2901a55 refactor(structure): consolidate all projects under workspace/
- 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>
2026-05-31 23:50:56 +09:00

553 lines
21 KiB
Python

"""
정기 PM (예방정비) 관리 API.
엔드포인트:
Templates:
GET /api/pm/templates — 체크리스트 템플릿 목록
POST /api/pm/templates — 템플릿 생성
PATCH /api/pm/templates/{id} — 템플릿 수정
DELETE /api/pm/templates/{id} — 템플릿 삭제
Schedules:
GET /api/pm/schedules — PM 반복 스케줄 목록
POST /api/pm/schedules — 스케줄 등록
PATCH /api/pm/schedules/{id} — 스케줄 수정
DELETE /api/pm/schedules/{id} — 스케줄 삭제
POST /api/pm/schedules/{id}/trigger — 즉시 실행 (WorkTimetable 생성)
Results:
GET /api/pm/results/{timetable_id} — 점검 결과 목록
POST /api/pm/results/{timetable_id}/init — 체크리스트 초기화 (템플릿 → 결과 행 생성)
PATCH /api/pm/results/{result_id} — 단일 결과 업데이트
GET /api/pm/results/{timetable_id}/report — Excel 보고서 다운로드
"""
from __future__ import annotations
import io
import logging
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_
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
Institution, Server, WorkTimetable,
PmFrequency, PmItemResult,
PmTemplate, PmTemplateCreate, PmTemplateOut,
PmSchedule, PmScheduleCreate, PmScheduleOut,
PmResult, PmResultOut, PmResultUpdate,
User, UserRole,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/pm", tags=["pm"])
RESULT_COLOR = {
"PASS": "C6EFCE",
"FAIL": "FFC7CE",
"WARNING": "FFEB9C",
"NA": "F4F4F4",
}
# ══════════════════════════════════════════════════════════════════════════════
# PM 체크리스트 템플릿
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/templates", response_model=List[PmTemplateOut])
async def list_templates(
server_role: Optional[str] = Query(None),
category: Optional[str] = Query(None),
active_only: bool = Query(True),
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""체크리스트 템플릿 목록. server_role / category 필터 지원."""
q = select(PmTemplate)
if active_only:
q = q.where(PmTemplate.is_active.is_(True))
if server_role:
q = q.where(PmTemplate.server_role == server_role)
if category:
q = q.where(PmTemplate.category == category)
q = q.order_by(PmTemplate.server_role, PmTemplate.category, PmTemplate.item_order)
result = await db.execute(q)
return result.scalars().all()
@router.post("/templates", response_model=PmTemplateOut, status_code=201)
async def create_template(
payload: PmTemplateCreate,
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 권한이 필요합니다.")
tmpl = PmTemplate(**payload.model_dump())
db.add(tmpl)
await db.commit()
await db.refresh(tmpl)
return tmpl
@router.patch("/templates/{template_id}", response_model=PmTemplateOut)
async def update_template(
template_id: int,
payload: PmTemplateCreate,
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(PmTemplate).where(PmTemplate.id == template_id))
tmpl = r.scalars().first()
if not tmpl:
raise HTTPException(404, "템플릿을 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(tmpl, k, v)
await db.commit()
await db.refresh(tmpl)
return tmpl
@router.delete("/templates/{template_id}", status_code=204)
async def delete_template(
template_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(PmTemplate).where(PmTemplate.id == template_id))
tmpl = r.scalars().first()
if not tmpl:
raise HTTPException(404, "템플릿을 찾을 수 없습니다.")
tmpl.is_active = False # 소프트 삭제
await db.commit()
# ══════════════════════════════════════════════════════════════════════════════
# PM 반복 스케줄
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/schedules", response_model=List[PmScheduleOut])
async def list_schedules(
inst_id: Optional[int] = Query(None),
active_only: bool = Query(True),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(PmSchedule)
if active_only:
q = q.where(PmSchedule.is_active.is_(True))
if inst_id:
q = q.where(PmSchedule.inst_id == inst_id)
if current_user.role == UserRole.CUSTOMER and current_user.inst_code:
r_i = await db.execute(
select(Institution).where(Institution.inst_code == current_user.inst_code)
)
own = r_i.scalars().first()
if own:
q = q.where(PmSchedule.inst_id == own.id)
q = q.order_by(PmSchedule.next_scheduled)
result = await db.execute(q)
return result.scalars().all()
@router.post("/schedules", response_model=PmScheduleOut, status_code=201)
async def create_schedule(
payload: PmScheduleCreate,
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 권한이 필요합니다.")
if payload.frequency == PmFrequency.CUSTOM and not payload.cron_expr:
raise HTTPException(422, "CUSTOM 주기에는 cron_expr이 필요합니다.")
sched = PmSchedule(
**payload.model_dump(),
created_by=current_user.username,
)
# next_scheduled 초기 계산
from core.scheduler import _calc_next
sched.next_scheduled = _calc_next(sched, datetime.now())
db.add(sched)
await db.commit()
await db.refresh(sched)
return sched
@router.patch("/schedules/{schedule_id}", response_model=PmScheduleOut)
async def update_schedule(
schedule_id: int,
payload: PmScheduleCreate,
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(PmSchedule).where(PmSchedule.id == schedule_id))
sched = r.scalars().first()
if not sched:
raise HTTPException(404, "스케줄을 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(sched, k, v)
sched.updated_at = datetime.now()
await db.commit()
await db.refresh(sched)
return sched
@router.delete("/schedules/{schedule_id}", status_code=204)
async def delete_schedule(
schedule_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(PmSchedule).where(PmSchedule.id == schedule_id))
sched = r.scalars().first()
if not sched:
raise HTTPException(404, "스케줄을 찾을 수 없습니다.")
sched.is_active = False
await db.commit()
@router.post("/schedules/{schedule_id}/trigger", status_code=201)
async def trigger_pm_now(
schedule_id: int,
scheduled_at: Optional[datetime] = Query(None, description="예정 일시 (미입력 시 즉시)"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""PM 스케줄을 즉시 또는 지정 일시로 WorkTimetable에 등록."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "권한이 없습니다.")
r = await db.execute(select(PmSchedule).where(PmSchedule.id == schedule_id))
sched = r.scalars().first()
if not sched:
raise HTTPException(404, "스케줄을 찾을 수 없습니다.")
target_dt = scheduled_at or datetime.now()
wt = WorkTimetable(
work_type = "PM",
title = f"{sched.schedule_name} PM (수동 실행)",
inst_id = sched.inst_id,
server_id = sched.server_id,
scheduled_at = target_dt,
content = (
f"PM 수동 실행\n"
f"스케줄: {sched.schedule_name} ({sched.frequency})\n"
f"담당자: {sched.assignee or '미지정'}"
),
result_status = "PENDING",
assignee = sched.assignee,
reviewer = sched.reviewer,
created_by = current_user.username,
)
db.add(wt)
sched.last_generated = datetime.now()
await db.commit()
await db.refresh(wt)
# 템플릿 ID가 설정된 경우 PmResult 행 초기화
timetable_id = wt.id
if sched.template_ids:
template_id_list = [
int(t.strip()) for t in sched.template_ids.split(",") if t.strip().isdigit()
]
if template_id_list:
tmpl_result = await db.execute(
select(PmTemplate).where(
PmTemplate.id.in_(template_id_list),
PmTemplate.is_active.is_(True),
).order_by(PmTemplate.item_order)
)
templates = tmpl_result.scalars().all()
for tmpl in templates:
pr = PmResult(
timetable_id = timetable_id,
template_id = tmpl.id,
item_title = tmpl.item_title,
item_desc = tmpl.item_desc,
check_command = tmpl.check_command,
result = PmItemResult.NA,
)
db.add(pr)
await db.commit()
return {
"timetable_id": timetable_id,
"scheduled_at": target_dt.isoformat(),
"message": f"PM WorkTimetable 생성 완료 (ID: {timetable_id})",
}
# ══════════════════════════════════════════════════════════════════════════════
# PM 점검 결과 (체크리스트)
# ══════════════════════════════════════════════════════════════════════════════
@router.get("/results/{timetable_id}", response_model=List[PmResultOut])
async def get_results(
timetable_id: int,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
"""타임테이블 ID에 해당하는 PM 체크리스트 항목 목록."""
result = await db.execute(
select(PmResult)
.where(PmResult.timetable_id == timetable_id)
.order_by(PmResult.id)
)
return result.scalars().all()
@router.post("/results/{timetable_id}/init", status_code=201)
async def init_checklist(
timetable_id: int,
server_role: Optional[str] = Query(None, description="템플릿 필터 — 서버 역할"),
category: Optional[str] = Query(None, description="템플릿 필터 — 카테고리"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
템플릿에서 PmResult 행을 일괄 초기화.
이미 결과가 있으면 건너뜀.
"""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "권한이 없습니다.")
# WorkTimetable 확인
wt = (await db.execute(
select(WorkTimetable).where(WorkTimetable.id == timetable_id)
)).scalars().first()
if not wt:
raise HTTPException(404, "타임테이블을 찾을 수 없습니다.")
# 기존 결과 있으면 건너뜀
existing = (await db.execute(
select(PmResult).where(PmResult.timetable_id == timetable_id)
)).scalars().all()
if existing:
return {"created": 0, "message": "이미 체크리스트가 초기화되어 있습니다.", "total": len(existing)}
# 템플릿 조회
q = select(PmTemplate).where(PmTemplate.is_active.is_(True))
if server_role:
q = q.where(PmTemplate.server_role == server_role)
if category:
q = q.where(PmTemplate.category == category)
q = q.order_by(PmTemplate.item_order)
templates = (await db.execute(q)).scalars().all()
for tmpl in templates:
pr = PmResult(
timetable_id = timetable_id,
template_id = tmpl.id,
item_title = tmpl.item_title,
item_desc = tmpl.item_desc,
check_command = tmpl.check_command,
result = PmItemResult.NA,
)
db.add(pr)
await db.commit()
return {
"created": len(templates),
"message": f"{len(templates)}개 체크리스트 항목이 초기화되었습니다.",
"total": len(templates),
}
@router.patch("/results/{result_id}", response_model=PmResultOut)
async def update_result(
result_id: int,
payload: PmResultUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""단일 PM 점검 항목 결과 업데이트."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
raise HTTPException(403, "권한이 없습니다.")
r = await db.execute(select(PmResult).where(PmResult.id == result_id))
item = r.scalars().first()
if not item:
raise HTTPException(404, "점검 결과 항목을 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(item, k, v)
item.checked_by = current_user.username
item.checked_at = datetime.now()
await db.commit()
await db.refresh(item)
return item
@router.get("/results/{timetable_id}/report")
async def download_report(
timetable_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""PM 점검 보고서 Excel 다운로드."""
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")
# 타임테이블 정보
wt = (await db.execute(
select(WorkTimetable).where(WorkTimetable.id == timetable_id)
)).scalars().first()
if not wt:
raise HTTPException(404, "타임테이블을 찾을 수 없습니다.")
results = (await db.execute(
select(PmResult).where(PmResult.timetable_id == timetable_id).order_by(PmResult.id)
)).scalars().all()
# 기관/서버명 조회
inst_name = ""
if wt.inst_id:
inst = (await db.execute(
select(Institution).where(Institution.id == wt.inst_id)
)).scalars().first()
if inst:
inst_name = inst.inst_name
server_name = ""
if wt.server_id:
srv = (await db.execute(
select(Server).where(Server.id == wt.server_id)
)).scalars().first()
if srv:
server_name = srv.server_name
# Excel 생성
wb = Workbook()
ws = wb.active
ws.title = "PM 점검결과"
ws.freeze_panes = "A3"
hdr_fill = PatternFill("solid", fgColor="1E3A5F")
hdr_font = Font(name="맑은 고딕", bold=True, color="FFFFFF", size=10)
thin = Side(style="thin", color="CCCCCC")
bdr = Border(left=thin, right=thin, top=thin, bottom=thin)
# 타이틀 행
ws.merge_cells("A1:H1")
title_cell = ws["A1"]
title_cell.value = (
f"GUARDiA ITSM — PM 점검 결과 | {wt.title} | "
f"{wt.scheduled_at.strftime('%Y-%m-%d') if wt.scheduled_at else ''}"
)
title_cell.font = Font(name="맑은 고딕", bold=True, size=12)
title_cell.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 25
# 헤더
headers = [
("No", 5), ("카테고리", 14), ("점검 항목", 35), ("확인 명령어", 30),
("기준값", 18), ("실제값", 20), ("결과", 10), ("비고", 25),
]
for ci, (h, w) in enumerate(headers, 1):
cell = ws.cell(2, ci, h)
cell.font = hdr_font; cell.fill = hdr_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = bdr
ws.column_dimensions[get_column_letter(ci)].width = w
ws.row_dimensions[2].height = 20
# 데이터 행
for ri, item in enumerate(results, 3):
tmpl_cat = ""
if item.template_id:
tmpl_obj = (await db.execute(
select(PmTemplate).where(PmTemplate.id == item.template_id)
)).scalars().first()
if tmpl_obj:
tmpl_cat = tmpl_obj.category or ""
row_vals = [
ri - 2,
tmpl_cat,
item.item_title,
item.check_command or "",
(await db.execute(
select(PmTemplate.expected_value).where(PmTemplate.id == item.template_id)
)).scalar_one_or_none() if item.template_id else "",
item.actual_value or "",
item.result or "NA",
item.result_note or "",
]
result_fill_color = RESULT_COLOR.get(item.result or "NA", "F4F4F4")
for ci, val in enumerate(row_vals, 1):
cell = ws.cell(ri, ci, val)
cell.border = bdr
cell.alignment = Alignment(
horizontal="center" if ci in (1, 7) else "left",
vertical="center",
wrap_text=True,
)
if ci == 7:
cell.fill = PatternFill("solid", fgColor=result_fill_color)
# 통계
ws.append([])
pass_cnt = sum(1 for r in results if r.result == PmItemResult.PASS)
fail_cnt = sum(1 for r in results if r.result == PmItemResult.FAIL)
warn_cnt = sum(1 for r in results if r.result == PmItemResult.WARNING)
na_cnt = sum(1 for r in results if r.result == PmItemResult.NA)
summary_row = ws.max_row + 1
ws.cell(summary_row, 1, "합계")
ws.cell(summary_row, 2, f"{len(results)}")
ws.cell(summary_row, 3, f"PASS: {pass_cnt} FAIL: {fail_cnt} WARNING: {warn_cnt} NA: {na_cnt}")
for ci in range(1, 4):
ws.cell(summary_row, ci).font = Font(name="맑은 고딕", bold=True)
# 메타 시트
ws2 = wb.create_sheet("점검정보")
meta = [
("타임테이블 ID", timetable_id),
("작업명", wt.title),
("기관", inst_name),
("서버", server_name),
("예정일", wt.scheduled_at.strftime("%Y-%m-%d") if wt.scheduled_at else ""),
("담당자", wt.assignee or ""),
("검토자", wt.reviewer or ""),
("결과상태", wt.result_status),
]
for r, (k, v) in enumerate(meta, 1):
ws2.cell(r, 1, k).font = Font(bold=True)
ws2.cell(r, 2, str(v))
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
from urllib.parse import quote
fname = f"PM점검결과_{wt.scheduled_at.strftime('%Y%m%d') if wt.scheduled_at else 'NO_DATE'}.xlsx"
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(fname)}"},
)