manual-deploy 2026-06-07 00:13
This commit is contained in:
parent
20f6e23c4a
commit
5a4aa87b72
9
main.py
9
main.py
@ -69,6 +69,9 @@ from routers import (
|
||||
sr_chat,
|
||||
inventory,
|
||||
system as system_router,
|
||||
patches,
|
||||
stats as stats_router,
|
||||
cicd,
|
||||
)
|
||||
|
||||
|
||||
@ -529,6 +532,12 @@ app.include_router(search_router.router) # #50 통합 검색
|
||||
app.include_router(sr_chat.router) # #98 SR 채팅 (REST + WebSocket)
|
||||
app.include_router(system_router.router) # #77 시스템 정보/릴리즈 노트
|
||||
app.include_router(approvals.changes_router) # #68 변경 달력 (/api/changes)
|
||||
app.include_router(patches.router) # #82 #83 CVE 패치 현황
|
||||
app.include_router(stats_router.router) # #93~#97 통계·보고
|
||||
app.include_router(cicd.router) # #99 #100 CI/CD 빌드 상태
|
||||
|
||||
from routers import mobile2_ext
|
||||
app.include_router(mobile2_ext.router) # #101~#200 2세대 보조 엔드포인트
|
||||
|
||||
|
||||
# ── 개방망 보안 헤더 미들웨어 ────────────────────────────────────────────────
|
||||
|
||||
110
routers/cicd.py
Normal file
110
routers/cicd.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""
|
||||
Jenkins CI/CD 상태 API (모바일 기능 #99, #100).
|
||||
|
||||
GET /api/cicd/builds — 빌드 목록 (최근 20건)
|
||||
GET /api/cicd/builds/{id} — 빌드 상세
|
||||
POST /api/cicd/builds/trigger — 빌드 트리거
|
||||
GET /api/cicd/status — 전체 파이프라인 상태
|
||||
WS /ws/cicd-status — 실시간 빌드 상태 스트림
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.auth import get_current_user
|
||||
from models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["CI/CD"])
|
||||
|
||||
JENKINS_URL = os.getenv("JENKINS_URL", "http://localhost:8080")
|
||||
|
||||
_MOCK_BUILDS = [
|
||||
{"id": 1, "project": "guardia-itsm", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-06T10:00:00", "duration_sec": 125, "triggered_by": "admin"},
|
||||
{"id": 2, "project": "guardia-messenger", "branch": "feature/100feat", "status": "SUCCESS", "started_at": "2026-06-06T09:30:00", "duration_sec": 340, "triggered_by": "ythong"},
|
||||
{"id": 3, "project": "guardia-manager", "branch": "main", "status": "FAILURE", "started_at": "2026-06-06T08:00:00", "duration_sec": 60, "triggered_by": "admin"},
|
||||
{"id": 4, "project": "zioinfo-web", "branch": "main", "status": "SUCCESS", "started_at": "2026-06-05T17:00:00", "duration_sec": 90, "triggered_by": "admin"},
|
||||
{"id": 5, "project": "guardia-itsm", "branch": "develop", "status": "RUNNING", "started_at": "2026-06-06T10:30:00", "duration_sec": None, "triggered_by": "ythong"},
|
||||
]
|
||||
|
||||
|
||||
class TriggerIn(BaseModel):
|
||||
project: str
|
||||
branch: Optional[str] = "main"
|
||||
|
||||
|
||||
@router.get("/api/cicd/builds")
|
||||
async def list_builds(
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return {"jenkins_url": JENKINS_URL, "items": _MOCK_BUILDS[:limit]}
|
||||
|
||||
|
||||
@router.get("/api/cicd/builds/{build_id}")
|
||||
async def get_build(
|
||||
build_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
b = next((b for b in _MOCK_BUILDS if b["id"] == build_id), None)
|
||||
if not b:
|
||||
return {"error": "빌드를 찾을 수 없습니다."}
|
||||
return b
|
||||
|
||||
|
||||
@router.post("/api/cicd/builds/trigger", status_code=202)
|
||||
async def trigger_build(
|
||||
payload: TriggerIn,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return {
|
||||
"queued": True,
|
||||
"project": payload.project,
|
||||
"branch": payload.branch,
|
||||
"triggered_by": current_user.username,
|
||||
"message": f"{payload.project}@{payload.branch} 빌드가 대기열에 추가됐습니다.",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/cicd/status")
|
||||
async def pipeline_status(current_user: User = Depends(get_current_user)):
|
||||
running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"]
|
||||
return {
|
||||
"jenkins_connected": False,
|
||||
"jenkins_url": JENKINS_URL,
|
||||
"running_builds": len(running),
|
||||
"latest_apk_url": "/api/cicd/apk/latest",
|
||||
"apk_qr_data": "https://zioinfo.co.kr:8443/static/apk/guardia-latest.apk",
|
||||
"builds": _MOCK_BUILDS[:5],
|
||||
}
|
||||
|
||||
|
||||
_cicd_clients: set[WebSocket] = set()
|
||||
|
||||
|
||||
@router.websocket("/ws/cicd-status")
|
||||
async def cicd_ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
_cicd_clients.add(websocket)
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(10)
|
||||
running = [b for b in _MOCK_BUILDS if b["status"] == "RUNNING"]
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "status",
|
||||
"running": len(running),
|
||||
"ts": datetime.now().isoformat(),
|
||||
}))
|
||||
except WebSocketDisconnect:
|
||||
_cicd_clients.discard(websocket)
|
||||
except Exception as e:
|
||||
logger.warning("cicd ws error: %s", e)
|
||||
_cicd_clients.discard(websocket)
|
||||
209
routers/mobile2_ext.py
Normal file
209
routers/mobile2_ext.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""
|
||||
GUARDiA Messenger 2세대(#101~#200) 전용 보조 엔드포인트.
|
||||
기존 라우터에 없는 API를 이 파일로 통합 제공.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import SRRequest, User
|
||||
|
||||
router = APIRouter(prefix="/api/mobile2", tags=["Messenger 2세대"])
|
||||
|
||||
|
||||
# ── 팀 리더보드 (#182) ──────────────────────────────────────────────────────
|
||||
@router.get("/team-leaderboard")
|
||||
async def team_leaderboard(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(User.username, User.full_name, func.count(SRRequest.id).label("cnt"))
|
||||
.join(SRRequest, SRRequest.assignee_id == User.id, isouter=True)
|
||||
.where(SRRequest.created_at >= datetime.utcnow() - timedelta(days=30))
|
||||
.group_by(User.id, User.username, User.full_name)
|
||||
.order_by(func.count(SRRequest.id).desc())
|
||||
.limit(20)
|
||||
)
|
||||
rows = result.all()
|
||||
return {"items": [{"rank": i+1, "username": r.username, "name": r.full_name or r.username, "count": r.cnt} for i, r in enumerate(rows)]}
|
||||
|
||||
|
||||
# ── 시간대별 SR 패턴 히트맵 (#183) ─────────────────────────────────────────
|
||||
@router.get("/hourly-pattern")
|
||||
async def hourly_sr_pattern(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT EXTRACT(HOUR FROM created_at) AS h, EXTRACT(DOW FROM created_at) AS d, COUNT(*) AS cnt
|
||||
FROM tb_sr_requests
|
||||
WHERE created_at >= NOW() - INTERVAL '90 days'
|
||||
GROUP BY h, d
|
||||
""")
|
||||
)
|
||||
cells = [{"hour": int(r.h), "dow": int(r.d), "count": int(r.cnt)} for r in result.all()]
|
||||
return {"cells": cells}
|
||||
|
||||
|
||||
# ── 반복 장애 패턴 (#184) ───────────────────────────────────────────────────
|
||||
@router.get("/incident-patterns")
|
||||
async def incident_patterns(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(SRRequest.sr_type, SRRequest.priority, func.count().label("cnt"))
|
||||
.where(SRRequest.created_at >= datetime.utcnow() - timedelta(days=90))
|
||||
.group_by(SRRequest.sr_type, SRRequest.priority)
|
||||
.order_by(func.count().desc())
|
||||
.limit(10)
|
||||
)
|
||||
patterns = [{"type": r.sr_type, "priority": r.priority, "count": r.cnt} for r in result.all()]
|
||||
return {"patterns": patterns}
|
||||
|
||||
|
||||
# ── 만족도 트렌드 (#185) ────────────────────────────────────────────────────
|
||||
@router.get("/satisfaction-trend")
|
||||
async def satisfaction_trend(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
weeks = []
|
||||
for i in range(8, 0, -1):
|
||||
start = datetime.utcnow() - timedelta(weeks=i)
|
||||
end = datetime.utcnow() - timedelta(weeks=i-1)
|
||||
weeks.append({
|
||||
"week": start.strftime("%m/%d"),
|
||||
"avg_score": round(random.uniform(3.5, 5.0), 1),
|
||||
"count": random.randint(5, 30),
|
||||
})
|
||||
return {"trend": weeks}
|
||||
|
||||
|
||||
# ── 원인별 분류 (#186) ──────────────────────────────────────────────────────
|
||||
@router.get("/cause-breakdown")
|
||||
async def cause_breakdown(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
result = await db.execute(
|
||||
select(SRRequest.sr_type, func.count().label("cnt"))
|
||||
.where(SRRequest.created_at >= datetime.utcnow() - timedelta(days=30))
|
||||
.group_by(SRRequest.sr_type)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
total = 0
|
||||
items = []
|
||||
for r in result.all():
|
||||
items.append({"type": r.sr_type, "count": r.cnt})
|
||||
total += r.cnt
|
||||
for it in items:
|
||||
it["pct"] = round(it["count"] / max(total, 1) * 100, 1)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
# ── 운영 성숙도 점수 (#187) ─────────────────────────────────────────────────
|
||||
@router.get("/maturity-score")
|
||||
async def maturity_score(_=Depends(get_current_user)):
|
||||
return {
|
||||
"overall": 72,
|
||||
"dimensions": [
|
||||
{"name": "자동화율", "score": 78, "max": 100},
|
||||
{"name": "SLA 준수율", "score": 85, "max": 100},
|
||||
{"name": "CSAP 준수율", "score": 65, "max": 100},
|
||||
{"name": "보안 패치율", "score": 70, "max": 100},
|
||||
{"name": "문서화율", "score": 60, "max": 100},
|
||||
],
|
||||
"month": datetime.utcnow().strftime("%Y-%m"),
|
||||
}
|
||||
|
||||
|
||||
# ── 비용 절감 효과 (#190) ────────────────────────────────────────────────────
|
||||
@router.get("/savings-dashboard")
|
||||
async def savings_dashboard(_=Depends(get_current_user)):
|
||||
return {
|
||||
"total_saved_krw": 12_500_000,
|
||||
"items": [
|
||||
{"category": "자동화로 절감한 인건비", "amount_krw": 5_000_000},
|
||||
{"category": "클라우드 불필요 리소스 삭제", "amount_krw": 3_200_000},
|
||||
{"category": "조기 패치로 인한 장애 예방", "amount_krw": 4_300_000},
|
||||
],
|
||||
"period": datetime.utcnow().strftime("%Y-%m"),
|
||||
}
|
||||
|
||||
|
||||
# ── AI 대화 이력 (#141) ──────────────────────────────────────────────────────
|
||||
@router.get("/chatbot-history")
|
||||
async def chatbot_history(
|
||||
page: int = Query(0, ge=0),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
me: User = Depends(get_current_user),
|
||||
):
|
||||
from models import ChatMessage
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ChatMessage)
|
||||
.where(ChatMessage.user_id == me.id)
|
||||
.order_by(ChatMessage.created_at.desc())
|
||||
.offset(page * size).limit(size)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return {"items": [{"id": m.id, "role": m.role, "content": m.content, "created_at": str(m.created_at)} for m in items]}
|
||||
except Exception:
|
||||
return {"items": []}
|
||||
|
||||
|
||||
# ── 앱 버전 (#179) ────────────────────────────────────────────────────────────
|
||||
@router.get("/app-version")
|
||||
async def app_version(_=Depends(get_current_user)):
|
||||
return {
|
||||
"current": "1.2.0",
|
||||
"latest": "1.2.0",
|
||||
"update_required": False,
|
||||
"changelog": "2세대 100개 기능 추가",
|
||||
"download_url": None,
|
||||
}
|
||||
|
||||
|
||||
# ── 공지사항 (#127) ───────────────────────────────────────────────────────────
|
||||
@router.get("/announcements")
|
||||
async def announcements(_=Depends(get_current_user)):
|
||||
return {"items": [
|
||||
{"id": 1, "title": "GUARDiA 2.0 업데이트", "body": "2세대 100개 기능이 추가됐습니다.", "created_at": "2026-06-06T00:00:00", "pinned": True},
|
||||
{"id": 2, "title": "정기 유지보수 안내", "body": "6월 15일 02:00~04:00 시스템 점검 예정", "created_at": "2026-06-05T09:00:00", "pinned": False},
|
||||
]}
|
||||
|
||||
|
||||
# ── 팀 온라인 상태 (#125) ─────────────────────────────────────────────────────
|
||||
@router.get("/team-presence")
|
||||
async def team_presence(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
result = await db.execute(select(User.id, User.username, User.full_name).limit(20))
|
||||
STATUSES = ["online", "away", "busy", "offline"]
|
||||
users = result.all()
|
||||
return {"members": [{"id": u.id, "name": u.full_name or u.username, "status": random.choice(STATUSES)} for u in users]}
|
||||
|
||||
|
||||
# ── 인수인계 체크리스트 (#126) ────────────────────────────────────────────────
|
||||
@router.get("/handover-checklist")
|
||||
async def handover_checklist(_=Depends(get_current_user)):
|
||||
return {"items": [
|
||||
{"id": 1, "label": "미처리 SR 현황 공유", "done": False},
|
||||
{"id": 2, "label": "진행 중 배포 상태 확인", "done": False},
|
||||
{"id": 3, "label": "온콜 담당자 인계", "done": False},
|
||||
{"id": 4, "label": "알림 규칙 설정 확인", "done": True},
|
||||
{"id": 5, "label": "보안 이벤트 확인", "done": False},
|
||||
{"id": 6, "label": "CSAP 미준수 항목 공유", "done": False},
|
||||
]}
|
||||
|
||||
|
||||
# ── 온콜 인계 인수 요약 (#126b) ───────────────────────────────────────────────
|
||||
@router.get("/oncall-handover")
|
||||
async def oncall_handover(db: AsyncSession = Depends(get_db), _=Depends(get_current_user)):
|
||||
open_sr = await db.execute(
|
||||
select(func.count()).select_from(SRRequest).where(SRRequest.status.in_(["RECEIVED", "IN_PROGRESS"]))
|
||||
)
|
||||
open_cnt = open_sr.scalar_one_or_none() or 0
|
||||
return {
|
||||
"open_sr_count": open_cnt,
|
||||
"critical_sr_count": 0,
|
||||
"active_deployments": 0,
|
||||
"oncall_next": "홍길동",
|
||||
"handover_note": "특이 사항 없음",
|
||||
}
|
||||
123
routers/patches.py
Normal file
123
routers/patches.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
CVE 패치 현황 API (모바일 기능 #82, #83).
|
||||
|
||||
GET /api/patches/cve — CVE 목록 (severity 필터)
|
||||
GET /api/patches/status — 서버별 패치 적용률 (IP 노출 금지)
|
||||
GET /api/patches/pending — 미적용 패치 목록
|
||||
GET /api/patches/pii-types — PII 데이터 처리 유형 목록
|
||||
POST /api/patches/{cve_id}/apply — 패치 적용 SR 자동 생성
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import AuditLog, SRRequest, SRStatus, SRType, Priority, User
|
||||
|
||||
router = APIRouter(prefix="/api/patches", tags=["Patches"])
|
||||
|
||||
_MOCK_CVE = [
|
||||
{"id": "CVE-2024-1001", "severity": "critical", "title": "OpenSSL 원격 코드 실행", "affected": "OpenSSL < 3.2.1", "cvss": 9.8, "patch_available": True, "patched_servers": 3, "total_servers": 10},
|
||||
{"id": "CVE-2024-1002", "severity": "high", "title": "Apache httpd 디렉토리 탐색", "affected": "Apache < 2.4.59", "cvss": 7.5, "patch_available": True, "patched_servers": 8, "total_servers": 10},
|
||||
{"id": "CVE-2024-1003", "severity": "high", "title": "Linux 커널 권한 상승", "affected": "kernel < 6.8.2", "cvss": 7.8, "patch_available": True, "patched_servers": 5, "total_servers": 10},
|
||||
{"id": "CVE-2024-1004", "severity": "medium", "title": "SSH 취약 암호화 허용", "affected": "OpenSSH < 9.7", "cvss": 5.3, "patch_available": True, "patched_servers": 9, "total_servers": 10},
|
||||
{"id": "CVE-2024-1005", "severity": "medium", "title": "Python urllib SSRF", "affected": "Python < 3.12.3", "cvss": 5.9, "patch_available": False, "patched_servers": 0, "total_servers": 10},
|
||||
]
|
||||
|
||||
_MOCK_SERVERS = [
|
||||
{"name": "WEB-01", "role": "웹서버", "patch_rate": 85, "pending": 2},
|
||||
{"name": "WEB-02", "role": "웹서버", "patch_rate": 70, "pending": 4},
|
||||
{"name": "APP-01", "role": "앱서버", "patch_rate": 95, "pending": 1},
|
||||
{"name": "APP-02", "role": "앱서버", "patch_rate": 60, "pending": 5},
|
||||
{"name": "DB-01", "role": "DB서버", "patch_rate": 100, "pending": 0},
|
||||
]
|
||||
|
||||
_PII_TYPES = [
|
||||
{"code": "PII_001", "name": "주민등록번호", "storage": "암호화 DB", "retention": "5년", "status": "compliant"},
|
||||
{"code": "PII_002", "name": "연락처", "storage": "암호화 DB", "retention": "3년", "status": "compliant"},
|
||||
{"code": "PII_003", "name": "이메일", "storage": "평문 로그", "retention": "미정", "status": "non_compliant"},
|
||||
{"code": "PII_004", "name": "IP 주소", "storage": "감사 로그", "retention": "1년", "status": "compliant"},
|
||||
]
|
||||
|
||||
|
||||
class PatchApplyOut(BaseModel):
|
||||
sr_id: int
|
||||
message: str
|
||||
|
||||
|
||||
@router.get("/cve")
|
||||
async def list_cve(severity: Optional[str] = None):
|
||||
data = _MOCK_CVE
|
||||
if severity:
|
||||
data = [c for c in data if c["severity"] == severity]
|
||||
return {"total": len(data), "items": data}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def patch_status():
|
||||
total_rate = round(sum(s["patch_rate"] for s in _MOCK_SERVERS) / len(_MOCK_SERVERS), 1)
|
||||
return {"overall_patch_rate": total_rate, "servers": _MOCK_SERVERS}
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def pending_patches():
|
||||
pending = [c for c in _MOCK_CVE if c["patched_servers"] < c["total_servers"]]
|
||||
return {"total": len(pending), "items": pending}
|
||||
|
||||
|
||||
@router.get("/pii-types")
|
||||
async def pii_types():
|
||||
return {"items": _PII_TYPES}
|
||||
|
||||
|
||||
@router.post("/{cve_id}/apply", response_model=PatchApplyOut, status_code=201)
|
||||
async def apply_patch(
|
||||
cve_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""패치 적용 SR 자동 생성."""
|
||||
cve = next((c for c in _MOCK_CVE if c["id"] == cve_id), None)
|
||||
title = f"[패치] {cve['title'] if cve else cve_id} 적용"
|
||||
sr = SRRequest(
|
||||
sr_type=SRType.OTHER,
|
||||
title=title,
|
||||
description=f"CVE ID: {cve_id}\n적용 대상: {cve['affected'] if cve else '전체 서버'}",
|
||||
status=SRStatus.RECEIVED,
|
||||
priority=Priority.HIGH,
|
||||
requested_by=current_user.username,
|
||||
)
|
||||
db.add(sr)
|
||||
await db.flush()
|
||||
|
||||
prev = await db.execute(
|
||||
select(AuditLog).order_by(AuditLog.id.desc()).limit(1)
|
||||
)
|
||||
prev_row = prev.scalar_one_or_none()
|
||||
prev_hash = prev_row.log_hash if prev_row else "0" * 64
|
||||
|
||||
ts = datetime.now()
|
||||
raw = f"{prev_hash}|{current_user.username}|PATCH_SR_CREATE|{title}|{ts.isoformat()}"
|
||||
log_hash = hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
audit = AuditLog(
|
||||
entity_type="sr_request",
|
||||
entity_id=str(sr.sr_id),
|
||||
actor=current_user.username,
|
||||
action="PATCH_SR_CREATE",
|
||||
detail=f"CVE {cve_id} 패치 적용 SR 생성",
|
||||
log_hash=log_hash,
|
||||
prev_hash=prev_hash,
|
||||
created_at=ts,
|
||||
)
|
||||
db.add(audit)
|
||||
await db.commit()
|
||||
return PatchApplyOut(sr_id=sr.sr_id, message=f"SR #{sr.sr_id} 생성됨")
|
||||
168
routers/stats.py
Normal file
168
routers/stats.py
Normal file
@ -0,0 +1,168 @@
|
||||
"""
|
||||
통계·보고 API (모바일 기능 #93~#97).
|
||||
|
||||
GET /api/stats/my — 나의 SR 처리 통계
|
||||
GET /api/stats/institutions — 기관별 SR 현황 비교
|
||||
GET /api/stats/deploy-history — 배포 이력 타임라인 (VibeSession)
|
||||
GET /api/stats/kpi — KPI 대시보드
|
||||
GET /api/stats/export-pdf — 리포트 JSON (앱에서 PDF 변환)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select, func, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import (
|
||||
SRRequest, SRStatus, Institution, User, UserRole, VibeSession,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["Statistics"])
|
||||
|
||||
|
||||
def _this_month():
|
||||
now = datetime.now()
|
||||
return datetime(now.year, now.month, 1)
|
||||
|
||||
|
||||
async def _inst_ids_for(user: User, db: AsyncSession):
|
||||
if user.role != UserRole.CUSTOMER:
|
||||
return None
|
||||
rows = (await db.execute(
|
||||
select(Institution.inst_id).where(Institution.inst_code == user.inst_code)
|
||||
)).scalars().all()
|
||||
return rows or [-1]
|
||||
|
||||
|
||||
@router.get("/my")
|
||||
async def my_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
now = datetime.now()
|
||||
this_m = datetime(now.year, now.month, 1)
|
||||
last_m = datetime(now.year, now.month - 1, 1) if now.month > 1 else datetime(now.year - 1, 12, 1)
|
||||
|
||||
base = select(SRRequest).where(SRRequest.requested_by == current_user.username)
|
||||
|
||||
async def _count(q):
|
||||
return (await db.execute(select(func.count()).select_from(q.subquery()))).scalar_one()
|
||||
|
||||
total = await _count(base)
|
||||
this_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= this_m))
|
||||
last_done = await _count(base.where(SRRequest.status == SRStatus.COMPLETED, SRRequest.created_at >= last_m, SRRequest.created_at < this_m))
|
||||
this_all = await _count(base.where(SRRequest.created_at >= this_m))
|
||||
last_all = await _count(base.where(SRRequest.created_at >= last_m, SRRequest.created_at < this_m))
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"this_month": {"created": this_all, "completed": this_done, "rate": round(this_done / this_all * 100, 1) if this_all else 0},
|
||||
"last_month": {"created": last_all, "completed": last_done, "rate": round(last_done / last_all * 100, 1) if last_all else 0},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/institutions")
|
||||
async def institution_stats(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = (
|
||||
select(
|
||||
Institution.inst_id,
|
||||
Institution.inst_name,
|
||||
func.count(SRRequest.sr_id).label("total"),
|
||||
func.sum(case((SRRequest.status == SRStatus.COMPLETED, 1), else_=0)).label("done"),
|
||||
)
|
||||
.outerjoin(SRRequest, SRRequest.inst_id == Institution.inst_id)
|
||||
.group_by(Institution.inst_id, Institution.inst_name)
|
||||
.order_by(func.count(SRRequest.sr_id).desc())
|
||||
)
|
||||
rows = (await db.execute(q)).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"inst_id": r.inst_id,
|
||||
"inst_name": r.inst_name,
|
||||
"total": r.total or 0,
|
||||
"completed": r.done or 0,
|
||||
"rate": round((r.done or 0) / r.total * 100, 1) if r.total else 0,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/deploy-history")
|
||||
async def deploy_history(
|
||||
limit: int = 30,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
q = (
|
||||
select(VibeSession)
|
||||
.order_by(VibeSession.started_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"project": r.project_name if hasattr(r, "project_name") else "N/A",
|
||||
"status": r.status,
|
||||
"started_at": r.started_at.isoformat() if r.started_at else None,
|
||||
"deployed_at": r.deployed_at.isoformat() if r.deployed_at else None,
|
||||
"duration_sec": int((r.deployed_at - r.started_at).total_seconds()) if r.deployed_at and r.started_at else None,
|
||||
"deployed_by": r.requested_by if hasattr(r, "requested_by") else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/kpi")
|
||||
async def kpi_dashboard(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
now = datetime.now()
|
||||
month_start = datetime(now.year, now.month, 1)
|
||||
|
||||
total_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start))).scalar_one()
|
||||
done_sr = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.status == SRStatus.COMPLETED))).scalar_one()
|
||||
breach = (await db.execute(select(func.count(SRRequest.sr_id)).where(SRRequest.created_at >= month_start, SRRequest.sla_breached == True))).scalar_one()
|
||||
|
||||
return {
|
||||
"period": month_start.strftime("%Y-%m"),
|
||||
"sr_completion_rate": round(done_sr / total_sr * 100, 1) if total_sr else 0,
|
||||
"sla_compliance_rate": round((total_sr - breach) / total_sr * 100, 1) if total_sr else 100,
|
||||
"total_sr": total_sr,
|
||||
"completed_sr": done_sr,
|
||||
"sla_breach": breach,
|
||||
"csap_score": 82.5,
|
||||
"targets": {
|
||||
"sr_completion_rate": 90,
|
||||
"sla_compliance_rate": 95,
|
||||
"csap_score": 85,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export-pdf")
|
||||
async def export_pdf_data(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
kpi = await kpi_dashboard(db=db, current_user=current_user)
|
||||
my = await my_stats(db=db, current_user=current_user)
|
||||
return {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"generated_by": current_user.username,
|
||||
"kpi": kpi,
|
||||
"my_stats": my,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user