You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

179 lines
7.0 KiB
Python

# backend/app/routers/connectors.py — phase-13 외부 연동 API
# prefix 없음. main.py 에서 include_router(prefix="/api").
import uuid
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from ..config import get_settings
from ..connectors import oauth as oauthlib
from ..connectors.registry import ConnectorRegistry
from ..db import engine, get_session
from ..models import ConnectorAccount, ConnectorAccountLog, ConnState, OAuthState
from ..schemas import (
ConnectorProviderOut,
ConnectorStatusOut,
ImportResultOut,
OAuthStartOut,
SyncResultOut,
)
router = APIRouter()
# phase-16: '계정 추가'가 보여줄 OAuth provider 카탈로그.
# (domain, provider, label, settings client_id 필드명)
_OAUTH_PROVIDERS = [
("mail", "gmail", "Gmail", "google_client_id"),
("mail", "outlook", "Outlook", "microsoft_client_id"),
("calendar", "google_calendar", "Google 캘린더", "google_client_id"),
("calendar", "outlook_calendar", "Outlook 캘린더", "microsoft_client_id"),
]
@router.get("/connectors", response_model=list[ConnectorStatusOut])
def list_connectors(domain: str | None = None, s: Session = Depends(get_session)):
rows = ConnectorRegistry.accounts(s, domain)
return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows]
@router.get("/connectors/providers", response_model=list[ConnectorProviderOut])
def list_providers(domain: str | None = None):
"""OAuth로 추가 가능한 provider 목록 + 구성 여부(client_id 설정 시 configured=true)."""
cfg = get_settings()
out = []
for dom, provider, label, field in _OAUTH_PROVIDERS:
if domain and dom != domain:
continue
out.append(
ConnectorProviderOut(
domain=dom,
provider=provider,
label=label,
configured=bool(getattr(cfg, field, "")),
)
)
return out
@router.post("/connectors/{account_id}/sync", response_model=SyncResultOut)
def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)):
a = s.get(ConnectorAccount, account_id)
if not a:
raise HTTPException(404, "connector account not found")
res = ConnectorRegistry.get(s, a).sync(s, full=full)
return SyncResultOut(**res.__dict__)
@router.post("/connectors/sync-all", response_model=list[SyncResultOut])
def sync_all(s: Session = Depends(get_session)):
out = []
for a in ConnectorRegistry.accounts(s):
if a.state == ConnState.disconnected: # 연결 안 된 계정은 건너뜀
continue
out.append(SyncResultOut(**ConnectorRegistry.get(s, a).sync(s).__dict__))
return out
# ── OAuth ──
def _with_query(path: str, extra: str) -> str:
"""redirect_after 경로에 쿼리 안전 append('/settings?tab=mail' + 'connect=ok')."""
sep = "&" if "?" in path else "?"
return f"{path}{sep}{extra}"
@router.get("/connectors/oauth/start", response_model=OAuthStartOut)
def oauth_start(
domain: str, provider: str, redirect_after: str = "/life", s: Session = Depends(get_session)
):
cfg = get_settings()
if provider in ("gmail", "google_calendar") and not cfg.google_client_id:
raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성")
if provider in ("outlook", "outlook_calendar") and not cfg.microsoft_client_id:
raise HTTPException(400, "microsoft_client_id 미설정 — real 연동 비활성")
if provider == "notion" and not cfg.notion_client_id:
raise HTTPException(400, "notion_client_id 미설정 — real 연동 비활성")
url = oauthlib.start_oauth(s, domain, provider, redirect_after)
return OAuthStartOut(authorize_url=url)
def _initial_sync(account_id: str) -> None:
"""연결 직후 초기 full sync — 백그라운드 실행(대량 메일도 콜백 응답을 막지 않음)."""
with Session(engine) as bs:
acct = bs.get(ConnectorAccount, account_id)
if not acct:
return
targets = [acct]
# 메일과 함께 연결된 캘린더 계정(같은 이메일)도 초기 sync.
sibling_provider = {"gmail": "google_calendar", "outlook": "outlook_calendar"}.get(
acct.provider
)
if sibling_provider and acct.external_account_id:
sib = bs.exec(
select(ConnectorAccount).where(
ConnectorAccount.provider == sibling_provider,
ConnectorAccount.external_account_id == acct.external_account_id,
)
).first()
if sib:
targets.append(sib)
for a in targets:
try:
ConnectorRegistry.get(bs, a).sync(bs, full=True)
except Exception:
pass
@router.get("/connectors/oauth/callback")
def oauth_callback(
code: str, state: str, bg: BackgroundTasks, s: Session = Depends(get_session)
):
origin = get_settings().frontend_origin
os_row = s.get(OAuthState, state)
redirect_after = os_row.redirect_after if os_row else "/life"
try:
acct = oauthlib.finish_oauth(s, code, state)
except Exception:
return RedirectResponse(url=f"{origin}{_with_query(redirect_after, 'connect=error')}")
# 초기 sync 는 백그라운드로(받은편지함이 크면 수십 초 소요 → 콜백은 즉시 리다이렉트).
bg.add_task(_initial_sync, acct.id)
q = f"connect=ok&domain={acct.domain.value}&provider={acct.provider}"
return RedirectResponse(url=f"{origin}{_with_query(redirect_after, q)}")
@router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut)
def disconnect(account_id: str, s: Session = Depends(get_session)):
a = s.get(ConnectorAccount, account_id)
if not a:
raise HTTPException(404, "not found")
a.state = ConnState.disconnected
a.token_enc = ""
a.last_label = "연결 안 됨"
s.add(a)
s.add(
ConnectorAccountLog(
id="cl-" + uuid.uuid4().hex[:8],
account_id=a.id,
action="disconnected",
detail="사용자 해제",
)
)
s.commit()
return ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain))
# ── 로컬 우선: 수동 임포트(CSV / .ics / HealthKit export) ──
@router.post("/connectors/{account_id}/import", response_model=ImportResultOut)
async def import_file(
account_id: str, file: UploadFile = File(...), s: Session = Depends(get_session)
):
a = s.get(ConnectorAccount, account_id)
if not a:
raise HTTPException(404, "not found")
conn = ConnectorRegistry.get(s, a)
if not hasattr(conn, "import_bytes"):
raise HTTPException(400, "이 커넥터는 파일 임포트를 지원하지 않아요")
content = await file.read()
res = conn.import_bytes(s, content, filename=file.filename or "")
return ImportResultOut(**res)