feat(itsm): PMS/준수성/JMeter/공공기관 기능 + Nifty UI + 로고 Copyright
[PMS 완성] - core/si_report.py: 일/주/월 보고서 (Excel/HTML/PDF/DOCX/PPTX) - routers/si_report.py: daily|weekly|monthly + 메신저 발송 - routers/deliverables.py: 산출물 CRUD + 제출/검토 - si_issues.py: 이슈→SR 자동 연결 - scheduler.py: 일일 18:00 + 주간 금 17:00 자동 보고서 - models.py: Deliverable 모델 [준수성 자동 점검] - core/compliance_check.py: SC-8개/WA-7개/PI-6개 규칙 - routers/compliance.py: 스캔 + HTML/Excel 보고서 [JMeter 성능 테스트] - routers/jmeter.py: JTL 업로드 + 내장 부하 테스트 + 보고서 [공공기관 필수 기능] - routers/public_checklist.py: 행안부 기준 19개 항목 [UI/브랜드] - 로고(ziologo.png) + Copyright 2026 All Rights Reserved - Nifty 계층형 사이드바 (PMS 서브메뉴) - X-Powered-By + X-Copyright 응답 헤더 - manual/15_UI_Nifty_가이드.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
336f11191b
commit
1f8b926066
291
core/compliance_check.py
Normal file
291
core/compliance_check.py
Normal file
@ -0,0 +1,291 @@
|
||||
"""
|
||||
GUARDiA 준수성 자동 점검 엔진
|
||||
|
||||
1. 시큐어코딩 (행안부 SW 보안약점 기준 + OWASP Top 10)
|
||||
2. 웹 접근성 (WCAG 2.1 / KWCAG 2.1 — 한국형)
|
||||
3. 개인정보보호법 (PIPA — 개인정보 식별자 탐지)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 1. 시큐어코딩 점검 ───────────────────────────────────────────────────────
|
||||
|
||||
# 행안부 SW 보안약점 주요 패턴
|
||||
SECURE_CODING_RULES = [
|
||||
# SQL 인젝션
|
||||
{
|
||||
"id": "SC-001",
|
||||
"category": "SQL 인젝션",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'execute\s*\(\s*f["\'].*\{|execute\s*\(\s*".*%.*%|execute\s*\(\s*\'.*\'\.format\(',
|
||||
"message": "SQL 문자열 직접 포맷팅 — 파라미터 바인딩 사용 필요",
|
||||
},
|
||||
# XSS
|
||||
{
|
||||
"id": "SC-002",
|
||||
"category": "XSS",
|
||||
"severity": "HIGH",
|
||||
"pattern": r'innerHTML\s*=\s*(?!"|\')|\.html\s*\(',
|
||||
"message": "innerHTML 직접 삽입 — textContent 또는 escapeHtml 함수 사용 필요",
|
||||
},
|
||||
# 하드코딩 비밀번호/키
|
||||
{
|
||||
"id": "SC-003",
|
||||
"category": "하드코딩 자격증명",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'(?i)(password|passwd|secret|api_key|apikey|token)\s*=\s*["\'][^"\']{6,}["\']',
|
||||
"message": "하드코딩된 자격증명 — 환경변수 또는 시크릿 관리자 사용 필요",
|
||||
},
|
||||
# OS 명령어 인젝션
|
||||
{
|
||||
"id": "SC-004",
|
||||
"category": "OS 명령어 인젝션",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'os\.system\s*\(|subprocess\.call\s*\(.*shell\s*=\s*True|eval\s*\(',
|
||||
"message": "shell=True 또는 eval 사용 — 입력값 검증 필수",
|
||||
},
|
||||
# 경로 조작
|
||||
{
|
||||
"id": "SC-005",
|
||||
"category": "경로 조작",
|
||||
"severity": "HIGH",
|
||||
"pattern": r'open\s*\(\s*.*\+|open\s*\(\s*f["\']',
|
||||
"message": "사용자 입력 기반 파일 경로 — Path.resolve().relative_to() 검증 필수",
|
||||
},
|
||||
# 정보 노출
|
||||
{
|
||||
"id": "SC-006",
|
||||
"category": "민감 정보 노출",
|
||||
"severity": "MEDIUM",
|
||||
"pattern": r'traceback\.print_exc\(\)|print\s*\(.*exception|logger\.(info|debug).*password',
|
||||
"message": "예외 스택트레이스 직접 출력 — 상세 오류 메시지 은닉 필요",
|
||||
},
|
||||
# CSRF 위험 (GET으로 상태 변경)
|
||||
{
|
||||
"id": "SC-007",
|
||||
"category": "CSRF",
|
||||
"severity": "MEDIUM",
|
||||
"pattern": r'@router\.get\s*\([^\)]+\)\s*\nasync def.*(delete|remove|drop|truncate)',
|
||||
"message": "GET 메서드로 데이터 변경 — POST/DELETE 사용 및 CSRF 토큰 적용 필요",
|
||||
},
|
||||
# 취약한 암호화
|
||||
{
|
||||
"id": "SC-008",
|
||||
"category": "취약 암호화",
|
||||
"severity": "HIGH",
|
||||
"pattern": r'md5|sha1\s*\(|DES\.|RC4\.',
|
||||
"message": "취약한 해시/암호화 알고리즘 — SHA-256 이상 또는 AES-256-GCM 사용 필요",
|
||||
},
|
||||
]
|
||||
|
||||
# ── 2. 웹 접근성 점검 (HTML 기반) ────────────────────────────────────────────
|
||||
|
||||
ACCESSIBILITY_RULES = [
|
||||
{
|
||||
"id": "WA-001",
|
||||
"category": "대체 텍스트",
|
||||
"level": "A",
|
||||
"pattern": r'<img(?![^>]*alt=)[^>]*>',
|
||||
"message": "img 요소에 alt 속성 없음 — 시각 장애인 스크린리더 접근 불가",
|
||||
},
|
||||
{
|
||||
"id": "WA-002",
|
||||
"category": "색상 대비",
|
||||
"level": "AA",
|
||||
"pattern": r'color:\s*#(?:aaa|999|bbb|ccc|ddd|eee|f{3,6})',
|
||||
"message": "낮은 색상 대비 — 4.5:1 이상 비율 필요 (WCAG 2.1 AA)",
|
||||
},
|
||||
{
|
||||
"id": "WA-003",
|
||||
"category": "키보드 접근성",
|
||||
"level": "A",
|
||||
"pattern": r'onclick="[^"]*"(?![^>]*tabindex)',
|
||||
"message": "onclick만 있는 요소 — tabindex + onkeydown 추가 또는 <button> 사용",
|
||||
},
|
||||
{
|
||||
"id": "WA-004",
|
||||
"category": "폼 레이블",
|
||||
"level": "A",
|
||||
"pattern": r'<input(?![^>]*aria-label|[^>]*id=)[^>]*>',
|
||||
"message": "input 요소에 label/aria-label 없음 — 폼 접근성 미준수",
|
||||
},
|
||||
{
|
||||
"id": "WA-005",
|
||||
"category": "언어 설정",
|
||||
"level": "A",
|
||||
"pattern": r'<html(?![^>]*lang=)',
|
||||
"message": "html 요소에 lang 속성 없음 — <html lang=\"ko\"> 필요",
|
||||
},
|
||||
{
|
||||
"id": "WA-006",
|
||||
"category": "포커스 표시",
|
||||
"level": "AA",
|
||||
"pattern": r'outline:\s*none|outline:\s*0',
|
||||
"message": "포커스 표시 제거 — 키보드 사용자 탐색 불가, :focus-visible 활용 권장",
|
||||
},
|
||||
{
|
||||
"id": "WA-007",
|
||||
"category": "ARIA 역할",
|
||||
"level": "A",
|
||||
"pattern": r'<div\s+onclick|<span\s+onclick',
|
||||
"message": "div/span에 onclick — <button role=\"button\"> 또는 role+tabindex 추가 필요",
|
||||
},
|
||||
]
|
||||
|
||||
# ── 3. 개인정보보호법 위반 탐지 ──────────────────────────────────────────────
|
||||
|
||||
PIPA_RULES = [
|
||||
{
|
||||
"id": "PI-001",
|
||||
"category": "주민등록번호",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'\d{6}[-–]\d{7}|\d{13}',
|
||||
"message": "주민등록번호 패턴 감지 — 법적 수집 제한 항목 (개인정보보호법 제24조)",
|
||||
},
|
||||
{
|
||||
"id": "PI-002",
|
||||
"category": "신용카드번호",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'\b(?:\d{4}[-\s]?){3}\d{4}\b',
|
||||
"message": "신용카드번호 패턴 감지 — 마스킹 처리 필요",
|
||||
},
|
||||
{
|
||||
"id": "PI-003",
|
||||
"category": "이메일 수집",
|
||||
"severity": "MEDIUM",
|
||||
"pattern": r'email.*=.*Column.*String|Column\(.*email',
|
||||
"message": "이메일 DB 저장 — 수집 동의 여부 및 암호화 저장 확인 필요",
|
||||
},
|
||||
{
|
||||
"id": "PI-004",
|
||||
"category": "비밀번호 평문",
|
||||
"severity": "CRITICAL",
|
||||
"pattern": r'password.*=.*Column(?!.*hash)|password_hash\s*=\s*None',
|
||||
"message": "비밀번호 평문 저장 의심 — bcrypt/argon2 해시 저장 필수",
|
||||
},
|
||||
{
|
||||
"id": "PI-005",
|
||||
"category": "전화번호",
|
||||
"severity": "LOW",
|
||||
"pattern": r'01[016789][-–]?\d{3,4}[-–]?\d{4}',
|
||||
"message": "전화번호 패턴 감지 — 불필요한 수집 여부 검토 필요",
|
||||
},
|
||||
{
|
||||
"id": "PI-006",
|
||||
"category": "로그에 개인정보",
|
||||
"severity": "HIGH",
|
||||
"pattern": r'logger\.(info|debug|warning).*(?:email|phone|name|password)',
|
||||
"message": "로그에 개인정보 기록 의심 — 로그 마스킹 처리 필요",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── 점검 실행 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def scan_file(filepath: str, content: str) -> list[dict]:
|
||||
"""단일 파일에 대한 전체 점검 수행."""
|
||||
ext = Path(filepath).suffix.lower()
|
||||
findings = []
|
||||
|
||||
rule_sets = []
|
||||
if ext in (".py",):
|
||||
rule_sets = [SECURE_CODING_RULES, PIPA_RULES]
|
||||
if ext in (".html", ".css", ".js"):
|
||||
rule_sets.append(ACCESSIBILITY_RULES)
|
||||
if ext in (".html", ".js"):
|
||||
rule_sets.append(SECURE_CODING_RULES) # XSS 등
|
||||
if ext in (".py", ".js", ".html"):
|
||||
rule_sets.append(PIPA_RULES)
|
||||
|
||||
# 중복 제거
|
||||
seen_ids: set = set()
|
||||
for rules in rule_sets:
|
||||
for rule in rules:
|
||||
if rule["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(rule["id"])
|
||||
try:
|
||||
matches = list(re.finditer(rule["pattern"], content, re.IGNORECASE | re.MULTILINE))
|
||||
for m in matches:
|
||||
line_no = content[:m.start()].count("\n") + 1
|
||||
findings.append({
|
||||
"file": filepath,
|
||||
"line": line_no,
|
||||
"rule_id": rule["id"],
|
||||
"category": rule["category"],
|
||||
"severity": rule.get("severity", rule.get("level", "INFO")),
|
||||
"message": rule["message"],
|
||||
"snippet": content[max(0, m.start()-20):m.end()+20].strip()[:100],
|
||||
})
|
||||
except re.error:
|
||||
pass
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
async def scan_project(base_dir: str = None) -> dict[str, Any]:
|
||||
"""GUARDiA 프로젝트 전체 준수성 점검."""
|
||||
if not base_dir:
|
||||
base_dir = str(Path(__file__).parent.parent)
|
||||
|
||||
target_dirs = [
|
||||
Path(base_dir) / "routers",
|
||||
Path(base_dir) / "core",
|
||||
Path(base_dir) / "static",
|
||||
]
|
||||
|
||||
all_findings = []
|
||||
scanned = 0
|
||||
|
||||
for tdir in target_dirs:
|
||||
if not tdir.exists():
|
||||
continue
|
||||
for fpath in tdir.rglob("*"):
|
||||
if fpath.suffix not in (".py", ".html", ".js", ".css"):
|
||||
continue
|
||||
if fpath.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
content = fpath.read_text(encoding="utf-8", errors="ignore")
|
||||
findings = scan_file(str(fpath.relative_to(base_dir)), content)
|
||||
all_findings.extend(findings)
|
||||
scanned += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 집계
|
||||
by_severity: dict = {}
|
||||
by_category: dict = {}
|
||||
for f in all_findings:
|
||||
sev = f["severity"]
|
||||
cat = f["category"]
|
||||
by_severity[sev] = by_severity.get(sev, 0) + 1
|
||||
by_category[cat] = by_category.get(cat, 0) + 1
|
||||
|
||||
critical = by_severity.get("CRITICAL", 0)
|
||||
high = by_severity.get("HIGH", 0)
|
||||
risk_level = "CRITICAL" if critical > 0 else "HIGH" if high > 0 else "MEDIUM" if all_findings else "LOW"
|
||||
|
||||
return {
|
||||
"scan_time": datetime.utcnow().isoformat(),
|
||||
"scanned_files": scanned,
|
||||
"total_findings": len(all_findings),
|
||||
"risk_level": risk_level,
|
||||
"by_severity": by_severity,
|
||||
"by_category": by_category,
|
||||
"findings": all_findings[:200], # 최대 200건
|
||||
"summary": {
|
||||
"secure_coding": sum(1 for f in all_findings if f["rule_id"].startswith("SC")),
|
||||
"accessibility": sum(1 for f in all_findings if f["rule_id"].startswith("WA")),
|
||||
"privacy": sum(1 for f in all_findings if f["rule_id"].startswith("PI")),
|
||||
}
|
||||
}
|
||||
199
core/oauth.py
Normal file
199
core/oauth.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""
|
||||
OAuth2 / OpenID Connect 소셜 로그인 — GUARDiA ITSM
|
||||
|
||||
지원 제공자:
|
||||
google : Google OAuth2 (GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET)
|
||||
github : GitHub OAuth2 (GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET)
|
||||
kakao : 카카오 로그인 (KAKAO_CLIENT_ID)
|
||||
naver : 네이버 로그인 (NAVER_CLIENT_ID / NAVER_CLIENT_SECRET)
|
||||
keycloak: 온프레미스 Keycloak OIDC (KEYCLOAK_BASE_URL / KEYCLOAK_REALM / ...)
|
||||
|
||||
환경변수 미설정 시 해당 제공자는 로그인 화면에서 숨겨진다.
|
||||
외부망 차단 환경에서는 Keycloak(온프레미스)만 활성화 권장.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
# ── 환경변수 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_BASE = os.getenv("OAUTH_REDIRECT_BASE", "http://localhost:8001")
|
||||
|
||||
PROVIDERS: dict[str, dict] = {
|
||||
"google": {
|
||||
"name": "Google",
|
||||
"icon": "google",
|
||||
"enabled": bool(os.getenv("GOOGLE_CLIENT_ID")),
|
||||
"client_id": os.getenv("GOOGLE_CLIENT_ID", ""),
|
||||
"client_secret": os.getenv("GOOGLE_CLIENT_SECRET", ""),
|
||||
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"userinfo_url": "https://www.googleapis.com/oauth2/v3/userinfo",
|
||||
"scope": "openid email profile",
|
||||
"redirect_uri": f"{_BASE}/api/auth/oauth/google/callback",
|
||||
},
|
||||
"github": {
|
||||
"name": "GitHub",
|
||||
"icon": "github",
|
||||
"enabled": bool(os.getenv("GITHUB_CLIENT_ID")),
|
||||
"client_id": os.getenv("GITHUB_CLIENT_ID", ""),
|
||||
"client_secret": os.getenv("GITHUB_CLIENT_SECRET", ""),
|
||||
"auth_url": "https://github.com/login/oauth/authorize",
|
||||
"token_url": "https://github.com/login/oauth/access_token",
|
||||
"userinfo_url": "https://api.github.com/user",
|
||||
"scope": "read:user user:email",
|
||||
"redirect_uri": f"{_BASE}/api/auth/oauth/github/callback",
|
||||
},
|
||||
"kakao": {
|
||||
"name": "카카오",
|
||||
"icon": "kakao",
|
||||
"enabled": bool(os.getenv("KAKAO_CLIENT_ID")),
|
||||
"client_id": os.getenv("KAKAO_CLIENT_ID", ""),
|
||||
"client_secret": os.getenv("KAKAO_CLIENT_SECRET", ""),
|
||||
"auth_url": "https://kauth.kakao.com/oauth/authorize",
|
||||
"token_url": "https://kauth.kakao.com/oauth/token",
|
||||
"userinfo_url": "https://kapi.kakao.com/v2/user/me",
|
||||
"scope": "profile_nickname account_email",
|
||||
"redirect_uri": f"{_BASE}/api/auth/oauth/kakao/callback",
|
||||
},
|
||||
"naver": {
|
||||
"name": "네이버",
|
||||
"icon": "naver",
|
||||
"enabled": bool(os.getenv("NAVER_CLIENT_ID")),
|
||||
"client_id": os.getenv("NAVER_CLIENT_ID", ""),
|
||||
"client_secret": os.getenv("NAVER_CLIENT_SECRET", ""),
|
||||
"auth_url": "https://nid.naver.com/oauth2.0/authorize",
|
||||
"token_url": "https://nid.naver.com/oauth2.0/token",
|
||||
"userinfo_url": "https://openapi.naver.com/v1/nid/me",
|
||||
"scope": "name email",
|
||||
"redirect_uri": f"{_BASE}/api/auth/oauth/naver/callback",
|
||||
},
|
||||
"keycloak": {
|
||||
"name": "SSO (Keycloak)",
|
||||
"icon": "sso",
|
||||
"enabled": bool(os.getenv("KEYCLOAK_BASE_URL")),
|
||||
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "guardia"),
|
||||
"client_secret": os.getenv("KEYCLOAK_CLIENT_SECRET", ""),
|
||||
"auth_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/auth",
|
||||
"token_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/token",
|
||||
"userinfo_url": f"{os.getenv('KEYCLOAK_BASE_URL','')}/realms/{os.getenv('KEYCLOAK_REALM','master')}/protocol/openid-connect/userinfo",
|
||||
"scope": "openid email profile",
|
||||
"redirect_uri": f"{_BASE}/api/auth/oauth/keycloak/callback",
|
||||
},
|
||||
}
|
||||
|
||||
# 임시 state 저장소 (메모리 — 분산 환경에서는 Redis 사용)
|
||||
_states: dict[str, float] = {}
|
||||
|
||||
|
||||
def get_enabled_providers() -> list[dict]:
|
||||
"""활성화된 OAuth 제공자 목록 (민감정보 제외)."""
|
||||
return [
|
||||
{"id": pid, "name": p["name"], "icon": p["icon"]}
|
||||
for pid, p in PROVIDERS.items()
|
||||
if p["enabled"]
|
||||
]
|
||||
|
||||
|
||||
def build_auth_url(provider_id: str) -> str:
|
||||
"""OAuth 인증 URL 생성 + CSRF state 발급."""
|
||||
p = PROVIDERS.get(provider_id)
|
||||
if not p or not p["enabled"]:
|
||||
raise ValueError(f"OAuth 제공자 미설정: {provider_id}")
|
||||
|
||||
state = secrets.token_urlsafe(32)
|
||||
_states[state] = time.time()
|
||||
# 5분 초과 state 정리
|
||||
cutoff = time.time() - 300
|
||||
expired = [k for k, v in _states.items() if v < cutoff]
|
||||
for k in expired:
|
||||
_states.pop(k, None)
|
||||
|
||||
params = {
|
||||
"client_id": p["client_id"],
|
||||
"redirect_uri": p["redirect_uri"],
|
||||
"scope": p["scope"],
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
return p["auth_url"] + "?" + urlencode(params)
|
||||
|
||||
|
||||
def verify_state(state: str) -> bool:
|
||||
"""CSRF state 검증."""
|
||||
ts = _states.pop(state, None)
|
||||
if ts is None:
|
||||
return False
|
||||
return (time.time() - ts) < 300 # 5분 이내
|
||||
|
||||
|
||||
async def exchange_code(provider_id: str, code: str) -> Optional[dict]:
|
||||
"""인가 코드 → 액세스 토큰 교환 → 사용자 정보 조회."""
|
||||
p = PROVIDERS.get(provider_id)
|
||||
if not p:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
# 토큰 교환
|
||||
token_data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": p["redirect_uri"],
|
||||
"client_id": p["client_id"],
|
||||
"client_secret": p["client_secret"],
|
||||
}
|
||||
headers = {"Accept": "application/json"}
|
||||
resp = await client.post(p["token_url"], data=token_data, headers=headers)
|
||||
resp.raise_for_status()
|
||||
tokens = resp.json()
|
||||
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# 사용자 정보 조회
|
||||
userinfo_resp = await client.get(
|
||||
p["userinfo_url"],
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
userinfo_resp.raise_for_status()
|
||||
return userinfo_resp.json()
|
||||
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def extract_email(provider_id: str, userinfo: dict) -> Optional[str]:
|
||||
"""제공자별 이메일 추출."""
|
||||
if provider_id in ("google", "keycloak"):
|
||||
return userinfo.get("email")
|
||||
if provider_id == "github":
|
||||
return userinfo.get("email") or f"{userinfo.get('login', '')}@github.com"
|
||||
if provider_id == "kakao":
|
||||
return userinfo.get("kakao_account", {}).get("email")
|
||||
if provider_id == "naver":
|
||||
return userinfo.get("response", {}).get("email")
|
||||
return userinfo.get("email")
|
||||
|
||||
|
||||
def extract_name(provider_id: str, userinfo: dict) -> str:
|
||||
"""제공자별 표시 이름 추출."""
|
||||
if provider_id in ("google", "keycloak"):
|
||||
return userinfo.get("name", "")
|
||||
if provider_id == "github":
|
||||
return userinfo.get("name") or userinfo.get("login", "")
|
||||
if provider_id == "kakao":
|
||||
return userinfo.get("kakao_account", {}).get("profile", {}).get("nickname", "")
|
||||
if provider_id == "naver":
|
||||
return userinfo.get("response", {}).get("name", "")
|
||||
return userinfo.get("name", "")
|
||||
@ -622,6 +622,82 @@ def start_scheduler() -> None:
|
||||
except Exception as exc:
|
||||
logger.warning("SLA 스케줄 등록 실패 (무시): %s", exc)
|
||||
|
||||
# ── SI 프로젝트 자동 보고서 ─────────────────────────────────
|
||||
try:
|
||||
async def _si_daily_report():
|
||||
"""매일 18:00 — 활성 SI 프로젝트 일일 보고서 메신저 발송."""
|
||||
from database import SessionLocal
|
||||
from models import SiProject
|
||||
from sqlalchemy import select
|
||||
from core.si_report import collect_project_data, generate_html
|
||||
import os, httpx
|
||||
|
||||
async with SessionLocal() as db:
|
||||
projects = (await db.execute(
|
||||
select(SiProject).where(SiProject.is_active == True)
|
||||
)).scalars().all()
|
||||
|
||||
for proj in projects:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
data = await collect_project_data(proj.id, db)
|
||||
from core.si_report import generate_html as gh
|
||||
_ = gh(data, "daily") # 생성 검증
|
||||
summary = (
|
||||
f"[일일보고] {proj.project_name}\n"
|
||||
f"진척: {data['overall_progress']}% | 이슈: {data['issue_open']}건 | 지연WBS: {data['wbs_delayed']}건"
|
||||
)
|
||||
await _push_ops_notify(f"[SI 일일보고] {proj.project_name}", summary)
|
||||
except Exception as ex:
|
||||
logger.warning("SI 일일보고 실패: project=%d err=%s", proj.id, ex)
|
||||
|
||||
_scheduler.add_job(
|
||||
_si_daily_report,
|
||||
CronTrigger(hour=18, minute=0, timezone="Asia/Seoul"),
|
||||
id="si_daily_report",
|
||||
name="SI 일일 보고서 발송 (18:00)",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
|
||||
async def _si_weekly_report():
|
||||
"""매주 금요일 17:00 — 주간 보고서 메신저 발송."""
|
||||
from database import SessionLocal
|
||||
from models import SiProject
|
||||
from sqlalchemy import select
|
||||
from core.si_report import collect_project_data
|
||||
|
||||
async with SessionLocal() as db:
|
||||
projects = (await db.execute(
|
||||
select(SiProject).where(SiProject.is_active == True)
|
||||
)).scalars().all()
|
||||
|
||||
for proj in projects:
|
||||
try:
|
||||
async with SessionLocal() as db:
|
||||
data = await collect_project_data(proj.id, db)
|
||||
msg = (
|
||||
f"[주간보고] {proj.project_name} ({proj.project_code})\n"
|
||||
f"진척률: {data['overall_progress']}% | 단계: {proj.phase}\n"
|
||||
f"WBS {data['wbs_done']}/{data['wbs_total']} 완료 | 지연 {data['wbs_delayed']}건\n"
|
||||
f"미결이슈: {data['issue_open']}건 | 고위험: {len(data['high_risks'])}건"
|
||||
)
|
||||
await _push_ops_notify(f"[SI 주간보고] {proj.project_name}", msg)
|
||||
except Exception as ex:
|
||||
logger.warning("SI 주간보고 실패: project=%d err=%s", proj.id, ex)
|
||||
|
||||
_scheduler.add_job(
|
||||
_si_weekly_report,
|
||||
CronTrigger(day_of_week="fri", hour=17, minute=0, timezone="Asia/Seoul"),
|
||||
id="si_weekly_report",
|
||||
name="SI 주간 보고서 발송 (금 17:00)",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
logger.info("SI 자동 보고서 스케줄 등록 완료 (일일 18:00 / 주간 금 17:00)")
|
||||
except Exception as exc:
|
||||
logger.warning("SI 보고서 스케줄 등록 실패 (무시): %s", exc)
|
||||
|
||||
# ── Scouter APM 알람 수집 (5분마다) ─────────────────────────
|
||||
try:
|
||||
async def _scouter_alert_check():
|
||||
|
||||
683
core/si_report.py
Normal file
683
core/si_report.py
Normal file
@ -0,0 +1,683 @@
|
||||
"""
|
||||
SI 프로젝트 보고서 자동 생성 엔진
|
||||
|
||||
지원 형식:
|
||||
- Excel (.xlsx): WBS 현황 + 이슈 목록 + 산출물 현황 + KPI
|
||||
- HTML: 웹 보고서 (대시보드 내 표시)
|
||||
- PDF: HTML → PDF 변환 (weasyprint)
|
||||
- DOCX: Word 보고서 (python-docx)
|
||||
- PPTX: PowerPoint 보고서 (python-pptx)
|
||||
|
||||
보고서 유형:
|
||||
- daily: 일일 진행 현황 (당일 WBS 완료 + 이슈 발생)
|
||||
- weekly: 주간 보고서 (완료율 + 이슈 + 위험 요약)
|
||||
- monthly: 월간 보고서 (KPI + 예산 + 이달의 산출물)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 프로젝트 데이터 수집 ──────────────────────────────────────────────────────
|
||||
|
||||
async def collect_project_data(project_id: int, db) -> Dict[str, Any]:
|
||||
"""보고서에 필요한 전체 프로젝트 데이터 수집."""
|
||||
from models import (
|
||||
SiProject, WbsItem, WbsStatus,
|
||||
ProjectIssue, IssueStatus,
|
||||
ProjectMilestone,
|
||||
ProjectRisk, RiskLevel,
|
||||
Deliverable, DeliverableStatus,
|
||||
)
|
||||
from sqlalchemy import select, func
|
||||
|
||||
proj = await db.get(SiProject, project_id)
|
||||
if not proj:
|
||||
raise ValueError(f"프로젝트 {project_id}를 찾을 수 없습니다.")
|
||||
|
||||
today = date.today()
|
||||
|
||||
# WBS 항목
|
||||
wbs_items = (await db.execute(
|
||||
select(WbsItem).where(WbsItem.project_id == project_id)
|
||||
.order_by(WbsItem.wbs_code)
|
||||
)).scalars().all()
|
||||
|
||||
leaf_items = [w for w in wbs_items if w.is_leaf]
|
||||
completed = [w for w in leaf_items if w.completion_pct >= 100]
|
||||
delayed = [w for w in leaf_items if w.planned_end and w.planned_end < today and w.completion_pct < 100]
|
||||
|
||||
# 이슈
|
||||
issues = (await db.execute(
|
||||
select(ProjectIssue).where(ProjectIssue.project_id == project_id)
|
||||
.order_by(ProjectIssue.raised_date.desc())
|
||||
)).scalars().all()
|
||||
open_issues = [i for i in issues if i.status in (IssueStatus.OPEN, IssueStatus.IN_PROGRESS)]
|
||||
closed_issues = [i for i in issues if i.status == IssueStatus.CLOSED]
|
||||
|
||||
# 마일스톤
|
||||
milestones = (await db.execute(
|
||||
select(ProjectMilestone).where(ProjectMilestone.project_id == project_id)
|
||||
.order_by(ProjectMilestone.target_date)
|
||||
)).scalars().all()
|
||||
upcoming_milestones = [
|
||||
m for m in milestones if m.target_date and m.target_date >= today
|
||||
][:3]
|
||||
|
||||
# 위험
|
||||
risks = (await db.execute(
|
||||
select(ProjectRisk).where(ProjectRisk.project_id == project_id)
|
||||
)).scalars().all()
|
||||
high_risks = [r for r in risks if r.risk_level in (RiskLevel.HIGH, RiskLevel.CRITICAL)]
|
||||
|
||||
# 산출물
|
||||
deliverables = (await db.execute(
|
||||
select(Deliverable).where(Deliverable.project_id == project_id)
|
||||
)).scalars().all()
|
||||
overdue_deliverables = [
|
||||
d for d in deliverables
|
||||
if d.status == "PENDING" and d.due_date and d.due_date < today
|
||||
]
|
||||
|
||||
# 진척률 계산
|
||||
overall_progress = (
|
||||
sum(w.completion_pct for w in leaf_items) / len(leaf_items)
|
||||
if leaf_items else 0
|
||||
)
|
||||
|
||||
# 예산 소진율
|
||||
budget_pct = (
|
||||
round(proj.budget_used / proj.budget_total * 100, 1)
|
||||
if proj.budget_total else 0.0
|
||||
)
|
||||
|
||||
return {
|
||||
"project": proj,
|
||||
"today": today,
|
||||
"wbs_items": wbs_items,
|
||||
"leaf_items": leaf_items,
|
||||
"completed_wbs": completed,
|
||||
"delayed_wbs": delayed,
|
||||
"issues": issues,
|
||||
"open_issues": open_issues,
|
||||
"closed_issues": closed_issues,
|
||||
"milestones": milestones,
|
||||
"upcoming_milestones": upcoming_milestones,
|
||||
"risks": risks,
|
||||
"high_risks": high_risks,
|
||||
"deliverables": deliverables,
|
||||
"overdue_deliverables": overdue_deliverables,
|
||||
"overall_progress": round(overall_progress, 1),
|
||||
"budget_pct": budget_pct,
|
||||
"wbs_total": len(leaf_items),
|
||||
"wbs_done": len(completed),
|
||||
"wbs_delayed": len(delayed),
|
||||
"issue_open": len(open_issues),
|
||||
"issue_closed": len(closed_issues),
|
||||
}
|
||||
|
||||
|
||||
# ── Excel 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_excel(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
||||
"""Excel (.xlsx) 보고서 생성."""
|
||||
try:
|
||||
import openpyxl
|
||||
from openpyxl.styles import (
|
||||
Font, PatternFill, Alignment, Border, Side, numbers
|
||||
)
|
||||
from openpyxl.utils import get_column_letter
|
||||
except ImportError:
|
||||
raise ValueError("openpyxl 미설치: pip install openpyxl")
|
||||
|
||||
proj = data["project"]
|
||||
today = data["today"]
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
wb.remove(wb.active)
|
||||
|
||||
# ── 공통 스타일 ────────────────────────────────────────────
|
||||
HEADER_FILL = PatternFill("solid", fgColor="1E3A5F")
|
||||
HEADER_FONT = Font(bold=True, color="FFFFFF", size=11)
|
||||
TITLE_FONT = Font(bold=True, size=13, color="1E3A5F")
|
||||
OK_FILL = PatternFill("solid", fgColor="D5F5E3")
|
||||
WARN_FILL = PatternFill("solid", fgColor="FEF9E7")
|
||||
DANGER_FILL = PatternFill("solid", fgColor="FADBD8")
|
||||
CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
LEFT = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||
thin = Side(style="thin", color="CCCCCC")
|
||||
BORDER = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
|
||||
def header_row(ws, headers, row=1):
|
||||
for col, h in enumerate(headers, 1):
|
||||
c = ws.cell(row=row, column=col, value=h)
|
||||
c.font = HEADER_FONT; c.fill = HEADER_FILL
|
||||
c.alignment = CENTER; c.border = BORDER
|
||||
|
||||
def data_row(ws, values, row, fills=None):
|
||||
for col, val in enumerate(values, 1):
|
||||
c = ws.cell(row=row, column=col, value=val)
|
||||
c.alignment = LEFT; c.border = BORDER
|
||||
if fills and col-1 < len(fills) and fills[col-1]:
|
||||
c.fill = fills[col-1]
|
||||
|
||||
# ── 시트 1: 프로젝트 개요 ─────────────────────────────────
|
||||
ws1 = wb.create_sheet("프로젝트 개요")
|
||||
ws1.column_dimensions["A"].width = 22
|
||||
ws1.column_dimensions["B"].width = 35
|
||||
|
||||
ws1["A1"] = f"📋 {proj.project_name}"
|
||||
ws1["A1"].font = TITLE_FONT
|
||||
ws1.merge_cells("A1:D1")
|
||||
|
||||
kpi_rows = [
|
||||
("프로젝트 코드", proj.project_code),
|
||||
("현재 단계", proj.phase),
|
||||
("전체 진척률", f"{data['overall_progress']}%"),
|
||||
("건강 상태", proj.health_status),
|
||||
("계획 기간", f"{proj.planned_start} ~ {proj.planned_end}"),
|
||||
("PM", proj.pm_user or "—"),
|
||||
("예산 소진율", f"{data['budget_pct']}%"),
|
||||
("WBS 완료 수", f"{data['wbs_done']} / {data['wbs_total']}"),
|
||||
("미결 이슈", f"{data['issue_open']}건"),
|
||||
("지연 WBS", f"{data['wbs_delayed']}건"),
|
||||
("보고일", str(today)),
|
||||
]
|
||||
for r, (k, v) in enumerate(kpi_rows, 3):
|
||||
ws1.cell(row=r, column=1, value=k).font = Font(bold=True)
|
||||
ws1.cell(row=r, column=2, value=v)
|
||||
|
||||
# ── 시트 2: WBS 현황 ──────────────────────────────────────
|
||||
ws2 = wb.create_sheet("WBS 현황")
|
||||
for col, w in zip("ABCDEFG", [10, 40, 20, 15, 15, 12, 25]):
|
||||
ws2.column_dimensions[col].width = w
|
||||
|
||||
header_row(ws2, ["WBS 코드", "제목", "단계", "예정 시작", "예정 완료", "진척률(%)", "상태"])
|
||||
for r, item in enumerate(data["wbs_items"], 2):
|
||||
status = "완료" if item.completion_pct >= 100 else (
|
||||
"지연" if item.planned_end and item.planned_end < today and item.completion_pct < 100
|
||||
else "진행중"
|
||||
)
|
||||
fill = OK_FILL if status == "완료" else (DANGER_FILL if status == "지연" else None)
|
||||
fills = [None, fill, None, None, None, fill, fill]
|
||||
data_row(ws2, [
|
||||
item.wbs_code, item.title, item.phase or "",
|
||||
str(item.planned_start or ""), str(item.planned_end or ""),
|
||||
item.completion_pct, status,
|
||||
], r, fills)
|
||||
|
||||
# ── 시트 3: 이슈 목록 ─────────────────────────────────────
|
||||
ws3 = wb.create_sheet("이슈 관리")
|
||||
for col, w in zip("ABCDEFG", [18, 15, 35, 20, 12, 18, 30]):
|
||||
ws3.column_dimensions[col].width = w
|
||||
|
||||
header_row(ws3, ["이슈 ID", "유형", "제목", "담당자", "심각도", "상태", "발생일"])
|
||||
for r, iss in enumerate(data["issues"], 2):
|
||||
severity_fill = DANGER_FILL if getattr(iss, "severity", "") in ("CRITICAL", "HIGH") else None
|
||||
data_row(ws3, [
|
||||
iss.issue_id, iss.issue_type, iss.title,
|
||||
iss.assigned_to or "—", getattr(iss, "severity", "—"),
|
||||
iss.status, str(iss.raised_date or ""),
|
||||
], r, [None, None, None, None, severity_fill, None, None])
|
||||
|
||||
# ── 시트 4: 산출물 현황 ───────────────────────────────────
|
||||
ws4 = wb.create_sheet("산출물 현황")
|
||||
for col, w in zip("ABCDEF", [30, 15, 15, 15, 15, 20]):
|
||||
ws4.column_dimensions[col].width = w
|
||||
|
||||
header_row(ws4, ["산출물명", "유형", "버전", "상태", "제출기한", "제출일"])
|
||||
for r, dlv in enumerate(data["deliverables"], 2):
|
||||
status_fill = (
|
||||
OK_FILL if dlv.status == "APPROVED" else
|
||||
DANGER_FILL if dlv.status == "REJECTED" else
|
||||
WARN_FILL if dlv.status == "REVIEWING" else
|
||||
DANGER_FILL if dlv.due_date and dlv.due_date < today and dlv.status == "PENDING"
|
||||
else None
|
||||
)
|
||||
data_row(ws4, [
|
||||
dlv.name, dlv.deliverable_type, dlv.version,
|
||||
dlv.status,
|
||||
str(dlv.due_date or ""),
|
||||
str(dlv.submitted_at.date() if dlv.submitted_at else "—"),
|
||||
], r, [None, None, None, status_fill, None, None])
|
||||
|
||||
# ── 시트 5: 위험 관리 ─────────────────────────────────────
|
||||
ws5 = wb.create_sheet("위험 관리")
|
||||
for col, w in zip("ABCDE", [18, 35, 12, 12, 40]):
|
||||
ws5.column_dimensions[col].width = w
|
||||
|
||||
header_row(ws5, ["위험 ID", "제목", "레벨", "상태", "대응 계획"])
|
||||
for r, risk in enumerate(data["risks"], 2):
|
||||
fill = DANGER_FILL if getattr(risk, "risk_level", "") in ("HIGH", "CRITICAL") else WARN_FILL
|
||||
data_row(ws5, [
|
||||
risk.risk_id, risk.title,
|
||||
getattr(risk, "risk_level", "—"), risk.status,
|
||||
risk.mitigation or "",
|
||||
], r, [None, None, fill, None, None])
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_html(data: Dict[str, Any], report_type: str = "weekly") -> str:
|
||||
"""HTML 보고서 생성 (대시보드 표시 + PDF 변환용)."""
|
||||
try:
|
||||
from jinja2 import Template
|
||||
except ImportError:
|
||||
raise ValueError("jinja2 미설치: pip install jinja2")
|
||||
|
||||
proj = data["project"]
|
||||
today = data["today"]
|
||||
|
||||
template_str = """<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body { font-family: 'Noto Sans KR', Arial, sans-serif; margin: 30px; color: #333; font-size: 13px; }
|
||||
h1 { color: #1e3a5f; border-bottom: 3px solid #1e3a5f; padding-bottom: 8px; }
|
||||
h2 { color: #2c5282; margin-top: 24px; font-size: 15px; }
|
||||
.kpi-grid { display: flex; gap: 16px; flex-wrap: wrap; margin: 16px 0; }
|
||||
.kpi-card { background: #ebf8ff; border-radius: 8px; padding: 12px 18px; min-width: 130px; text-align: center; }
|
||||
.kpi-card .value { font-size: 24px; font-weight: bold; color: #2b6cb0; }
|
||||
.kpi-card .label { font-size: 11px; color: #718096; margin-top: 4px; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 16px; }
|
||||
th { background: #2d3748; color: white; padding: 8px; text-align: left; font-size: 12px; }
|
||||
td { padding: 7px 8px; border-bottom: 1px solid #e2e8f0; }
|
||||
tr:nth-child(even) { background: #f7fafc; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; }
|
||||
.ok { background: #c6f6d5; color: #276749; }
|
||||
.warn { background: #fefcbf; color: #744210; }
|
||||
.danger{ background: #fed7d7; color: #c53030; }
|
||||
.meta { color: #718096; font-size: 12px; }
|
||||
@media print { .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>{{ report_title }}</h1>
|
||||
<p class="meta">프로젝트: {{ proj.project_name }} ({{ proj.project_code }}) | 보고일: {{ today }} | 단계: {{ proj.phase }}</p>
|
||||
|
||||
<h2>📊 KPI 현황</h2>
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card"><div class="value">{{ data.overall_progress }}%</div><div class="label">전체 진척률</div></div>
|
||||
<div class="kpi-card"><div class="value">{{ data.wbs_done }}/{{ data.wbs_total }}</div><div class="label">WBS 완료</div></div>
|
||||
<div class="kpi-card"><div class="value {{ 'danger' if data.wbs_delayed > 0 else 'ok' }}">{{ data.wbs_delayed }}</div><div class="label">지연 항목</div></div>
|
||||
<div class="kpi-card"><div class="value {{ 'danger' if data.issue_open > 0 else 'ok' }}">{{ data.issue_open }}</div><div class="label">미결 이슈</div></div>
|
||||
<div class="kpi-card"><div class="value">{{ data.budget_pct }}%</div><div class="label">예산 소진율</div></div>
|
||||
<div class="kpi-card"><div class="value {{ 'danger' if proj.health_status == 'RED' else 'warn' if proj.health_status == 'YELLOW' else 'ok' }}">{{ proj.health_status }}</div><div class="label">건강 상태</div></div>
|
||||
</div>
|
||||
|
||||
<h2>📋 WBS 지연 현황 ({{ data.wbs_delayed }}건)</h2>
|
||||
{% if data.delayed_wbs %}
|
||||
<table>
|
||||
<tr><th>WBS 코드</th><th>제목</th><th>예정 완료</th><th>진척률</th></tr>
|
||||
{% for item in data.delayed_wbs %}
|
||||
<tr>
|
||||
<td>{{ item.wbs_code }}</td>
|
||||
<td>{{ item.title }}</td>
|
||||
<td><span class="badge danger">{{ item.planned_end }}</span></td>
|
||||
<td>{{ item.completion_pct }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p style="color:#48bb78">✅ 지연 WBS 없음</p>{% endif %}
|
||||
|
||||
<h2>🔴 미결 이슈 ({{ data.issue_open }}건)</h2>
|
||||
{% if data.open_issues %}
|
||||
<table>
|
||||
<tr><th>이슈 ID</th><th>제목</th><th>유형</th><th>담당자</th><th>발생일</th></tr>
|
||||
{% for iss in data.open_issues[:10] %}
|
||||
<tr>
|
||||
<td>{{ iss.issue_id }}</td>
|
||||
<td>{{ iss.title }}</td>
|
||||
<td>{{ iss.issue_type }}</td>
|
||||
<td>{{ iss.assigned_to or '—' }}</td>
|
||||
<td>{{ iss.raised_date }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}<p style="color:#48bb78">✅ 미결 이슈 없음</p>{% endif %}
|
||||
|
||||
<h2>📦 산출물 제출 현황</h2>
|
||||
<table>
|
||||
<tr><th>산출물명</th><th>유형</th><th>버전</th><th>상태</th><th>제출기한</th></tr>
|
||||
{% for dlv in data.deliverables %}
|
||||
<tr>
|
||||
<td>{{ dlv.name }}</td>
|
||||
<td>{{ dlv.deliverable_type }}</td>
|
||||
<td>{{ dlv.version }}</td>
|
||||
<td><span class="badge {{ 'ok' if dlv.status == 'APPROVED' else 'danger' if dlv.status == 'REJECTED' else 'warn' }}">{{ dlv.status }}</span></td>
|
||||
<td>{{ dlv.due_date or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
{% if data.upcoming_milestones %}
|
||||
<h2>🏁 다가오는 마일스톤</h2>
|
||||
<table>
|
||||
<tr><th>마일스톤</th><th>목표일</th><th>상태</th></tr>
|
||||
{% for m in data.upcoming_milestones %}
|
||||
<tr><td>{{ m.name }}</td><td>{{ m.target_date }}</td><td>{{ m.status }}</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if data.high_risks %}
|
||||
<h2>⚠️ 고위험 요소 ({{ data.high_risks|length }}건)</h2>
|
||||
<table>
|
||||
<tr><th>위험 ID</th><th>제목</th><th>레벨</th><th>대응 계획</th></tr>
|
||||
{% for r in data.high_risks %}
|
||||
<tr>
|
||||
<td>{{ r.risk_id }}</td>
|
||||
<td>{{ r.title }}</td>
|
||||
<td><span class="badge danger">{{ r.risk_level }}</span></td>
|
||||
<td>{{ r.mitigation or '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<p class="meta" style="margin-top:32px;border-top:1px solid #e2e8f0;padding-top:12px">
|
||||
생성: GUARDiA ITSM | {{ today }}
|
||||
</p>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
title_map = {
|
||||
"daily": f"일일 보고서 ({today})",
|
||||
"weekly": f"주간 보고서 ({today} 기준)",
|
||||
"monthly": f"월간 보고서 ({today.strftime('%Y년 %m월')})",
|
||||
}
|
||||
|
||||
t = Template(template_str)
|
||||
return t.render(
|
||||
proj=proj,
|
||||
data=data,
|
||||
today=today,
|
||||
report_title=title_map.get(report_type, "보고서"),
|
||||
)
|
||||
|
||||
|
||||
# ── PDF 보고서 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_pdf(html_content: str) -> bytes:
|
||||
"""HTML → PDF 변환."""
|
||||
try:
|
||||
import weasyprint
|
||||
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
except ImportError:
|
||||
logger.warning("weasyprint 미설치 — HTML 반환")
|
||||
return html_content.encode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error("PDF 변환 오류: %s", e)
|
||||
return html_content.encode("utf-8")
|
||||
|
||||
|
||||
# ── DOCX 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_docx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
||||
"""Word (.docx) 보고서 생성."""
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.shared import Pt, RGBColor, Cm
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
except ImportError:
|
||||
raise ValueError("python-docx 미설치: pip install python-docx")
|
||||
|
||||
proj = data["project"]
|
||||
today = data["today"]
|
||||
doc = Document()
|
||||
|
||||
# 제목
|
||||
rtype_kor = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
|
||||
title = doc.add_heading(
|
||||
f"{proj.project_name} — {rtype_kor}보고서",
|
||||
level=0
|
||||
)
|
||||
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
|
||||
doc.add_paragraph(f"보고일: {today} | 단계: {proj.phase} | PM: {proj.pm_user or '—'}")
|
||||
|
||||
# KPI 테이블
|
||||
doc.add_heading("KPI 현황", level=1)
|
||||
kpi_table = doc.add_table(rows=1, cols=4)
|
||||
kpi_table.style = "Table Grid"
|
||||
for cell, text in zip(kpi_table.rows[0].cells, ["항목", "계획", "실적", "상태"]):
|
||||
cell.text = text
|
||||
|
||||
kpi_rows = [
|
||||
("전체 진척률", "—", f"{data['overall_progress']}%",
|
||||
"양호" if data['overall_progress'] >= 80 else "주의"),
|
||||
("WBS 완료율", f"{data['wbs_total']}건",
|
||||
f"{data['wbs_done']}건 ({round(data['wbs_done']/data['wbs_total']*100 if data['wbs_total'] else 0, 1)}%)",
|
||||
"양호" if data['wbs_delayed'] == 0 else "지연"),
|
||||
("예산 소진율", "100%", f"{data['budget_pct']}%",
|
||||
"양호" if data['budget_pct'] <= 80 else "주의"),
|
||||
("미결 이슈", "0건", f"{data['issue_open']}건",
|
||||
"양호" if data['issue_open'] == 0 else "주의"),
|
||||
]
|
||||
for row_data in kpi_rows:
|
||||
row = kpi_table.add_row()
|
||||
for cell, val in zip(row.cells, row_data):
|
||||
cell.text = val
|
||||
|
||||
# 지연 WBS
|
||||
if data["delayed_wbs"]:
|
||||
doc.add_heading(f"WBS 지연 현황 ({data['wbs_delayed']}건)", level=1)
|
||||
t = doc.add_table(rows=1, cols=4)
|
||||
t.style = "Table Grid"
|
||||
for c, h in zip(t.rows[0].cells, ["WBS 코드", "제목", "예정 완료", "진척률"]):
|
||||
c.text = h
|
||||
for item in data["delayed_wbs"]:
|
||||
r = t.add_row()
|
||||
for c, v in zip(r.cells, [item.wbs_code, item.title, str(item.planned_end), f"{item.completion_pct}%"]):
|
||||
c.text = v
|
||||
|
||||
# 이슈
|
||||
if data["open_issues"]:
|
||||
doc.add_heading(f"미결 이슈 ({data['issue_open']}건)", level=1)
|
||||
t = doc.add_table(rows=1, cols=4)
|
||||
t.style = "Table Grid"
|
||||
for c, h in zip(t.rows[0].cells, ["이슈 ID", "제목", "담당자", "발생일"]):
|
||||
c.text = h
|
||||
for iss in data["open_issues"][:10]:
|
||||
r = t.add_row()
|
||||
for c, v in zip(r.cells, [iss.issue_id, iss.title, iss.assigned_to or "—", str(iss.raised_date or "")]):
|
||||
c.text = v
|
||||
|
||||
doc.add_paragraph(f"\n생성: GUARDiA ITSM | {today}")
|
||||
|
||||
buf = io.BytesIO()
|
||||
doc.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── PPTX 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_pptx(data: Dict[str, Any], report_type: str = "weekly") -> bytes:
|
||||
"""PowerPoint (.pptx) 보고서 생성."""
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt, Emu
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
except ImportError:
|
||||
raise ValueError("python-pptx 미설치: pip install python-pptx")
|
||||
|
||||
proj = data["project"]
|
||||
today = data["today"]
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.33)
|
||||
prs.slide_height = Inches(7.5)
|
||||
|
||||
DARK_BLUE = RGBColor(0x1E, 0x3A, 0x5F)
|
||||
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
||||
|
||||
def add_slide(layout_idx=5):
|
||||
layout = prs.slide_layouts[layout_idx]
|
||||
return prs.slides.add_slide(layout)
|
||||
|
||||
def set_bg(slide, color=RGBColor(0xF7, 0xFA, 0xFC)):
|
||||
from pptx.util import Emu
|
||||
fill = slide.background.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = color
|
||||
|
||||
def add_text_box(slide, text, left, top, width, height, font_size=14, bold=False, color=None):
|
||||
txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
run = p.add_run()
|
||||
run.text = text
|
||||
run.font.size = Pt(font_size)
|
||||
run.font.bold = bold
|
||||
if color:
|
||||
run.font.color.rgb = color
|
||||
return txBox
|
||||
|
||||
# ── 슬라이드 1: 표지 ──────────────────────────────────────
|
||||
slide1 = add_slide(6) # blank
|
||||
set_bg(slide1, DARK_BLUE)
|
||||
add_text_box(slide1, proj.project_name, 1, 2, 11, 1.5, font_size=32, bold=True, color=WHITE)
|
||||
rtype_name = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, "")
|
||||
add_text_box(slide1, f"{rtype_name} 보고서", 1, 3.8, 11, 0.8, font_size=20, color=RGBColor(0xBD, 0xE3, 0xFF))
|
||||
add_text_box(slide1, f"보고일: {today} | 단계: {proj.phase}", 1, 5, 11, 0.6, font_size=14, color=RGBColor(0xA0, 0xC4, 0xFF))
|
||||
|
||||
# ── 슬라이드 2: KPI 요약 ──────────────────────────────────
|
||||
slide2 = add_slide(6)
|
||||
set_bg(slide2)
|
||||
add_text_box(slide2, "KPI 현황", 0.5, 0.3, 12, 0.6, font_size=22, bold=True, color=DARK_BLUE)
|
||||
|
||||
kpis = [
|
||||
("전체 진척률", f"{data['overall_progress']}%"),
|
||||
("WBS 완료", f"{data['wbs_done']}/{data['wbs_total']}"),
|
||||
("지연 항목", f"{data['wbs_delayed']}건"),
|
||||
("미결 이슈", f"{data['issue_open']}건"),
|
||||
("예산 소진", f"{data['budget_pct']}%"),
|
||||
("건강 상태", proj.health_status),
|
||||
]
|
||||
for i, (label, value) in enumerate(kpis):
|
||||
x = 0.5 + (i % 3) * 4.2
|
||||
y = 1.5 + (i // 3) * 2.2
|
||||
box = slide2.shapes.add_shape(
|
||||
1, Inches(x), Inches(y), Inches(3.8), Inches(1.8)
|
||||
)
|
||||
box.fill.solid(); box.fill.fore_color.rgb = RGBColor(0xEB, 0xF8, 0xFF)
|
||||
box.line.color.rgb = RGBColor(0x90, 0xCF, 0xF8)
|
||||
tf = box.text_frame
|
||||
tf.word_wrap = True
|
||||
p1 = tf.paragraphs[0]
|
||||
p1.alignment = PP_ALIGN.CENTER
|
||||
r1 = p1.add_run(); r1.text = value
|
||||
r1.font.size = Pt(28); r1.font.bold = True
|
||||
r1.font.color.rgb = DARK_BLUE
|
||||
p2 = tf.add_paragraph()
|
||||
p2.alignment = PP_ALIGN.CENTER
|
||||
r2 = p2.add_run(); r2.text = label
|
||||
r2.font.size = Pt(12); r2.font.color.rgb = RGBColor(0x71, 0x80, 0x96)
|
||||
|
||||
# ── 슬라이드 3: WBS 현황 ──────────────────────────────────
|
||||
if data["delayed_wbs"]:
|
||||
slide3 = add_slide(6)
|
||||
set_bg(slide3)
|
||||
add_text_box(slide3, f"WBS 지연 현황 ({data['wbs_delayed']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
|
||||
|
||||
from pptx.util import Inches as I
|
||||
tbl = slide3.shapes.add_table(
|
||||
min(len(data["delayed_wbs"]) + 1, 8), 4,
|
||||
I(0.5), I(1.2), I(12), I(4.5)
|
||||
).table
|
||||
headers = ["WBS 코드", "제목", "예정 완료", "진척률"]
|
||||
for col, h in enumerate(headers):
|
||||
tbl.cell(0, col).text = h
|
||||
for r, item in enumerate(data["delayed_wbs"][:6], 1):
|
||||
vals = [item.wbs_code, item.title[:30], str(item.planned_end), f"{item.completion_pct}%"]
|
||||
for col, v in enumerate(vals):
|
||||
tbl.cell(r, col).text = v
|
||||
|
||||
# ── 슬라이드 4: 이슈 요약 ─────────────────────────────────
|
||||
if data["open_issues"]:
|
||||
slide4 = add_slide(6)
|
||||
set_bg(slide4)
|
||||
add_text_box(slide4, f"미결 이슈 ({data['issue_open']}건)", 0.5, 0.3, 12, 0.6, 22, True, DARK_BLUE)
|
||||
|
||||
from pptx.util import Inches as I
|
||||
tbl = slide4.shapes.add_table(
|
||||
min(len(data["open_issues"]) + 1, 8), 4,
|
||||
I(0.5), I(1.2), I(12), I(4.5)
|
||||
).table
|
||||
headers = ["이슈 ID", "제목", "유형", "담당자"]
|
||||
for col, h in enumerate(headers):
|
||||
tbl.cell(0, col).text = h
|
||||
for r, iss in enumerate(data["open_issues"][:6], 1):
|
||||
vals = [iss.issue_id, iss.title[:30], iss.issue_type, iss.assigned_to or "—"]
|
||||
for col, v in enumerate(vals):
|
||||
tbl.cell(r, col).text = v
|
||||
|
||||
buf = io.BytesIO()
|
||||
prs.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 보고서 유형 통합 생성 ─────────────────────────────────────────────────────
|
||||
|
||||
async def generate_report(
|
||||
project_id: int,
|
||||
report_type: str, # daily | weekly | monthly
|
||||
output_fmt: str, # excel | html | pdf | docx | pptx
|
||||
db,
|
||||
) -> tuple[bytes, str, str]:
|
||||
"""
|
||||
보고서 생성 통합 함수.
|
||||
|
||||
Returns:
|
||||
(content_bytes, media_type, filename)
|
||||
"""
|
||||
data = await collect_project_data(project_id, db)
|
||||
proj = data["project"]
|
||||
today = data["today"]
|
||||
|
||||
rtype = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(report_type, report_type)
|
||||
base_name = f"{proj.project_code}_{rtype}보고서_{today}"
|
||||
|
||||
if output_fmt == "excel":
|
||||
content = generate_excel(data, report_type)
|
||||
mime = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
filename = f"{base_name}.xlsx"
|
||||
|
||||
elif output_fmt == "html":
|
||||
content = generate_html(data, report_type).encode("utf-8")
|
||||
mime = "text/html; charset=utf-8"
|
||||
filename = f"{base_name}.html"
|
||||
|
||||
elif output_fmt == "pdf":
|
||||
html_str = generate_html(data, report_type)
|
||||
content = generate_pdf(html_str)
|
||||
mime = "application/pdf"
|
||||
filename = f"{base_name}.pdf"
|
||||
|
||||
elif output_fmt == "docx":
|
||||
content = generate_docx(data, report_type)
|
||||
mime = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
filename = f"{base_name}.docx"
|
||||
|
||||
elif output_fmt == "pptx":
|
||||
content = generate_pptx(data, report_type)
|
||||
mime = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
filename = f"{base_name}.pptx"
|
||||
|
||||
else:
|
||||
raise ValueError(f"지원하지 않는 형식: {output_fmt}")
|
||||
|
||||
return content, mime, filename
|
||||
BIN
guardia_itsm.db.bak
Normal file
BIN
guardia_itsm.db.bak
Normal file
Binary file not shown.
28
main.py
28
main.py
@ -40,6 +40,11 @@ from routers import (
|
||||
learning,
|
||||
push as push_router,
|
||||
scouter as scouter_router,
|
||||
deliverables,
|
||||
si_report,
|
||||
compliance,
|
||||
jmeter,
|
||||
public_checklist,
|
||||
)
|
||||
|
||||
|
||||
@ -98,7 +103,15 @@ async def lifespan(app: FastAPI):
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="GUARDiA ITSM", version="1.0.0", lifespan=lifespan)
|
||||
app = FastAPI(title="GUARDiA ITSM", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_copyright_header(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Powered-By"] = "GUARDiA ITSM 2.0"
|
||||
response.headers["X-Copyright"] = "Copyright 2026 GUARDiA All Rights Reserved"
|
||||
return response
|
||||
|
||||
# ── F-2: Redis 캐시 종료 훅 ──────────────────────────────────────────────────
|
||||
# (lifespan의 yield 이후에 실행 — close_redis는 shutdown시 호출)
|
||||
@ -230,6 +243,19 @@ app.include_router(push_router.router)
|
||||
# Scouter APM
|
||||
app.include_router(scouter_router.router)
|
||||
|
||||
# PMS — 산출물 + 보고서
|
||||
app.include_router(deliverables.router)
|
||||
app.include_router(si_report.router)
|
||||
|
||||
# 준수성 점검 (시큐어코딩/웹접근성/개인정보보호)
|
||||
app.include_router(compliance.router)
|
||||
|
||||
# 성능 테스트 (JMeter JTL 분석 + 내장 부하 테스트)
|
||||
app.include_router(jmeter.router)
|
||||
|
||||
# 공공기관 필수 기능 체크리스트
|
||||
app.include_router(public_checklist.router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
|
||||
98
models.py
98
models.py
@ -2017,6 +2017,8 @@ class SiProject(Base):
|
||||
cascade="all, delete-orphan")
|
||||
phase_checklists = relationship("SiPhaseChecklist", back_populates="project",
|
||||
cascade="all, delete-orphan")
|
||||
deliverables = relationship("Deliverable", back_populates="project",
|
||||
cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class SiPhaseChecklist(Base):
|
||||
@ -2204,6 +2206,102 @@ class ProjectDeliverable(Base):
|
||||
milestone = relationship("ProjectMilestone", back_populates="deliverables")
|
||||
|
||||
|
||||
# ── ORM: 산출물 관리 ──────────────────────────────────────────────────────────
|
||||
|
||||
class DeliverableStatus(str, Enum):
|
||||
PENDING = "PENDING" # 미제출
|
||||
SUBMITTED = "SUBMITTED" # 제출됨
|
||||
REVIEWING = "REVIEWING" # 검토중
|
||||
APPROVED = "APPROVED" # 승인
|
||||
REJECTED = "REJECTED" # 반려
|
||||
|
||||
|
||||
class DeliverableType(str, Enum):
|
||||
DOCUMENT = "DOCUMENT" # 문서 (분석서, 설계서 등)
|
||||
CODE = "CODE" # 소스코드
|
||||
TEST_RESULT = "TEST_RESULT" # 테스트 결과
|
||||
DESIGN = "DESIGN" # 설계도/UI
|
||||
REPORT = "REPORT" # 보고서
|
||||
MANUAL = "MANUAL" # 매뉴얼
|
||||
OTHER = "OTHER"
|
||||
|
||||
|
||||
class Deliverable(Base):
|
||||
"""산출물(Deliverable) — WBS 항목·마일스톤별 제출 추적."""
|
||||
__tablename__ = "tb_deliverable"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("tb_si_project.id"), nullable=False, index=True)
|
||||
wbs_item_id = Column(Integer, ForeignKey("tb_wbs_item.id"), nullable=True)
|
||||
milestone_id = Column(Integer, ForeignKey("tb_project_milestone.id"), nullable=True)
|
||||
# 식별
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
deliverable_type= Column(String(30), default=DeliverableType.DOCUMENT)
|
||||
# 상태
|
||||
status = Column(String(20), default=DeliverableStatus.PENDING)
|
||||
version = Column(String(20), default="1.0")
|
||||
# 일정
|
||||
due_date = Column(Date, nullable=True)
|
||||
submitted_at = Column(DateTime, nullable=True)
|
||||
submitted_by = Column(String(100), nullable=True)
|
||||
# 검토
|
||||
reviewer = Column(String(100), nullable=True)
|
||||
reviewed_at = Column(DateTime, nullable=True)
|
||||
review_comment = Column(Text, nullable=True)
|
||||
# 파일
|
||||
file_path = Column(String(500), nullable=True)
|
||||
file_name = Column(String(200), nullable=True)
|
||||
# 이력
|
||||
created_by = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
project = relationship("SiProject", foreign_keys=[project_id])
|
||||
|
||||
|
||||
class DeliverableOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
project_id: int
|
||||
wbs_item_id: Optional[int] = None
|
||||
milestone_id: Optional[int] = None
|
||||
name: str
|
||||
deliverable_type:str
|
||||
status: str
|
||||
version: str
|
||||
due_date: Optional[date] = None
|
||||
submitted_at: Optional[datetime] = None
|
||||
submitted_by: Optional[str] = None
|
||||
reviewer: Optional[str] = None
|
||||
reviewed_at: Optional[datetime] = None
|
||||
review_comment: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DeliverableCreate(BaseModel):
|
||||
project_id: int
|
||||
wbs_item_id: Optional[int] = None
|
||||
milestone_id: Optional[int] = None
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
deliverable_type:str = DeliverableType.DOCUMENT
|
||||
version: str = "1.0"
|
||||
due_date: Optional[date] = None
|
||||
reviewer: Optional[str] = None
|
||||
|
||||
|
||||
class DeliverableUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
due_date: Optional[date] = None
|
||||
reviewer: Optional[str] = None
|
||||
review_comment: Optional[str] = None
|
||||
|
||||
|
||||
# ── ORM: 변경 요청 ────────────────────────────────────────────────────────────
|
||||
|
||||
class ChangeRequest(Base):
|
||||
|
||||
@ -20,3 +20,9 @@ pywebpush>=2.0.0
|
||||
alembic>=1.13.0
|
||||
asyncpg>=0.29.0
|
||||
psycopg2-binary>=2.9.0
|
||||
# SI 보고서 생성 (WBS/산출물/일간/주간/월간)
|
||||
openpyxl>=3.1.2
|
||||
python-docx>=1.1.0
|
||||
python-pptx>=0.6.23
|
||||
jinja2>=3.1.3
|
||||
weasyprint>=62.0
|
||||
|
||||
@ -451,3 +451,95 @@ async def admin_user_lock_status(
|
||||
"locked_until": target.locked_until.isoformat() if target.locked_until else None,
|
||||
"remaining_minutes": remaining_min,
|
||||
}
|
||||
|
||||
|
||||
# ── OAuth2 소셜 로그인 ────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/oauth/providers")
|
||||
async def oauth_providers():
|
||||
"""활성화된 OAuth 제공자 목록 반환 (로그인 페이지에서 버튼 표시 여부 결정)."""
|
||||
from core.oauth import get_enabled_providers
|
||||
return {"providers": get_enabled_providers()}
|
||||
|
||||
|
||||
@router.get("/oauth/{provider}/start")
|
||||
async def oauth_start(provider: str):
|
||||
"""OAuth 인증 흐름 시작 — 제공자 인증 페이지로 리디렉트."""
|
||||
from core.oauth import build_auth_url, PROVIDERS
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
if provider not in PROVIDERS or not PROVIDERS[provider]["enabled"]:
|
||||
raise HTTPException(400, f"OAuth 제공자 '{provider}'가 설정되지 않았습니다.")
|
||||
|
||||
url = build_auth_url(provider)
|
||||
return RedirectResponse(url)
|
||||
|
||||
|
||||
@router.get("/oauth/{provider}/callback")
|
||||
async def oauth_callback(
|
||||
provider: str,
|
||||
code: str = "",
|
||||
state: str = "",
|
||||
error: str = "",
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
OAuth 콜백 처리 — 인가 코드로 토큰 교환 후 GUARDiA 계정과 연결.
|
||||
|
||||
- 이메일 기반으로 기존 계정 조회 또는 신규 계정 자동 생성 (auth_type=oauth)
|
||||
- 성공 시 /login 페이지로 리디렉트하며 JWT 토큰을 URL 파라미터로 전달
|
||||
"""
|
||||
from core.oauth import verify_state, exchange_code, extract_email, extract_name, PROVIDERS
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
LOGIN_FAIL_URL = "/login?error=oauth_failed"
|
||||
|
||||
if error:
|
||||
return RedirectResponse(f"/login?error={error}")
|
||||
|
||||
if not verify_state(state):
|
||||
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=state_mismatch")
|
||||
|
||||
if provider not in PROVIDERS or not PROVIDERS[provider]["enabled"]:
|
||||
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=unknown_provider")
|
||||
|
||||
userinfo = await exchange_code(provider, code)
|
||||
if not userinfo:
|
||||
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=token_exchange")
|
||||
|
||||
email = extract_email(provider, userinfo)
|
||||
if not email:
|
||||
return RedirectResponse(f"{LOGIN_FAIL_URL}&reason=no_email")
|
||||
|
||||
# 기존 계정 조회 (이메일 기준)
|
||||
result = await db.execute(select(User).where(User.email == email, User.is_active == True))
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
# 자동 계정 생성 (CUSTOMER 역할, 비밀번호 없음)
|
||||
display = extract_name(provider, userinfo)
|
||||
username = email.split("@")[0].replace(".", "_")[:30]
|
||||
# username 중복 처리
|
||||
existing = (await db.execute(select(User).where(User.username == username))).scalars().first()
|
||||
if existing:
|
||||
username = f"{username}_{provider[:3]}"
|
||||
|
||||
user = User(
|
||||
username = username,
|
||||
display_name = display or username,
|
||||
email = email,
|
||||
hashed_pw = "", # OAuth 계정은 비밀번호 없음
|
||||
role = "CUSTOMER",
|
||||
is_active = True,
|
||||
must_change_pw= False,
|
||||
auth_type = f"oauth:{provider}",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
user.last_login_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
token = create_access_token({"sub": user.username, "role": user.role})
|
||||
# 토큰을 URL 파라미터로 전달 → 로그인 페이지 JS에서 처리
|
||||
return RedirectResponse(f"/login?oauth_token={token}&username={user.username}&role={user.role}")
|
||||
|
||||
229
routers/compliance.py
Normal file
229
routers/compliance.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""
|
||||
준수성 자동 점검 API (시큐어코딩 / 웹 접근성 / 개인정보보호법)
|
||||
|
||||
엔드포인트:
|
||||
POST /api/compliance/scan — 전체 프로젝트 스캔 (ADMIN 전용)
|
||||
GET /api/compliance/results — 최근 스캔 결과 조회
|
||||
GET /api/compliance/rules — 점검 규칙 목록
|
||||
POST /api/compliance/scan/file — 파일 텍스트 단건 점검
|
||||
GET /api/compliance/report/html — HTML 점검 보고서
|
||||
GET /api/compliance/report/excel — Excel 점검 보고서
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from pydantic import BaseModel
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/compliance", tags=["compliance"])
|
||||
|
||||
# 최근 스캔 결과 캐시 (메모리)
|
||||
_last_result: dict = {}
|
||||
|
||||
|
||||
class FileScanRequest(BaseModel):
|
||||
filename: str
|
||||
content: str
|
||||
|
||||
|
||||
# ── 전체 스캔 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/scan")
|
||||
async def run_scan(
|
||||
current_user: User = Depends(require_admin_role),
|
||||
):
|
||||
"""GUARDiA 소스 전체 준수성 점검 (ADMIN 전용)."""
|
||||
global _last_result
|
||||
from core.compliance_check import scan_project
|
||||
try:
|
||||
result = await scan_project()
|
||||
_last_result = result
|
||||
return {
|
||||
"message": f"점검 완료 — {result['scanned_files']}개 파일, {result['total_findings']}건 발견",
|
||||
"risk_level": result["risk_level"],
|
||||
"total_findings": result["total_findings"],
|
||||
"by_severity": result["by_severity"],
|
||||
"summary": result["summary"],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"점검 오류: {str(e)[:200]}")
|
||||
|
||||
|
||||
# ── 결과 조회 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/results")
|
||||
async def get_results(
|
||||
category: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""최근 스캔 결과 조회."""
|
||||
if not _last_result:
|
||||
return {"message": "스캔 결과 없음 — POST /api/compliance/scan 실행 필요", "findings": []}
|
||||
|
||||
findings = _last_result.get("findings", [])
|
||||
if category:
|
||||
findings = [f for f in findings if category.lower() in f["category"].lower()]
|
||||
if severity:
|
||||
findings = [f for f in findings if f["severity"].upper() == severity.upper()]
|
||||
|
||||
return {
|
||||
"scan_time": _last_result.get("scan_time"),
|
||||
"risk_level": _last_result.get("risk_level"),
|
||||
"total_findings": _last_result.get("total_findings", 0),
|
||||
"by_severity": _last_result.get("by_severity", {}),
|
||||
"by_category": _last_result.get("by_category", {}),
|
||||
"summary": _last_result.get("summary", {}),
|
||||
"findings": findings[:limit],
|
||||
}
|
||||
|
||||
|
||||
# ── 규칙 목록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/rules")
|
||||
async def list_rules(
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""점검 규칙 목록 (패턴 제외)."""
|
||||
from core.compliance_check import SECURE_CODING_RULES, ACCESSIBILITY_RULES, PIPA_RULES
|
||||
return {
|
||||
"secure_coding": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in SECURE_CODING_RULES],
|
||||
"accessibility": [{"id": r["id"], "category": r["category"], "level": r["level"], "message": r["message"]} for r in ACCESSIBILITY_RULES],
|
||||
"privacy": [{"id": r["id"], "category": r["category"], "severity": r["severity"], "message": r["message"]} for r in PIPA_RULES],
|
||||
}
|
||||
|
||||
|
||||
# ── 단건 파일 점검 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/scan/file")
|
||||
async def scan_single_file(
|
||||
body: FileScanRequest,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""파일 텍스트 단건 점검 (개발 중 빠른 검토용)."""
|
||||
from core.compliance_check import scan_file
|
||||
findings = scan_file(body.filename, body.content)
|
||||
return {
|
||||
"filename": body.filename,
|
||||
"findings": findings,
|
||||
"total": len(findings),
|
||||
"by_severity": {
|
||||
sev: sum(1 for f in findings if f["severity"] == sev)
|
||||
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
|
||||
if any(f["severity"] == sev for f in findings)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/report/html", response_class=HTMLResponse)
|
||||
async def compliance_html_report(
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""준수성 점검 HTML 보고서."""
|
||||
if not _last_result:
|
||||
return HTMLResponse("<h2>스캔 결과 없음</h2><p>POST /api/compliance/scan 을 먼저 실행하세요.</p>")
|
||||
|
||||
findings = _last_result.get("findings", [])
|
||||
sev_color = {"CRITICAL": "#fed7d7", "HIGH": "#feebc8", "MEDIUM": "#fefcbf", "LOW": "#e6fffa"}
|
||||
|
||||
def _row(f):
|
||||
bg = sev_color.get(f["severity"], "#fff")
|
||||
return (
|
||||
f"<tr style='background:{bg}'>"
|
||||
f"<td>{f['rule_id']}</td><td>{f['category']}</td>"
|
||||
f"<td><b>{f['severity']}</b></td>"
|
||||
f"<td>{f['file']}:{f['line']}</td>"
|
||||
f"<td style='font-size:12px'>{f['message']}</td>"
|
||||
f"<td><code style='font-size:11px'>{f.get('snippet','')[:60]}</code></td></tr>"
|
||||
)
|
||||
rows = "".join(_row(f) for f in findings[:100])
|
||||
|
||||
risk = _last_result.get("risk_level", "?")
|
||||
risk_badge_color = {"CRITICAL": "#c53030", "HIGH": "#c05621", "MEDIUM": "#744210", "LOW": "#276749"}.get(risk, "#666")
|
||||
|
||||
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
|
||||
<title>GUARDiA 준수성 점검 보고서</title>
|
||||
<style>
|
||||
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
|
||||
h1{{color:#1a365d}} h2{{color:#2c5282;margin-top:20px}}
|
||||
table{{border-collapse:collapse;width:100%}} th{{background:#2d3748;color:#fff;padding:8px;text-align:left}}
|
||||
td{{padding:6px 8px;border-bottom:1px solid #e2e8f0}}
|
||||
.badge{{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:bold;color:white;background:{risk_badge_color}}}
|
||||
</style></head><body>
|
||||
<h1>GUARDiA 준수성 점검 보고서</h1>
|
||||
<p>점검일시: {_last_result.get('scan_time','')} | 스캔 파일: {_last_result.get('scanned_files',0)}개</p>
|
||||
<p>종합 위험도: <span class="badge">{risk}</span>
|
||||
| 총 발견: {_last_result.get('total_findings',0)}건
|
||||
| 시큐어코딩: {_last_result.get('summary',{}).get('secure_coding',0)}건
|
||||
| 웹접근성: {_last_result.get('summary',{}).get('accessibility',0)}건
|
||||
| 개인정보: {_last_result.get('summary',{}).get('privacy',0)}건
|
||||
</p>
|
||||
<h2>점검 결과 (상위 100건)</h2>
|
||||
<table>
|
||||
<tr><th>규칙ID</th><th>분류</th><th>심각도</th><th>위치</th><th>내용</th><th>코드</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
<p style="margin-top:24px;color:#718096;font-size:11px">
|
||||
Copyright © 2026 GUARDiA All Rights Reserved. | 이 보고서는 자동 생성되었습니다.
|
||||
</p></body></html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
# ── Excel 보고서 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/report/excel")
|
||||
async def compliance_excel_report(
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""준수성 점검 Excel 보고서."""
|
||||
if not _last_result:
|
||||
raise HTTPException(404, "스캔 결과 없음 — POST /api/compliance/scan 먼저 실행")
|
||||
|
||||
try:
|
||||
import io, openpyxl
|
||||
from openpyxl.styles import Font, PatternFill, Alignment
|
||||
except ImportError:
|
||||
raise HTTPException(500, "openpyxl 미설치")
|
||||
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "준수성 점검 결과"
|
||||
|
||||
headers = ["규칙 ID", "분류", "심각도", "파일", "라인", "내용", "코드 스니펫"]
|
||||
for col, h in enumerate(headers, 1):
|
||||
c = ws.cell(row=1, column=col, value=h)
|
||||
c.font = Font(bold=True, color="FFFFFF")
|
||||
c.fill = PatternFill("solid", fgColor="1a365d")
|
||||
|
||||
sev_colors = {"CRITICAL": "FED7D7", "HIGH": "FEEBC8", "MEDIUM": "FEFCBF", "LOW": "E6FFFA"}
|
||||
for row, f in enumerate(_last_result.get("findings", []), 2):
|
||||
color = sev_colors.get(f["severity"], "FFFFFF")
|
||||
vals = [f["rule_id"], f["category"], f["severity"], f["file"], f["line"], f["message"], f.get("snippet", "")]
|
||||
for col, val in enumerate(vals, 1):
|
||||
c = ws.cell(row=row, column=col, value=val)
|
||||
c.fill = PatternFill("solid", fgColor=color)
|
||||
|
||||
for col_idx, width in enumerate([10, 18, 12, 40, 7, 50, 40], 1):
|
||||
ws.column_dimensions[ws.cell(1, col_idx).column_letter].width = width
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
today = datetime.utcnow().strftime("%Y%m%d")
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="GUARDiA_compliance_{today}.xlsx"'},
|
||||
)
|
||||
263
routers/deliverables.py
Normal file
263
routers/deliverables.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""
|
||||
SI 프로젝트 산출물(Deliverable) 관리 API
|
||||
|
||||
엔드포인트:
|
||||
GET /api/si/projects/{pid}/deliverables — 산출물 목록
|
||||
POST /api/si/projects/{pid}/deliverables — 산출물 등록
|
||||
GET /api/si/projects/{pid}/deliverables/{id} — 산출물 상세
|
||||
PATCH /api/si/projects/{pid}/deliverables/{id} — 산출물 수정
|
||||
DELETE /api/si/projects/{pid}/deliverables/{id} — 산출물 삭제
|
||||
POST /api/si/projects/{pid}/deliverables/{id}/submit — 제출 처리
|
||||
POST /api/si/projects/{pid}/deliverables/{id}/review — 검토 결과 등록
|
||||
GET /api/si/projects/{pid}/deliverables/summary — 제출 현황 요약
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import (
|
||||
Deliverable, DeliverableCreate, DeliverableOut, DeliverableUpdate,
|
||||
DeliverableStatus, SiProject, User, UserRole,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/si/projects", tags=["deliverables"])
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
result: str # APPROVED | REJECTED
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
async def _get_project(db: AsyncSession, pid: int) -> SiProject:
|
||||
proj = await db.get(SiProject, pid)
|
||||
if not proj:
|
||||
raise HTTPException(404, f"프로젝트 ID {pid}를 찾을 수 없습니다.")
|
||||
return proj
|
||||
|
||||
|
||||
async def _get_deliverable(db: AsyncSession, pid: int, did: int) -> Deliverable:
|
||||
d = await db.get(Deliverable, did)
|
||||
if not d or d.project_id != pid:
|
||||
raise HTTPException(404, f"산출물 ID {did}를 찾을 수 없습니다.")
|
||||
return d
|
||||
|
||||
|
||||
# ── 목록 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/deliverables", response_model=List[DeliverableOut])
|
||||
async def list_deliverables(
|
||||
pid: int,
|
||||
status: Optional[str] = None,
|
||||
type_: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""프로젝트 산출물 목록 조회."""
|
||||
await _get_project(db, pid)
|
||||
q = select(Deliverable).where(Deliverable.project_id == pid)
|
||||
if status:
|
||||
q = q.where(Deliverable.status == status)
|
||||
if type_:
|
||||
q = q.where(Deliverable.deliverable_type == type_)
|
||||
q = q.order_by(Deliverable.due_date.asc().nullslast())
|
||||
rows = (await db.execute(q)).scalars().all()
|
||||
return rows
|
||||
|
||||
|
||||
# ── 생성 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{pid}/deliverables", response_model=DeliverableOut, status_code=201)
|
||||
async def create_deliverable(
|
||||
pid: int,
|
||||
body: DeliverableCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""산출물 등록."""
|
||||
await _get_project(db, pid)
|
||||
body.project_id = pid
|
||||
d = Deliverable(
|
||||
project_id = pid,
|
||||
wbs_item_id = body.wbs_item_id,
|
||||
milestone_id = body.milestone_id,
|
||||
name = body.name,
|
||||
description = body.description,
|
||||
deliverable_type= body.deliverable_type,
|
||||
version = body.version,
|
||||
due_date = body.due_date,
|
||||
reviewer = body.reviewer,
|
||||
created_by = cu.username,
|
||||
)
|
||||
db.add(d)
|
||||
await db.commit()
|
||||
await db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
# ── 상세 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/deliverables/{did}", response_model=DeliverableOut)
|
||||
async def get_deliverable(
|
||||
pid: int, did: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
return await _get_deliverable(db, pid, did)
|
||||
|
||||
|
||||
# ── 수정 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{pid}/deliverables/{did}", response_model=DeliverableOut)
|
||||
async def update_deliverable(
|
||||
pid: int, did: int,
|
||||
body: DeliverableUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
d = await _get_deliverable(db, pid, did)
|
||||
for field, val in body.model_dump(exclude_none=True).items():
|
||||
setattr(d, field, val)
|
||||
d.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
await db.refresh(d)
|
||||
return d
|
||||
|
||||
|
||||
# ── 삭제 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.delete("/{pid}/deliverables/{did}", status_code=204)
|
||||
async def delete_deliverable(
|
||||
pid: int, did: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
if cu.role not in (UserRole.ADMIN, UserRole.PM):
|
||||
raise HTTPException(403, "ADMIN 또는 PM만 산출물을 삭제할 수 있습니다.")
|
||||
d = await _get_deliverable(db, pid, did)
|
||||
await db.delete(d)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── 제출 처리 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{pid}/deliverables/{did}/submit")
|
||||
async def submit_deliverable(
|
||||
pid: int,
|
||||
did: int,
|
||||
file: Optional[UploadFile] = File(None),
|
||||
comment: str = Form(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""산출물 제출 (파일 첨부 선택)."""
|
||||
d = await _get_deliverable(db, pid, did)
|
||||
if d.status in (DeliverableStatus.APPROVED,):
|
||||
raise HTTPException(400, "이미 승인된 산출물입니다.")
|
||||
|
||||
# 파일 저장
|
||||
if file:
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
upload_dir = Path(__file__).parent.parent / "uploads" / "deliverables" / str(pid)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_name = f"{did}_{file.filename}"
|
||||
out_path = upload_dir / safe_name
|
||||
async with aiofiles.open(out_path, "wb") as f:
|
||||
content = await file.read()
|
||||
await f.write(content)
|
||||
d.file_path = str(out_path)
|
||||
d.file_name = file.filename
|
||||
|
||||
d.status = DeliverableStatus.SUBMITTED
|
||||
d.submitted_at = datetime.now()
|
||||
d.submitted_by = cu.username
|
||||
d.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"message": f"산출물 '{d.name}' 제출 완료",
|
||||
"status": d.status,
|
||||
"submitted_at": d.submitted_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
# ── 검토 결과 등록 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{pid}/deliverables/{did}/review")
|
||||
async def review_deliverable(
|
||||
pid: int,
|
||||
did: int,
|
||||
body: ReviewRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""검토 결과 등록 (APPROVED / REJECTED)."""
|
||||
if cu.role not in (UserRole.ADMIN, UserRole.PM):
|
||||
raise HTTPException(403, "PM/ADMIN만 검토 결과를 등록할 수 있습니다.")
|
||||
d = await _get_deliverable(db, pid, did)
|
||||
if d.status not in (DeliverableStatus.SUBMITTED, DeliverableStatus.REVIEWING):
|
||||
raise HTTPException(400, "제출된 산출물에만 검토 결과를 등록할 수 있습니다.")
|
||||
|
||||
d.status = body.result # APPROVED or REJECTED
|
||||
d.reviewer = cu.username
|
||||
d.reviewed_at = datetime.now()
|
||||
d.review_comment = body.comment
|
||||
d.updated_at = datetime.now()
|
||||
await db.commit()
|
||||
|
||||
msg = "승인" if body.result == DeliverableStatus.APPROVED else "반려"
|
||||
return {"message": f"산출물 '{d.name}' {msg} 처리 완료", "status": d.status}
|
||||
|
||||
|
||||
# ── 제출 현황 요약 ────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/deliverables/summary")
|
||||
async def deliverable_summary(
|
||||
pid: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""산출물 제출 현황 요약."""
|
||||
await _get_project(db, pid)
|
||||
rows = (await db.execute(
|
||||
select(Deliverable).where(Deliverable.project_id == pid)
|
||||
)).scalars().all()
|
||||
|
||||
total = len(rows)
|
||||
by_status= {}
|
||||
overdue = []
|
||||
today = date.today()
|
||||
|
||||
for d in rows:
|
||||
by_status[d.status] = by_status.get(d.status, 0) + 1
|
||||
if d.status == DeliverableStatus.PENDING and d.due_date and d.due_date < today:
|
||||
overdue.append({
|
||||
"id": d.id,
|
||||
"name": d.name,
|
||||
"due_date": d.due_date.isoformat(),
|
||||
"days_overdue": (today - d.due_date).days,
|
||||
})
|
||||
|
||||
approved = by_status.get(DeliverableStatus.APPROVED, 0)
|
||||
submit_rate = round(
|
||||
(total - by_status.get(DeliverableStatus.PENDING, 0)) / total * 100, 1
|
||||
) if total else 0.0
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"approved": approved,
|
||||
"submit_rate": submit_rate,
|
||||
"approval_rate": round(approved / total * 100, 1) if total else 0.0,
|
||||
"overdue": overdue,
|
||||
}
|
||||
431
routers/jmeter.py
Normal file
431
routers/jmeter.py
Normal file
@ -0,0 +1,431 @@
|
||||
"""
|
||||
JMeter 성능 테스트 연동 API
|
||||
|
||||
기능:
|
||||
1. JMeter JTL 결과 파일 업로드 → 분석 및 보고서 생성
|
||||
2. GUARDiA API 자동 성능 테스트 (내장 Python httpx 기반)
|
||||
3. 결과 HTML/Excel 보고서 다운로드
|
||||
|
||||
엔드포인트:
|
||||
POST /api/perf/upload/jtl — JTL 파일 업로드 및 분석
|
||||
POST /api/perf/run — GUARDiA API 자동 성능 테스트
|
||||
GET /api/perf/results — 최근 테스트 결과 목록
|
||||
GET /api/perf/results/{id} — 특정 결과 상세
|
||||
GET /api/perf/results/{id}/html — HTML 보고서
|
||||
GET /api/perf/results/{id}/excel — Excel 보고서
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import csv
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User, UserRole
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/perf", tags=["performance"])
|
||||
|
||||
# 결과 인메모리 저장소
|
||||
_results: dict[str, dict] = {}
|
||||
|
||||
|
||||
class PerfTestRequest(BaseModel):
|
||||
target_url: str = "http://localhost:8001"
|
||||
endpoints: list[str] = ["/", "/api/tasks", "/api/dashboard/me"]
|
||||
users: int = 10 # 동시 사용자
|
||||
duration: int = 30 # 테스트 시간 (초)
|
||||
ramp_up: int = 5 # 사용자 증가 시간 (초)
|
||||
think_time: float = 0.1 # 요청 간 대기 (초)
|
||||
|
||||
|
||||
# ── JTL 파일 분석 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_jtl(content: str) -> dict[str, Any]:
|
||||
"""JMeter JTL (CSV) 파일 파싱 및 통계 계산."""
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
rows = list(reader)
|
||||
|
||||
if not rows:
|
||||
return {"error": "JTL 파일에 데이터가 없습니다."}
|
||||
|
||||
# 필드 이름 정규화
|
||||
def get_field(row, *names):
|
||||
for n in names:
|
||||
if n in row:
|
||||
return row[n]
|
||||
return ""
|
||||
|
||||
samples = []
|
||||
for row in rows:
|
||||
try:
|
||||
elapsed = int(get_field(row, "elapsed", "Elapsed"))
|
||||
success = get_field(row, "success", "Success").lower() == "true"
|
||||
label = get_field(row, "label", "Label", "sampler_label")
|
||||
rc = get_field(row, "responseCode", "ResponseCode")
|
||||
ts = int(get_field(row, "timeStamp", "Timestamp") or 0)
|
||||
samples.append({
|
||||
"elapsed": elapsed,
|
||||
"success": success,
|
||||
"label": label,
|
||||
"rc": rc,
|
||||
"ts": ts,
|
||||
})
|
||||
except (ValueError, KeyError):
|
||||
continue
|
||||
|
||||
if not samples:
|
||||
return {"error": "유효한 샘플 데이터 없음"}
|
||||
|
||||
elapsed_list = [s["elapsed"] for s in samples]
|
||||
success_count = sum(1 for s in samples if s["success"])
|
||||
error_count = len(samples) - success_count
|
||||
|
||||
elapsed_sorted = sorted(elapsed_list)
|
||||
p_idx = lambda p: max(0, int(len(elapsed_sorted) * p / 100) - 1)
|
||||
|
||||
# 초당 처리량
|
||||
ts_list = [s["ts"] for s in samples if s["ts"] > 0]
|
||||
if ts_list:
|
||||
duration_ms = max(ts_list) - min(ts_list)
|
||||
tps = len(samples) / (duration_ms / 1000) if duration_ms > 0 else 0
|
||||
else:
|
||||
tps = 0
|
||||
|
||||
# 레이블별 통계
|
||||
by_label: dict = {}
|
||||
for s in samples:
|
||||
lbl = s["label"]
|
||||
if lbl not in by_label:
|
||||
by_label[lbl] = {"count": 0, "success": 0, "elapsed": []}
|
||||
by_label[lbl]["count"] += 1
|
||||
by_label[lbl]["success"] += int(s["success"])
|
||||
by_label[lbl]["elapsed"].append(s["elapsed"])
|
||||
|
||||
label_stats = {}
|
||||
for lbl, d in by_label.items():
|
||||
es = sorted(d["elapsed"])
|
||||
label_stats[lbl] = {
|
||||
"count": d["count"],
|
||||
"error_rate": round((d["count"] - d["success"]) / d["count"] * 100, 1),
|
||||
"avg_ms": round(sum(es) / len(es), 1) if es else 0,
|
||||
"p90_ms": es[max(0, int(len(es) * 0.9) - 1)] if es else 0,
|
||||
"p95_ms": es[max(0, int(len(es) * 0.95) - 1)] if es else 0,
|
||||
"max_ms": max(es) if es else 0,
|
||||
}
|
||||
|
||||
return {
|
||||
"total_samples": len(samples),
|
||||
"success_count": success_count,
|
||||
"error_count": error_count,
|
||||
"error_rate_pct": round(error_count / len(samples) * 100, 2),
|
||||
"tps": round(tps, 2),
|
||||
"avg_response_ms": round(sum(elapsed_list) / len(elapsed_list), 1),
|
||||
"min_response_ms": min(elapsed_list),
|
||||
"max_response_ms": max(elapsed_list),
|
||||
"p50_ms": elapsed_sorted[p_idx(50)],
|
||||
"p90_ms": elapsed_sorted[p_idx(90)],
|
||||
"p95_ms": elapsed_sorted[p_idx(95)],
|
||||
"p99_ms": elapsed_sorted[p_idx(99)],
|
||||
"by_label": label_stats,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload/jtl")
|
||||
async def upload_jtl(
|
||||
file: UploadFile = File(...),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""JMeter JTL 파일 업로드 및 통계 분석."""
|
||||
if not file.filename.endswith(".jtl") and not file.filename.endswith(".csv"):
|
||||
raise HTTPException(400, "JTL 또는 CSV 파일만 지원합니다.")
|
||||
|
||||
content = (await file.read()).decode("utf-8", errors="ignore")
|
||||
stats = _parse_jtl(content)
|
||||
|
||||
if "error" in stats:
|
||||
raise HTTPException(400, stats["error"])
|
||||
|
||||
result_id = str(uuid.uuid4())[:8]
|
||||
_results[result_id] = {
|
||||
"id": result_id,
|
||||
"type": "jtl_upload",
|
||||
"filename": file.filename,
|
||||
"created_at":datetime.utcnow().isoformat(),
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
return {
|
||||
"result_id": result_id,
|
||||
"message": f"JTL 분석 완료 — 총 {stats['total_samples']}개 샘플",
|
||||
"summary": {
|
||||
"tps": stats["tps"],
|
||||
"avg_ms": stats["avg_response_ms"],
|
||||
"p95_ms": stats["p95_ms"],
|
||||
"error_rate_pct": stats["error_rate_pct"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 내장 성능 테스트 ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/run")
|
||||
async def run_performance_test(
|
||||
body: PerfTestRequest,
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""GUARDiA API 자동 성능 테스트 (httpx 기반, JMeter 불필요)."""
|
||||
if cu.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
||||
raise HTTPException(403, "ADMIN/PM/ENGINEER만 성능 테스트를 실행할 수 있습니다.")
|
||||
|
||||
import httpx
|
||||
|
||||
result_id = str(uuid.uuid4())[:8]
|
||||
samples: list[dict] = []
|
||||
|
||||
async def hit_endpoint(client: httpx.AsyncClient, url: str) -> dict:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
r = await client.get(url, timeout=10.0)
|
||||
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||
return {"url": url, "status": r.status_code, "elapsed_ms": elapsed_ms, "success": r.status_code < 400}
|
||||
except Exception as e:
|
||||
elapsed_ms = round((time.monotonic() - t0) * 1000, 1)
|
||||
return {"url": url, "status": 0, "elapsed_ms": elapsed_ms, "success": False, "error": str(e)[:50]}
|
||||
|
||||
# 점진적 부하 증가 (ramp-up)
|
||||
start_time = time.monotonic()
|
||||
users_active = 0
|
||||
ramp_interval = body.ramp_up / max(body.users, 1)
|
||||
|
||||
async with httpx.AsyncClient(base_url=body.target_url) as client:
|
||||
while time.monotonic() - start_time < body.duration:
|
||||
# 현재 활성 사용자 수 계산
|
||||
elapsed_total = time.monotonic() - start_time
|
||||
target_users = min(body.users, int(elapsed_total / ramp_interval) + 1)
|
||||
|
||||
tasks = []
|
||||
for _ in range(target_users):
|
||||
for ep in body.endpoints:
|
||||
tasks.append(hit_endpoint(client, ep))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
samples.extend(results)
|
||||
await asyncio.sleep(body.think_time)
|
||||
|
||||
if time.monotonic() - start_time >= body.duration:
|
||||
break
|
||||
|
||||
# 통계 계산
|
||||
elapsed_list = [s["elapsed_ms"] for s in samples]
|
||||
success_count = sum(1 for s in samples if s["success"])
|
||||
el_sorted = sorted(elapsed_list)
|
||||
p_idx = lambda p: max(0, int(len(el_sorted) * p / 100) - 1)
|
||||
duration_sec = time.monotonic() - start_time
|
||||
|
||||
stats = {
|
||||
"total_samples": len(samples),
|
||||
"success_count": success_count,
|
||||
"error_count": len(samples) - success_count,
|
||||
"error_rate_pct": round((len(samples) - success_count) / max(len(samples), 1) * 100, 2),
|
||||
"tps": round(len(samples) / duration_sec, 2),
|
||||
"avg_response_ms": round(sum(elapsed_list) / max(len(elapsed_list), 1), 1),
|
||||
"min_response_ms": min(elapsed_list) if elapsed_list else 0,
|
||||
"max_response_ms": max(elapsed_list) if elapsed_list else 0,
|
||||
"p50_ms": el_sorted[p_idx(50)] if el_sorted else 0,
|
||||
"p90_ms": el_sorted[p_idx(90)] if el_sorted else 0,
|
||||
"p95_ms": el_sorted[p_idx(95)] if el_sorted else 0,
|
||||
"p99_ms": el_sorted[p_idx(99)] if el_sorted else 0,
|
||||
"by_endpoint": {
|
||||
ep: {
|
||||
"count": sum(1 for s in samples if s["url"] == ep),
|
||||
"avg_ms": round(
|
||||
sum(s["elapsed_ms"] for s in samples if s["url"] == ep) /
|
||||
max(sum(1 for s in samples if s["url"] == ep), 1), 1
|
||||
),
|
||||
"errors": sum(1 for s in samples if s["url"] == ep and not s["success"]),
|
||||
}
|
||||
for ep in body.endpoints
|
||||
},
|
||||
}
|
||||
|
||||
_results[result_id] = {
|
||||
"id": result_id,
|
||||
"type": "auto_test",
|
||||
"target_url": body.target_url,
|
||||
"users": body.users,
|
||||
"duration": body.duration,
|
||||
"created_at": datetime.utcnow().isoformat(),
|
||||
"stats": stats,
|
||||
"config": body.model_dump(),
|
||||
}
|
||||
|
||||
return {
|
||||
"result_id": result_id,
|
||||
"message": f"성능 테스트 완료 ({body.duration}초, {body.users}명)",
|
||||
"summary": {
|
||||
"tps": stats["tps"],
|
||||
"avg_ms": stats["avg_response_ms"],
|
||||
"p95_ms": stats["p95_ms"],
|
||||
"error_rate_pct": stats["error_rate_pct"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 결과 목록/상세 ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/results")
|
||||
async def list_results(_u: User = Depends(get_current_user)):
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"type": r["type"],
|
||||
"created_at": r["created_at"],
|
||||
"tps": r["stats"].get("tps", 0),
|
||||
"avg_ms": r["stats"].get("avg_response_ms", 0),
|
||||
"error_rate": r["stats"].get("error_rate_pct", 0),
|
||||
}
|
||||
for r in sorted(_results.values(), key=lambda x: x["created_at"], reverse=True)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/results/{rid}")
|
||||
async def get_result(rid: str, _u: User = Depends(get_current_user)):
|
||||
r = _results.get(rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "결과를 찾을 수 없습니다.")
|
||||
return r
|
||||
|
||||
|
||||
# ── HTML 보고서 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/results/{rid}/html", response_class=HTMLResponse)
|
||||
async def perf_html_report(rid: str, _u: User = Depends(get_current_user)):
|
||||
r = _results.get(rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "결과를 찾을 수 없습니다.")
|
||||
|
||||
s = r["stats"]
|
||||
err_color = "green" if s["error_rate_pct"] < 1 else ("orange" if s["error_rate_pct"] < 5 else "red")
|
||||
|
||||
by_ep = s.get("by_endpoint") or s.get("by_label", {})
|
||||
ep_rows = "".join(
|
||||
f"<tr><td>{k}</td><td>{v.get('count','-')}</td><td>{v.get('avg_ms','-')} ms</td>"
|
||||
f"<td>{v.get('errors',v.get('error_rate','-'))}</td></tr>"
|
||||
for k, v in by_ep.items()
|
||||
)
|
||||
|
||||
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
|
||||
<title>GUARDiA 성능 테스트 보고서</title>
|
||||
<style>
|
||||
body{{font-family:Arial,sans-serif;margin:30px;color:#333;font-size:13px}}
|
||||
h1{{color:#1a365d}} .kpi{{display:flex;gap:16px;flex-wrap:wrap;margin:16px 0}}
|
||||
.kcard{{background:#ebf8ff;border-radius:8px;padding:12px 18px;text-align:center;min-width:120px}}
|
||||
.kcard .v{{font-size:22px;font-weight:bold;color:#2b6cb0}}
|
||||
.kcard .l{{font-size:11px;color:#718096}}
|
||||
table{{border-collapse:collapse;width:100%;margin:12px 0}}
|
||||
th{{background:#2d3748;color:#fff;padding:8px}} td{{padding:7px 8px;border-bottom:1px solid #e2e8f0}}
|
||||
</style></head><body>
|
||||
<h1>⚡ 성능 테스트 보고서</h1>
|
||||
<p>생성일시: {r['created_at']} | 유형: {r['type']}</p>
|
||||
<div class="kpi">
|
||||
<div class="kcard"><div class="v">{s['tps']}</div><div class="l">TPS</div></div>
|
||||
<div class="kcard"><div class="v">{s['avg_response_ms']} ms</div><div class="l">평균 응답</div></div>
|
||||
<div class="kcard"><div class="v">{s['p95_ms']} ms</div><div class="l">P95</div></div>
|
||||
<div class="kcard"><div class="v">{s['p99_ms']} ms</div><div class="l">P99</div></div>
|
||||
<div class="kcard"><div class="v">{s['total_samples']}</div><div class="l">총 요청</div></div>
|
||||
<div class="kcard"><div class="v" style="color:{err_color}">{s['error_rate_pct']}%</div><div class="l">에러율</div></div>
|
||||
</div>
|
||||
<h2>응답시간 분포</h2>
|
||||
<table><tr><th>지표</th><th>값</th></tr>
|
||||
<tr><td>최소</td><td>{s['min_response_ms']} ms</td></tr>
|
||||
<tr><td>평균</td><td>{s['avg_response_ms']} ms</td></tr>
|
||||
<tr><td>P50</td><td>{s['p50_ms']} ms</td></tr>
|
||||
<tr><td>P90</td><td>{s['p90_ms']} ms</td></tr>
|
||||
<tr><td>P95</td><td>{s['p95_ms']} ms</td></tr>
|
||||
<tr><td>P99</td><td>{s['p99_ms']} ms</td></tr>
|
||||
<tr><td>최대</td><td>{s['max_response_ms']} ms</td></tr>
|
||||
</table>
|
||||
<h2>엔드포인트별 결과</h2>
|
||||
<table><tr><th>엔드포인트</th><th>요청 수</th><th>평균 응답</th><th>오류</th></tr>
|
||||
{ep_rows}
|
||||
</table>
|
||||
<p style="margin-top:24px;color:#718096;font-size:11px">
|
||||
Copyright © 2026 GUARDiA All Rights Reserved.
|
||||
</p></body></html>"""
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
# ── Excel 보고서 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/results/{rid}/excel")
|
||||
async def perf_excel_report(rid: str, _u: User = Depends(get_current_user)):
|
||||
r = _results.get(rid)
|
||||
if not r:
|
||||
raise HTTPException(404, "결과를 찾을 수 없습니다.")
|
||||
|
||||
try:
|
||||
import io, openpyxl
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
except ImportError:
|
||||
raise HTTPException(500, "openpyxl 미설치")
|
||||
|
||||
s = r["stats"]
|
||||
wb = openpyxl.Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "성능 테스트 결과"
|
||||
|
||||
header_fill = PatternFill("solid", fgColor="1a365d")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
# 요약 시트
|
||||
summary_rows = [
|
||||
("테스트 유형", r["type"]),
|
||||
("생성일시", r["created_at"]),
|
||||
("총 요청 수", s["total_samples"]),
|
||||
("TPS", s["tps"]),
|
||||
("평균 응답 (ms)", s["avg_response_ms"]),
|
||||
("P50 (ms)", s["p50_ms"]),
|
||||
("P90 (ms)", s["p90_ms"]),
|
||||
("P95 (ms)", s["p95_ms"]),
|
||||
("P99 (ms)", s["p99_ms"]),
|
||||
("최대 응답 (ms)", s["max_response_ms"]),
|
||||
("에러율 (%)", s["error_rate_pct"]),
|
||||
("에러 수", s["error_count"]),
|
||||
]
|
||||
for row, (k, v) in enumerate(summary_rows, 1):
|
||||
ws.cell(row=row, column=1, value=k).font = Font(bold=True)
|
||||
ws.cell(row=row, column=2, value=v)
|
||||
|
||||
# 엔드포인트 시트
|
||||
ws2 = wb.create_sheet("엔드포인트별")
|
||||
by_ep = s.get("by_endpoint") or s.get("by_label", {})
|
||||
headers = ["엔드포인트/라벨", "요청 수", "평균 응답 (ms)", "오류"]
|
||||
for col, h in enumerate(headers, 1):
|
||||
c = ws2.cell(row=1, column=col, value=h)
|
||||
c.font = header_font; c.fill = header_fill
|
||||
for row, (k, v) in enumerate(by_ep.items(), 2):
|
||||
ws2.cell(row=row, column=1, value=k)
|
||||
ws2.cell(row=row, column=2, value=v.get("count", "-"))
|
||||
ws2.cell(row=row, column=3, value=v.get("avg_ms", "-"))
|
||||
ws2.cell(row=row, column=4, value=v.get("errors", v.get("error_rate", "-")))
|
||||
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
today = datetime.utcnow().strftime("%Y%m%d")
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="GUARDiA_perf_{today}.xlsx"'},
|
||||
)
|
||||
354
routers/public_checklist.py
Normal file
354
routers/public_checklist.py
Normal file
@ -0,0 +1,354 @@
|
||||
"""
|
||||
공공기관 정보화사업 필수 기능 체크리스트 API
|
||||
|
||||
근거 법령/기준:
|
||||
- 행안부 「정보시스템 구축·운영 지침」
|
||||
- 행안부 「소프트웨어 개발보안 가이드」
|
||||
- 「개인정보보호법」 및 시행령
|
||||
- 「정보보안 관리체계(ISMS-P) 인증기준」
|
||||
- 「장애인차별금지법」 (웹 접근성 의무)
|
||||
- 「전자서명법」
|
||||
|
||||
엔드포인트:
|
||||
GET /api/public/checklist — 공공기관 필수 기능 체크리스트
|
||||
POST /api/public/checklist/{id}/check — 항목 완료 처리
|
||||
GET /api/public/status — GUARDiA 공공기관 준비 현황
|
||||
GET /api/public/report/html — 준비 현황 HTML 보고서
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/public", tags=["public_checklist"])
|
||||
|
||||
# 공공기관 필수 기능 체크리스트
|
||||
PUBLIC_CHECKLIST = [
|
||||
# ── 보안 ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "SEC-001",
|
||||
"category": "보안",
|
||||
"law": "행안부 SW 개발보안 가이드",
|
||||
"title": "시큐어코딩 적용",
|
||||
"desc": "OWASP Top 10 및 행안부 47개 보안약점 제거",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "POST /api/compliance/scan",
|
||||
},
|
||||
{
|
||||
"id": "SEC-002",
|
||||
"category": "보안",
|
||||
"law": "ISMS-P 인증기준",
|
||||
"title": "취약점 자동 스캔",
|
||||
"desc": "서버 포트/CVE 취약점 정기 스캔 및 패치 추적",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "POST /api/vuln/scan",
|
||||
},
|
||||
{
|
||||
"id": "SEC-003",
|
||||
"category": "보안",
|
||||
"law": "개인정보보호법 제29조",
|
||||
"title": "접근권한 관리 (RBAC)",
|
||||
"desc": "역할 기반 최소권한 원칙, 퇴직자 계정 즉시 비활성화",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/auth/users",
|
||||
},
|
||||
{
|
||||
"id": "SEC-004",
|
||||
"category": "보안",
|
||||
"law": "개인정보보호법 제25조",
|
||||
"title": "감사 로그 (불변 로그)",
|
||||
"desc": "모든 접근 및 변경 이력 해시체인 기록 (최소 1년 보관)",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/audit",
|
||||
},
|
||||
{
|
||||
"id": "SEC-005",
|
||||
"category": "보안",
|
||||
"law": "전자서명법",
|
||||
"title": "전자서명 / MFA",
|
||||
"desc": "관리자 계정 다단계 인증 필수",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "POST /api/auth/mfa/setup",
|
||||
},
|
||||
{
|
||||
"id": "SEC-006",
|
||||
"category": "보안",
|
||||
"law": "ISMS-P",
|
||||
"title": "특권 접근 관리 (PAM)",
|
||||
"desc": "root/admin 계정 사용 제어, 세션 녹화, 명령 로깅",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/pam/sessions",
|
||||
},
|
||||
# ── 개인정보보호 ─────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "PI-001",
|
||||
"category": "개인정보보호",
|
||||
"law": "개인정보보호법 제30조",
|
||||
"title": "개인정보처리방침 게시",
|
||||
"desc": "처리 목적·보유기간·제3자 제공 현황 공개",
|
||||
"guardia_status": "미구현",
|
||||
"action": "/static/privacy-policy.html 페이지 생성 필요",
|
||||
},
|
||||
{
|
||||
"id": "PI-002",
|
||||
"category": "개인정보보호",
|
||||
"law": "개인정보보호법 제28조",
|
||||
"title": "개인정보 암호화",
|
||||
"desc": "비밀번호, 주민번호, 바이오정보 AES-256 이상 암호화",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "모델: os_pw_enc 컬럼 AES-256-GCM",
|
||||
},
|
||||
{
|
||||
"id": "PI-003",
|
||||
"category": "개인정보보호",
|
||||
"law": "개인정보보호법 제31조",
|
||||
"title": "개인정보 영향평가 (PIA)",
|
||||
"desc": "10만명 이상 처리 시스템 PIA 의무 수행",
|
||||
"guardia_status": "해당없음",
|
||||
"action": "사용 규모에 따라 PIA 수행 여부 판단 필요",
|
||||
},
|
||||
# ── 접근성 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "ACC-001",
|
||||
"category": "웹 접근성",
|
||||
"law": "장애인차별금지법 제21조",
|
||||
"title": "KWCAG 2.1 준수 (수준 AA)",
|
||||
"desc": "대체 텍스트, 자막, 색상 대비, 키보드 접근성 등 4개 원칙",
|
||||
"guardia_status": "부분구현",
|
||||
"api": "POST /api/compliance/scan/file → WA- 규칙",
|
||||
},
|
||||
{
|
||||
"id": "ACC-002",
|
||||
"category": "웹 접근성",
|
||||
"law": "장애인차별금지법",
|
||||
"title": "접근성 인증 마크",
|
||||
"desc": "한국웹접근성인증평가원(WA) 인증 취득",
|
||||
"guardia_status": "미구현",
|
||||
"action": "접근성 점검 완료 후 WA 인증 신청 필요",
|
||||
},
|
||||
# ── 성능/가용성 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "PERF-001",
|
||||
"category": "성능",
|
||||
"law": "행안부 정보시스템 구축·운영 지침",
|
||||
"title": "성능 테스트 수행",
|
||||
"desc": "오픈 전 목표 부하의 120% 이상 성능 검증",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "POST /api/perf/run 또는 POST /api/perf/upload/jtl",
|
||||
},
|
||||
{
|
||||
"id": "PERF-002",
|
||||
"category": "가용성",
|
||||
"law": "행안부 운영 지침",
|
||||
"title": "99.9% 가용성 (연간 8.7시간 이하 다운)",
|
||||
"desc": "이중화 구성, 자동 장애조치, 헬스체크",
|
||||
"guardia_status": "부분구현",
|
||||
"action": "Nginx HA, DB 이중화 설정 필요",
|
||||
},
|
||||
# ── 형상관리/배포 ────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "CM-001",
|
||||
"category": "형상관리",
|
||||
"law": "행안부 정보화사업 관리지침",
|
||||
"title": "소스코드 형상관리 (Git)",
|
||||
"desc": "브랜치 전략, 코드 리뷰, 변경 이력 관리",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "Gitea: http://localhost:3000",
|
||||
},
|
||||
{
|
||||
"id": "CM-002",
|
||||
"category": "CI/CD",
|
||||
"law": "행안부 SW 품질관리 지침",
|
||||
"title": "자동 빌드·테스트·배포 파이프라인",
|
||||
"desc": "Jenkins CI/CD, SonarQube 품질 게이트",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "POST /api/vibe/{id}/build",
|
||||
},
|
||||
# ── 문서화 ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "DOC-001",
|
||||
"category": "문서화",
|
||||
"law": "행안부 정보화사업 관리지침",
|
||||
"title": "산출물 목록 관리",
|
||||
"desc": "분석서, 설계서, 시험계획서, 시험결과서, 운영매뉴얼 필수",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/si/projects/{id}/deliverables",
|
||||
},
|
||||
{
|
||||
"id": "DOC-002",
|
||||
"category": "문서화",
|
||||
"law": "행안부 정보화사업 관리지침",
|
||||
"title": "정기 보고서 제출 (주간/월간)",
|
||||
"desc": "주간 업무 보고, 월간 실적 보고 PM에게 제출",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/si/projects/{id}/report/weekly",
|
||||
},
|
||||
# ── 운영이관 ────────────────────────────────────────────────────────────
|
||||
{
|
||||
"id": "OP-001",
|
||||
"category": "운영이관",
|
||||
"law": "행안부 운영 지침",
|
||||
"title": "CMDB 등록 (자산 관리)",
|
||||
"desc": "서버·SW·네트워크 자산 CMDB 등록 및 라이프사이클 관리",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/cmdb/cis",
|
||||
},
|
||||
{
|
||||
"id": "OP-002",
|
||||
"category": "운영이관",
|
||||
"law": "행안부 운영 지침",
|
||||
"title": "On-Call 체계 구축",
|
||||
"desc": "24/7 장애 대응 담당자 지정, 에스컬레이션 체계",
|
||||
"guardia_status": "구현됨",
|
||||
"api": "GET /api/oncall/on-duty",
|
||||
},
|
||||
]
|
||||
|
||||
# 항목별 완료 상태 저장 (실제 운영 시 DB 테이블로 이전)
|
||||
_check_status: dict[str, dict] = {}
|
||||
|
||||
|
||||
class CheckUpdateRequest(BaseModel):
|
||||
completed: bool
|
||||
note: Optional[str] = None
|
||||
evidence_url: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/checklist")
|
||||
async def get_checklist(
|
||||
category: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""공공기관 정보화사업 필수 체크리스트."""
|
||||
items = []
|
||||
for item in PUBLIC_CHECKLIST:
|
||||
if category and item["category"] != category:
|
||||
continue
|
||||
check = _check_status.get(item["id"], {})
|
||||
merged = {**item, "completed": check.get("completed", False),
|
||||
"note": check.get("note"), "evidence_url": check.get("evidence_url"),
|
||||
"checked_by": check.get("checked_by"), "checked_at": check.get("checked_at")}
|
||||
if status == "done" and not merged["completed"]:
|
||||
continue
|
||||
if status == "todo" and merged["completed"]:
|
||||
continue
|
||||
items.append(merged)
|
||||
|
||||
completed = sum(1 for i in items if i["completed"])
|
||||
return {
|
||||
"total": len(items),
|
||||
"completed": completed,
|
||||
"completion_rate": round(completed / len(items) * 100, 1) if items else 0,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/checklist/{check_id}/check")
|
||||
async def update_check(
|
||||
check_id: str,
|
||||
body: CheckUpdateRequest,
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""체크리스트 항목 완료 처리."""
|
||||
item = next((i for i in PUBLIC_CHECKLIST if i["id"] == check_id), None)
|
||||
if not item:
|
||||
raise HTTPException(404, f"체크리스트 항목 {check_id}를 찾을 수 없습니다.")
|
||||
|
||||
_check_status[check_id] = {
|
||||
"completed": body.completed,
|
||||
"note": body.note,
|
||||
"evidence_url": body.evidence_url,
|
||||
"checked_by": _u.username,
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
return {"message": f"{item['title']} — {'완료' if body.completed else '미완료'} 처리"}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def public_status(_u: User = Depends(get_current_user)):
|
||||
"""GUARDiA 공공기관 준비 현황 요약."""
|
||||
items = PUBLIC_CHECKLIST
|
||||
total = len(items)
|
||||
impl_done = sum(1 for i in items if i.get("guardia_status") in ("구현됨", "해당없음"))
|
||||
check_done= sum(1 for i in items if _check_status.get(i["id"], {}).get("completed"))
|
||||
|
||||
by_cat: dict = {}
|
||||
for item in items:
|
||||
cat = item["category"]
|
||||
if cat not in by_cat:
|
||||
by_cat[cat] = {"total": 0, "impl": 0, "checked": 0}
|
||||
by_cat[cat]["total"] += 1
|
||||
if item.get("guardia_status") in ("구현됨", "해당없음"):
|
||||
by_cat[cat]["impl"] += 1
|
||||
if _check_status.get(item["id"], {}).get("completed"):
|
||||
by_cat[cat]["checked"] += 1
|
||||
|
||||
return {
|
||||
"total_items": total,
|
||||
"guardia_impl": impl_done,
|
||||
"verified": check_done,
|
||||
"impl_rate": round(impl_done / total * 100, 1),
|
||||
"verify_rate": round(check_done / total * 100, 1),
|
||||
"by_category": by_cat,
|
||||
"action_items": [i["id"] + ": " + i.get("action","") for i in items if i.get("action") and not _check_status.get(i["id"],{}).get("completed")],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/report/html", response_class=HTMLResponse)
|
||||
async def public_report_html(_u: User = Depends(get_current_user)):
|
||||
"""공공기관 준비 현황 HTML 보고서."""
|
||||
rows = ""
|
||||
for item in PUBLIC_CHECKLIST:
|
||||
chk = _check_status.get(item["id"], {})
|
||||
done = chk.get("completed", False)
|
||||
gstatus = item.get("guardia_status", "—")
|
||||
color = "#c6f6d5" if done else ("#fef9e7" if gstatus == "구현됨" else "#fed7d7")
|
||||
gstatus_color = "green" if gstatus == "구현됨" else "orange"
|
||||
check_mark = "✅ 검증완료" if done else "⬜ 미검증"
|
||||
note_text = item.get("action", "") or item.get("api", "")
|
||||
rows += (
|
||||
f"<tr style='background:{color}'>"
|
||||
f"<td>{item['id']}</td><td>{item['category']}</td>"
|
||||
f"<td><b>{item['title']}</b><br><small>{item['desc']}</small></td>"
|
||||
f"<td>{item['law']}</td>"
|
||||
f"<td style='color:{gstatus_color}'>{gstatus}</td>"
|
||||
f"<td>{check_mark}</td>"
|
||||
f"<td><small>{note_text}</small></td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
status = await public_status(_u)
|
||||
html = f"""<!DOCTYPE html><html lang="ko"><head><meta charset="UTF-8">
|
||||
<title>공공기관 필수 기능 체크리스트</title>
|
||||
<style>
|
||||
body{{font-family:Arial,sans-serif;margin:30px;font-size:12px}}
|
||||
h1{{color:#1a365d}} table{{border-collapse:collapse;width:100%}}
|
||||
th{{background:#2d3748;color:#fff;padding:8px;text-align:left}}
|
||||
td{{padding:6px 8px;border-bottom:1px solid #e2e8f0;vertical-align:top}}
|
||||
.bar{{background:#e2e8f0;border-radius:4px;height:12px;overflow:hidden}}
|
||||
.bar-fill{{background:#4299e1;height:100%;border-radius:4px}}
|
||||
</style></head><body>
|
||||
<h1>공공기관 정보화사업 필수 기능 체크리스트</h1>
|
||||
<p>총 {status['total_items']}개 항목 | GUARDiA 구현율: <b>{status['impl_rate']}%</b> | 검증 완료: <b>{status['verify_rate']}%</b></p>
|
||||
<div class="bar"><div class="bar-fill" style="width:{status['impl_rate']}%"></div></div>
|
||||
<br>
|
||||
<table>
|
||||
<tr><th>ID</th><th>분류</th><th>항목</th><th>법적 근거</th><th>GUARDiA</th><th>검증</th><th>비고</th></tr>
|
||||
{rows}
|
||||
</table>
|
||||
<p style="margin-top:20px;color:#718096;font-size:11px">
|
||||
Copyright © 2026 GUARDiA All Rights Reserved.
|
||||
</p></body></html>"""
|
||||
return HTMLResponse(html)
|
||||
@ -200,6 +200,65 @@ async def get_issue_stats(
|
||||
}
|
||||
|
||||
|
||||
# ── 이슈 → SR 자동 연결 ───────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{project_id}/issues/{issue_id}/create-sr", status_code=201)
|
||||
async def create_sr_from_issue(
|
||||
project_id: int,
|
||||
issue_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
프로젝트 이슈를 ITSM SR(서비스 요청)로 자동 변환.
|
||||
|
||||
- 이슈 내용 → SR 제목/설명 자동 매핑
|
||||
- 이슈 우선순위 → SR 우선순위 변환
|
||||
- 생성된 SR ID를 이슈의 note에 기록
|
||||
"""
|
||||
from models import SRRequest, SRStatus, SiProject
|
||||
from uuid import uuid4
|
||||
from datetime import datetime as dt
|
||||
|
||||
issue = await _get_issue_or_404(project_id, issue_id, db)
|
||||
proj = await db.get(SiProject, project_id)
|
||||
|
||||
# 우선순위 매핑
|
||||
prio_map = {"CRITICAL": "CRITICAL", "HIGH": "HIGH", "MEDIUM": "MEDIUM", "LOW": "LOW"}
|
||||
priority = prio_map.get(getattr(issue, "priority", "MEDIUM"), "MEDIUM")
|
||||
|
||||
sr_id = f"SR-{dt.now().strftime('%Y%m%d')}-{str(uuid4())[:6].upper()}"
|
||||
sr = SRRequest(
|
||||
sr_id = sr_id,
|
||||
title = f"[{proj.project_code if proj else ''}] {issue.title}",
|
||||
description = (
|
||||
f"프로젝트 이슈에서 자동 생성\n"
|
||||
f"이슈 ID: {issue.issue_id}\n"
|
||||
f"이슈 유형: {issue.issue_type}\n"
|
||||
f"담당자: {issue.assigned_to or '—'}\n\n"
|
||||
f"{issue.description or ''}"
|
||||
),
|
||||
sr_type = "OTHER",
|
||||
priority = priority,
|
||||
requested_by = current_user.username,
|
||||
assigned_to = issue.assigned_to,
|
||||
status = SRStatus.RECEIVED,
|
||||
)
|
||||
db.add(sr)
|
||||
|
||||
# 이슈에 SR 연결 기록
|
||||
old_note = issue.note or ""
|
||||
issue.note = f"{old_note}\n[SR 연결] {sr_id} (생성자: {current_user.username})".strip()
|
||||
issue.updated_at = dt.now()
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"message": f"이슈 '{issue.title}' → SR {sr_id} 자동 생성 완료",
|
||||
"sr_id": sr_id,
|
||||
"issue_id": issue.issue_id,
|
||||
}
|
||||
|
||||
|
||||
# ── 내부 헬퍼 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _assert_project(project_id: int, db: AsyncSession) -> None:
|
||||
|
||||
189
routers/si_report.py
Normal file
189
routers/si_report.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
SI 프로젝트 보고서 생성 API
|
||||
|
||||
엔드포인트:
|
||||
GET /api/si/projects/{pid}/report/daily — 일일 보고서
|
||||
GET /api/si/projects/{pid}/report/weekly — 주간 보고서
|
||||
GET /api/si/projects/{pid}/report/monthly — 월간 보고서
|
||||
GET /api/si/projects/{pid}/report/status — 현황 요약 (JSON)
|
||||
POST /api/si/projects/{pid}/report/send — 메신저 자동 발송
|
||||
|
||||
쿼리 파라미터:
|
||||
format: excel | html | pdf | docx | pptx (기본: html)
|
||||
send_messenger: true — 발송 후 파일도 반환
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.auth import get_current_user
|
||||
from database import get_db
|
||||
from models import SiProject, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/si/projects", tags=["si_report"])
|
||||
|
||||
_MIME = {
|
||||
"excel": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"html": "text/html; charset=utf-8",
|
||||
"pdf": "application/pdf",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
}
|
||||
|
||||
_EXT = {"excel": "xlsx", "html": "html", "pdf": "pdf", "docx": "docx", "pptx": "pptx"}
|
||||
|
||||
|
||||
class SendRequest(BaseModel):
|
||||
room: str = "ops"
|
||||
report_type: str = "weekly"
|
||||
fmt: str = "excel"
|
||||
|
||||
|
||||
async def _build_report(pid: int, report_type: str, fmt: str, db: AsyncSession):
|
||||
"""보고서 생성 공통 헬퍼."""
|
||||
from core.si_report import generate_report
|
||||
try:
|
||||
content, mime, filename = await generate_report(pid, report_type, fmt, db)
|
||||
return content, mime, filename
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
logger.error("보고서 생성 오류: %s", e, exc_info=True)
|
||||
raise HTTPException(500, f"보고서 생성 실패: {str(e)[:200]}")
|
||||
|
||||
|
||||
# ── 일일 보고서 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/report/daily")
|
||||
async def daily_report(
|
||||
pid: int,
|
||||
format: str = Query("html", description="출력 형식: excel|html|pdf|docx|pptx"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
content, mime, filename = await _build_report(pid, "daily", format, db)
|
||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
if format == "html":
|
||||
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
|
||||
return Response(content=content, media_type=mime, headers=headers)
|
||||
|
||||
|
||||
# ── 주간 보고서 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/report/weekly")
|
||||
async def weekly_report(
|
||||
pid: int,
|
||||
format: str = Query("excel"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
content, mime, filename = await _build_report(pid, "weekly", format, db)
|
||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
if format == "html":
|
||||
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
|
||||
return Response(content=content, media_type=mime, headers=headers)
|
||||
|
||||
|
||||
# ── 월간 보고서 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/report/monthly")
|
||||
async def monthly_report(
|
||||
pid: int,
|
||||
format: str = Query("pptx"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
content, mime, filename = await _build_report(pid, "monthly", format, db)
|
||||
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||
if format == "html":
|
||||
return HTMLResponse(content.decode("utf-8") if isinstance(content, bytes) else content)
|
||||
return Response(content=content, media_type=mime, headers=headers)
|
||||
|
||||
|
||||
# ── 현황 요약 (JSON) ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{pid}/report/status")
|
||||
async def project_status(
|
||||
pid: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_u: User = Depends(get_current_user),
|
||||
):
|
||||
"""프로젝트 현황 JSON 요약 (대시보드용)."""
|
||||
from core.si_report import collect_project_data
|
||||
data = await collect_project_data(pid, db)
|
||||
proj = data["project"]
|
||||
return {
|
||||
"project_code": proj.project_code,
|
||||
"project_name": proj.project_name,
|
||||
"phase": proj.phase,
|
||||
"health_status": proj.health_status,
|
||||
"overall_progress": data["overall_progress"],
|
||||
"budget_pct": data["budget_pct"],
|
||||
"wbs_total": data["wbs_total"],
|
||||
"wbs_done": data["wbs_done"],
|
||||
"wbs_delayed": data["wbs_delayed"],
|
||||
"issue_open": data["issue_open"],
|
||||
"issue_closed": data["issue_closed"],
|
||||
"high_risks": len(data["high_risks"]),
|
||||
"overdue_deliverables": len(data["overdue_deliverables"]),
|
||||
"upcoming_milestones": [
|
||||
{"name": m.name, "target_date": str(m.target_date)}
|
||||
for m in data["upcoming_milestones"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── 메신저 자동 발송 ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{pid}/report/send")
|
||||
async def send_report(
|
||||
pid: int,
|
||||
body: SendRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
cu: User = Depends(get_current_user),
|
||||
):
|
||||
"""보고서를 생성하여 메신저 채널로 자동 발송."""
|
||||
from core.si_report import collect_project_data, generate_excel, generate_html
|
||||
import os, httpx
|
||||
|
||||
data = await collect_project_data(pid, db)
|
||||
proj = data["project"]
|
||||
|
||||
rtype_name = {"daily": "일일", "weekly": "주간", "monthly": "월간"}.get(body.report_type, body.report_type)
|
||||
|
||||
# 텍스트 요약 생성
|
||||
summary = (
|
||||
f"[{rtype_name} 보고서] {proj.project_name}\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"단계: {proj.phase} | 건강상태: {proj.health_status}\n"
|
||||
f"진척률: {data['overall_progress']}% | 예산소진: {data['budget_pct']}%\n"
|
||||
f"WBS 완료: {data['wbs_done']}/{data['wbs_total']} | 지연: {data['wbs_delayed']}건\n"
|
||||
f"미결 이슈: {data['issue_open']}건 | 고위험: {len(data['high_risks'])}건\n"
|
||||
f"미제출 산출물: {len(data['overdue_deliverables'])}건\n"
|
||||
f"━━━━━━━━━━━━━━━━━━━━\n"
|
||||
f"상세: http://localhost:8001/si (SI 프로젝트 관리)"
|
||||
)
|
||||
|
||||
# 메신저 발송
|
||||
messenger_url = os.getenv("MESSENGER_BASE_URL", "http://localhost:8002")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
await client.post(
|
||||
f"{messenger_url}/api/webhook/itsm",
|
||||
json={"room": body.room, "text": summary, "event": "project_report"},
|
||||
)
|
||||
except Exception:
|
||||
pass # Fail-Safe
|
||||
|
||||
return {
|
||||
"message": f"{rtype_name} 보고서 발송 완료",
|
||||
"room": body.room,
|
||||
"summary": summary[:200],
|
||||
}
|
||||
@ -20,6 +20,32 @@ if ('serviceWorker' in navigator) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
Nifty 사이드바 계층 메뉴 토글
|
||||
══════════════════════════════════════════════════ */
|
||||
function toggleNavGroup(header) {
|
||||
const body = header.nextElementSibling;
|
||||
const isOpen = body.classList.contains('open');
|
||||
body.classList.toggle('open', !isOpen);
|
||||
header.setAttribute('aria-expanded', String(!isOpen));
|
||||
}
|
||||
|
||||
// 현재 URL에 해당하는 메뉴 자동 열기
|
||||
(function autoOpenNavGroup() {
|
||||
document.querySelectorAll('.nav-group-body .nav-sub-item').forEach(item => {
|
||||
const href = item.getAttribute('href') || '';
|
||||
if (href && location.pathname.startsWith(href.split('?')[0])) {
|
||||
const body = item.closest('.nav-group-body');
|
||||
const header = body?.previousElementSibling;
|
||||
if (body && header) {
|
||||
body.classList.add('open');
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
item.classList.add('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* ══════════════════════════════════════════════════
|
||||
테마 관리 (스크립트 최상단 — FOUC 방지)
|
||||
══════════════════════════════════════════════════ */
|
||||
|
||||
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
static/icons/logo.png
Normal file
BIN
static/icons/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GUARDiA ITSM</title>
|
||||
<title>GUARDiA ITSM — AI 기반 레거시 인프라 자율 운영 플랫폼</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- Chart.js — 대시보드 차트 (CDN 실패 시 로컬 폴백) -->
|
||||
<script>
|
||||
@ -38,7 +39,12 @@
|
||||
<!-- ── Sidebar ───────────────────────────────────── -->
|
||||
<aside id="sidebar">
|
||||
<div id="sidebar-logo">
|
||||
<img src="/static/icons/logo.png" alt="GUARDiA 로고"
|
||||
style="height:36px;width:auto;object-fit:contain;margin-right:10px"
|
||||
onerror="this.style.display='none';document.getElementById('logo-fallback').style.display='flex'">
|
||||
<div id="logo-fallback" style="display:none;align-items:center;gap:8px">
|
||||
<div class="logo-icon">G</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="logo-title">GUARDiA ITSM</div>
|
||||
<div class="logo-sub">인프라 자동화 플랫폼</div>
|
||||
@ -46,24 +52,47 @@
|
||||
</div>
|
||||
|
||||
<nav id="sidebar-nav">
|
||||
<!-- 대시보드 -->
|
||||
<div class="nav-item active" data-view="dashboard">
|
||||
<span class="nav-icon">📊</span> 대시보드
|
||||
</div>
|
||||
<div class="nav-item" data-view="board">
|
||||
<span class="nav-icon">🗂️</span> 칸반 보드
|
||||
|
||||
<!-- SR 관리 -->
|
||||
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||
<span class="nav-icon">🗂️</span><span>SR 관리</span>
|
||||
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div class="nav-item" data-view="list">
|
||||
<span class="nav-icon">📋</span> SR 목록
|
||||
<div class="nav-group-body" role="group">
|
||||
<div class="nav-sub-item" data-view="board">칸반 보드</div>
|
||||
<div class="nav-sub-item" data-view="list">SR 목록</div>
|
||||
<div class="nav-sub-item" data-view="audit">감사 로그</div>
|
||||
</div>
|
||||
<div class="nav-item" data-view="audit">
|
||||
<span class="nav-icon">🔐</span> 감사 로그
|
||||
|
||||
<!-- PMS 프로젝트 관리 -->
|
||||
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||
<span class="nav-icon">🏗️</span><span>PMS 프로젝트</span>
|
||||
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div class="nav-item" data-view="cmdb">
|
||||
<span class="nav-icon">🖥️</span> CMDB
|
||||
<div class="nav-group-body" role="group">
|
||||
<a class="nav-sub-item" href="/si">프로젝트 목록</a>
|
||||
<a class="nav-sub-item" href="/si?tab=wbs">WBS 관리</a>
|
||||
<a class="nav-sub-item" href="/si?tab=deliverables">산출물 관리</a>
|
||||
<a class="nav-sub-item" href="/si?tab=issues">이슈 관리</a>
|
||||
<a class="nav-sub-item" href="/si?tab=risks">위험 관리</a>
|
||||
<a class="nav-sub-item" href="/si?tab=report">보고서 (일/주/월)</a>
|
||||
</div>
|
||||
<div class="nav-item" data-view="kb">
|
||||
<span class="nav-icon">📚</span> 기술 문서 KB
|
||||
|
||||
<!-- 인프라 -->
|
||||
<div class="nav-group-header" onclick="toggleNavGroup(this)" aria-expanded="false">
|
||||
<span class="nav-icon">🖥️</span><span>인프라</span>
|
||||
<span class="nav-arrow" aria-hidden="true">▾</span>
|
||||
</div>
|
||||
<div class="nav-group-body" role="group">
|
||||
<div class="nav-sub-item" data-view="cmdb">CMDB</div>
|
||||
<div class="nav-sub-item" data-view="kb">기술 문서 KB</div>
|
||||
<div class="nav-sub-item" data-view="institutions">기관/사이트</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-separator"></div>
|
||||
<a class="nav-item nav-link-ext" href="/incidents">
|
||||
<span class="nav-icon">🚨</span> 장애 관리
|
||||
@ -109,6 +138,12 @@
|
||||
<div class="status-dot online"></div>
|
||||
<span>시스템 정상</span>
|
||||
</div>
|
||||
<div style="padding:8px 16px;font-size:10px;color:var(--text-muted);line-height:1.5;border-top:1px solid rgba(255,255,255,.06)">
|
||||
<img src="/static/icons/logo.png" alt="" style="height:18px;vertical-align:middle;margin-right:4px;opacity:.6"
|
||||
onerror="this.style.display='none'">
|
||||
Copyright © 2026 GUARDiA<br>
|
||||
All Rights Reserved.
|
||||
</div>
|
||||
|
||||
<!-- 사용자 정보 + 로그아웃 -->
|
||||
<div id="sidebar-user">
|
||||
|
||||
@ -5,33 +5,93 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>GUARDiA ITSM — 로그인</title>
|
||||
<link rel="stylesheet" href="/static/login.css">
|
||||
<style>
|
||||
/* ── 소셜 로그인 추가 스타일 ── */
|
||||
.social-divider {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
.social-divider hr { flex: 1; border: none; border-top: 1px solid rgba(255,255,255,.12); }
|
||||
.social-divider span { font-size: 11px; color: var(--text-muted, #94a3b8); white-space: nowrap; }
|
||||
|
||||
.social-btns { display: flex; flex-direction: column; gap: 9px; }
|
||||
.social-btns.hidden { display: none; }
|
||||
|
||||
.btn-social {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.15);
|
||||
background: rgba(255,255,255,.06);
|
||||
color: #e2e8f0; font-size: 13px; font-weight: 500;
|
||||
cursor: pointer; text-decoration: none;
|
||||
transition: background .18s, border-color .18s;
|
||||
}
|
||||
.btn-social:hover { background: rgba(255,255,255,.13); border-color: rgba(255,255,255,.28); }
|
||||
|
||||
.social-icon {
|
||||
width: 20px; height: 20px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 4px; font-size: 13px;
|
||||
}
|
||||
.icon-google { background: #fff; }
|
||||
.icon-github { background: #24292e; color:#fff; }
|
||||
.icon-kakao { background: #fee500; }
|
||||
.icon-naver { background: #03c75a; color:#fff; }
|
||||
.icon-sso { background: #4b6cf8; color:#fff; font-size:10px; }
|
||||
|
||||
/* ── 탭 (ID/PW ↔ 소셜) ── */
|
||||
.login-tabs { display: flex; gap: 0; margin-bottom: 18px; border-bottom: 1px solid rgba(255,255,255,.12); }
|
||||
.login-tab {
|
||||
flex: 1; padding: 9px 0; text-align: center;
|
||||
font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
color: var(--text-muted, #94a3b8);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .2s, border-color .2s;
|
||||
}
|
||||
.login-tab.active { color: #818cf8; border-bottom-color: #818cf8; }
|
||||
|
||||
.login-panel { display: none; }
|
||||
.login-panel.active { display: block; }
|
||||
|
||||
/* OAuth 오류 배너 */
|
||||
#oauth-error {
|
||||
background: rgba(239,68,68,.18); border: 1px solid rgba(239,68,68,.4);
|
||||
border-radius: 8px; padding: 10px 14px; font-size: 12px; color: #fca5a5;
|
||||
margin-bottom: 14px; display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="login-bg">
|
||||
<div id="login-wrap">
|
||||
|
||||
<!-- 카드 -->
|
||||
<div id="login-card">
|
||||
|
||||
<!-- 로고 -->
|
||||
<div id="login-logo">
|
||||
<div class="logo-shield">
|
||||
<span>G</span>
|
||||
</div>
|
||||
<div class="logo-shield"><span>G</span></div>
|
||||
<div class="logo-text">
|
||||
<div class="logo-title">GUARDiA ITSM</div>
|
||||
<div class="logo-sub">인프라 자동화 플랫폼</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 폼 -->
|
||||
<!-- OAuth 오류 배너 -->
|
||||
<div id="oauth-error"></div>
|
||||
|
||||
<!-- 탭 -->
|
||||
<div class="login-tabs" id="login-tabs">
|
||||
<div class="login-tab active" data-tab="password" onclick="switchTab('password')">계정 로그인</div>
|
||||
<div class="login-tab" data-tab="social" onclick="switchTab('social')" id="social-tab-btn">소셜 로그인</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 패널 1: 계정 ID/PW ── -->
|
||||
<div class="login-panel active" id="panel-password">
|
||||
<form id="login-form" autocomplete="off">
|
||||
<div class="field-group">
|
||||
<label for="username">아이디</label>
|
||||
<input type="text" id="username" name="username"
|
||||
placeholder="아이디를 입력하세요" autocomplete="username" required>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="password">비밀번호</label>
|
||||
<div class="pw-wrap">
|
||||
@ -49,12 +109,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 힌트 -->
|
||||
<div class="login-hint">
|
||||
초기 비밀번호 <code>1111</code> — 최초 로그인 시 변경 필요
|
||||
</div>
|
||||
|
||||
<!-- 테스트 계정 표 -->
|
||||
<!-- 테스트 계정 -->
|
||||
<div class="account-table">
|
||||
<div class="account-title">테스트 계정</div>
|
||||
<div class="account-row">
|
||||
@ -75,13 +130,127 @@
|
||||
</div>
|
||||
<div class="account-note">↑ 클릭하면 자동 입력됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 패널 2: 소셜 로그인 ── -->
|
||||
<div class="login-panel" id="panel-social">
|
||||
<div id="social-loading" style="text-align:center;padding:20px;color:#94a3b8;font-size:13px">
|
||||
로그인 방법을 불러오는 중...
|
||||
</div>
|
||||
<div class="social-btns hidden" id="social-btns"></div>
|
||||
<div id="social-none" style="display:none;text-align:center;padding:20px;font-size:12px;color:#94a3b8">
|
||||
소셜 로그인이 설정되지 않았습니다.<br>
|
||||
<small>환경변수 GOOGLE_CLIENT_ID 등을 설정하세요.</small>
|
||||
</div>
|
||||
|
||||
<div class="social-divider" style="margin-top:18px">
|
||||
<hr><span>또는</span><hr>
|
||||
</div>
|
||||
<button class="btn-social" onclick="switchTab('password')" style="justify-content:center">
|
||||
🔑 계정 ID/PW로 로그인
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 힌트 -->
|
||||
<div class="login-hint">
|
||||
초기 비밀번호 <code>1111</code> — 최초 로그인 시 변경 필요
|
||||
</div>
|
||||
|
||||
</div><!-- /login-card -->
|
||||
|
||||
<div id="login-footer">© 2026 GUARDiA — 폐쇄망 인프라 자동화 플랫폼 | on-premise sLLM</div>
|
||||
<div id="login-footer">
|
||||
<img src="/static/icons/logo.png" alt="" style="height:20px;vertical-align:middle;margin-right:6px;opacity:.7" onerror="this.style.display='none'">
|
||||
Copyright © 2026 GUARDiA All Rights Reserved. — 온프레미스 인프라 자동화 플랫폼
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/login.js"></script>
|
||||
<script>
|
||||
// ── 탭 전환 ──────────────────────────────────────────────
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.login-tab').forEach(t =>
|
||||
t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.querySelectorAll('.login-panel').forEach(p =>
|
||||
p.classList.toggle('active', p.id === `panel-${tab}`));
|
||||
}
|
||||
|
||||
// ── OAuth 오류 처리 ──────────────────────────────────────
|
||||
(function handleOAuthCallback() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const oauthToken = params.get('oauth_token');
|
||||
const oauthError = params.get('error');
|
||||
const reason = params.get('reason');
|
||||
|
||||
if (oauthToken) {
|
||||
// OAuth 성공 → 토큰 저장 후 메인으로
|
||||
const username = params.get('username') || '';
|
||||
const role = params.get('role') || 'CUSTOMER';
|
||||
localStorage.setItem('access_token', oauthToken);
|
||||
localStorage.setItem('username', username);
|
||||
localStorage.setItem('role', role);
|
||||
location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
if (oauthError || reason) {
|
||||
const msgs = {
|
||||
oauth_failed: 'OAuth 인증에 실패했습니다.',
|
||||
state_mismatch: 'CSRF 검증 실패 — 다시 시도하세요.',
|
||||
token_exchange: '토큰 교환 중 오류가 발생했습니다.',
|
||||
no_email: '이메일 정보를 가져올 수 없습니다.',
|
||||
access_denied: '로그인이 취소되었습니다.',
|
||||
unknown_provider: '지원하지 않는 로그인 방법입니다.',
|
||||
};
|
||||
const msg = msgs[reason] || msgs[oauthError] || `인증 오류: ${reason || oauthError}`;
|
||||
const el = document.getElementById('oauth-error');
|
||||
el.textContent = '⚠️ ' + msg;
|
||||
el.style.display = 'block';
|
||||
history.replaceState({}, '', '/login');
|
||||
}
|
||||
})();
|
||||
|
||||
// ── 소셜 제공자 버튼 동적 로드 ──────────────────────────
|
||||
const PROVIDER_META = {
|
||||
google: { label: 'Google', bg: '#fff', color: '#333', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>' },
|
||||
github: { label: 'GitHub', bg: '#24292e', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24" fill="white"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg>' },
|
||||
kakao: { label: '카카오', bg: '#fee500', color: '#3c1e1e', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="#3c1e1e" d="M12 3C6.48 3 2 6.48 2 10.8c0 2.75 1.64 5.17 4.12 6.59l-.84 3.07c-.07.26.22.46.44.31l3.71-2.46c.82.15 1.67.23 2.57.23 5.52 0 10-3.48 10-7.74S17.52 3 12 3z"/></svg>' },
|
||||
naver: { label: '네이버', bg: '#03c75a', color: '#fff', svg: '<svg width="18" height="18" viewBox="0 0 24 24"><path fill="white" d="M16.273 12.845L7.376 0H0v24h7.727V11.155L16.624 24H24V0h-7.727z"/></svg>' },
|
||||
keycloak: { label: 'SSO (Keycloak)', bg: '#4b6cf8', color: '#fff', svg: '🔐' },
|
||||
};
|
||||
|
||||
async function loadSocialProviders() {
|
||||
try {
|
||||
const res = await fetch('/api/auth/oauth/providers');
|
||||
const { providers } = await res.json();
|
||||
|
||||
document.getElementById('social-loading').style.display = 'none';
|
||||
|
||||
if (!providers || providers.length === 0) {
|
||||
document.getElementById('social-none').style.display = 'block';
|
||||
document.getElementById('social-tab-btn').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const btns = document.getElementById('social-btns');
|
||||
btns.classList.remove('hidden');
|
||||
|
||||
providers.forEach(p => {
|
||||
const meta = PROVIDER_META[p.icon] || { label: p.name, bg: '#4b6cf8', color: '#fff', svg: '🔗' };
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/auth/oauth/${p.id}/start`;
|
||||
a.className = 'btn-social';
|
||||
a.innerHTML = `
|
||||
<span class="social-icon" style="background:${meta.bg};color:${meta.color}">${meta.svg}</span>
|
||||
<span>${meta.label}로 로그인</span>`;
|
||||
btns.appendChild(a);
|
||||
});
|
||||
} catch {
|
||||
document.getElementById('social-loading').textContent = '소셜 로그인 정보를 불러올 수 없습니다.';
|
||||
}
|
||||
}
|
||||
|
||||
loadSocialProviders();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
120
static/style.css
120
static/style.css
@ -278,10 +278,128 @@ html, body {
|
||||
.stat-card.purple .stat-icon { background: rgba(167,139,250,.15); color: var(--purple); }
|
||||
.stat-card.orange .stat-icon { background: rgba(251,146,60,.15); color: var(--orange); }
|
||||
|
||||
/* ─── Dashboard grid ────────────────────────────── */
|
||||
/* ─── Dashboard grid (legacy) ───────────────────── */
|
||||
.dashboard-grid { display: grid; grid-template-columns: 1fr 340px; gap: 18px; }
|
||||
@media (max-width: 960px) { .dashboard-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ─── Nifty 사이드바 계층 메뉴 ──────────────────── */
|
||||
.nav-group-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 16px; cursor: pointer;
|
||||
color: var(--text-muted); font-size: 12px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .6px;
|
||||
border-radius: 6px; margin: 1px 6px;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.nav-group-header:hover { color: var(--text-bright); background: var(--surface-2); }
|
||||
.nav-group-header .nav-arrow {
|
||||
margin-left: auto; font-size: 10px; transition: transform .2s;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nav-group-header[aria-expanded="true"] .nav-arrow { transform: rotate(180deg); }
|
||||
|
||||
.nav-group-body {
|
||||
display: none; padding: 2px 6px 4px 30px;
|
||||
}
|
||||
.nav-group-body.open { display: block; }
|
||||
|
||||
.nav-sub-item {
|
||||
display: block; padding: 7px 12px; font-size: 13px;
|
||||
color: var(--text-muted); text-decoration: none;
|
||||
border-radius: 6px; cursor: pointer; transition: all .15s;
|
||||
}
|
||||
.nav-sub-item:hover { background: var(--surface-2); color: var(--text-bright); }
|
||||
.nav-sub-item.active { background: rgba(129,140,248,.15); color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Topbar */
|
||||
#topbar {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 20px; height: 54px;
|
||||
background: var(--topbar-bg, var(--surface));
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
#topbar-search input {
|
||||
background: var(--surface-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 6px 12px; font-size: 13px;
|
||||
color: var(--text-bright); width: 260px; outline: none;
|
||||
}
|
||||
#topbar-search input:focus { border-color: var(--accent); }
|
||||
#topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.topbar-icon {
|
||||
padding: 6px 10px; border-radius: 8px; cursor: pointer;
|
||||
color: var(--text-muted); font-size: 16px; position: relative;
|
||||
background: none; border: none;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.topbar-icon:hover { background: var(--surface-2); color: var(--text-bright); }
|
||||
.topbar-icon .badge {
|
||||
position: absolute; top: 2px; right: 2px;
|
||||
background: #f87171; color: #fff; font-size: 9px;
|
||||
border-radius: 50%; width: 14px; height: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Page tabs (서브페이지 탭) */
|
||||
.page-tabs {
|
||||
display: flex; gap: 0; border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 18px; overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.page-tabs::-webkit-scrollbar { display: none; }
|
||||
.page-tab {
|
||||
padding: 9px 18px; background: none; border: none;
|
||||
color: var(--text-muted); font-size: 13px; font-weight: 600;
|
||||
cursor: pointer; border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px; white-space: nowrap;
|
||||
transition: color .15s, border-color .15s;
|
||||
}
|
||||
.page-tab:hover { color: var(--accent); }
|
||||
.page-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ─── Dashboard Tabs ─────────────────────────────── */
|
||||
.dash-tab-nav {
|
||||
display: flex; gap: 2px; margin-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.dash-tab-nav::-webkit-scrollbar { display: none; }
|
||||
.dash-tab {
|
||||
padding: 9px 18px; font-size: 13px; font-weight: 600;
|
||||
color: var(--text-muted); background: none; border: none;
|
||||
border-bottom: 3px solid transparent; margin-bottom: -2px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
transition: color .18s, border-color .18s;
|
||||
}
|
||||
.dash-tab:hover { color: var(--accent); }
|
||||
.dash-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
.dash-tab-panel { display: none; }
|
||||
.dash-tab-panel.active { display: block; }
|
||||
|
||||
/* ─── Chart grid ─────────────────────────────────── */
|
||||
.chart-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
@media (max-width: 960px) { .chart-grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
.chart-card { min-height: 280px; }
|
||||
.chart-body { padding: 14px 16px; display: flex; align-items: center; justify-content: center; }
|
||||
.chart-body canvas { max-height: 240px; width: 100% !important; }
|
||||
|
||||
/* ─── Server health tile ─────────────────────────── */
|
||||
.server-tile {
|
||||
display: inline-flex; flex-direction: column; align-items: center;
|
||||
gap: 4px; padding: 10px 12px; border-radius: 8px;
|
||||
font-size: 11px; font-weight: 600; cursor: default;
|
||||
min-width: 80px; text-align: center;
|
||||
}
|
||||
.server-tile.ok { background: rgba(52,211,153,.18); color: #34d399; }
|
||||
.server-tile.warn { background: rgba(251,191,36,.18); color: #fbbf24; }
|
||||
.server-tile.critical{ background: rgba(239,68,68,.18); color: #f87171; }
|
||||
.server-tile.unknown { background: rgba(148,163,184,.12); color: #94a3b8; }
|
||||
.server-tile-name { font-size: 10px; color: var(--text-muted); max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Recent SR rows */
|
||||
.recent-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user