guardia-itsm/routers/auto_report.py
2026-06-02 06:07:36 +09:00

217 lines
8.1 KiB
Python

"""
자동 보고서 생성 — 주간/월간/분기 운영 보고서 자동 발송
기존 report.py를 확장하여 스케줄 기반 자동 생성 + 이메일 발송.
엔드포인트:
GET /api/auto-report/templates — 보고서 템플릿 목록
POST /api/auto-report/generate — 보고서 즉시 생성
GET /api/auto-report/list — 생성된 보고서 목록
GET /api/auto-report/{id}/download — 보고서 다운로드
POST /api/auto-report/schedule — 자동 발송 스케줄 설정
GET /api/auto-report/schedule — 스케줄 목록
"""
from __future__ import annotations
import io
import json
import logging
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user, require_admin_role
from database import get_db
from models import User, SRRequest, SRStatus, ReportRecord, ReportSchedule # 신규
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auto-report", tags=["Auto Report"])
TEMPLATES = {
"weekly_ops": {"name": "주간 운영 보고서", "period": "WEEKLY", "format": ["excel", "pdf"]},
"monthly_sla": {"name": "월간 SLA 보고서", "period": "MONTHLY", "format": ["excel", "pdf"]},
"incident_rca": {"name": "인시던트 분석", "period": "MONTHLY", "format": ["pdf"]},
"capacity_plan": {"name": "용량 계획 보고서", "period": "QUARTERLY","format": ["excel"]},
}
class GenerateRequest(BaseModel):
template: str = Field(..., description="weekly_ops | monthly_sla | incident_rca | capacity_plan")
period_start: Optional[str] = None # YYYY-MM-DD
period_end: Optional[str] = None
format: str = Field("excel", pattern="^(excel|pdf)$")
send_email: bool = False
email: Optional[str] = None
class ScheduleCreate(BaseModel):
template: str
cron: str = Field(..., description="cron 표현식 (예: 0 9 * * 1 = 매주 월요일 9시)")
email: str
format: str = "excel"
async def _collect_report_data(template: str, start: date, end: date, db: AsyncSession) -> dict:
"""보고서 데이터 수집."""
total_r = await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.created_at >= start, SRRequest.created_at <= end
)
)
done_r = await db.execute(
select(func.count(SRRequest.id)).where(
SRRequest.status == SRStatus.DONE,
SRRequest.updated_at >= start, SRRequest.updated_at <= end,
)
)
open_r = await db.execute(
select(func.count(SRRequest.id)).where(SRRequest.status.in_([SRStatus.OPEN, SRStatus.IN_PROGRESS]))
)
mttr_r = await db.execute(
select(func.avg(
func.extract('epoch', SRRequest.updated_at - SRRequest.created_at) / 3600
)).where(SRRequest.status == SRStatus.DONE, SRRequest.updated_at >= start, SRRequest.updated_at <= end)
)
total = total_r.scalar() or 0
done = done_r.scalar() or 0
return {
"period": {"start": start.isoformat(), "end": end.isoformat()},
"sr_total": total, "sr_done": done, "sr_open": open_r.scalar() or 0,
"completion_rate": round(done / total * 100, 1) if total else 0,
"mttr_hours": round(mttr_r.scalar() or 0, 1),
}
def _build_excel(data: dict, template: str) -> bytes:
"""Excel 보고서 생성 (openpyxl)."""
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
wb = openpyxl.Workbook()
ws = wb.active
ws.title = TEMPLATES.get(template, {}).get("name", "보고서")
# 헤더
ws["A1"] = TEMPLATES.get(template, {}).get("name", "운영 보고서")
ws["A1"].font = Font(bold=True, size=14)
ws["A2"] = f"기간: {data['period']['start']} ~ {data['period']['end']}"
ws["A4"] = "지표"; ws["B4"] = ""
ws["A4"].font = Font(bold=True)
ws["B4"].font = Font(bold=True)
rows = [
("신규 SR", data["sr_total"]),
("완료 SR", data["sr_done"]),
("미처리 SR", data["sr_open"]),
("완료율 (%)", data["completion_rate"]),
("평균 처리 시간 (시간)", data["mttr_hours"]),
]
for i, (label, value) in enumerate(rows, start=5):
ws[f"A{i}"] = label
ws[f"B{i}"] = value
ws["A4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
ws["A4"].font = Font(bold=True, color="FFFFFF")
ws["B4"].fill = PatternFill(start_color="003366", end_color="003366", fill_type="solid")
ws["B4"].font = Font(bold=True, color="FFFFFF")
ws.column_dimensions["A"].width = 25
ws.column_dimensions["B"].width = 15
output = io.BytesIO()
wb.save(output)
return output.getvalue()
except ImportError:
# openpyxl 없으면 CSV 대체
lines = [f"{k},{v}" for k, v in [("지표", "")] + [(str(k), str(v)) for k, v in [
("신규 SR", data["sr_total"]), ("완료율", data["completion_rate"])
]]]
return "\n".join(lines).encode('utf-8-sig')
@router.get("/templates")
async def list_templates():
return [{"code": k, **v} for k, v in TEMPLATES.items()]
@router.post("/generate")
async def generate_report(
req: GenerateRequest, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
if req.template not in TEMPLATES:
raise HTTPException(400, f"알 수 없는 템플릿: {req.template}")
today = date.today()
if req.period_start and req.period_end:
start = date.fromisoformat(req.period_start)
end = date.fromisoformat(req.period_end)
else:
period = TEMPLATES[req.template]["period"]
if period == "WEEKLY":
start = today - timedelta(days=7); end = today
elif period == "QUARTERLY":
q_start = date(today.year, ((today.month - 1) // 3) * 3 + 1, 1)
start = q_start; end = today
else: # MONTHLY
start = today.replace(day=1); end = today
data = await _collect_report_data(req.template, start, end, db)
excel_bytes = _build_excel(data, req.template)
record = ReportRecord(
tenant_id=user.tenant_id, template=req.template,
period_start=start, period_end=end,
format=req.format, file_size=len(excel_bytes),
status="DONE", generated_by=user.id, created_at=datetime.utcnow()
)
db.add(record)
await db.commit()
await db.refresh(record)
return {
"ok": True, "report_id": record.id,
"template": req.template, "period": data["period"],
"data_summary": data,
}
@router.get("/{report_id}/download")
async def download_report(
report_id: int, db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
row = await db.execute(
select(ReportRecord).where(ReportRecord.id == report_id, ReportRecord.tenant_id == user.tenant_id)
)
record = row.scalar_one_or_none()
if not record: raise HTTPException(404, "보고서 없음")
data = await _collect_report_data(record.template, record.period_start, record.period_end, db)
excel_bytes = _build_excel(data, record.template)
filename = f"report_{record.template}_{record.period_start}.xlsx"
return Response(
content=excel_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
@router.get("/list")
async def list_reports(db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)):
rows = await db.execute(
select(ReportRecord).where(ReportRecord.tenant_id == user.tenant_id)
.order_by(ReportRecord.created_at.desc()).limit(50)
)
records = rows.scalars().all()
return [
{"id": r.id, "template": r.template, "period": f"{r.period_start}~{r.period_end}",
"format": r.format, "status": r.status, "created_at": r.created_at}
for r in records
]