mgraphctl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mgraphctl might be problematic. Click here for more details.
- mgraphctl/__init__.py +3 -0
- mgraphctl/__main__.py +3 -0
- mgraphctl/auth.py +395 -0
- mgraphctl/cli.py +259 -0
- mgraphctl/commands/__init__.py +71 -0
- mgraphctl/commands/api.py +164 -0
- mgraphctl/commands/calendar.py +491 -0
- mgraphctl/commands/chats.py +329 -0
- mgraphctl/commands/config_cmd.py +140 -0
- mgraphctl/commands/groups.py +65 -0
- mgraphctl/commands/mail.py +705 -0
- mgraphctl/commands/mailbox.py +176 -0
- mgraphctl/commands/meetings.py +294 -0
- mgraphctl/commands/onedrive.py +351 -0
- mgraphctl/commands/onenote.py +196 -0
- mgraphctl/commands/org.py +102 -0
- mgraphctl/commands/people.py +201 -0
- mgraphctl/commands/planner.py +268 -0
- mgraphctl/commands/presence.py +102 -0
- mgraphctl/commands/search.py +75 -0
- mgraphctl/commands/sharepoint.py +304 -0
- mgraphctl/commands/teams.py +235 -0
- mgraphctl/commands/todo.py +253 -0
- mgraphctl/commands/top.py +284 -0
- mgraphctl/config.py +385 -0
- mgraphctl/errors.py +276 -0
- mgraphctl/fixtures.py +149 -0
- mgraphctl/graph/__init__.py +1 -0
- mgraphctl/graph/calendar.py +313 -0
- mgraphctl/graph/chats.py +447 -0
- mgraphctl/graph/files.py +269 -0
- mgraphctl/graph/groups.py +55 -0
- mgraphctl/graph/mail.py +663 -0
- mgraphctl/graph/mailbox.py +89 -0
- mgraphctl/graph/meetings.py +269 -0
- mgraphctl/graph/onedrive.py +35 -0
- mgraphctl/graph/onenote.py +138 -0
- mgraphctl/graph/org.py +86 -0
- mgraphctl/graph/people.py +83 -0
- mgraphctl/graph/planner.py +415 -0
- mgraphctl/graph/presence.py +89 -0
- mgraphctl/graph/search.py +178 -0
- mgraphctl/graph/sharepoint.py +294 -0
- mgraphctl/graph/teams.py +240 -0
- mgraphctl/graph/todo.py +199 -0
- mgraphctl/graph/users.py +85 -0
- mgraphctl/html.py +127 -0
- mgraphctl/http.py +843 -0
- mgraphctl/odata.py +72 -0
- mgraphctl/render.py +627 -0
- mgraphctl/resolve.py +111 -0
- mgraphctl/token_store.py +196 -0
- mgraphctl-0.1.0.dist-info/METADATA +226 -0
- mgraphctl-0.1.0.dist-info/RECORD +57 -0
- mgraphctl-0.1.0.dist-info/WHEEL +4 -0
- mgraphctl-0.1.0.dist-info/entry_points.txt +3 -0
- mgraphctl-0.1.0.dist-info/licenses/LICENSE +21 -0
mgraphctl/fixtures.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Record/replay httpx transport for offline runs and tests (spec §5.8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from mgraphctl import config
|
|
13
|
+
from mgraphctl.errors import FixtureError
|
|
14
|
+
from mgraphctl.http import redact_body
|
|
15
|
+
|
|
16
|
+
# Recorded responses never carry credentials, and never carry framing that would not
|
|
17
|
+
# survive re-serialising the body.
|
|
18
|
+
DROPPED_HEADERS = frozenset(
|
|
19
|
+
{"authorization", "set-cookie", "content-length", "content-encoding", "transfer-encoding"}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fixture_key(request: httpx.Request) -> str:
|
|
24
|
+
"""`"<METHOD> <path>?<query>"`; the query is dropped for unauthenticated requests.
|
|
25
|
+
|
|
26
|
+
Download targets and `uploadUrl` PUTs are pre-authenticated: their query strings carry
|
|
27
|
+
per-request tokens that differ on every run, so they cannot be part of the key.
|
|
28
|
+
"""
|
|
29
|
+
if "authorization" in request.headers:
|
|
30
|
+
return f"{request.method} {request.url.raw_path.decode()}"
|
|
31
|
+
return f"{request.method} {request.url.path}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fixture_path(dir: Path, key: str) -> Path: # noqa: A002 - name fixed by the Phase 0 contract
|
|
35
|
+
"""File holding the recorded responses for `key`."""
|
|
36
|
+
return Path(dir) / (hashlib.sha256(key.encode("utf-8")).hexdigest()[:16] + ".json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _clean_headers(headers: httpx.Headers) -> dict[str, str]:
|
|
40
|
+
cleaned = {}
|
|
41
|
+
for name, value in headers.items():
|
|
42
|
+
lowered = name.lower()
|
|
43
|
+
if lowered in DROPPED_HEADERS:
|
|
44
|
+
continue
|
|
45
|
+
cleaned[lowered] = value.split("?", 1)[0] if lowered == "location" else value
|
|
46
|
+
return cleaned
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _encode_body(response: httpx.Response) -> dict:
|
|
50
|
+
raw = response.content
|
|
51
|
+
if not raw:
|
|
52
|
+
return {}
|
|
53
|
+
content_type = response.headers.get("content-type", "")
|
|
54
|
+
if "json" in content_type:
|
|
55
|
+
try:
|
|
56
|
+
# A recorded body must never keep a token: the OAuth pair and the `uploadUrl`
|
|
57
|
+
# query string are redacted here exactly as the debug log redacts them.
|
|
58
|
+
return {"body": json.loads(redact_body(raw.decode("utf-8")))}
|
|
59
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
60
|
+
pass
|
|
61
|
+
if content_type.startswith("text/"):
|
|
62
|
+
return {"body_text": raw.decode("utf-8", errors="replace")}
|
|
63
|
+
return {"body_b64": base64.b64encode(raw).decode("ascii")}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decode_body(entry: dict) -> bytes:
|
|
67
|
+
if "body" in entry:
|
|
68
|
+
return json.dumps(entry["body"], ensure_ascii=False).encode("utf-8")
|
|
69
|
+
if "body_text" in entry:
|
|
70
|
+
return entry["body_text"].encode("utf-8")
|
|
71
|
+
if "body_b64" in entry:
|
|
72
|
+
return base64.b64decode(entry["body_b64"])
|
|
73
|
+
return b""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class FixtureTransport(httpx.BaseTransport):
|
|
77
|
+
"""Replays recorded Graph traffic, or records it when `record` is set."""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
dir: Path, # noqa: A002 - name fixed by the Phase 0 contract
|
|
82
|
+
*,
|
|
83
|
+
record: bool,
|
|
84
|
+
inner: httpx.BaseTransport | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
self.dir = Path(dir)
|
|
87
|
+
self.record = record
|
|
88
|
+
self.inner = inner if inner is not None else (httpx.HTTPTransport() if record else None)
|
|
89
|
+
self._cursor: dict[str, int] = {}
|
|
90
|
+
|
|
91
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
92
|
+
if request.url.host == config.TOKEN_HOST:
|
|
93
|
+
raise FixtureError(
|
|
94
|
+
"FIXTURE_FORBIDDEN_HOST",
|
|
95
|
+
f"refusing to record or replay {config.TOKEN_HOST} traffic",
|
|
96
|
+
)
|
|
97
|
+
key = fixture_key(request)
|
|
98
|
+
path = fixture_path(self.dir, key)
|
|
99
|
+
return self._record(request, key, path) if self.record else self._replay(key, path)
|
|
100
|
+
|
|
101
|
+
def _record(self, request: httpx.Request, key: str, path: Path) -> httpx.Response:
|
|
102
|
+
if self.inner is None: # pragma: no cover - guarded by __init__
|
|
103
|
+
raise FixtureError("FIXTURE_MISSING", "recording needs an inner transport")
|
|
104
|
+
response = self.inner.handle_request(request)
|
|
105
|
+
response.request = request
|
|
106
|
+
response.read()
|
|
107
|
+
entry = {
|
|
108
|
+
"status": response.status_code,
|
|
109
|
+
"headers": _clean_headers(response.headers),
|
|
110
|
+
**_encode_body(response),
|
|
111
|
+
}
|
|
112
|
+
document = (
|
|
113
|
+
json.loads(path.read_text(encoding="utf-8"))
|
|
114
|
+
if path.exists()
|
|
115
|
+
else {"key": key, "responses": []}
|
|
116
|
+
)
|
|
117
|
+
document["responses"].append(entry)
|
|
118
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
120
|
+
# Hand back the live response, headers untouched, so recording behaves like a real run.
|
|
121
|
+
return httpx.Response(
|
|
122
|
+
response.status_code, headers=response.headers, content=response.content
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _replay(self, key: str, path: Path) -> httpx.Response:
|
|
126
|
+
if not path.exists():
|
|
127
|
+
raise FixtureError("FIXTURE_MISSING", f"no fixture for {key} (expected {path})")
|
|
128
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
129
|
+
responses = document.get("responses") or []
|
|
130
|
+
index = self._cursor.get(key, 0)
|
|
131
|
+
if index >= len(responses):
|
|
132
|
+
raise FixtureError(
|
|
133
|
+
"FIXTURE_EXHAUSTED",
|
|
134
|
+
f"fixture for {key} has only {len(responses)} response(s) ({path})",
|
|
135
|
+
)
|
|
136
|
+
self._cursor[key] = index + 1
|
|
137
|
+
entry = responses[index]
|
|
138
|
+
return httpx.Response(
|
|
139
|
+
entry["status"], headers=entry.get("headers") or {}, content=_decode_body(entry)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
if self.inner is not None:
|
|
144
|
+
self.inner.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def transport_from_env(s: config.Settings) -> FixtureTransport | None:
|
|
148
|
+
"""The transport implied by `MGRAPHCTL_FIXTURE_DIR` / `MGRAPHCTL_RECORD`, or `None`."""
|
|
149
|
+
return FixtureTransport(s.fixture_dir, record=s.record) if s.fixture_dir else None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pure Microsoft Graph service functions (no CLI, no rendering)."""
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Graph operations for Outlook calendar (spec §8.4).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / PageResult / Plan out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, time, timedelta
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from mgraphctl import odata
|
|
14
|
+
from mgraphctl.errors import UsageError
|
|
15
|
+
from mgraphctl.http import JSON_HEADERS, GraphClient, PageResult, Plan, PlannedRequest
|
|
16
|
+
from mgraphctl.render import fmt_person, iso_duration, to_graph_dtz, to_iso_offset
|
|
17
|
+
from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
|
|
18
|
+
|
|
19
|
+
LIST_SELECT = (
|
|
20
|
+
"id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeeting,"
|
|
21
|
+
"isCancelled,isAllDay,showAs,responseStatus,seriesMasterId,bodyPreview,webLink"
|
|
22
|
+
)
|
|
23
|
+
PAGE_LIST, CAP_LIST = 50, 200
|
|
24
|
+
|
|
25
|
+
CALENDARS_SELECT = "id,name,isDefaultCalendar,canEdit,owner,color"
|
|
26
|
+
PAGE_CALENDARS, CAP_CALENDARS = 100, 500
|
|
27
|
+
|
|
28
|
+
RESPOND_ENDPOINTS = {"accept": "accept", "decline": "decline", "tentative": "tentativelyAccept"}
|
|
29
|
+
RESPOND_STATUS = {
|
|
30
|
+
"accept": "accepted",
|
|
31
|
+
"decline": "declined",
|
|
32
|
+
"tentative": "tentativelyAccepted",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------- resolution
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def list_calendars(client: GraphClient) -> list[dict]:
|
|
40
|
+
"""Every calendar the signed-in user has, id and display fields only."""
|
|
41
|
+
return client.paginate(
|
|
42
|
+
"/me/calendars",
|
|
43
|
+
params={"$select": CALENDARS_SELECT},
|
|
44
|
+
limit=None,
|
|
45
|
+
all_=True,
|
|
46
|
+
cap=CAP_CALENDARS,
|
|
47
|
+
page_size=PAGE_CALENDARS,
|
|
48
|
+
).items
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_calendar(client: GraphClient, value: str | None) -> str | None:
|
|
52
|
+
"""`--calendar` resolution (spec §6.6): an id, or a case-insensitive name in /me/calendars.
|
|
53
|
+
|
|
54
|
+
`None` (no `--calendar` given) resolves to `None`, meaning "use the default calendar".
|
|
55
|
+
"""
|
|
56
|
+
if value is None:
|
|
57
|
+
return None
|
|
58
|
+
bare, forced = split_id_prefix(value)
|
|
59
|
+
if forced or looks_like_id(value, "calendar"):
|
|
60
|
+
return bare
|
|
61
|
+
calendars = list_calendars(client)
|
|
62
|
+
return pick_unique(calendars, "name", value, what="calendar")["id"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- list / get
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def flags(event: dict) -> str:
|
|
69
|
+
"""`T` for an online meeting, `X` for a cancelled event (spec §8.4 `list` Notes)."""
|
|
70
|
+
marks = []
|
|
71
|
+
if event.get("isOnlineMeeting"):
|
|
72
|
+
marks.append("T")
|
|
73
|
+
if event.get("isCancelled"):
|
|
74
|
+
marks.append("X")
|
|
75
|
+
return "".join(marks)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _search_haystack(event: dict) -> str:
|
|
79
|
+
parts = [event.get("subject") or ""]
|
|
80
|
+
organizer = (event.get("organizer") or {}).get("emailAddress") or {}
|
|
81
|
+
parts += [organizer.get("name") or "", organizer.get("address") or ""]
|
|
82
|
+
for attendee in event.get("attendees") or []:
|
|
83
|
+
email = attendee.get("emailAddress") or {}
|
|
84
|
+
parts += [email.get("name") or "", email.get("address") or ""]
|
|
85
|
+
return " ".join(parts).casefold()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def filter_search(items: list[dict], term: str) -> list[dict]:
|
|
89
|
+
"""Client-side `--search`: keep events whose subject/organizer/attendees contain `term`."""
|
|
90
|
+
needle = term.casefold()
|
|
91
|
+
return [event for event in items if needle in _search_haystack(event)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def calendar_view(
|
|
95
|
+
client: GraphClient,
|
|
96
|
+
*,
|
|
97
|
+
start: datetime,
|
|
98
|
+
end: datetime,
|
|
99
|
+
calendar_id: str | None,
|
|
100
|
+
select: str | None,
|
|
101
|
+
limit: int | None,
|
|
102
|
+
all_: bool,
|
|
103
|
+
tz: str,
|
|
104
|
+
) -> PageResult:
|
|
105
|
+
"""`calendar list` (spec §8.4): a `calendarView` window, default or a named calendar."""
|
|
106
|
+
path = (
|
|
107
|
+
"/me/calendarView"
|
|
108
|
+
if calendar_id is None
|
|
109
|
+
else odata.p("me", "calendars", calendar_id, "calendarView")
|
|
110
|
+
)
|
|
111
|
+
params = {
|
|
112
|
+
"startDateTime": to_iso_offset(start),
|
|
113
|
+
"endDateTime": to_iso_offset(end),
|
|
114
|
+
"$select": select or LIST_SELECT,
|
|
115
|
+
"$orderby": "start/dateTime",
|
|
116
|
+
}
|
|
117
|
+
return client.paginate(
|
|
118
|
+
path,
|
|
119
|
+
params=params,
|
|
120
|
+
outlook_tz=True,
|
|
121
|
+
limit=limit,
|
|
122
|
+
all_=all_,
|
|
123
|
+
cap=CAP_LIST,
|
|
124
|
+
page_size=PAGE_LIST,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_event(client: GraphClient, event_id: str, *, html: bool, tz: str) -> dict:
|
|
129
|
+
"""`calendar get` (spec §8.4): the full event, text body unless `--html`."""
|
|
130
|
+
return client.get(odata.p("me", "events", event_id), outlook_tz=True, text_body=not html)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def attendees_block(event: dict) -> str:
|
|
134
|
+
"""`Attendees` section for `calendar get` text output: one `Name <addr> (status)` per line."""
|
|
135
|
+
lines = ["Attendees"]
|
|
136
|
+
for attendee in event.get("attendees") or []:
|
|
137
|
+
status = (attendee.get("status") or {}).get("response") or "none"
|
|
138
|
+
lines.append(f" {fmt_person(attendee)} ({status})")
|
|
139
|
+
return "\n".join(lines)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------- create / update
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class EventParams:
|
|
147
|
+
"""The options every event write verb shares (spec §8.4 `create`/`update`)."""
|
|
148
|
+
|
|
149
|
+
subject: str | None = None
|
|
150
|
+
start: datetime | None = None
|
|
151
|
+
end: datetime | None = None
|
|
152
|
+
duration: timedelta | None = None
|
|
153
|
+
all_day: bool | None = None
|
|
154
|
+
attendees: list[str] = field(default_factory=list)
|
|
155
|
+
optional: list[str] = field(default_factory=list)
|
|
156
|
+
body: str | None = None
|
|
157
|
+
html: bool = False
|
|
158
|
+
location: str | None = None
|
|
159
|
+
teams: bool | None = None
|
|
160
|
+
reminder: int | None = None
|
|
161
|
+
show_as: str | None = None
|
|
162
|
+
categories: list[str] = field(default_factory=list)
|
|
163
|
+
calendar: str | None = None
|
|
164
|
+
transaction_id: str | None = None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def event_body(p: EventParams, tz: str, *, partial: bool) -> dict:
|
|
168
|
+
"""The JSON body for `create` (`partial=False`) or `update` (`partial=True`, given fields)."""
|
|
169
|
+
if p.all_day and p.start is not None and p.start.time() != time():
|
|
170
|
+
raise UsageError("USAGE", "--all-day needs a date-only --start/--end")
|
|
171
|
+
body: dict[str, Any] = {}
|
|
172
|
+
if p.subject is not None:
|
|
173
|
+
body["subject"] = p.subject
|
|
174
|
+
if p.start is not None:
|
|
175
|
+
body["start"] = to_graph_dtz(p.start, tz)
|
|
176
|
+
end_dt: datetime | None = None
|
|
177
|
+
if p.end is not None:
|
|
178
|
+
end_dt = p.end
|
|
179
|
+
elif p.start is not None and p.duration is not None:
|
|
180
|
+
end_dt = p.start + p.duration
|
|
181
|
+
elif p.start is not None and p.all_day:
|
|
182
|
+
end_dt = p.start + timedelta(days=1)
|
|
183
|
+
if end_dt is not None:
|
|
184
|
+
body["end"] = to_graph_dtz(end_dt, tz)
|
|
185
|
+
if not partial:
|
|
186
|
+
body["isAllDay"] = bool(p.all_day)
|
|
187
|
+
elif p.all_day is not None:
|
|
188
|
+
body["isAllDay"] = p.all_day
|
|
189
|
+
attendees = [{"emailAddress": {"address": a}, "type": "required"} for a in p.attendees]
|
|
190
|
+
attendees += [{"emailAddress": {"address": a}, "type": "optional"} for a in p.optional]
|
|
191
|
+
if attendees:
|
|
192
|
+
body["attendees"] = attendees
|
|
193
|
+
if p.body is not None:
|
|
194
|
+
body["body"] = {"contentType": "HTML" if p.html else "Text", "content": p.body}
|
|
195
|
+
if p.location is not None:
|
|
196
|
+
body["location"] = {"displayName": p.location}
|
|
197
|
+
if p.teams is not None:
|
|
198
|
+
body["isOnlineMeeting"] = p.teams
|
|
199
|
+
if p.teams:
|
|
200
|
+
body["onlineMeetingProvider"] = "teamsForBusiness"
|
|
201
|
+
if p.reminder is not None:
|
|
202
|
+
body["reminderMinutesBeforeStart"] = p.reminder
|
|
203
|
+
if p.show_as is not None:
|
|
204
|
+
body["showAs"] = p.show_as
|
|
205
|
+
if p.categories:
|
|
206
|
+
body["categories"] = list(p.categories)
|
|
207
|
+
if not partial:
|
|
208
|
+
body["transactionId"] = p.transaction_id or str(uuid.uuid4())
|
|
209
|
+
return body
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def plan_create(client: GraphClient, p: EventParams, tz: str) -> Plan:
|
|
213
|
+
body = event_body(p, tz, partial=False)
|
|
214
|
+
path = "/me/events" if p.calendar is None else odata.p("me", "calendars", p.calendar, "events")
|
|
215
|
+
return [PlannedRequest("POST", client.url(path), dict(JSON_HEADERS), body)]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def plan_update(client: GraphClient, event_id: str, p: EventParams, tz: str) -> Plan:
|
|
219
|
+
body = event_body(p, tz, partial=True)
|
|
220
|
+
url = client.url(odata.p("me", "events", event_id))
|
|
221
|
+
return [PlannedRequest("PATCH", url, dict(JSON_HEADERS), body)]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def plan_delete(client: GraphClient, event_id: str) -> Plan:
|
|
225
|
+
url = client.url(odata.p("me", "events", event_id))
|
|
226
|
+
return [PlannedRequest("DELETE", url, {}, None, expect="none")]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def plan_respond(
|
|
230
|
+
client: GraphClient,
|
|
231
|
+
event_id: str,
|
|
232
|
+
action: str,
|
|
233
|
+
*,
|
|
234
|
+
comment: str | None,
|
|
235
|
+
send: bool,
|
|
236
|
+
propose: tuple[datetime, datetime] | None,
|
|
237
|
+
tz: str,
|
|
238
|
+
) -> Plan:
|
|
239
|
+
endpoint = RESPOND_ENDPOINTS[action]
|
|
240
|
+
body: dict[str, Any] = {"sendResponse": send}
|
|
241
|
+
if comment is not None:
|
|
242
|
+
body["comment"] = comment
|
|
243
|
+
if propose is not None:
|
|
244
|
+
start, end = propose
|
|
245
|
+
body["proposedNewTime"] = {"start": to_graph_dtz(start, tz), "end": to_graph_dtz(end, tz)}
|
|
246
|
+
url = client.url(odata.p("me", "events", event_id, endpoint))
|
|
247
|
+
return [PlannedRequest("POST", url, dict(JSON_HEADERS), body)]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def run_plan(client: GraphClient, plan: Plan) -> list:
|
|
251
|
+
"""Execute a plan whose steps are independent (no ids threaded between them)."""
|
|
252
|
+
return [client.execute(step) for step in plan]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# --------------------------------------------------------------------------- availability
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def get_schedule(
|
|
259
|
+
client: GraphClient, *, users: list[str], start: datetime, end: datetime, interval: int, tz: str
|
|
260
|
+
) -> dict:
|
|
261
|
+
"""`calendar availability` (spec §8.4): `getSchedule` for one or more mailboxes."""
|
|
262
|
+
body = {
|
|
263
|
+
"schedules": list(users),
|
|
264
|
+
"startTime": to_graph_dtz(start, tz),
|
|
265
|
+
"endTime": to_graph_dtz(end, tz),
|
|
266
|
+
"availabilityViewInterval": interval,
|
|
267
|
+
}
|
|
268
|
+
return client.post("/me/calendar/getSchedule", json=body)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def free_windows(
|
|
272
|
+
schedule_item: dict, start: datetime, interval: int
|
|
273
|
+
) -> list[tuple[datetime, datetime]]:
|
|
274
|
+
"""Free windows from `availabilityView` (`0` = free), merging consecutive free slots."""
|
|
275
|
+
view = schedule_item.get("availabilityView") or ""
|
|
276
|
+
windows: list[tuple[datetime, datetime]] = []
|
|
277
|
+
slot_start: datetime | None = None
|
|
278
|
+
for i, ch in enumerate(view):
|
|
279
|
+
slot_time = start + timedelta(minutes=interval * i)
|
|
280
|
+
if ch == "0":
|
|
281
|
+
if slot_start is None:
|
|
282
|
+
slot_start = slot_time
|
|
283
|
+
elif slot_start is not None:
|
|
284
|
+
windows.append((slot_start, slot_time))
|
|
285
|
+
slot_start = None
|
|
286
|
+
if slot_start is not None:
|
|
287
|
+
windows.append((slot_start, start + timedelta(minutes=interval * len(view))))
|
|
288
|
+
return windows
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def find_times(
|
|
292
|
+
client: GraphClient,
|
|
293
|
+
*,
|
|
294
|
+
attendees: list[str],
|
|
295
|
+
duration: timedelta,
|
|
296
|
+
start: datetime,
|
|
297
|
+
end: datetime,
|
|
298
|
+
max_candidates: int,
|
|
299
|
+
domain: str,
|
|
300
|
+
tz: str,
|
|
301
|
+
) -> dict:
|
|
302
|
+
"""`calendar find-times` (spec §8.4): `findMeetingTimes`."""
|
|
303
|
+
body = {
|
|
304
|
+
"attendees": [{"emailAddress": {"address": a}, "type": "required"} for a in attendees],
|
|
305
|
+
"timeConstraint": {
|
|
306
|
+
"activityDomain": domain,
|
|
307
|
+
"timeSlots": [{"start": to_graph_dtz(start, tz), "end": to_graph_dtz(end, tz)}],
|
|
308
|
+
},
|
|
309
|
+
"meetingDuration": iso_duration(duration),
|
|
310
|
+
"maxCandidates": max_candidates,
|
|
311
|
+
"returnSuggestionReasons": True,
|
|
312
|
+
}
|
|
313
|
+
return client.post("/me/findMeetingTimes", json=body)
|