""" 화이트라벨 브랜딩 — 기관별 로고·색상·도메인 커스터마이즈 기능: - 기관별 로고 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 변수 동적 생성 (인증 불필요 — 프론트엔드에서 직접 로드). """ 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"""
{merged['company_name']}
""" default_footer = f"""
{merged['company_name']} | {merged.get('footer_text', 'GUARDiA ITSM 자동 발송 메일입니다.')}
""" 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""" {subject} {template['header_html']}

{subject}

{body}
{template['footer_html']} """ return Response(content=html, media_type="text/html")