또는 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")),
+ }
+ }
diff --git a/core/oauth.py b/core/oauth.py
new file mode 100644
index 0000000..85d9e82
--- /dev/null
+++ b/core/oauth.py
@@ -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", "")
diff --git a/core/scheduler.py b/core/scheduler.py
index dccdeed..775e252 100644
--- a/core/scheduler.py
+++ b/core/scheduler.py
@@ -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():
diff --git a/core/si_report.py b/core/si_report.py
new file mode 100644
index 0000000..5d1ad3e
--- /dev/null
+++ b/core/si_report.py
@@ -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 = """
+
+
+
+
+
+
+
{{ report_title }}
+
프로젝트: {{ proj.project_name }} ({{ proj.project_code }}) | 보고일: {{ today }} | 단계: {{ proj.phase }}
+
+
📊 KPI 현황
+
+
{{ data.overall_progress }}%
전체 진척률
+
{{ data.wbs_done }}/{{ data.wbs_total }}
WBS 완료
+
{{ data.wbs_delayed }}
지연 항목
+
{{ data.issue_open }}
미결 이슈
+
{{ data.budget_pct }}%
예산 소진율
+
{{ proj.health_status }}
건강 상태
+
+
+
📋 WBS 지연 현황 ({{ data.wbs_delayed }}건)
+{% if data.delayed_wbs %}
+
+| WBS 코드 | 제목 | 예정 완료 | 진척률 |
+{% for item in data.delayed_wbs %}
+
+ | {{ item.wbs_code }} |
+ {{ item.title }} |
+ {{ item.planned_end }} |
+ {{ item.completion_pct }}% |
+
+{% endfor %}
+
+{% else %}
✅ 지연 WBS 없음
{% endif %}
+
+
🔴 미결 이슈 ({{ data.issue_open }}건)
+{% if data.open_issues %}
+
+| 이슈 ID | 제목 | 유형 | 담당자 | 발생일 |
+{% for iss in data.open_issues[:10] %}
+
+ | {{ iss.issue_id }} |
+ {{ iss.title }} |
+ {{ iss.issue_type }} |
+ {{ iss.assigned_to or '—' }} |
+ {{ iss.raised_date }} |
+
+{% endfor %}
+
+{% else %}
✅ 미결 이슈 없음
{% endif %}
+
+
📦 산출물 제출 현황
+
+| 산출물명 | 유형 | 버전 | 상태 | 제출기한 |
+{% for dlv in data.deliverables %}
+
+ | {{ dlv.name }} |
+ {{ dlv.deliverable_type }} |
+ {{ dlv.version }} |
+ {{ dlv.status }} |
+ {{ dlv.due_date or '—' }} |
+
+{% endfor %}
+
+
+{% if data.upcoming_milestones %}
+
🏁 다가오는 마일스톤
+
+| 마일스톤 | 목표일 | 상태 |
+{% for m in data.upcoming_milestones %}
+| {{ m.name }} | {{ m.target_date }} | {{ m.status }} |
+{% endfor %}
+
+{% endif %}
+
+{% if data.high_risks %}
+
⚠️ 고위험 요소 ({{ data.high_risks|length }}건)
+
+| 위험 ID | 제목 | 레벨 | 대응 계획 |
+{% for r in data.high_risks %}
+
+ | {{ r.risk_id }} |
+ {{ r.title }} |
+ {{ r.risk_level }} |
+ {{ r.mitigation or '—' }} |
+
+{% endfor %}
+
+{% endif %}
+
+
+ 생성: GUARDiA ITSM | {{ today }}
+
+
+"""
+
+ 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
diff --git a/guardia_itsm.db.bak b/guardia_itsm.db.bak
new file mode 100644
index 0000000..fc5b34e
Binary files /dev/null and b/guardia_itsm.db.bak differ
diff --git a/main.py b/main.py
index d195139..1fa4564 100644
--- a/main.py
+++ b/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")
diff --git a/models.py b/models.py
index 36ab11c..0aa07be 100644
--- a/models.py
+++ b/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):
diff --git a/requirements.txt b/requirements.txt
index 5e65ea9..70ee4b6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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
diff --git a/routers/auth.py b/routers/auth.py
index 6947185..5ee96c8 100644
--- a/routers/auth.py
+++ b/routers/auth.py
@@ -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}")
diff --git a/routers/compliance.py b/routers/compliance.py
new file mode 100644
index 0000000..33f983a
--- /dev/null
+++ b/routers/compliance.py
@@ -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("
스캔 결과 없음
POST /api/compliance/scan 을 먼저 실행하세요.
")
+
+ 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"
"
+ f"| {f['rule_id']} | {f['category']} | "
+ f"{f['severity']} | "
+ f"{f['file']}:{f['line']} | "
+ f"{f['message']} | "
+ f"{f.get('snippet','')[:60]} |
"
+ )
+ 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"""
+
GUARDiA 준수성 점검 보고서
+
+
GUARDiA 준수성 점검 보고서
+
점검일시: {_last_result.get('scan_time','')} | 스캔 파일: {_last_result.get('scanned_files',0)}개
+
종합 위험도: {risk}
+ | 총 발견: {_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)}건
+
+
점검 결과 (상위 100건)
+
+| 규칙ID | 분류 | 심각도 | 위치 | 내용 | 코드 |
+{rows}
+
+
+ Copyright © 2026 GUARDiA All Rights Reserved. | 이 보고서는 자동 생성되었습니다.
+
"""
+ 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"'},
+ )
diff --git a/routers/deliverables.py b/routers/deliverables.py
new file mode 100644
index 0000000..1a67899
--- /dev/null
+++ b/routers/deliverables.py
@@ -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,
+ }
diff --git a/routers/jmeter.py b/routers/jmeter.py
new file mode 100644
index 0000000..64d9803
--- /dev/null
+++ b/routers/jmeter.py
@@ -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"
| {k} | {v.get('count','-')} | {v.get('avg_ms','-')} ms | "
+ f"{v.get('errors',v.get('error_rate','-'))} |
"
+ for k, v in by_ep.items()
+ )
+
+ html = f"""
+
GUARDiA 성능 테스트 보고서
+
+
⚡ 성능 테스트 보고서
+
생성일시: {r['created_at']} | 유형: {r['type']}
+
+
+
{s['avg_response_ms']} ms
평균 응답
+
+
+
+
{s['error_rate_pct']}%
에러율
+
+
응답시간 분포
+
| 지표 | 값 |
+| 최소 | {s['min_response_ms']} ms |
+| 평균 | {s['avg_response_ms']} ms |
+| P50 | {s['p50_ms']} ms |
+| P90 | {s['p90_ms']} ms |
+| P95 | {s['p95_ms']} ms |
+| P99 | {s['p99_ms']} ms |
+| 최대 | {s['max_response_ms']} ms |
+
+
엔드포인트별 결과
+
| 엔드포인트 | 요청 수 | 평균 응답 | 오류 |
+{ep_rows}
+
+
+ Copyright © 2026 GUARDiA All Rights Reserved.
+
"""
+ 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"'},
+ )
diff --git a/routers/public_checklist.py b/routers/public_checklist.py
new file mode 100644
index 0000000..3b1cd6d
--- /dev/null
+++ b/routers/public_checklist.py
@@ -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"
"
+ f"| {item['id']} | {item['category']} | "
+ f"{item['title']} {item['desc']} | "
+ f"{item['law']} | "
+ f"{gstatus} | "
+ f"{check_mark} | "
+ f"{note_text} | "
+ "
"
+ )
+
+ status = await public_status(_u)
+ html = f"""
+
공공기관 필수 기능 체크리스트
+
+
공공기관 정보화사업 필수 기능 체크리스트
+
총 {status['total_items']}개 항목 | GUARDiA 구현율: {status['impl_rate']}% | 검증 완료: {status['verify_rate']}%
+
+
+
+| ID | 분류 | 항목 | 법적 근거 | GUARDiA | 검증 | 비고 |
+{rows}
+
+
+ Copyright © 2026 GUARDiA All Rights Reserved.
+
"""
+ return HTMLResponse(html)
diff --git a/routers/si_issues.py b/routers/si_issues.py
index 52b7a73..e523c5f 100644
--- a/routers/si_issues.py
+++ b/routers/si_issues.py
@@ -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:
diff --git a/routers/si_report.py b/routers/si_report.py
new file mode 100644
index 0000000..fecb7f7
--- /dev/null
+++ b/routers/si_report.py
@@ -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],
+ }
diff --git a/static/app.js b/static/app.js
index 0ccc569..d3e73e3 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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 방지)
══════════════════════════════════════════════════ */
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..3252b9f
Binary files /dev/null and b/static/favicon.ico differ
diff --git a/static/icons/logo.png b/static/icons/logo.png
new file mode 100644
index 0000000..0ae65e0
Binary files /dev/null and b/static/icons/logo.png differ
diff --git a/static/index.html b/static/index.html
index 28583df..5769b46 100644
--- a/static/index.html
+++ b/static/index.html
@@ -3,7 +3,8 @@
-
GUARDiA ITSM
+
GUARDiA ITSM — AI 기반 레거시 인프라 자율 운영 플랫폼
+
+
diff --git a/static/style.css b/static/style.css
index 6c8e8c7..558ec37 100644
--- a/static/style.css
+++ b/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;