라우터 (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>
260 lines
9.4 KiB
Python
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")
|