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
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)
|