# backend/app/connectors/calendar/real_google.py — Google Calendar API sync (phase-13) # 증분: syncToken(델타). 410 GONE 시 full resync 후 새 토큰 획득. 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, ensure_gcal_calendar, normalize_event, upsert_event, ) from .recurrence import rrule_to_google API = "https://www.googleapis.com/calendar/v3" TZ = "Asia/Seoul" def _event_resource(fields: dict) -> dict: """내부 이벤트 필드 → Google Calendar event 리소스(쓰기용).""" body = { "summary": fields.get("title", ""), "location": fields.get("loc", "") or "", "description": fields.get("note", "") or "", } 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": TZ} body["end"] = {"dateTime": f"{date_str}T{end or start}:00", "timeZone": TZ} else: # 종일 이벤트(end.date 는 배타적 → +1일) d = datetime.fromisoformat(date_str) if date_str else datetime.now(UTC) body["start"] = {"date": d.date().isoformat()} body["end"] = {"date": (d + timedelta(days=1)).date().isoformat()} people = fields.get("people") or [] if people: body["attendees"] = [{"email": e} for e in people if e] rrule = fields.get("rrule") or "" if rrule: body["recurrence"] = rrule_to_google(rrule) reminders = fields.get("reminders") if reminders: # 시작 N분 전 팝업 알림 body["reminders"] = { "useDefault": False, "overrides": [{"method": "popup", "minutes": int(m)} for m in reminders], } return body def create_remote_event(session: Session, account, fields: dict) -> dict: """Google Calendar(primary)에 이벤트 생성 → 생성된 event 리소스 반환.""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.post( f"{API}/calendars/primary/events", headers={"Authorization": f"Bearer {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: """Google Calendar 이벤트 부분 수정(PATCH) → 갱신된 event 리소스 반환.""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.patch( f"{API}/calendars/primary/events/{external_id}", headers={"Authorization": f"Bearer {token}"}, json=_event_resource(fields), ) r.raise_for_status() return r.json() def delete_remote_event(session: Session, account, external_id: str) -> None: """Google Calendar 이벤트 삭제(이미 삭제됨 410/404 는 성공으로 간주).""" token = valid_access_token(session, account) with httpx.Client(timeout=get_settings().connector_http_timeout) as c: r = c.delete( f"{API}/calendars/primary/events/{external_id}", headers={"Authorization": f"Bearer {token}"}, ) if r.status_code not in (200, 204, 404, 410): r.raise_for_status() def respond_to_event(session: Session, account, external_id: str, status: str) -> dict: """초대 RSVP — 내 attendee.responseStatus 를 갱신(accepted/declined/tentative).""" token = valid_access_token(session, account) self_email = (getattr(account, "external_account_id", "") or "").lower() with httpx.Client(timeout=get_settings().connector_http_timeout) as c: g = c.get( f"{API}/calendars/primary/events/{external_id}", headers={"Authorization": f"Bearer {token}"}, ) g.raise_for_status() ev = g.json() atts = ev.get("attendees", []) or [] for a in atts: if a.get("self") or a.get("email", "").lower() == self_email: a["responseStatus"] = status r = c.patch( f"{API}/calendars/primary/events/{external_id}", headers={"Authorization": f"Bearer {token}"}, json={"attendees": atts}, ) r.raise_for_status() return r.json() def mirror_event(session: Session, account, ev_json: dict) -> str: """Google API 응답 1건을 로컬 CalEvent 로 upsert + ExternalLink 보장 → entity_id 반환.""" ensure_gcal_calendar(session, account) raw = RawRecord(external_id=ev_json["id"], payload=ev_json, etag=ev_json.get("etag", "")) norm = normalize_event(account, raw, provider="google_calendar") eid, _ = upsert_event(session, norm) ensure_external_link(session, account, raw.external_id, "event", eid, etag=norm.etag) return eid class GoogleCalendarConnector(BaseConnector): domain = "calendar" entity_type = "event" _rl = RateLimiter(rate=5, per=1.0) def fetch(self, session: Session, *, full: bool = False): ensure_gcal_calendar(session, self.account) # 이벤트가 묶일 Calendar 행 보장 token = valid_access_token(session, self.account) headers = {"Authorization": f"Bearer {token}"} cfg = get_settings() # 실제 날짜 윈도우만 수집(옛 반복일정 유입 차단): 최근 60일 ~ 향후 365일. now = datetime.now(UTC) base = { "maxResults": cfg.sync_page_size, "singleEvents": "true", "orderBy": "startTime", "timeMin": (now - timedelta(days=60)).isoformat().replace("+00:00", "Z"), "timeMax": (now + timedelta(days=365)).isoformat().replace("+00:00", "Z"), } with httpx.Client(timeout=cfg.connector_http_timeout) as c: page_token = None seen = 0 while seen < cfg.sync_max_messages: params = dict(base) if page_token: params["pageToken"] = page_token self._rl.acquire() r = c.get(f"{API}/calendars/primary/events", headers=headers, params=params) self._rl.handle_response(r) r.raise_for_status() body = r.json() for ev in body.get("items", []): if ev.get("status") == "cancelled": continue seen += 1 yield RawRecord( external_id=ev["id"], payload=ev, etag=ev.get("etag", ""), external_updated_at=None, ) page_token = body.get("nextPageToken") if not page_token: break def normalize(self, raw: RawRecord) -> NormalizedRecord: return normalize_event(self.account, raw, provider="google_calendar") def write(self, session: Session, norm: NormalizedRecord): return upsert_event(session, norm) def event_for(self, norm, entity_id): return "calendar.updated"