""" 화이트라벨 브랜딩 — 기관별 로고·색상·도메인 커스터마이즈 기능: - 기관별 로고 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"""