zioinfo-mail/workspace/guardia-itsm/routers/white_label.py
DESKTOP-TKLFCPR\ython 09bab3c2ff feat(expansion): GUARDiA v3 P2 — 5 routers + 5 DB tables
라우터 (611개 엔드포인트, P1+P2 75개 신규):
- kubernetes.py: K8s 에이전트리스 관리 (SSH kubectl)
- sso_provider.py: SAML 2.0 / OIDC / OAuth2 통합 인증
- predictive_ops.py: SLA위반·SR급증·서버장애 예측 + Ollama 인사이트
- slack_connector.py: Slack Incoming Webhook + Slash Commands
- white_label.py: 기관별 브랜딩 + CSS 변수 동적 생성

DB 모델 (5개 신규):
tb_k8s_cluster, tb_sso_config, tb_sso_session,
tb_slack_config, tb_tenant_branding

수정: K8sCluster ForeignKey tb_server → tb_server_info

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 05:57:02 +09:00

260 lines
9.4 KiB
Python

"""
화이트라벨 브랜딩 — 기관별 로고·색상·도메인 커스터마이즈
기능:
- 기관별 로고 URL, 브랜드 색상, 회사명 설정
- 이메일 발신 템플릿 커스터마이즈
- 커스텀 도메인 설정 (nginx 설정 자동 반영)
- 프론트엔드에서 CSS 변수로 동적 적용
엔드포인트:
GET /api/brand/ — 현재 테넌트 브랜딩 설정 조회
PUT /api/brand/ — 브랜딩 설정 저장/수정
POST /api/brand/logo — 로고 이미지 URL 설정
GET /api/brand/css — CSS 변수 동적 생성 (프론트엔드용)
GET /api/brand/email-template — 이메일 템플릿 조회
PUT /api/brand/email-template — 이메일 템플릿 수정
POST /api/brand/preview-email — 이메일 미리보기
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel, Field
from sqlalchemy import select
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, TenantBranding # 신규 모델
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/brand", tags=["White Label Branding"])
# 기본 브랜딩 (GUARDiA 기본값)
DEFAULT_BRANDING = {
"company_name": "GUARDiA ITSM",
"logo_url": "/logo.png",
"logo_dark_url": "/logo-white.png",
"favicon_url": "/favicon.ico",
"primary_color": "#003366",
"secondary_color": "#00A0C8",
"accent_color": "#10B981",
"font_family": "Pretendard, -apple-system, sans-serif",
"login_bg_color": "#001f4d",
"header_bg_color": "#003366",
}
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class BrandingUpdate(BaseModel):
company_name: Optional[str] = Field(None, max_length=200)
logo_url: Optional[str] = Field(None, max_length=500)
logo_dark_url: Optional[str] = Field(None, max_length=500)
favicon_url: Optional[str] = Field(None, max_length=500)
primary_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
secondary_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
accent_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
font_family: Optional[str] = Field(None, max_length=200)
login_bg_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
header_bg_color: Optional[str] = Field(None, pattern=r'^#[0-9A-Fa-f]{6}$')
custom_domain: Optional[str] = Field(None, max_length=200)
footer_text: Optional[str] = Field(None, max_length=500)
class EmailTemplateUpdate(BaseModel):
subject_prefix: str = Field("[GUARDiA]", max_length=50)
header_html: Optional[str] = None
footer_html: Optional[str] = None
primary_color: Optional[str] = None
# ── 유틸 ────────────────────────────────────────────────────────────────────
def _merge_branding(db_branding: Optional[TenantBranding]) -> dict:
"""DB 설정과 기본값 병합."""
if not db_branding:
return DEFAULT_BRANDING.copy()
merged = DEFAULT_BRANDING.copy()
for key in DEFAULT_BRANDING:
val = getattr(db_branding, key, None)
if val:
merged[key] = val
merged["company_name"] = db_branding.company_name or merged["company_name"]
merged["custom_domain"] = db_branding.custom_domain
merged["footer_text"] = db_branding.footer_text
return merged
def _build_css_vars(branding: dict) -> str:
"""CSS 변수 문자열 생성 (프론트엔드 동적 적용용)."""
return f"""
:root {{
--brand-primary: {branding.get('primary_color', '#003366')};
--brand-secondary: {branding.get('secondary_color', '#00A0C8')};
--brand-accent: {branding.get('accent_color', '#10B981')};
--brand-font: {branding.get('font_family', 'Pretendard, -apple-system, sans-serif')};
--brand-login-bg: {branding.get('login_bg_color', '#001f4d')};
--brand-header-bg: {branding.get('header_bg_color', '#003366')};
--brand-company: "{branding.get('company_name', 'GUARDiA ITSM')}";
}}
""".strip()
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.get("/")
async def get_branding(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""현재 테넌트 브랜딩 설정 조회."""
row = await db.execute(
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
)
branding = row.scalar_one_or_none()
return _merge_branding(branding)
@router.put("/")
async def update_branding(
req: BrandingUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""브랜딩 설정 저장."""
row = await db.execute(
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
)
branding = row.scalar_one_or_none()
update_data = {k: v for k, v in req.model_dump().items() if v is not None}
if branding:
for k, v in update_data.items():
setattr(branding, k, v)
branding.updated_at = datetime.utcnow()
else:
branding = TenantBranding(
tenant_id=user.tenant_id,
**update_data,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(branding)
await db.commit()
await db.refresh(branding)
return {"ok": True, "branding": _merge_branding(branding)}
@router.get("/css")
async def get_brand_css(
tenant_id: Optional[int] = None,
db: AsyncSession = Depends(get_db),
):
"""
CSS 변수 동적 생성 (인증 불필요 — 프론트엔드에서 직접 로드).
<link rel="stylesheet" href="/api/brand/css">
"""
tid = tenant_id
if tid:
row = await db.execute(select(TenantBranding).where(TenantBranding.tenant_id == tid))
branding = row.scalar_one_or_none()
css_vars = _build_css_vars(_merge_branding(branding))
else:
css_vars = _build_css_vars(DEFAULT_BRANDING)
return Response(
content=css_vars,
media_type="text/css",
headers={"Cache-Control": "public, max-age=300"},
)
@router.get("/email-template")
async def get_email_template(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""이메일 템플릿 조회."""
row = await db.execute(
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
)
branding = row.scalar_one_or_none()
merged = _merge_branding(branding)
header_html = getattr(branding, 'email_header_html', None) if branding else None
footer_html = getattr(branding, 'email_footer_html', None) if branding else None
default_header = f"""
<div style="background:{merged['primary_color']};padding:20px;text-align:center;">
<img src="{merged['logo_url']}" height="40" alt="{merged['company_name']}"/>
</div>"""
default_footer = f"""
<div style="background:#f5f5f5;padding:15px;text-align:center;font-size:12px;color:#666;">
{merged['company_name']} | {merged.get('footer_text', 'GUARDiA ITSM 자동 발송 메일입니다.')}
</div>"""
return {
"subject_prefix": f"[{merged['company_name']}]",
"header_html": header_html or default_header,
"footer_html": footer_html or default_footer,
"primary_color": merged["primary_color"],
}
@router.put("/email-template")
async def update_email_template(
req: EmailTemplateUpdate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_admin_role),
):
"""이메일 템플릿 수정."""
row = await db.execute(
select(TenantBranding).where(TenantBranding.tenant_id == user.tenant_id)
)
branding = row.scalar_one_or_none()
if not branding:
branding = TenantBranding(
tenant_id=user.tenant_id,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
)
db.add(branding)
if req.header_html is not None:
branding.email_header_html = req.header_html
if req.footer_html is not None:
branding.email_footer_html = req.footer_html
branding.updated_at = datetime.utcnow()
await db.commit()
return {"ok": True}
@router.post("/preview-email")
async def preview_email(
subject: str = "테스트 메일",
body: str = "메일 본문 내용입니다.",
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""이메일 미리보기 (HTML 반환)."""
template = await get_email_template(db, user)
html = f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"/><title>{subject}</title></head>
<body style="font-family:{DEFAULT_BRANDING['font_family']};margin:0;padding:0;">
{template['header_html']}
<div style="padding:24px;max-width:600px;margin:0 auto;">
<h2 style="color:{template['primary_color']};">{subject}</h2>
<div>{body}</div>
</div>
{template['footer_html']}
</body>
</html>"""
return Response(content=html, media_type="text/html")