# backend/app/connectors/calendar/real_outlook.py — Microsoft Graph 캘린더 sync (phase-16) # OAuth2 액세스 토큰으로 Graph /me/calendarView(기간 윈도우) 호출 → CalEvent upsert. # 증분 델타 대신 윈도우 재조회 + ExternalLink 멱등 upsert(중복 없음, polling 철학). from datetime import UTC, datetime, timedelta import httpx from sqlmodel import Session from ...config import get_settings from ..base import BaseConnector, NormalizedRecord, RawRecord from ..oauth import valid_access_token from ..ratelimit import RateLimiter from .normalize import ensure_external_link, normalize_event, upsert_event from .recurrence import rrule_to_graph GRAPH = "https://graph.microsoft.com/v1.0" CAL_TZ = "Asia/Seoul" # Prefer 헤더로 응답 시각을 KST 로 받음(데모 로케일) WINDOW_BACK = 1 # 조회 윈도우: 오늘 -1일 WINDOW_FWD = 60 # 조회 윈도우: 오늘 +60일 def _event_resource(fields: dict) -> dict: """내부 이벤트 필드 → Microsoft Graph event 리소스(쓰기용).""" body: dict = { "subject": fields.get("title", ""), "body": {"contentType": "text", "content": fields.get("note", "") or ""}, } if fields.get("loc"): body["location"] = {"displayName": fields["loc"]} date_str = fields.get("date") or "" start = fields.get("start") or "" end = fields.get("end") or "" if start: # 시간 지정 이벤트 body["start"] = {"dateTime": f"{date_str}T{start}:00", "timeZone": CAL_TZ} body["end"] = {"dateTime": f"{date_str}T{end or start}:00", "timeZone": CAL_TZ} else: # 종일 이벤트 d = datetime.fromisoformat(date_str) if date_str else datetime.now(UTC) nxt = d + timedelta(days=1) body["isAllDay"] = True body["start"] = {"dateTime": f"{d.date().isoformat()}T00:00:00", "timeZone": CAL_TZ} body["end"] = {"dateTime": f"{nxt.date().isoformat()}T00:00:00", "timeZone": CAL_TZ} people = fields.get("people") or [] if people: body["attendees"] = [ {"emailAddress": {"address": e}, "type": "required"} for e in people if e ] rrule = fields.get("rrule") or "" if rrule: rec = rrule_to_graph(rrule, date_str) if rec: rec["range"]["recurrenceTimeZone"] = CAL_TZ body["recurrence"] = rec reminders = fields.get("reminders") if reminders: # Graph 는 단일 알림 — 가장 이른 시각 사용 body["isReminderOn"] = True body["reminderMinutesBeforeStart"] = int(min(reminders)) else: body["isReminderOn"] = False return body def _write_headers(token: str) -> dict: return {"Authorization": f"Bearer {token}", "Prefer": f'outlook.timezone="{CAL_TZ}"'} def create_remote_event(session: Session, account, fields: dict) -> dict: """Outlook(Graph)에 이벤트 생성 → 생성된 event 리소스 반환.""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.post( f"{GRAPH}/me/events", headers=_write_headers(token), json=_event_resource(fields) ) r.raise_for_status() return r.json() def update_remote_event(session: Session, account, external_id: str, fields: dict) -> dict: """Outlook 이벤트 수정(PATCH) → 갱신된 event 리소스 반환.""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.patch( f"{GRAPH}/me/events/{external_id}", headers=_write_headers(token), json=_event_resource(fields), ) r.raise_for_status() return r.json() def delete_remote_event(session: Session, account, external_id: str) -> None: """Outlook 이벤트 삭제(이미 삭제됨 404 는 성공으로 간주).""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.delete( f"{GRAPH}/me/events/{external_id}", headers={"Authorization": f"Bearer {token}"} ) if r.status_code not in (200, 204, 404): r.raise_for_status() _RSVP_ACTION = { "accepted": "accept", "declined": "decline", "tentative": "tentativelyAccept", } def respond_to_event(session: Session, account, external_id: str, status: str) -> dict: """초대 RSVP — Graph accept/decline/tentativelyAccept 액션 호출.""" action = _RSVP_ACTION.get(status) if not action: raise ValueError(f"unknown rsvp status: {status}") token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.post( f"{GRAPH}/me/events/{external_id}/{action}", headers={"Authorization": f"Bearer {token}"}, json={"sendResponse": True}, ) if r.status_code not in (200, 202, 204): r.raise_for_status() return {"status": status} def mirror_event(session: Session, account, ev_json: dict) -> str: """Graph 이벤트 응답 1건을 로컬 CalEvent 로 upsert + ExternalLink 보장 → entity_id 반환.""" from .normalize import ensure_calendar_row ensure_calendar_row(session, account) raw = RawRecord( external_id=ev_json["id"], payload=ev_json, etag=str(ev_json.get("@odata.etag", "")) ) norm = normalize_event(account, raw, provider="outlook_calendar") eid, _ = upsert_event(session, norm) ensure_external_link(session, account, raw.external_id, "event", eid, etag=norm.etag) return eid class OutlookCalendarConnector(BaseConnector): domain = "calendar" entity_type = "event" _rl = RateLimiter(rate=4, per=1.0) # Graph throttling 보수적 제한 def fetch(self, session: Session, *, full: bool = False): token = valid_access_token(session, self.account) # token_expired 면 예외 → sync error cfg = get_settings() now = datetime.now(UTC) start = (now - timedelta(days=WINDOW_BACK)).strftime("%Y-%m-%dT00:00:00Z") end = (now + timedelta(days=WINDOW_FWD)).strftime("%Y-%m-%dT00:00:00Z") headers = { "Authorization": f"Bearer {token}", "Prefer": f'outlook.timezone="{CAL_TZ}"', } params = { "startDateTime": start, "endDateTime": end, "$top": cfg.sync_page_size, "$orderby": "start/dateTime", } url = f"{GRAPH}/me/calendarView" with httpx.Client(timeout=cfg.connector_http_timeout) as c: while url: self._rl.acquire() r = c.get(url, headers=headers, params=params) self._rl.handle_response(r) r.raise_for_status() body = r.json() for ev in body.get("value", []): if ev.get("isCancelled"): continue yield RawRecord( external_id=ev["id"], payload=ev, etag=str(ev.get("@odata.etag", "")), external_updated_at=None, ) url = body.get("@odata.nextLink", "") # 다음 페이지(쿼리 포함) params = None # nextLink 에 쿼리가 이미 들어있음 def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_event(self.account, raw, provider="outlook_calendar") def write(self, session: Session, norm: NormalizedRecord): return upsert_event(session, norm) def event_for(self, norm, entity_id): return "calendar.updated"