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.
181 lines
7.3 KiB
Python
181 lines
7.3 KiB
Python
# 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"
|