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.
188 lines
7.6 KiB
Python
188 lines
7.6 KiB
Python
# 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"
|